Files
tools/doc/ce-que-traefik-voit.md
arcodangeandClaude Opus 5 2cb19809c6
Helm Charts / Detect changed charts (pull_request) Successful in 1m6s
Helm Charts / Library charts tool (pull_request) Has been skipped
Helm Charts / Application charts pgcat (pull_request) Has been skipped
doc(reseau) — le confort LAN ne se règle pas dans Traefik : mesure, pièges, voie retenue
Demande : « ne pas avoir le basic auth Traefik quand on est sur le Wi-Fi partagé
avec le homelab ». La réponse intuitive — une règle `ClientIP(192.168.1.0/24)` —
ne peut pas marcher, et la variante « je regarde CF-Connecting-IP » est un
contournement d'authentification. Ce commit écrit la mesure pour qu'on ne
re-découvre ni l'une ni l'autre.

Mesuré (2026-07-28, homelab) :

- `kadans.arcodange.fr` résout vers Cloudflare MÊME depuis le LAN, et redescend
  par le tunnel cloudflared. L'adresse de socket vue par Traefik est donc
  toujours celle d'un pod, en 10.42.x.x.
- Traefik SAIT qui est le vrai client (journal d'accès : l'IPv6 de la maison,
  `2a01:cb04:dff:cf00::/64` — pas l'IPv4 qu'`ipify` injecte dans `localIp`), car
  l'entrypoint `web` fait confiance aux en-têtes venant de 10.42.0.0/16.
- Mais le matcher de routeur `ClientIP()`, lui, juge la SOCKET. Prouvé par trois
  routeurs temporaires vers un Service sans endpoint (503 = règle matchée,
  401 = repli sur le routeur normal) : témoin 503, `ClientIP(<IPv6 maison>)` 401,
  `ClientIP(10.42.0.0/16)` 503. Sondes retirées après mesure.

D'où deux pièges, écrits là où on les rencontrerait :

- `localIp@file` sur un routeur `.fr` laisserait entrer Internet entier, parce
  que `10.42.0.0/16` est dans son `sourceRange` et qu'`ipAllowList` juge lui
  aussi la socket. Vérifié : aucun routeur `.fr` ne le porte aujourd'hui — le
  piège est latent, et les cinq endroits d'où on pourrait le recopier
  (2 gabarits `.fr`, 3 values `.lab`) portent désormais l'avertissement.
- Un en-tête posé par le client ne peut pas piloter une exemption d'auth :
  Cloudflare ne retire pas les en-têtes inconnus, et Traefik reste joignable en
  direct sur 192.168.1.201.

Voie retenue, et pourquoi celle-là : la décision « suis-je à la maison ? » doit
être prise là où la vraie IP est native et non falsifiable, donc au bord, chez
Cloudflare. Une règle de transformation y pré-remplit l'en-tête `Authorization`
pour les IP du foyer. Traefik ne bouge pas, aucun certificat public à produire,
aucun changement DNS. Surface d'attaque ajoutée : AUCUNE — `kadans:kkadans` est
déjà en clair dans le chart de kadans, Cloudflare ne fait que le taper à notre
place. Et quand l'IP du foyer dérive, la règle cesse de matcher : le navigateur
redemande le mot de passe. Dégradation douce, pas de panne.

Le doc écrit l'action Cloudflare mot pour mot (expression, en-tête, valeur, et
l'équivalent Terraform pour `cms/cloudflare`) plutôt que de la supposer faite,
avec les deux commandes qui la vérifient — et nomme le repli si Cloudflare
refuse de modifier `Authorization` : Cloudflare Access avec une politique de
bypass, PAS un en-tête secret.

Enfin, un contre-exemple utile : le bouncer CrowdSec, lui, juge la VRAIE IP
(journal à l'appui). Son `clientTrustedIPs` contient `10.42.0.0/16` sans que ce
soit un trou — noté pour que personne ne « corrige » cette ligne en croyant y
reproduire le piège.

⚠ Aucun des réglages en cause ne vit dans ce dépôt : les valeurs Helm de
Traefik, `dynamic.yaml`/`localIp` et le Middleware crowdsec sont dans `factory`
(ansible), le tunnel et le DNS dans `cms`. Ce dépôt reçoit la mesure et les
garde-fous parce que c'est lui qui héberge les gabarits `.fr` et les
consommateurs de `localIp@file`.

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01GdUCA5Uz8QyMwa2P4Pg2hK
2026-07-28 14:29:35 +02:00

11 KiB

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 <tunnel-id>.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.frRulesTransform RulesModify Request HeaderCreate 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})
    
  • ThenSet static :

    • Header name : Authorization
    • Value : Basic a2FkYW5zOmtrYWRhbnM= (base64 de kadans:kkadans)

Équivalent Terraform, si on préfère le déclarer dans cms/cloudflare :

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

# 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 factoryansible/arcodange/factory/playbooks/system/k3s_config.yml
Middleware crowdsec@kube-system factory.../playbooks/tools/roles/crowdsec/tasks/main.yml
tunnel + DNS *.arcodange.fr cmscloudflare/iac.tf, cloudflare/modules/cloudflared_tunnel/
basic auth .fr de kadans kadanschart/values.yaml (public.basicAuth)
gabarits d'exposition .fr de ce dépôt grafana/templates/ingress-public.yaml, minio/templates/ingress-public.yaml