Files
tools/doc/ce-que-traefik-voit.md
T
CI BotandClaude Opus 5.5 8052b533da
Helm Charts / Detect changed charts (pull_request) Successful in 39s
Helm Charts / Library charts tool (pull_request) Skipped
Helm Charts / Application charts alloy (pull_request) Skipped
Helm Charts / Application charts chart (pull_request) Skipped
Helm Charts / Application charts grafana (pull_request) Skipped
Helm Charts / Application charts hashicorp-vault (pull_request) Skipped
Helm Charts / Application charts loki (pull_request) Skipped
Helm Charts / Application charts minio (pull_request) Skipped
Helm Charts / Application charts pgbouncer (pull_request) Skipped
Helm Charts / Application charts pgcat (pull_request) Skipped
Helm Charts / Application charts prometheus (pull_request) Skipped
Helm Charts / Application charts redis (pull_request) Skipped
Helm Charts / Application charts crowdsec (pull_request) Successful in 1m0s
doc(réseau) — ce que le videur juge bien, et les deux limites qu'on ne voyait pas
La section 6 disait que la convention « crowdsec devant tout » tenait parce
que le videur juge la vraie IP. C'est vrai, mais le 2026-10-08 a montré deux
limites : l'agent ne lisait aucune ligne (racine Docker non montée), et le
plugin en mode stream ignore les décisions de plage (vérifié : la LAPI rend
bien la décision de plage en mode live, le flux stream la livre comme une
valeur que le plugin ne compare qu'à l'égalité).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 16:29:17 +02:00

12 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.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 :

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.

⚠ Mise à jour du 2026-10-08 — juger la bonne IP ne suffisait pas. Deux limites que ce constat ne pouvait pas voir, mesurées pendant l'aspiration de la forge :

  1. L'agent ne lisait rien. Les journaux de conteneurs de pi1 pointent sous /mnt/arcodange/docker (racine Docker déplacée), que l'agent ne montait pas : Error setting up tail for /var/log/containers/traefik-… à chaque démarrage, aucune ligne lue, aucun scénario HTTP n'a jamais débordé. Corrigé dans crowdsec/values.yaml (agent.extraVolumes).
  2. Un ban de plage n'est pas appliqué. En mode stream, le plugin (v1.3.3 ici, et toujours sur sa branche principale) range chaque décision par sa valeur exacte et cherche l'adresse du client telle quelle : cscli decisions add --range 16.216.88.0/24 laissait passer 16.216.88.122 (isBanned:false cache:miss). Les scénarios maison gardent donc une portée Ip, et un ban manuel de plage se fait adresse par adresse (cscli decisions import). Le mode live interrogerait la LAPI (/v1/decisions?ip=…), qui, elle, rend la décision de plage pour une adresse qu'elle contient (vérifié sur une LAPI v1.7.0 locale) — au prix d'un appel par adresse nouvelle.

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