ProxyAuthK8S

Cluster derrière un agent

Proxifier un cluster que le proxy ne peut pas joindre, grâce à un agent qui ouvre le tunnel depuis le cluster

Un cluster derrière un NAT, un pare-feu ou un réseau privé n'accepte aucune connexion entrante : le proxy ne peut pas joindre son apiserver. Avec service: Agent, c'est un agent, installé sur ce cluster, qui ouvre un tunnel sortant vers le proxy ; les requêtes des utilisateurs passent ensuite dans ce tunnel (RFC 0012).

Le TLS vers l'apiserver est négocié entre le proxy et l'apiserver, de bout en bout : l'agent relaie des enregistrements TLS qu'il ne peut ni lire ni modifier, et ne connaît qu'une destination, son apiserver. Un agent compromis ne voit ni les jetons ni les réponses.

Comment ça marche

  1. Le chart du proxy déploie une passerelle d'agents (agentGateway), même image, rôle gateway : elle tient les tunnels, l'enrôlement et la CA des agents.
  2. L'agent s'enrôle : il présente un jeton de ServiceAccount projeté de son cluster, vérifié contre les clés publiques de ce cluster déclarées dans le ProxyKubeApi, et reçoit un certificat de 24 h pour ce seul ProxyKubeApi.
  3. Il ouvre un WebSocket vers la passerelle (à travers votre Ingress), puis un TLS mutuel sous la CA des agents à l'intérieur, et y multiplexe les flux (yamux).
  4. Le proxy joint le pod de la passerelle qui tient le tunnel par un CONNECT interne au cluster du proxy, et fait son TLS vers l'apiserver à travers lui.

1. Activer la passerelle

Dans les values du chart du proxy :

agentGateway:
  enabled: true
  mode: separate          # ou embedded : dans les pods du back
  replicaCount: 2
  ingress:
    enabled: true
    host: agent.proxy.example.com
    tls:
      enabled: true
      secretName: proxyauthk8s-agent-tls

L'Ingress ne route que /agent/v1/. Le port connectPort (8445) n'est jamais exposé : la NetworkPolicy (networkPolicy.enabled) ne l'ouvre qu'aux pods du back, et chaque CONNECT porte un jeton généré par le chart (agentGateway.tokenSecretName pour fournir le vôtre).

ValueDéfautDescription
agentGateway.limits.tunnelsPerAgentIdentity4Tunnels d'un même cluster par pod de la passerelle
agentGateway.limits.streamsPerTunnel128Flux simultanés par tunnel (32 Mio de fenêtre au pire)
agentGateway.limits.maxStreamsPerTunnel512Plafond d'une surcharge par cluster
agentGateway.limits.handshakeTimeoutSeconds10Délai de la poignée de main TLS et du contrôle
agentGateway.limits.enrollPerMinute10Enrôlements par adresse cliente (TRUSTED_PROXY_COUNT derrière un Ingress)
agentGateway.limits.enrollPerMinuteTotal120Enrôlements de tous les agents
agentGateway.metricsPort9464Mode separate, avec metrics.prometheus.enabled : le /metrics de la passerelle
agentGateway.podDisruptionBudget.*activé, minAvailable: 1PDB des pods de la passerelle ; maxUnavailable: 1 avec un seul pod au minimum, voir Disponibilité
agentGateway.autoscaling.*désactivéHPA (minReplicas, maxReplicas, targetCPUUtilizationPercentage, scaleDownStabilizationSeconds: 600, scaleDownPods: 1, scaleDownPeriodSeconds: 300) ; chaque pod retiré ferme ses tunnels, d'où une réduction lente. Les scaleDown* s'appliquent aussi à back.autoscaling en mode embedded. Argo CD : voir Disponibilité

TLS de bout en bout jusqu'à la passerelle (passthrough)

Par défaut (tlsMode: inner), l'Ingress termine le TLS et l'agent ouvre un second TLS, mutuel, dans le WebSocket. Si votre Ingress ou votre load balancer sait router sur le SNI sans déchiffrer, passthrough retire cette couche : la passerelle termine elle-même le TLS avec votre certificat, et y lit le certificat client de l'agent.

