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 :
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 unConfigMapque je fournis dans le namespace de laBackendTLSPolicy. 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.
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: offqui traîne dans une annotation - 🧹 Une CA centralisée, des certificats feuilles auto-renouvelés, et un
ca.crtqui 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
- Gateway API - BackendTLSPolicy : gateway-api.sigs.k8s.io/api-types/backendtlspolicy
- cert-manager : cert-manager.io
- trust-manager : cert-manager.io/docs/trust/trust-manager
- Envoy Gateway : gateway.envoyproxy.io
