Kloak : injection de secrets en kernel-space via eBPF sur Kubernetes
Quand une application se fait trouer, la première chose qu’un attaquant va faire, c’est d’exfiltrer les données auxquelles il a accès. Des données clientes jusqu’aux tokens d’authentification, les secrets sont la cible numéro 1 (adieu vos PAT Github, vos bots Slack dans les channels #general, vos tokens Mailchimp, OpenAI, etc.). Il faudra révoquer en assumant que l’attaquant a déjà eu accès à ces informations.
Dans la gestion des secrets, on pense à OpenBao, External Secrets Operator, ou à des sidecars qui injectent les secrets ; ça fonctionne, mais l’application récupère quand même en mémoire le contenu du secret (et, de facto, un attaquant aussi).
En faisant ma veille sur GitHub (croyez-le ou non, mais on trouve des pépites sur la page d’accueil), je suis tombé sur un jeune projet qui a attisé ma curiosité : Kloak.
Kloak transparently intercepts outbound TLS traffic in Kubernetes using eBPF uprobes, replacing hashed placeholders with real secrets at the kernel level before encryption. Applications never handle actual credentials, and no sidecars or code changes are required.
Kloak propose une approche radicalement différente : un controller va intercepter le trafic TLS au niveau kernel avec des uprobes eBPF, et remplacer des placeholders par les vrais secrets juste avant le chiffrement. L’application ne voit jamais les vraies valeurs (et c’est justement ça qui va faire la différence en cas de compromission).
Il s’agit d’un projet ayant été open-sourcé (peut-être même qu’il a débuté en avril 2026), autant vous dire que pour l’instant, j’ai l’exclusivité sur ce sujet (mais plus pour très longtemps en voyant la traction qu’il a déjà).
Dans cet article, je vais vous présenter mon PoC de Kloak, les problèmes que j’ai rencontrés, et comment je les ai résolus. Avant ça, revenons déjà à la problématique initiale.
Le problème : votre application connaît vos secrets
Prenons un exemple concret : une application qui appelle une API externe (simulée par httpbin) :
import os
import httpx
api_key = os.environ["API_TOKEN"] # super-secret-bearer-token-12345
response = httpx.get(
"https://httpbin.org/headers",
headers={"Authorization": f"Bearer {api_key}"}
)
Même avec OpenBao, le secret finit en clair dans la mémoire du processus. Si le container est compromis (RCE par exemple), l’attaquant peut lire API_TOKEN directement depuis l’environnement ou la mémoire.
Kloak résout ça en ne donnant jamais le vrai secret à l’application. À la place, l’application reçoit un placeholder de la forme kloak:63KNA74T0FV868SK01KQAHH78 — et l’eBPF remplace ce placeholder par le vrai secret au moment où la donnée passe dans SSL_write, juste avant le chiffrement. httpbin.org reçoit super-secret-bearer-token-12345, le processus ne l’a jamais vu.
C’est là où c’est un peu magique : l’application n’a même pas conscience du secret/token qu’elle utilise.
Côté architecture, Kloak se décompose en deux plans bien distincts.
- Gère les Shadow Secrets, synchronise les eBPF maps.
- Intercepte la création de pods, réécrit les montages via un Mutating Admission Webhook.
Data Plane:
- Ajoute les hooks sur
SSL_writeetcrypto/tls.(*Conn).Write - Gère les DNS kprobes ( qui capturent les réponses DNS pour mapper IP, on en parle un peu après)
Le Controller tourne en DaemonSet. Il surveille les secrets labellés getkloak.io/enabled=true, crée des “Shadow Secrets” (c’est le nom des secrets factices gérés par Kloak) avec des placeholders kloak:<UUID> et synchronise les vraies valeurs dans des eBPF maps en kernel space.
Le Webhook est un Mutating Admission Webhook. Quand un pod démarre, il réécrit automatiquement les montages de secrets pour pointer vers les Shadow Secrets. L’application monte le fichier, lit kloak:Y1R3B718AGD3X4ZC01KQAHQ26, et l’utilise comme si c’était le vrai secret.
Petite limitation : il faut absolument que le point de montage soit un fichier et non une variable d’environnement… Une issue est ouverte, la feature arrivera bientôt !
Au moment où l’application appelle SSL_write avec le placeholder dans le buffer, l’uprobe eBPF intercepte l’appel, vérifie que la destination est autorisée (Si si, j’insiste, on en parle plus bas), et réécrit le placeholder avec la vraie valeur avant que OpenSSL ne chiffre le contenu.
Concernant les Shadow Secrets : vous n’aurez même pas à adapter vos charts Helm ni vos kustomize, c’est le controller qui se charge de les générer et de modifier vos pods pour qu’ils pointent vers les factices (Côté ArgoCD, il faudra juste vous mettre en server-side apply).
Installation
Comme d’habitude, une chart Helm est disponible directement :
helm repo add kloak https://chart.getkloak.io
helm repo update
helm install kloak kloak/kloak -n kloak-system --create-namespace
Utilisation
On part toujours d’un secret préexistant, Kloak ne créera que ses Shadow Secrets. Pour ce faire, on ajoute le label getkloak.io/enabled=true sur le Secret. Les labels getkloak.io/hosts et getkloak.io/port permettent de restreindre les destinations vers lesquelles le secret peut être envoyé.
Forcer la destination n’est pas obligatoire, vous pouvez juste cacher le contenu simplement.
apiVersion: v1
kind: Secret
metadata:
name: mytoken
labels:
getkloak.io/enabled: "true"
getkloak.io/hosts: "httpbin.org"
getkloak.io/port: "443"
stringData:
token: "super-secret-bearer-token-12345"
Dès que ce Secret est appliqué, le Controller Kloak crée automatiquement un Shadow Secret avec un placeholder kloak:<UUID> de la même longueur que les vraies valeurs.
kubectl get secrets -n kloak-test
NAME TYPE DATA AGE
mytoken Opaque 1 4s
mytoken-kloak Opaque 1 4s
Le placeholder est length-matched au caractère près :
# Secret original
kubectl get secret mytoken -o jsonpath='{.data.token}' | base64 -d
super-secret-bearer-token-12345
# Shadow secret
kubectl get secret mytoken-kloak -o jsonpath='{.data.token}' | base64 -d
kloak:Y1R3B718AGD3X4ZC01KQAHQ26
Côté application, il faut activer Kloak sur le namespace (ou directement sur le pod avec le label getkloak.io/enabled=true). Le Webhook se charge ensuite de réécrire le montage du volume automatiquement.
Pour activer Kloak, il suffit de mettre un label sur le namespace ou sur les pods directement :
# Activer Kloak sur le namespace entier
kubectl label namespace kloak-test getkloak.io/enabled=true
Et maintenant, on peut déployer notre application comme si de rien n’était.
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
template:
spec:
volumes:
- name: token-secret
secret:
secretName: mytoken # ← le vrai Secret
containers:
- name: agent
image: ghcr.io/unetassedecafe/test-app:latest
volumeMounts:
- name: token-secret
mountPath: /var/secrets
readOnly: true
Le Webhook intercepte ce pod au démarrage et réécrit le secretName pour pointer vers le Shadow Secret. On peut le vérifier via kubectl describe pod :
kubectl describe pod httpbin-test -n kloak-test | grep -A3 "Volumes:"
Volumes:
token-secret:
Type: Secret (a volume populated by a Secret)
SecretName: mytoken-kloak # ← réécrit automatiquement par le webhook
L’application monte le fichier et lit kloak:Y1R3B718AGD3X4ZC01KQAHQ26.
L’injection en kernel-space
Voici un pod de test Python qui lit le secret depuis le volume et l’envoie dans un header Authorization vers httpbin.org/headers (que j’utilise surtout pour simuler une API Externe et pouvoir voir ce qu’il reçoit dans sa réponse).
import os, urllib.request, json
api_key = open("/run/secrets/api/api-key").read().strip()
print(f"[APP] Secret lu depuis le fichier : {api_key}")
req = urllib.request.Request(
"https://httpbin.org/headers",
headers={"Authorization": f"Bearer {api_key}"}
)
with urllib.request.urlopen(req) as resp:
data = json.loads(resp.read())
auth_header = data["headers"].get("Authorization", "")
print(f"[NETWORK] Header reçu par httpbin.org : {auth_header}")
Résultat :
[APP] Secret lu depuis le fichier : kloak:Y1R3B718AGD3X4ZC01KQAHQ26
[APP] Longueur : 31 caractères
[NETWORK] Header reçu par httpbin.org : Bearer super-secret-bearer-token-12345
L’application a lu kloak:Y1R3B718AGD3X4ZC01KQAHQ26 et l’a envoyé tel quel. C’est l’uprobe eBPF accroché sur SSL_write qui a remplacé le placeholder par super-secret-bearer-token-12345 juste avant le chiffrement sans que ma (magnifique) application n’en ait jamais conscience.
La chaîne de vérification DNS
C’est la partie la plus intéressante de Kloak. Annoter un secret avec getkloak.io/hosts: "httpbin.org" ne suffit pas si l’attaquant peut l’envoyer vers son propre serveur malveillant pour récupérer le contenu du secret.
Mais c’est sans compter sur Kloak, qui incorpore une fonction qui va s’assurer que la connexion TCP en cours correspond vraiment à httpbin.org et pas à un serveur qui aurait intercepté la résolution.
Kloak utilise une chaîne de vérification complète :
- DNS capture : un kprobe sur
udp_recvmsgintercepte toutes les réponses DNS sur le node. Pour les hostnames dansgetkloak.io/hosts, les IPs résolues sont stockées dansdns_ip_mapavec leur TTL. - Connection tracking : des tracepoints sur
sys_enter/exit_connectenregistrent chaque connexion TCP avec le mapping fd → IP dansconn_ip_map. Si l’IP est dansdns_ip_map, le fd est marqué comme vérifié. - Résolution au moment du write : à l’uprobe
SSL_write, Kloak chaîne fd → IP → hostname pour identifier la destination réelle de la connexion TLS. - Filtrage : si le hostname résolu correspond à
getkloak.io/hosts, on réécrit. Sinon, le placeholderkloak:...est envoyé tel quel — le serveur distant reçoit une valeur invalide, et le secret ne fuite pas.
Information
Quand j’ai lu l’explication précédente dans la documentation, j’ai eu un peu de mal, alors je vous donne ce que j’ai compris après m’être renseigné, ainsi qu’un récapitulatif.
- Un kprobe (kernel probe) est un point d’accroche placé sur une fonction du kernel Linux. Quand le kernel exécute cette fonction (ici
udp_recvmsg), on peut, dans notre cas, inspecter le contenu du paquet UDP reçu. - Un uprobe (user probe) fait la même chose, mais dans l’espace utilisateur : on accroche un programme eBPF sur une fonction d’une bibliothèque partagée (
SSL_writedanslibssl.so.3). Le programme se déclenche à chaque appel, avec accès aux arguments, et surtout au buffer de données avant chiffrement. - Un tracepoint est un point d’accroche statique défini dans le kernel (ex.
sched_process_execpour chaqueexec(),sys_enter_connectpour chaque connexion TCP). - Les BPF maps sont des structures de données partagées entre le kernel et l’espace utilisateur. Kloak en utilise plusieurs :
secret_map(placeholder → vraie valeur),dns_ip_map(IP → hostname),conn_ip_map(fd → IP),cgroup_map(cgroups suivis). Le Controller Go les met à jour depuis l’espace utilisateur, les programmes eBPF les lisent depuis le kernel.
Mais bon, ça, c’est en théorie, car en pratique, j’ai eu quelques galères…
Le PoC (et le début du drame)
Vous pensiez être sur un article de “présentation”, et ben non ! Je vais vous détailler comment mes tests se sont passés à la place. Vous avez le droit de vous sentir trahis.
Le premier cluster sur lequel j’ai testé était un simple kind sur Docker Desktop macOS.
L’injection fonctionne. Le vrai secret est bien envoyé vers httpbin.org (ce qu’on veut).
=== httpbin.org (autorisé dans l'annotation) ===
"Authorization": "Bearer super-secret-bearer-token-12345"
En revanche le filtrage par host ne bloque pas httpbin.une-pause-cafe.fr — le vrai secret y est aussi envoyé. La raison est dans les logs du Controller :
{"msg":"Added trusted DNS server","ip":"10.96.0.10","source":"kube-dns auto-discovery"}
{"msg":"DNS server whitelist enabled","count":1}
Kloak n’écoute que les réponses DNS venant de kube-dns. Dans un cluster kind sur macOS, le kprobe udp_recvmsg ne capture pas ces paquets dans l’environnement linuxkit — dns_ip_map reste vide et Kloak injecte vers tous les hosts. Pas le choix : il va falloir un vrai cluster… C’est pourquoi je me suis dirigé vers Talos (ne faites pas les étonnés, vous vous y attendiez tous).
Cluster Talos (kernel 6.12) : diagnostic et correction du bug
L’injection ne fonctionne pas en v0.1.1. Les programmes eBPF se chargent correctement, le Controller détecte les pods, mais Successfully attached TLS uprobes n’apparaît jamais dans les logs.
Ainsi, le serveur autorisé reçoit l’UID Kloak alors qu’il aurait dû recevoir le vrai secret.
"Authorization": "Bearer kloak:RZFT5K6YRGJ49D9H01KQA",
Le diagnostic révèle la cause racine : le Controller recherche le cgroup du container en testant une liste de patterns de chemins (pkg/cgroups/utils.go). Sur Talos, les cgroups Guaranteed QoS sont organisés ainsi dans cgroupfs (ce qui n’est pas forcément la norme partout).
/sys/fs/cgroup/kubepods/pod<UID>/<containerID>/
Pour donner un peu de détail sur ce problème, Kubernetes répartit les pods dans trois classes QoS selon leurs requests/limits (eh eh, vous saviez que Denis et moi avions un talk à ce sujet 😁)
| Classe QoS | Condition | Layout cgroup |
|---|---|---|
| Guaranteed | requests == limits pour tous les containers | kubepods/pod<UID>/<CID>/ |
| Burstable | au moins un request défini, inégal aux limits | kubepods/burstable/pod<UID>/<CID>/ |
| BestEffort | aucun request/limit | kubepods/besteffort/pod<UID>/<CID>/ |
La plupart des workloads de production sont Guaranteed (requests = limits). Les pods de notre test (httpbin-test sans resources:) sont BestEffort — c’est pourquoi ils passaient même sans le fix.
Or la liste de patterns testait /sys/fs/cgroup/kubepods/pod<UID> (le cgroup pod) avant de descendre dans le répertoire container. Le Controller récupérait donc l’inode du cgroup parent du pod, pas celui du container :
# Ce que le controller loggait :
{"msg":"tracking container cgroup","cgroupID":336626} ← inode du pod
# L'inode réel du cgroup container :
stat /sys/fs/cgroup/kubepods/pod.../containerID/
Inode: 336699 ← jamais trouvé
Le tracepoint sched_process_exec filtre les execs par cgroup ID exact. Avec un ID incorrect, aucun syscall ne match et les uprobes ne sont jamais attachés. En gros, c’est comme si le container n’était pas configuré pour Kloak : il tourne, mais il ignorera les uprobes.
Le fix : ajouter le pattern manquant dans FindContainerCgroupPath avant le fallback pod-level :
// containerd cgroupfs driver (k3s default)
filepath.Join(cgroupRoot, "kubepods", "burstable",
fmt.Sprintf("pod%s", podUID), containerID),
filepath.Join(cgroupRoot, "kubepods", "besteffort",
fmt.Sprintf("pod%s", podUID), containerID),
filepath.Join(cgroupRoot, "kubepods", "guaranteed",
fmt.Sprintf("pod%s", podUID), containerID),
+// Notre ami Talos qui place les containers Guaranteed directement sous kubepods/pod<UID> sans le niveau QoS
+filepath.Join(cgroupRoot, "kubepods",
+ fmt.Sprintf("pod%s", podUID), containerID),
// pod-level fallback (dernier recours)
filepath.Join(cgroupRoot, "kubepods", "pod"+podUID),
Autant vous dire qu’avant de tomber sur ça, il m’a fallu pas mal de debug avec DevPod et un LLM pour m’aider 😅 ! J’ai ouvert une PR upstream pour que le fix profite à tout le monde.
Test de l’injection après correction sur Talos
Avec l’image corrigée, le Controller attache correctement les uprobes :
{"msg":"tracking container cgroup","cgroupID":347503}
{"msg":"Successfully attached TLS uprobes","pid":170996,"container":"curl"}
Et l’injection fonctionne end-to-end. Le pod voit le placeholder, httpbin.org reçoit le vrai secret :
$ kubectl exec -n kloak-test httpbin-test -- sh -c \
'TOKEN=$(cat /var/secrets/token) && \
echo "Pod voit : $TOKEN" && \
curl -s -H "Authorization: Bearer $TOKEN" https://httpbin.org/headers | grep -i auth'
Le pod applicatif voit : kloak:63KNA74T0FV868SK01KQAHH78 et le serveur destinataire voit : “Authorization”: “Bearer super-secret-bearer-token-12345”,
Information
Le fix corrige Talos et toute distribution qui place les containers Guaranteed QoS directement sous kubepods/pod<UID>/<containerID> sans sous-répertoire QoS. En v0.1.1, les patterns cgroupfs existants couvrent déjà burstable, besteffort et guaranteed dans les sous-répertoires QoS — ce qui fonctionne pour k3s et kubeadm.
Maintenant qu’on a validé ça, on peut tester la deuxième grosse feature : ne remplacer par le vrai secret QUE lorsqu’on communique sur le bon domaine.
$ kubectl exec -n kloak-test httpbin-test -- \
sh -c 'TOKEN=$(cat /var/secrets/token) && \
echo "Pod voit : $TOKEN" && \
curl -sk -H "Authorization: Bearer $TOKEN" https://httpbin.org/headers | grep -i auth && \
curl -sk -H "Authorization: Bearer $TOKEN" https://postman-echo.com/headers | grep -i auth
Pod voit : kloak:63KNA74T0FV868SK01KQAHH78
# httpbin.org (autorisé dans l'annotation)
"Authorization": "Bearer super-secret-bearer-token-12345"
# postman-echo.com (non autorisé)
"Authorization": "Bearer super-secret-bearer-token-12345"
L’injection TLS fonctionne. Mais quand j’utilise le label getkloak.io/hosts: "httpbin.org" sur le secret, le vrai token arrive aussi bien sur httpbin.org que sur postman-echo.com — le filtrage ne fonctionne pas…
Chercher dans les logs
La première chose à faire : activer les logs trace sur le DaemonSet.
kubectl set env daemonset/kloak-controller -n kloak-system KLOAK_LOG_LEVEL=trace
Le niveau trace expose deux choses intéressantes : le détail de chaque sync de secret vers les maps eBPF, et les compteurs internes du programme eBPF, dumpés toutes les 5 secondes.
{"msg":"synced secret into eBPF map","secret":"mytoken","key":"token","hostLen":11,"port":0,"protocol":0}
{"msg":"secret sync complete","enabledSecrets":1,"bpfKeys":2,"pruned":0,"watchedHosts":1}
hostLen:11 (longueur de "httpbin.org") — le Controller a bien chargé le filtre host. watchedHosts:1 : la map watched_hosts est active. Pourtant, l’injection arrive toujours vers postman-echo.com. Le filtre est configuré, mais ne trace pas les bons calls ; il faut qu’on aille consulter les “sachants” : les compteurs eBPF.
Analyser les compteurs eBPF
Les compteurs eBPF révèlent la cause. Ces compteurs sont exposés dans les logs trace du Controller (activé via KLOAK_LOG_LEVEL=trace), dumpés automatiquement toutes les 5 secondes :
# Activer les logs trace si ce n'est pas déjà fait
kubectl set env daemonset/kloak-controller -n kloak-system KLOAK_LOG_LEVEL=trace
# Lire les compteurs eBPF
kubectl logs -n kloak-system -l app.kubernetes.io/component=controller --tail=100 \
| grep "eBPF debug counter"
Dans les outputs, on retrouve ces fameux compteurs.
{"name":"kprobe_dport53", "count":1854}
{"name":"kretprobe_read_ok", "count":187}
{"name":"dns_parse_entry", "count":187}
{"name":"dns_not_response", "count":1852}
{"name":"dns_no_answers", "count":1}
{"name":"dns_not_watched", "count":1}
Décodage, parce que ce n’est pas forcément clair :
kprobe_dport53: 1854— le kprobe surudp_recvmsga capturé 1854 paquets impliquant le port 53.dns_not_response: 1852— 1852 de ces paquets ont le bit QR=0 (requête DNS, pas une réponse). Le kprobe voit massivement des requêtes, pas des réponses.kretprobe_read_ok: 187/dns_parse_entry: 187— seulement 187 lectures UDP complètes ont été parsées comme potentielles réponses DNS…- … dont 185 sont encore des non-réponses (
dns_not_response). Seuls 2 paquets passent le filtre QR, et sur ces 2 : 1 sans réponse DNS (dns_no_answers), 1 avec un hostname non surveillé (dns_not_watched).
Il manque la métrique dns_watched_hit qui aurait dû signaler qu’une requête est bien destinatrice du bon serveur (et donc faire le remplacement).
En résumé : le kprobe DNS ne match sur aucun domaine. Et vous savez quel “acteur” de mon cluster gère le réseau de manière un peu différente ?
Here’s a new challenger : Cilium
Ce cluster Talos utilise Cilium comme CNI. Cilium intègre un proxy DNS qui s’interpose entre les pods et kube-dns. Le flux DNS avec Cilium ressemble à ceci :
Pod → Cilium DNS proxy (port 53, espace réseau du node)
↓
kube-dns (résolution)
↓
Cilium DNS proxy (reçoit la réponse)
↓
Pod (reçoit la réponse... mais via eBPF Cilium, pas UDP brut)
Cilium intercepte la grande majorité des réponses DNS avant qu’elles ne remontent par le socket UDP du pod. Le kprobe udp_recvmsg de Kloak ne voit que quelques rares réponses (environ 5%) — dns_ip_map est donc presque toujours vide pour les noms surveillés. Le filtrage devient non-déterministe : il fonctionne quand une réponse DNS a eu la chance de passer avant le fast-path Cilium, et échoue silencieusement sinon.
TL;DR : le fast-path eBPF de Cilium bypass les filtres de Kloak dans la quasi-totalité des cas.
Je n’ai pas les compétences directes pour regarder kernel-side ce qu’il se passe, donc notre seul moyen de valider cette théorie est de tester.
Avec Flannel : it works
Il n’y a pas 36000 solutions : j’ai installé un cluster sans Cilium, uniquement du flannel (pardon Joseph, mais là j’ai pas le choix).
J’aurais aussi pu désactiver l’eBPF sur Cilium mais on va faire simple.
Avec le label getkloak.io/hosts=httpbin.org appliqué, les résultats :
# httpbin.org (autorisé)
$ kubectl exec kloak-test-pod -n kloak-test -- curl -sk \
-H "Authorization: Bearer $(cat /var/secrets/mytoken/token)" \
https://httpbin.org/headers | grep Auth
"Authorization": "Bearer super-secret-bearer-token-12345"
# postman-echo.com (non autorisé)
$ kubectl exec kloak-test-pod -n kloak-test -- curl -sk \
-H "Authorization: Bearer $(cat /var/secrets/mytoken/token)" \
https://postman-echo.com/headers | grep auth
"authorization": "Bearer kloak:46S12JCN0HS2GX3101KQATMNV"
Le vrai secret part uniquement vers httpbin.org. Le placeholder est envoyé à postman-echo.com. Le filtrage fonctionne 🎉 !
Les compteurs eBPF confirment que sur Flannel, le kprobe DNS voit bien les réponses :
{"name":"dns_parse_entry", "count":619}
{"name":"dns_not_response", "count":611}
{"name":"dns_watched_hit", "count":3} // La métrique que nous n'avions pas avant
{"name":"dns_answer_stored", "count":24}
dns_answer_stored:24 — les réponses DNS arrivent bien dans dns_ip_map 🎉 !
Retournement de situation: Cilium est finalement compatible
Initialement, cette section n’était pas présent dans l’article original, mais plutôt que de ré-écrire l’article complet, je préfère laisser la partie debug et ajouter une petite section Erratum.
Durant l’écriture de l’article, j’ai ouvert une issue et l’équipe Kloak m’a répondu rapidement. Selon eux, le problème vient du fait que Kloak ne fait confiance qu’aux réponses DNS provenant de kube-dns alors qu’avec Cilium, les réponses arrivent depuis le proxy Cilium, pas directement depuis kube-dns (ce qui validait ma théorie initiale).
Deux solutions existent :
Option 1 — trustedServers : configurer manuellement l’IP du proxy DNS Cilium dans les valeurs Helm de Kloak :
controller:
dns:
trustedServers:
- <IP du proxy DNS Cilium>
Option 2 — mode transparent Cilium : activer --dnsproxy-enable-transparent-mode sur Cilium. Avec ce flag, Cilium préserve l’IP source de kube-dns dans les réponses DNS qu’il proxifie — Kloak les reconnaît donc comme de confiance.
# Dans cilium-config (ConfigMap) ou les valeurs Helm Cilium
dnsproxy-enable-transparent-mode: "true"
J’ai retesté sur mes clusters Talos + Cilium 1.17 avec ce flag activé (c’est d’ailleurs la valeur par défaut sur les installations récentes), et le filtrage par host fonctionne correctement :
Pod voit : kloak:Y94DFH4VHAHNFN1P01KQASMXC
=== httpbin.org (autorisé) ===
"Authorization": "Bearer super-secret-bearer-token-12345" ✅
=== postman-echo.com (non autorisé) ===
"Authorization": "Bearer kloak:Y94DFH4VHAHNFN1P01KQASMXC" ✅
Kloak est donc compatible Cilium à condition que le mode transparent soit activé (ce qui devrait être le cas sur toutes les installations Cilium).
Les runtimes supportés
Un truc que je n’ai pas abordé, c’est que tout les langages ne gèrent les requêtes SSL de la même manière. Kloak s’accroche sur les fonctions TLS selon le runtime :
| Runtime | Bibliothèque TLS | Point d’accroche |
|---|---|---|
| Python, Rust, Ruby, PHP, curl | OpenSSL (libssl.so) | SSL_write / SSL_write_ex |
| Node.js | BoringSSL (statiquement lié) | SSL_write |
| Go | crypto/tls natif | crypto/tls.(*Conn).Write |
Pour Go, c’est plus complexe : il chiffre lui-même sans passer par OpenSSL, donc le path de réécriture est différent (ça reste compatible, mais ils ont dû faire une implémentation spécifique à ce langage)
Pour les copains qui font du .NET : le runtime passe par OpenSSL (donc libssl.so), donc Kloak devrait fonctionner de la même manière que pour Python. Cependant, ce runtime n’est pas listé dans les runtimes officiellement supportés par le projet et j’ai pas pu tester de moi-même.
Conclusion
Au final la promesse initiale est bien répondue, j’ai quand même pris plusieurs heures de debug pour comprendre réellement ce qu’il se passait, moi qui suit pas forcément ultra à l’aise sur le bas niveau, là j’en ai dégusté bien comme il faut (pour qu’au final, les solutions étaient à ma portée).
Ce qui semblait initialement être un no-go avec Cilium s’est finalement révélé être compatible. L’équipe a répondu rapidement à mon issue et la solution existe, comme détaillé dans la section précédente. À l’heure où j’écris cet article, Kloak n’a que deux semaines d’existence, et la réactivité de l’équipe est déjà très encourageante !
Si initialement, l’objectif de Kloak est surtout pour les agents LLM qui tournent sur des clusters, je trouve que la problématique à laquelle il répond est aussi intéressante coté Infra et avoir une application qui utilise des secrets sans les lire, je trouve ça beau. Bref, j’espère que cet article mi-debug, mi-présentation vous aura plu et peut-être qu’il vous aura donné envie de tester Kloak.
Bon kawa ! ☕️
