Gateway API, Backend TLS auto-signé & trust-manager

Comment gérer un backend HTTPS auto-signé avec Gateway API en gérant proprement la vérification du certificat avec trust-manager

RV
Rémi Verchère
Platform & Cloud Native
9 min de lecture

Dans le cadre de la migration de ressources Ingress vers Gateway API, j'ai dû gérer le cas d'un backend qui expose son service en HTTPS avec un certificat auto-signé. Avec ingress-nginx ça passait à coups d'annotations, sans trop se poser de questions. En Gateway API, c'est plus restrictif : il faut une vraie CA. Voici comment je m'en suis sorti proprement avec trust-manager.

Pour l'exemple, on va imaginer une app mysecureapp dans le namespace mysecurens, qui expose son service mysecureapp sur le port 8443 en HTTPS, avec un certificat auto-signé.

Avant : ingress-nginx & annotations

Côté ingress-nginx, c'était assez direct. Une ressource Ingress avec une seule annotation, et go :

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: mysecureapp
  namespace: mysecurens
  annotations:
    nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
    [...]
spec:
  ingressClassName: nginx
  rules:
    - host: mysecureapp.gravitek.io
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: mysecureapp
                port:
                  number: 8443
  tls:
    - hosts:
        - mysecureapp.gravitek.io
      secretName: mysecureapp.gravitek.io-tls # Certificat public Let's Encrypt

L'annotation backend-protocol: "HTTPS" est claire, elle indique à nginx de parler en TLS au backend. Et c'est tout. Par défaut, ingress-nginx ne vérifie pas le certificat du backend 🙈. Tant qu'on ne lui demande pas explicitement (proxy-ssl-verify: "on" + proxy-ssl-secret), il chiffre mais ne valide rien.

Après : Gateway API & BackendTLSPolicy