agentGateway:
  tlsMode: passthrough
  tls:
    secretName: agent-proxy-tls     # tls.crt / tls.key pour agent.proxy.example.com
  ingress:
    enabled: true
    host: agent.proxy.example.com
    annotations:
      nginx.ingress.kubernetes.io/ssl-passthrough: "true"   # ingress-nginx, --enable-ssl-passthrough

Le Secret est relu chaque jour : un renouvellement par cert-manager est pris sans redémarrage. L'Ingress rendu n'a alors pas de section tls. Avec Traefik, déclarez un IngressRouteTCP (tls.passthrough: true) vers le Service <release>-proxyauthk8s-gateway à la place. L'agent doit être installé avec le même mode (--set tlsMode=passthrough), et proxy.caBundle si ce certificat vient d'une PKI privée. L'enrôlement passe sans certificat client ; /agent/v1/connect en exige un, émis par la CA des agents (401 sinon).

2. Déclarer le cluster

Le JWKS du cluster distant se lit avec kubectl get --raw /openid/v1/jwks sur ce cluster ; il est public, rien de secret n'entre dans git. Sa CA est obligatoire : SystemRoots est refusé, il accepterait n'importe quel certificat public présenté par l'agent.

apiVersion: weebo.si.rs/v1
kind: ProxyKubeApi
metadata:
  name: prod
  namespace: team-a
spec:
  cert:
    ConfigMap: { name: prod-ca, key: ca.crt }   # la CA de l'apiserver distant
  service:
    Agent:
      server_name: kubernetes.default.svc       # le nom de son certificat
      bootstrap:
        ServiceAccountToken:
          subject: system:serviceaccount:proxyauth-agent:agent-proxyauthk8s-agent
          audience: proxyauth
          jwks: '{"keys":[…]}'                  # ou jwks_url (https), ou jwks_from
      # limits: { streams_per_tunnel: 384 }     # ≤ maxStreamsPerTunnel

