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
- Le chart du proxy déploie une passerelle d'agents (
agentGateway), même image, rôlegateway: elle tient les tunnels, l'enrôlement et la CA des agents. - 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 seulProxyKubeApi. - 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).
- Le proxy joint le pod de la passerelle qui tient le tunnel par un
CONNECTinterne 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-tlsL'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).
| Value | Défaut | Description |
|---|---|---|
agentGateway.limits.tunnelsPerAgentIdentity | 4 | Tunnels d'un même cluster par pod de la passerelle |
agentGateway.limits.streamsPerTunnel | 128 | Flux simultanés par tunnel (32 Mio de fenêtre au pire) |
agentGateway.limits.maxStreamsPerTunnel | 512 | Plafond d'une surcharge par cluster |
agentGateway.limits.handshakeTimeoutSeconds | 10 | Délai de la poignée de main TLS et du contrôle |
agentGateway.limits.enrollPerMinute | 10 | Enrôlements par adresse cliente (TRUSTED_PROXY_COUNT derrière un Ingress) |
agentGateway.limits.enrollPerMinuteTotal | 120 | Enrôlements de tous les agents |
agentGateway.metricsPort | 9464 | Mode separate, avec metrics.prometheus.enabled : le /metrics de la passerelle |
agentGateway.podDisruptionBudget.* | activé, minAvailable: 1 | PDB 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-passthroughLe 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 } # ≤ maxStreamsPerTunneljwks_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 ceProxyKubeApiet l'usageclient 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. epochne 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 quemax_cert_lifetime_hoursest 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.pemLe 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:1080http:// 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":["…"]}ReadyestFalseavec la raisonAgentNoTunneltant qu'aucun pod de la passerelle ne tient de tunnel ; le relais répond alors503 agent unavailable, jamais une connexion directe.caFingerprintsest le SHA-256 dukube-root-ca.crtvu par l'agent : comparez-le à votrecert.- Chaque ligne d'audit d'une requête porte
upstream_via: agent; la passerelle écritagent_enrolled,agent_refused(avec la raison),agent_connectedetagent_disconnected(Sécurité). - L'agent sert
/healthz,/readyz(un tunnel ouvert) et/metricssur: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 dansproxyauth_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_refusedraisonprotocol, avec la version de l'agent),Readypasse àFalseraisonAgentProtocolUnsupported,status.agent.refuseddonne la plage de l'agent, et l'agent attend 60 s avant de réessayer (reconnects_total{reason="protocol"}) ; son journalerrornomme les deux plages (agent_protocols,gateway_protocols,0-0pour 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 EventNormal) le nomme, mais le tunnel sert : seul le protocole bloque. Une version qui n'est pas un SemVer est affichéeinvalid. - 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.