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 :

 1apiVersion: networking.k8s.io/v1
 2kind: Ingress
 3metadata:
 4  name: mysecureapp
 5  namespace: mysecurens
 6  annotations:
 7    nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
 8    [...]
 9spec:
10  ingressClassName: nginx
11  rules:
12    - host: mysecureapp.gravitek.io
13      http:
14        paths:
15          - path: /
16            pathType: Prefix
17            backend:
18              service:
19                name: mysecureapp
20                port:
21                  number: 8443
22  tls:
23    - hosts:
24        - mysecureapp.gravitek.io
25      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 :

 1# HTTPRoute
 2apiVersion: gateway.networking.k8s.io/v1
 3kind: HTTPRoute
 4metadata:
 5  name: mysecureapp
 6  namespace: mysecurens
 7spec:
 8  hostnames:
 9    - mysecureapp.gravitek.io
10  parentRefs:
11    - kind: Gateway
12      name: eg # Nom de ma gateway
13      namespace: mysecurens # Pour l'exemple on a la GW dans le même NS que l'appli
14      sectionName: mysecureapp-https # Section de Gateway qui écoute en HTTPS
15  rules:
16    - backendRefs:
17        - kind: Service
18          name: mysecureapp
19          port: 8443
20      matches:
21        - path:
22            type: PathPrefix
23            value: /
 1# BackendTLSPolicy
 2apiVersion: gateway.networking.k8s.io/v1
 3kind: BackendTLSPolicy
 4metadata:
 5  name: mysecureapp-backend-tls
 6  namespace: mysecurens
 7spec:
 8  targetRefs:
 9    - kind: Service
10      name: mysecureapp
11  validation:
12    hostname: mysecureapp.mysecurens.svc.cluster.local
13    caCertificateRefs:
14      - kind: ConfigMap
15        name: internal-ca # on va voir cela après ;)

Côté flux, ça donne :

Workflow Gateway API : du client au pod

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 :

1$ kubectl -n mysecurens get secret mysecureapp-internal-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > ca.crt
2$ 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 :

 1apiVersion: policies.kyverno.io/v1alpha1
 2kind: GeneratingPolicy
 3metadata:
 4  name: sync-ca-to-configmap
 5spec:
 6  matchConstraints:
 7    resourceRules:
 8      - apiGroups: [""]
 9        apiVersions: ["v1"]
10        resources: ["secrets"]
11        operations: ["CREATE", "UPDATE"]
12        resourceNames: ["mysecureapp-internal-tls"]
13  generate:
14    - expression: |
15        [...]

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.

Architecture PKI interne avec cert-manager et trust-manager

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 :

1# ClusterIssuer self-signed
2apiVersion: cert-manager.io/v1
3kind: ClusterIssuer
4metadata:
5  name: selfsigned
6spec:
7  selfSigned: {}
 1# CA Interne
 2apiVersion: cert-manager.io/v1
 3kind: Certificate
 4metadata:
 5  name: internal-ca
 6  namespace: cert-manager
 7spec:
 8  isCA: true # On définit comme CA interne / privée
 9  commonName: internal-ca
10  secretName: internal-ca-tls
11  duration: 87600h0m0s # 10 ans
12  renewBefore: 2160h0m0s # 90 jours
13  privateKey:
14    algorithm: ECDSA
15    size: 256
16    rotationPolicy: Always
17  issuerRef:
18    name: selfsigned
19    kind: ClusterIssuer
1# ClusterIssuer utilisant la CA interne
2apiVersion: cert-manager.io/v1
3kind: ClusterIssuer
4metadata:
5  name: internal-ca-issuer
6spec:
7  ca:
8    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 :

 1apiVersion: cert-manager.io/v1
 2kind: Certificate
 3metadata:
 4  name: mysecureapp-internal-tls
 5  namespace: mysecurens
 6spec:
 7  secretName: mysecureapp-internal-tls
 8  duration: 2160h0m0s # 90 jours
 9  renewBefore: 360h0m0s # 15 jours
10  privateKey:
11    algorithm: ECDSA
12    size: 256
13    rotationPolicy: Always
14  dnsNames:
15    # Nom DNS interne pour joindre le service
16    - mysecureapp.mysecurens.svc.cluster.local
17    - mysecureapp.mysecurens.svc
18    - mysecureapp
19  issuerRef:
20    name: internal-ca-issuer
21    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 :

1$ helm upgrade --install trust-manager jetstack/trust-manager \
2    --namespace cert-manager \
3    --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 :

 1apiVersion: trust.cert-manager.io/v1alpha1
 2kind: Bundle
 3metadata:
 4  name: internal-ca
 5spec:
 6  sources:
 7    - secret:
 8        name: internal-ca-tls
 9        key: ca.crt
10  target:
11    configMap:
12      key: ca.crt
13    namespaceSelector:
14      matchLabels:
15        trust-ca: "true"

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

1$ kubectl label namespace mysecurens trust-ca=true

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

1$ kubectl -n mysecurens get cm internal-ca
2NAME          DATA   AGE
3internal-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