jwks_from lit le JWKS dans un ConfigMap ou un Secret à chaque enrôlement : une rotation de clé du cluster distant passe sans toucher au ProxyKubeApi (gardez l'ancienne et la nouvelle clé le temps de la rotation). Un JWKS vide ou illisible refuse l'enrôlement.

jwks_url est lu sans suivre de redirection, en 5 s au plus, et refusé au-delà de PROXY_JWKS_MAX_DOCUMENT_BYTES (64 Kio par défaut, la même borne que pour les émetteurs des JWTAuthenticator, voir Sécurité).

Pour que ce ConfigMap suive les rotations sans intervention, le chart de l'agent peut publier le JWKS lui-même : --set jwksPublisher.enabled=true installe un CronJob (toutes les 15 min, jwksPublisher.schedule) qui copie /openid/v1/jwks du cluster distant dans le ConfigMap proxyauth-agent-jwks (clé jwks.json). Il tourne sous son propre ServiceAccount, avec get et update sur ce seul ConfigMap ; l'agent, lui, reste sans RBAC. Un PushSecret d'External Secrets le porte vers Vault, un ExternalSecret le recrée côté proxy sous le nom que lit jwks_from. Un JWKS sans clé n'est jamais publié.

Sans JWKS : un jeton à usage unique (OneShotToken)

Quand les clés du cluster distant ne sont pas lisibles, le contrôleur du proxy émet un jeton à usage unique, valable une heure, dans le Secret <name>-agent-bootstrap (clé token) à côté du ProxyKubeApi. Activez d'abord agentGateway.oneShotToken.enabled dans le chart du proxy : le contrôleur reçoit create et patch sur les Secrets (par namespace avec rbac.referencedNamespaces).

    Agent:
      bootstrap:
        OneShotToken:
          auto_rotate: true   # un nouveau jeton dès que le précédent est consommé ou expiré

Portez ce Secret sur le cluster distant, à la main ou par un PushSecret d'External Secrets vers Vault et un ExternalSecret en face. Le premier replica de l'agent consomme le jeton et écrit sa clé et son certificat dans le Secret identitySecret de son namespace ; les autres, et tout pod redémarré, les relisent sans consommer de jeton. Sans auto_rotate, un jeton consommé reste dans le Secret : le ProxyKubeApi porte l'avis AgentBootstrapNeeded tant qu'aucun agent n'est connecté, et une nouvelle valeur de l'annotation proxyauth.weebo.si.rs/regenerate-bootstrap en fait émettre un autre. Le jeton n'est jamais gardé en clair côté proxy : seule son empreinte est dans Redis, prise une seule fois.

Votre propre PKI : ExternalCA

Si vos certificats viennent déjà d'un émetteur à vous (cert-manager, Vault PKI), l'agent ne s'enrôle pas : il présente un certificat de cet émetteur, et la passerelle le vérifie contre la CA que nomme le ProxyKubeApi.

    Agent:
      bootstrap:
        ExternalCA:
          ca_ref: { ConfigMap: { name: vault-agent-ca, key: ca.crt } }   # ou ca: '<PEM>' en ligne
          max_cert_lifetime_hours: 72                                    # défaut 168 (7 j)
  • Le certificat doit porter l'URI SAN proxyauth://<namespace>/<nom> de ce ProxyKubeApi et l'usage client auth. Une même CA peut servir plusieurs clusters : le SAN dit lequel, et un certificat qui en nomme un autre n'ouvre rien pour celui-ci.
  • epoch ne révoque pas un certificat que le proxy n'a pas émis : le monter ferme les tunnels ouverts, mais l'agent revient avec le même certificat. Révoquez chez l'émetteur et gardez des certificats courts : un certificat plus long que max_cert_lifetime_hours est refusé, et un tunnel se ferme à l'expiration de son certificat. L'agent reprend alors le certificat renouvelé.
  • Ni enrôlement, ni renouvellement par la passerelle pour ce cluster.

3. Installer l'agent

Sur le cluster distant, avec deploy/chart-agent (publié en oci://ghcr.io/batleforc/proxyauthk8s/chart-agent) :

helm install agent oci://ghcr.io/batleforc/proxyauthk8s/chart-agent \
  -n proxyauth-agent --create-namespace \
  --set proxy.url=wss://agent.proxy.example.com/agent/v1/connect \
  --set proxy.cluster=team-a/prod
# PKI privée pour l'URL du proxy : --set-file proxy.caBundle=ca.pem

Le nom du release fixe celui du ServiceAccount (<release>-proxyauthk8s-agent), c'est-à-dire le subject du ProxyKubeApi. L'agent n'a aucun RBAC et aucun jeton d'API monté : seul son jeton projeté (audience bootstrap.audience) sert, à l'enrôlement.

En OneShotToken :

helm install agent … \
  --set bootstrap.method=OneShotToken \
  --set bootstrap.tokenSecret=prod-agent-bootstrap   # Secret local, clé `token`

Le chart crée vide le Secret identitySecret (proxyauth-agent-identity) ; le seul droit de l'agent est get et update sur ce Secret, par son nom. Pour révoquer, montez epoch : l'agent vide ce Secret, et il faudra un nouveau jeton.

En ExternalCA, avec cert-manager :

helm install agent … \
  --set bootstrap.method=ExternalCA \
  --set bootstrap.certificate.issuerRef.name=vault-issuer \
  --set bootstrap.certificate.issuerRef.kind=ClusterIssuer \
  --set-file tunnelCa=agent-ca.crt   # tlsMode inner : la CA des agents du proxy (publique)

Avec un issuerRef, le chart écrit lui-même la Certificate (URI SAN, client auth, 72 h, renouvelée 24 h avant la fin) dans le Secret bootstrap.certificate.secretName ; sans, fournissez ce Secret TLS vous-même. En tlsMode: inner, le TLS du tunnel est ancré sur la CA des agents du proxy, tunnelCa : le ca.crt de son Secret proxy-auth-k8s-agent-ca. En passthrough, elle ne sert pas. L'agent n'a ni RBAC ni jeton projeté.

Sortir par un proxy d'entreprise

Si le cluster distant ne sort sur Internet que par un proxy, l'agent passe par lui, pour l'enrôlement comme pour le tunnel ; le proxy sortant ne voit qu'un flux TLS vers la passerelle.

kubectl -n proxyauth-agent create secret generic egress-proxy \
  --from-literal=username=agent --from-literal=password='…'
helm upgrade agent … \
  --set egress.proxy.url=http://squid.corp:3128 \
  --set egress.proxy.credentialsSecret=egress-proxy
# SOCKS5, noms résolus par le proxy : --set egress.proxy.url=socks5h://socks.corp:1080

http:// envoie un CONNECT (Proxy-Authorization: Basic), socks5h:// un SOCKS5 avec authentification nom / mot de passe (socks5:// résout le nom localement). Sans egress.proxy.url, l'agent lit HTTPS_PROXY, ALL_PROXY et NO_PROXY (valeurs env) ; sans elles, il sort en direct. Avec networkPolicy.enabled, ajoutez le proxy sortant à egressTo et son port à egressPorts. Un overlay WireGuard ou Netbird n'a besoin d'aucun de ces réglages : seulement de proxy.caBundle si l'URL du proxy a une PKI privée.

Vérifier

kubectl -n team-a get proxykubeapi prod -o jsonpath='{.status.agent}'
# {"tunnels":2,"gateways":["…-gateway-…"],"versions":["0.4.0"],"caFingerprints":["…"]}
  • Ready est False avec la raison AgentNoTunnel tant qu'aucun pod de la passerelle ne tient de tunnel ; le relais répond alors 503 agent unavailable, jamais une connexion directe.
  • caFingerprints est le SHA-256 du kube-root-ca.crt vu par l'agent : comparez-le à votre cert.
  • Chaque ligne d'audit d'une requête porte upstream_via: agent ; la passerelle écrit agent_enrolled, agent_refused (avec la raison), agent_connected et agent_disconnected (Sécurité).
  • L'agent sert /healthz, /readyz (un tunnel ouvert) et /metrics sur :9090 (proxyauth_agent_tunnel_up, proxyauth_agent_reconnects_total{reason}, proxyauth_agent_cert_expiry_seconds, proxyauth_agent_build_info{version,protocol_min,protocol_max}). La passerelle compte ses refus dans proxyauth_agent_tunnel_refusals_total{reason}.

Versions et compatibilité

L'agent tourne dans le cluster distant et se met à jour à son rythme : le proxy dit lesquels il sert et ce qu'il en pense (RFC 0013).

$ kubectl -n team-a get proxykubeapi prod -o wide
NAME   READY   REASON       AGE   TUNNELS   AGENTS
prod   True    Reconciled   3d    2         ["0.4.0"]
$ kubectl -n team-a get proxykubeapi prod -o jsonpath='{.status.agent}'
{"tunnels":2,…,"proxyVersion":"0.4.1","agents":[{"version":"0.4.0","protocol":1,"tunnels":2}]}
  • Protocole : à l'ouverture du tunnel, agent et passerelle annoncent chacun les protocoles qu'ils parlent et gardent le plus haut des deux. Sans protocole commun, la passerelle refuse le tunnel (agent_refused raison protocol, avec la version de l'agent), Ready passe à False raison AgentProtocolUnsupported, status.agent.refused donne la plage de l'agent, et l'agent attend 60 s avant de réessayer (reconnects_total{reason="protocol"}) ; son journal error nomme les deux plages (agent_protocols, gateway_protocols, 0-0 pour une passerelle 0.4.0). Mettez à jour celui des deux qui est en retard ; le refus disparaît 5 min après la dernière tentative.
  • Versions : un agent est attendu au plus une mineure derrière le proxy, jamais devant ; le patch ne compte pas. Au-delà, l'avis AgentVersionSkew (condition et Event Normal) le nomme, mais le tunnel sert : seul le protocole bloque. Une version qui n'est pas un SemVer est affichée invalid.
  • Alerte : le chart n'en livre pas ; par exemple increase(proxyauth_agent_tunnel_refusals_total{reason="protocol"}[15m]) > 0.

Révoquer

Augmentez spec.service.Agent.epoch : les tunnels des certificats émis avant tombent en moins de 30 s, et l'agent doit se réenrôler avec un jeton valide. epoch ne peut pas baisser (règle d'admission). Supprimer le ProxyKubeApi ferme aussi ses tunnels.

La clé de la CA des agents est dans le Secret proxy-auth-k8s-agent-ca, lisible par la seule passerelle. Sans rbac.referencedNamespaces, le back garde un get sur tous les Secrets du cluster, celui-ci compris : listez vos namespaces pour que la clé reste à la passerelle.

On this page