On reprend les articles sur Envoy Gateway ! Comment faire quand on accède directement à l'IP publique de notre cluster porté par une Gateway, ou qu'aucune HTTPRoute ne matche ? On va voir dans cet article comment servir une page de maintenance, la servir en HTTPS sur l'IP elle-même, grâce aux certificats Let's Encrypt sur adresse IP et au profil shortlived.

Imaginons une Envoy Gateway exposée sur l'IP publique 203.0.113.14, et une application maintenance (le service maintenance dans le namespace maintenance, sur le port 8080) qui sert une simple page "site en maintenance". On va voir comment servir cette page par défaut, en HTTPS sur l'IP publique elle-même.

⚠️ Je ne détaille pas l'installation d'Envoy Gateway ni de cert-manager (tous deux via Helm), ni non plus le déploiement de l'app de maintenance (simple page statique), qu'on suppose déjà en place. Nous allons nous concentrer sur le routing par défaut et le certificat sur IP.

Une réponse HTTP(S) par défaut "moche"

Quand on déclare des HTTPRoute avec des hostnames bien précis (app1.gravitek.io, app2.gravitek.io, etc.), tout le reste, qui ne correspond à aucune route (typiquement un curl https://203.0.113.14/, un scan, ou un DNS pas encore basculé) tombe dans le vide. Avec en prime une erreur de certificat TLS.

 1$ curl https://203.0.113.14/
 2curl: (60) SSL certificate problem: unable to get local issuer certificate
 3More details here: https://curl.se/docs/sslcerts.html
 4
 5curl failed to verify the legitimacy of the server and therefore could not
 6establish a secure connection to it. To learn more about this situation and
 7how to fix it, please visit the web page mentioned above.
 8
 9$ curl -k https://203.0.113.14/
10404 page not found

On va voir ci-après comment résoudre 2 problèmes d'un coup :

  1. Un backend par défaut : tout ce qui ne matche aucune route applicative atterrit sur une page de maintenance propre.
  2. Du HTTPS même sur l'IP : plus de gros warning de certificat invalide quand on accède à l'IP directement. Historiquement, impossible avec Let's Encrypt (que des domaines)... mais ça a changé 🎉.

Pour le point 2, ça résoud notamment un "problème" quand le mail du RSSI arrive : "Bonjour, votre IP 203.0.113.14 présente un certificat invalide, merci de corriger 🙏". Jusqu'à présent je lui répondais qu'on ne pouvait pas avoir un certificat sur une IP, mais c'est maintenant possible 😅.

Pré-requis : configuration des composants

Pour activer le support de la Gateway API & du Backend par défaut, deux paramètres doivent être activés dans les values des différents charts (si vous utilisez Helm, sinon je vous laisse chercher les équivalents niveau configuration) :

cert-manager : support de la Gateway API

cert-manager doit savoir résoudre un challenge ACME via une HTTPRoute (et non un Ingress). C'est l'option enableGatewayAPI :

1# custom-values-cert-manager.yaml
2cert-manager:
3  # CRDs
4  crds:
5    enabled: true
6
7  # Enable Gateway API Support
8  config:
9    enableGatewayAPI: true

Sans ça, le solver http01 de type gatewayHTTPRoute (qu'on verra plus bas) est tout simplement ignoré.

Envoy Gateway : activer l'API Backend

Pour router vers un endpoint qui n'est pas un Service Kubernetes (ici une app dans un autre namespace, qu'on veut joindre par son FQDN interne), Envoy Gateway propose une ressource Backend. C'est une extension API, on l'active :

1# custom-values-envoy.yaml
2# https://gateway.envoyproxy.io/v1.7/install/install-helm/#enable-backend-api
3config:
4  envoyGateway:
5    extensionApis:
6      enableBackend: true

La Gateway et son IP publique

Côté GatewayClass, rien de particulier : une classe standard rattachée au contrôleur Envoy Gateway.

1# gatewayclass-eg.yaml
2apiVersion: gateway.networking.k8s.io/v1
3kind: GatewayClass
4metadata:
5  name: eg
6spec:
7  controllerName: gateway.envoyproxy.io/gatewayclass-controller

La Gateway elle-même expose deux listeners, HTTP et HTTPS, ce dernier terminant le TLS avec le secret ip-cert-tls, qu'on va générer juste après :

 1# gateway-eg.yaml
 2apiVersion: gateway.networking.k8s.io/v1
 3kind: Gateway
 4metadata:
 5  name: eg
 6  namespace: envoy-gateway-system
 7spec:
 8  gatewayClassName: eg
 9  listeners:
10    - name: http
11      protocol: HTTP
12      port: 80
13      allowedRoutes:
14        namespaces:
15          from: All
16    - name: https
17      protocol: HTTPS
18      port: 443
19      tls:
20        mode: Terminate
21        certificateRefs:
22          - group: ""
23            kind: Secret
24            name: ip-cert-tls # Certificat IP
25      allowedRoutes:
26        namespaces:
27          from: All

Un certificat Let's Encrypt sur une IP

Pendant des années, Let's Encrypt ne signait que des noms de domaine. Taper https://203.0.113.14/ donnait un certificat invalide. Depuis l'annonce de Let's Encrypt sur les certificats pour adresses IP, c'est devenu possible (c'est désormais en GA (janvier 2026)) avec une contrainte : ces certificats ne sont délivrés que via le profil shortlived, des certificats à durée de vie très courte (~6 jours).

Le ClusterIssuer en profil shortlived

cert-manager supporte la sélection de ce profil ACME via le champ profile. On déclare un ClusterIssuer dédié, qui résout le challenge HTTP-01 via une HTTPRoute rattachée à notre gateway :

 1# clusterissuer-letsencrypt-prod-eg-shortlived.yaml
 2apiVersion: cert-manager.io/v1
 3kind: ClusterIssuer
 4metadata:
 5  name: letsencrypt-prod-shortlived
 6spec:
 7  acme:
 8    email: remi@gravitek.io
 9    server: https://acme-v02.api.letsencrypt.org/directory
10    privateKeySecretRef:
11      name: letsencrypt-prod-shortlived
12    profile: shortlived # Profil ACME
13    solvers:
14      - http01:
15          gatewayHTTPRoute: # Ref à ma Gateway
16            parentRefs:
17              - name: eg
18                namespace: envoy-gateway-system
19                group: gateway.networking.k8s.io
20                kind: Gateway

Deux points à noter :

  • profile: shortlived : c'est obligatoire pour obtenir un certificat sur IP. Un profil classique refusera la demande.
  • solvers.http01.gatewayHTTPRoute : cert-manager crée automatiquement une HTTPRoute temporaire sur la gateway eg le temps de résoudre le challenge /.well-known/acme-challenge/.... Pour un cert sur IP, seul HTTP-01 est possible : pas de DNS-01 (pas de zone DNS pour une IP, CQFD 🤷).

Le Certificate qui demande l'IP

Maintenant qu'on a notre Gateway, Route et ClusterIssuer, on peut déclarer notre certificat. Il ressemble à n'importe quel autre, sauf qu'on remplace dnsNames par ipAddresses :

 1# certificate-ip.yaml
 2apiVersion: cert-manager.io/v1
 3kind: Certificate
 4metadata:
 5  name: ip-cert
 6  namespace: envoy-gateway-system
 7spec:
 8  secretName: ip-cert-tls
 9  secretTemplate:
10    labels:
11      x509-certificate-exporter/exclude: "true"
12  issuerRef:
13    name: letsencrypt-prod-shortlived
14    kind: ClusterIssuer
15  ipAddresses:
16    - 203.0.113.14

L'IP doit être l'IP publique de la gateway (celle exposée par le Service LoadBalancer d'Envoy chez moi), et le secretName doit correspondre au certificateRefs du listener HTTPS.

💡 Pourquoi Le label x509-certificate-exporter/exclude: "true" ? Si vous monitorez l'expiration de vos certificats avec x509-certificate-exporter, un certificat qui expire tous les ~6 jours va générer une alerte en permanence. Je décide de le dégager de la supervision.

⚠️ Les certificats shortlived durent quelques jours seulement : cert-manager les renouvelle très fréquemment, attention au rate-limit Let's Encrypt et au solver HTTP-01.

Une fois le certificat émis :

1$ kubectl -n envoy-gateway-system get certificate ip-cert
2NAME         READY   SECRET           AGE
3ip-cert      True    ip-cert-tls      2m

Et curl https://203.0.113.14/ ne hurle plus au certificat invalide 🎉.

Le backend par défaut, mais avec la page de maintenance

On a maintenant un certificat valide pour l'IP, mais on a toujours une page par défaut moche. On va améliorer cela avec un backend par défaut.

La ressource Backend

L'app de maintenance vit dans son propre namespace maintenance. Plutôt que de jouer avec un Service cross-namespace et une ReferenceGrant, on déclare un Backend Envoy Gateway qui pointe vers le FQDN interne du service :

 1# backend-maintenance.yaml
 2apiVersion: gateway.envoyproxy.io/v1alpha1
 3kind: Backend
 4metadata:
 5  name: maintenance
 6  namespace: envoy-gateway-system
 7spec:
 8  endpoints:
 9    - fqdn:
10        hostname: maintenance.maintenance.svc.cluster.local
11        port: 8080

C'est exactement le cas d'usage de l'API Backend qu'on a activée plus haut : un endpoint arbitraire (FQDN, IP...) consommable depuis une HTTPRoute, dans le namespace de la gateway.

La HTTPRoute par défaut (catch-all)

Le "backend par défaut" est simplement une HTTPRoute sans hostnames. Sans hostname déclaré, elle matche tout le trafic du listener HTTPS qui n'est pas capté par une route applicative plus spécifique :

 1# httproute-default-backend.yaml
 2apiVersion: gateway.networking.k8s.io/v1
 3kind: HTTPRoute
 4metadata:
 5  name: default-backend
 6  namespace: envoy-gateway-system
 7spec:
 8  parentRefs:
 9    - group: gateway.networking.k8s.io
10      kind: Gateway
11      name: eg
12      sectionName: https
13  rules:
14    - backendRefs:
15        - group: gateway.envoyproxy.io
16          kind: Backend
17          name: maintenance
18          weight: 1
19      matches:
20        - path:
21            type: PathPrefix
22            value: /

Le backendRefs ne pointe pas un Service mais bien un kind: Backend du groupe gateway.envoyproxy.io. Les HTTPRoute applicatives, elles, ont des hostnames précis et sont donc plus spécifiques : Gateway API les fait gagner sur le catch-all. Notre default-backend ne capte donc que le reste IP directe, host inconnu, DNS pas encore basculé.

⚠️ Je vous invite à lire la doc Envoy Gateway, quelques warnings & restrictions existent.

Redirection "IP" HTTP vers HTTPS

Tant qu'on y est, on évite de servir la maintenance en clair sur le port 80 : on redirige tout HTTP vers HTTPS, là aussi via une route catch-all (sans hostname) attachée au listener http :

 1# httproute-https-redirect.yaml
 2apiVersion: gateway.networking.k8s.io/v1
 3kind: HTTPRoute
 4metadata:
 5  name: http-to-https-redirect
 6  namespace: envoy-gateway-system
 7spec:
 8  parentRefs:
 9    - group: gateway.networking.k8s.io
10      kind: Gateway
11      name: eg
12      sectionName: http
13  rules:
14    - filters:
15        - type: RequestRedirect
16          requestRedirect:
17            scheme: https
18            statusCode: 301
19      matches:
20        - path:
21            type: PathPrefix
22            value: /

⚠️ Attention à l'ordre des opérations avec le challenge ACME : le solver HTTP-01 a besoin de répondre en HTTP sur /.well-known/acme-challenge/. La HTTPRoute temporaire créée par cert-manager est plus spécifique (elle matche ce path précis) que notre redirect catch-all /, donc le challenge passe avant la redirection, mais faites attention on sait jamais.

Le résultat

Au final, n'importe quel accès qui ne tombe pas sur une app connue :

1# Accès direct par IP, en HTTP → redirigé en HTTPS
2$ curl -IL http://203.0.113.14/
3HTTP/1.1 301 Moved Permanently
4location: https://203.0.113.14/
5
6# ... puis servi par la page de maintenance, certificat valide
7$ curl https://203.0.113.14/
8<html><body><h1>Page non trouvée.</h1></body></html>

Plus de 404 page not found moche, plus de warning de certificat : un point d'entrée propre, en HTTPS, même sur l'IP nue.

Voici "en joli" ce que ça peut donner:

Page not found

Conclusion

En assemblant quelques ressources, on obtient un comportement par défaut soigné sur la gateway :

  • 🧭 Un backend par défaut via une HTTPRoute sans hostname + une ressource Backend Envoy Gateway, qui rattrape tout le trafic non routé.
  • 🔒 Du HTTPS valide sur l'IP elle-même, grâce aux certificats Let's Encrypt sur adresse IP et au profil shortlived.
  • 🤖 Le tout auto-renouvelé par cert-manager en solver HTTP-01, même si le certificat est court.

Avec tout ça, notre RSSI est content 😎. Ah, et pour les curieux, je n'ai pas testé en IPv6 !

Ressources