En Gateway API (j'utilise Envoy Gateway, mais le principe est le même côté API), la route HTTP devient une HTTPRoute, et le TLS vers le backend se déclare avec une BackendTLSPolicy :

# HTTPRoute
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: mysecureapp
  namespace: mysecurens
spec:
  hostnames:
    - mysecureapp.gravitek.io
  parentRefs:
    - kind: Gateway
      name: eg # Nom de ma gateway
      namespace: mysecurens # Pour l'exemple on a la GW dans le même NS que l'appli
      sectionName: mysecureapp-https # Section de Gateway qui écoute en HTTPS
  rules:
    - backendRefs:
        - kind: Service
          name: mysecureapp
          port: 8443
      matches:
        - path:
            type: PathPrefix
            value: /
# BackendTLSPolicy
apiVersion: gateway.networking.k8s.io/v1
kind: BackendTLSPolicy
metadata:
  name: mysecureapp-backend-tls
  namespace: mysecurens
spec:
  targetRefs:
    - kind: Service
      name: mysecureapp
  validation:
    hostname: mysecureapp.mysecurens.svc.cluster.local
    caCertificateRefs:
      - kind: ConfigMap
        name: internal-ca # on va voir cela après ;)

Côté flux, ça donne :

C'est propre, c'est déclaratif, c'est versionné dans un objet bien identifié... mais 💥, contrairement à nginx, il n'y a pas de mode verify: off. La spec impose soit une caCertificateRefs, soit un wellKnownCACertificates: System. Impossible de faire du SkipVerify (et c'est tant mieux).

Sauf que : mon backend a un certificat auto-signé, donc pas de CA "publique" à pointer. Et il faut bien matérialiser cette CA dans un ConfigMap du namespace mysecurens pour que la BackendTLSPolicy puisse la référencer. Comment faire ça proprement ? 🤔

BackendTLSPolicy : deux modes de validation

Avant d'aller plus loin, un mot sur les options possibles côté Gateway API pour parler en TLS au backend. La spec BackendTLSPolicy définit deux modes de validation, mutuellement exclusifs :

  • wellKnownCACertificates: System : le gateway utilise le bundle de CA du système (Let's Encrypt, DigiCert et autres autorités publiques). Si mon backend expose un certificat signé par une CA publique — typiquement un service managé ou un endpoint SaaS — c'est l'option la plus simple : rien à gérer, rien à distribuer.
  • caCertificateRefs : le gateway charge la (ou les) CA depuis un ConfigMap que je fournis dans le namespace de la BackendTLSPolicy. C'est le mode obligatoire dès qu'on a une CA privée : certificat auto-signé, PKI maison, etc.

C'est ce deuxième cas qui motive cet article : un service interne avec certificat auto-signé, qu'on veut consommer en TLS validé. Si le backend utilise un certificat public, wellKnownCACertificates: System et c'est plié — pas besoin de trust-manager.

caCertificateRefs : Secret ou ConfigMap ?

Avec Envoy Gateway, on pourrait très bien utiliser un Secret comme caCertificateRefs, et donc éviter en partie la suite de cet article (merci @Shinro pour la remarque, cf issue #2777). Mais c'est un support Implementation-Specific au sens de la spec Gateway API — Envoy Gateway le permet, d'autres implémentations non.

Cependant, ça ne résoudrait pas un problème de rotation de ca.crt, par exemple. Continuons alors la lecture ! 😉

Trois tentatives pour définir la CA dans un ConfigMap

Parmi mes expérimentations, j'ai noté 3 possibilités permettant d'avoir la CA du certificat auto-signé de mon application.

1. La recopie manuelle du ca.crt

La méthode "quick & dirty" : récupérer le ca.crt du Secret source, et le re-créer en ConfigMap à la main :

$ kubectl -n mysecurens get secret mysecureapp-internal-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > ca.crt
$ kubectl -n mysecurens create configmap mysecureapp-ca --from-file=ca.crt

Ça marche... jusqu'à la première rotation de la CA : il faut repasser à la main, régénérer, recommiter, rolling-restart. Et si on oublie, c'est BackendTLSPolicy rouge et erreur 503 à la clé. ❌

Cette solution simple ne faisait que décaler le problème pour mon moi futur 😬.

2. Synchro automatique avec Kyverno

Pour automatiser, j'ai pensé à Kyverno avec une GeneratingPolicy qui prend le Secret source et génère le ConfigMap cible :

apiVersion: policies.kyverno.io/v1alpha1
kind: GeneratingPolicy
metadata:
  name: sync-ca-to-configmap
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        resources: ["secrets"]
        operations: ["CREATE", "UPDATE"]
        resourceNames: ["mysecureapp-internal-tls"]
  generate:
    - expression: |
        [...]

Sur le papier, ça paraît simple : le mode synchronisé était censé garder le ConfigMap aligné sur le Secret à chaque update. En vrai, chaque UPDATE du secret source supprimait le ConfigMap au lieu de le mettre à jour, avant de le recréer. Concrètement : une BackendTLSPolicy qui flappe à chaque renouvellement, donc pire qu'avant. ❌

Étant sur une version de kyverno assez "vieille" (1.15) avec une CR v1alpha1, je suspecte un problème de stabilité / maturité, sur les dernières versions en v1 j'espère que ça devrait aller mieux.

Et donc comme pour le point précédent, ça pourrait le faire pour mon moi du futur qui fera la maj kyverno 😅.

3. trust-manager : le bon outil qui juste marche

Et puis je suis tombé sur trust-manager, l'outil officiel de l'écosystème cert-manager, dont le seul boulot est exactement ce qu'on cherche : prendre une CA en source et la distribuer sous forme de ConfigMap dans les namespaces ciblés, avec sync automatique en cas de rotation.

C'est cette troisième approche qu'on va retenir pour la suite.

La solution : cert-manager + trust-manager

L'idée : émettre tous les certificats internes du cluster avec une CA interne gérée par cert-manager, et distribuer le ca.crt dans les namespaces qui en ont besoin avec trust-manager.

Plutôt qu'une CA par appli (chacune dans son ns, à répliquer cross-namespace), on part sur une CA unique au niveau du cluster, qui signe tous les certificats feuilles internes. C'est un pattern PKI privée classique : une seule racine de confiance pour tout le trafic interne du cluster.

1. Une CA interne avec cert-manager

On bootstrap avec un ClusterIssuer self-signed, qui ne sert qu'à générer la CA racine, puis on déclare un ClusterIssuer qui signera tous les certificats feuilles :

# ClusterIssuer self-signed
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned
spec:
  selfSigned: {}
# CA Interne
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: internal-ca
  namespace: cert-manager
spec:
  isCA: true # On définit comme CA interne / privée
  commonName: internal-ca
  secretName: internal-ca-tls
  duration: 87600h0m0s # 10 ans
  renewBefore: 2160h0m0s # 90 jours
  privateKey:
    algorithm: ECDSA
    size: 256
    rotationPolicy: Always
  issuerRef:
    name: selfsigned
    kind: ClusterIssuer
# ClusterIssuer utilisant la CA interne
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal-ca-issuer
spec:
  ca:
    secretName: internal-ca-tls

À partir de là, internal-ca-issuer peut signer le certificat feuille de mysecureapp (et de tous les autres services internes du cluster). Côté mysecureapp, on remplace donc le certificat auto-signé par un certificat signé par cette CA interne :

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: mysecureapp-internal-tls
  namespace: mysecurens
spec:
  secretName: mysecureapp-internal-tls
  duration: 2160h0m0s # 90 jours
  renewBefore: 360h0m0s # 15 jours
  privateKey:
    algorithm: ECDSA
    size: 256
    rotationPolicy: Always
  dnsNames:
    # Nom DNS interne pour joindre le service
    - mysecureapp.mysecurens.svc.cluster.local
    - mysecureapp.mysecurens.svc
    - mysecureapp
  issuerRef:
    name: internal-ca-issuer
    kind: ClusterIssuer

⚠️ Point d'attention : le dnsNames doit contenir le hostname validé par la BackendTLSPolicy (ici mysecureapp.mysecurens.svc.cluster.local). Sinon Envoy rejette le certificat avec une erreur de SAN, même si la chaîne de confiance est parfaitement valide.

2. Distribuer le ca.crt avec trust-manager

trust-manager s'installe via Helm, dans le même namespace que cert-manager par défaut :

$ helm upgrade --install trust-manager jetstack/trust-manager \
    --namespace cert-manager \
    --wait

⚠️ Remarque : trust-manager lit ses sources dans un seul namespace, défini par --app.trust.namespace (par défaut cert-manager, donc pas besoin de le spécifier). C'est pour ça qu'on a émis internal-ca directement dans cert-manager plutôt que de chercher à répliquer le secret depuis ailleurs.

Une fois trust-manager en place, on déclare un Bundle : il prend une source (notre secret internal-ca-tls) et synchronise un ConfigMap cible dans tous les namespaces qui matchent un selector :

apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
  name: internal-ca
spec:
  sources:
    - secret:
        name: internal-ca-tls
        key: ca.crt
  target:
    configMap:
      key: ca.crt
    namespaceSelector:
      matchLabels:
        trust-ca: "true"

Pour qu'un namespace récupère la CA, il suffit de le tagger :

$ kubectl label namespace mysecurens trust-ca=true

Et c'est tout. Le ConfigMap internal-ca (clé ca.crt) apparaît automatiquement dans mysecurens :

$ kubectl -n mysecurens get cm internal-ca
NAME          DATA   AGE
internal-ca   1      42m

Ce ConfigMap est ensuite référencé par la BackendTLSPolicy (cf. plus haut). Le jour où la CA tourne, trust-manager propage le nouveau ca.crt dans tous les namespaces consommateurs, sans rien à toucher.

3. Bonus : un ConfigMap qui ne bouge pas, et c'est tant mieux 🎉

Un effet de bord plutôt sympa de cette archi : tant que la CA ne tourne pas (soit dans 10 ans 😅), le ConfigMap internal-ca est strictement immuable. Les certificats feuilles de chaque appli, eux, peuvent tourner toutes les 90 jours sans que la BackendTLSPolicy ni le ConfigMap ne bougent — la chaîne de confiance reste valide tant que la racine ne change pas.

Conclusion

Avec la migration Gateway API, et malgré la perte du SkipVerify, on y gagne au final sur plusieurs plans :

  • 🔒 Du TLS de bout en bout, avec validation de la chaîne, pas du verify: off qui traîne dans une annotation
  • 🧹 Une CA centralisée, des certificats feuilles auto-renouvelés, et un ca.crt qui se diffuse tout seul
  • 📦 Tout en déclaratif Kubernetes, versionné, sans vieilles annotations 🙈 (on prend goût à ne plus gérer d'annotations tordues)

Le ticket d'entrée est juste un poil plus élevé qu'une annotation nginx, mais le résultat est nettement plus solide. Et une fois la PKI interne en place, ajouter un nouveau backend HTTPS interne devient très simple : un Certificate, un label sur le namespace, une BackendTLSPolicy, et zou !

Ressources

© Gravitek. Tous droits réservés.

logo

Gravitek est une Société à taille humaine, guidée par la qualité de service et la construction d'une relation durable avec ses clients.