# Ce que Traefik voit du client — et pourquoi « pas de mot de passe à la maison » ne se règle pas dans Traefik Mesuré le 2026-07-28 sur le homelab (`kubectl --context=default`, k3s pi1/pi2/pi3). Ce document existe parce que la question « peut-on sauter le basic auth quand on est sur le Wi-Fi de la maison ? » a une réponse contre-intuitive, et que la réponse intuitive est un **trou de sécurité**. ## 1. Le chemin réel d'une requête `.fr` ``` navigateur (maison) └─ DNS : kadans.arcodange.fr → 172.67.130.8 / 104.21.3.16 (Cloudflare, PAS le LAN) └─ edge Cloudflare (proxy orange) └─ tunnel cloudflared (sortant, pod du cluster, CIDR 10.42.0.0/16) └─ traefik.kube-system.svc:80 (entrypoint `web`, pas de TLS ici) └─ crowdsec → basic auth → service ``` Le tunnel est **sortant** : il n'y a aucune redirection de port, aucun chemin depuis Internet vers Traefik autre que Cloudflare. Le nom `.fr` est un CNAME proxifié vers `.cfargotunnel.com` (`cms/cloudflare`, module `cloudflared_tunnel`, mapping unique `*.arcodange.fr`). **Conséquence n°1 : même depuis le salon, la requête sort de la maison, fait un aller-retour par Cloudflare, et revient par le tunnel.** L'adresse de la socket que Traefik accepte est toujours celle d'un pod `cloudflared` en `10.42.x.x`. Le `.lab`, lui, va droit au but : `kadans.arcodange.lab → 192.168.1.201`. ## 2. Deux notions d'« IP client », et elles ne coïncident pas L'entrypoint `web` fait confiance aux en-têtes transmis depuis le CIDR des pods (`ports.web.forwardedHeaders.trustedIPs: ["10.42.0.0/16"]`, playbook `factory/.../playbooks/system/k3s_config.yml`). Traefik **sait donc** qui est le vrai client — le journal d'accès le prouve, sur une requête émise depuis la maison : ``` 2a01:cb04:dff:cf00:f9d7:4026:9ffc:b042 - kadans [28/Jul/2026:14:22:49 +0200] "GET /?sonde=… HTTP/1.1" 200 … "kadans-kadans-public-kadans-arcodange-fr@kubernetes" ``` Deux choses à retenir de cette ligne : - Traefik **résout** le vrai client depuis `X-Forwarded-For` ; - la maison sort en **IPv6** (`2a01:cb04:dff:cf00::/64`), pas en IPv4. Le `86.238.234.54/32` que le playbook injecte dans `localIp` via `ipify` n'est qu'**une** des deux adresses publiques du foyer, et pas celle qu'un navigateur moderne utilise en priorité. Mais **le matcher de routeur `ClientIP()` n'utilise PAS cette résolution.** Il juge l'adresse de la socket. Mesuré, pas supposé — trois routeurs temporaires posés sur `kadans.arcodange.fr`, pointant vers un Service sans endpoint (donc `503` si la règle matche, et repli sur le routeur normal — `401` — sinon) : | sonde | règle ajoutée à `Host(kadans.arcodange.fr)` | attendu si… | mesuré | | ----- | -------------------------------------------- | ------------------------------ | ------ | | T | *(témoin, aucune contrainte d'IP)* | le dispositif fonctionne → 503 | **503** | | A | `ClientIP("2a01:cb04:dff:cf00::/64")` | `ClientIP` lit X-Forwarded-For | **401** | | B | `ClientIP("10.42.0.0/16")` | `ClientIP` lit la socket | **503** | Le témoin valide le montage ; A échoue, B réussit. **`ClientIP()` voit le pod `cloudflared`, jamais le vrai client.** (Sondes supprimées après mesure.) ## 3. Les deux pièges qui en découlent ### Piège A — `localIp@file` ne doit JAMAIS toucher un routeur `.fr` `localIp` est un `ipAllowList` dont le `sourceRange` contient `10.42.0.0/16` (le CIDR des pods). Comme `ipAllowList` juge lui aussi l'adresse de la socket par défaut, **l'attacher à un hôte `.fr` laisserait passer Internet entier** : toute requête arrivant par le tunnel se présente en `10.42.x.x`, donc « locale ». État vérifié le 2026-07-28 : aucun routeur `.fr` ne porte `localIp@file`. Le piège est **latent**, pas ouvert. Il le reste tant que personne ne recopie `localIp@file` depuis un values `.lab` (grafana, vault, plausible) vers un `ingress-public.yaml`. Les deux gabarits `.fr` de ce dépôt portent un avertissement à cet endroit précis. ### Piège B — se fier à un en-tête est un contournement d'authentification « Sauter le basic auth si `CF-Connecting-IP` est celle de la maison » (ou si un en-tête maison est présent) fait reposer une **exemption d'authentification** sur une valeur que le client écrit lui-même. Cloudflare ne retire pas les en-têtes inconnus : n'importe qui sur Internet peut les envoyer. Et Traefik reste joignable en direct sur le LAN (`192.168.1.201:80`). **On ne prend pas cette voie.** ## 4. Pourquoi ce n'est pas réparable dans Traefik Traefik enchaîne ses middlewares **inconditionnellement** : il n'existe pas de « basic auth sauf si … ». Le seul moyen de sauter un middleware est de faire matcher un **autre routeur** — et le seul discriminant d'IP au niveau routeur est `ClientIP()`, qui, on vient de le mesurer, ne voit que le tunnel. Un `ipAllowList` avec `ipStrategy.depth` verrait bien la vraie IP, mais un `ipAllowList` **bloque** ; il ne sait pas « laisser entrer sans mot de passe ». Faire pointer le LAN vers Traefik (DNS à double horizon, ou enregistrement Cloudflare « DNS only ») rendrait `ClientIP(192.168.1.0/24)` honnête — mais coûterait, en plus du DNS : un **certificat publiquement valide** pour `kadans.arcodange.fr` sur Traefik (aujourd'hui il sert la CA interne, `CN=arcodange.lab` ; un `curl --resolve … 192.168.1.201` échoue au TLS, code `000`), donc un nouveau `certResolver` DNS-01 Cloudflare et le jeton qui va avec, plus un routeur `websecure` pour un hôte qui vit aujourd'hui en `web` clair. Beaucoup, pour du confort. Et « DNS only » retirerait en prime le bouclier Cloudflare de cet hôte. **La décision doit donc être prise là où la vraie IP est native et non falsifiable : au bord, chez Cloudflare (`ip.src`).** ## 5. La voie retenue — Cloudflare tape le mot de passe à ta place Le basic auth de `kadans.arcodange.fr` est un **garde-fou assumé**, pas un secret : `kadans:kkadans`, et son hash bcrypt est en clair dans `chart/values.yaml` du dépôt `kadans`. C'est ce fait qui rend la solution simple **et** sans coût de sécurité : > Une règle Cloudflare pré-remplit l'en-tête `Authorization` pour les requêtes > venant du réseau de la maison. Traefik ne change pas, le mot de passe reste > exigé partout ailleurs. Cloudflare ne fait que le **taper à ta place**. Surface d'attaque ajoutée : **aucune**. Un attaquant qui forge cet en-tête obtient exactement ce que `curl -u kadans:kkadans` lui donne déjà aujourd'hui. C'est la différence de fond avec le piège B : ici on ne crée pas d'exemption, on automatise une saisie. Et quand l'IP du foyer change (elle est dynamique), la règle cesse simplement de matcher : **le navigateur redemande le mot de passe**. Dégradation douce, jamais de panne. ### Action manuelle du fondateur (à faire une fois) Cloudflare → zone `arcodange.fr` → **Rules** → **Transform Rules** → **Modify Request Header** → *Create rule* : - **Nom** : `kadans — confort LAN : Authorization pré-remplie depuis la maison` - **When incoming requests match** (éditeur d'expression) : ``` (http.host eq "kadans.arcodange.fr" and ip.src in {86.238.234.54 2a01:cb04:dff:cf00::/64}) ``` - **Then** → *Set static* : - Header name : `Authorization` - Value : `Basic a2FkYW5zOmtrYWRhbnM=` *(base64 de `kadans:kkadans`)* Équivalent Terraform, si on préfère le déclarer dans `cms/cloudflare` : ```hcl resource "cloudflare_ruleset" "confort_lan_kadans" { zone_id = cloudflare_zone.arcodange_fr.id name = "confort LAN" kind = "zone" phase = "http_request_late_transform" rules = [{ expression = "(http.host eq \"kadans.arcodange.fr\" and ip.src in {86.238.234.54 2a01:cb04:dff:cf00::/64})" description = "Depuis la maison, Cloudflare tape le garde-fou basic auth a notre place" action = "rewrite" action_parameters = { headers = { "Authorization" = { operation = "set", value = "Basic a2FkYW5zOmtrYWRhbnM=" } } } }] } ``` ### Vérifier que ça marche ```bash # depuis la maison — attendu : 200, SANS -u curl -s -o /dev/null -w '%{http_code}\n' https://kadans.arcodange.fr/ # depuis l'extérieur (partage de connexion du téléphone) — attendu : 401 curl -s -o /dev/null -w '%{http_code}\n' https://kadans.arcodange.fr/ ``` ### Ce qu'il faudra surveiller - **Les deux adresses du foyer sont dynamiques.** Le préfixe IPv6 relevé le 2026-07-28 est `2a01:cb04:dff:cf00::/64` et l'IPv4 `86.238.234.54` — la même que celle qu'`ipify` injecte dans `localIp` à chaque passage du playbook `k3s_config.yml`. S'il dérive, élargir en `/56` avant de soupçonner Cloudflare. - ⚠ **Non vérifié en vrai** : que Cloudflare accepte `Authorization` comme en-tête de requête modifiable (certains en-têtes sont réservés). Si l'éditeur refuse la règle, le repli est **Cloudflare Access** avec une politique *Bypass* sur les mêmes IP et une politique *Allow* par e-mail ailleurs — et le basic auth de Traefik est alors retiré. Le repli n'est **pas** un en-tête secret (piège B). ### La version encore plus simple, si le confort ne vaut pas la règle Le mot de passe est public. Ce qu'il achète réellement, c'est « le produit en chantier n'est pas indexé ni tombé dessus par hasard ». Un `robots.txt` + `X-Robots-Tag: noindex` fait ce travail-là sans mot de passe du tout. Retirer le garde-fou est une décision produit, pas une décision d'infra : elle n'est pas prise ici, seulement nommée. ## 6. Ce qui a été vérifié et qui va bien Le bouncer CrowdSec, lui, **juge la vraie IP** — contrairement à `ClientIP()`. Journal Traefik sur une requête de la maison et sur des robots : ``` CrowdsecBouncerTraefikPlugin: ServeHTTP ip:2a01:cb04:dff:cf00:f9d7:4026:9ffc:b042 isTrusted:false CrowdsecBouncerTraefikPlugin: ServeHTTP ip:144.24.58.222 isTrusted:false ``` Son `clientTrustedIPs` contient pourtant `10.42.0.0/16` : ce n'est **pas** un trou, parce que le plugin ne compare pas cette liste à l'adresse de la socket mais à l'IP résolue. La convention `.fr` (« crowdsec devant tout ») tient donc ce qu'elle promet. Écrit ici pour que personne ne « corrige » cette ligne en croyant reproduire le piège A. --- **Où vit quoi** (aucun de ces réglages n'est dans ce dépôt — vérifié) : | réglage | dépôt / fichier | | ------------------------------------------- | ------------------------------------------------------------------------ | | valeurs Helm Traefik, `dynamic.yaml`, `localIp` | `factory` — `ansible/arcodange/factory/playbooks/system/k3s_config.yml` | | Middleware `crowdsec@kube-system` | `factory` — `.../playbooks/tools/roles/crowdsec/tasks/main.yml` | | tunnel + DNS `*.arcodange.fr` | `cms` — `cloudflare/iac.tf`, `cloudflare/modules/cloudflared_tunnel/` | | basic auth `.fr` de kadans | `kadans` — `chart/values.yaml` (`public.basicAuth`) | | gabarits d'exposition `.fr` de ce dépôt | `grafana/templates/ingress-public.yaml`, `minio/templates/ingress-public.yaml` |