From 2cb19809c627afc16e8534535beef8b30885568d Mon Sep 17 00:00:00 2001 From: CI Bot Date: Tue, 28 Jul 2026 14:29:35 +0200 Subject: [PATCH] =?UTF-8?q?doc(reseau)=20=E2=80=94=20le=20confort=20LAN=20?= =?UTF-8?q?ne=20se=20r=C3=A8gle=20pas=20dans=20Traefik=20:=20mesure,=20pi?= =?UTF-8?q?=C3=A8ges,=20voie=20retenue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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()` 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 Claude-Session: https://claude.ai/code/session_01GdUCA5Uz8QyMwa2P4Pg2hK --- README.md | 7 + doc/ce-que-traefik-voit.md | 224 ++++++++++++++++++++++++++ grafana/templates/ingress-public.yaml | 6 + grafana/values.yaml | 2 + hashicorp-vault/values.yaml | 2 + minio/templates/ingress-public.yaml | 4 + plausible/plausibleValues.yaml | 2 + 7 files changed, 247 insertions(+) create mode 100644 doc/ce-que-traefik-voit.md diff --git a/README.md b/README.md index 7934af5..8735c87 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,13 @@ pour chaque dossier de premier niveau contenant un fichier Chart.yaml (sauf les le pousser dans le registre helm de gitea ``` +## Réseau + +- [Ce que Traefik voit du client](doc/ce-que-traefik-voit.md) — pourquoi + `ClientIP()` ne voit que le tunnel sur les hôtes `.fr`, pourquoi `localIp@file` + n'a rien à y faire, et comment obtenir « pas de mot de passe depuis la maison » + sans créer un contournement d'authentification. + ## pgbouncer ## prometheus diff --git a/doc/ce-que-traefik-voit.md b/doc/ce-que-traefik-voit.md new file mode 100644 index 0000000..4f3d474 --- /dev/null +++ b/doc/ce-que-traefik-voit.md @@ -0,0 +1,224 @@ +# 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` | diff --git a/grafana/templates/ingress-public.yaml b/grafana/templates/ingress-public.yaml index 8a37a5e..be1c2e6 100644 --- a/grafana/templates/ingress-public.yaml +++ b/grafana/templates/ingress-public.yaml @@ -10,6 +10,12 @@ # NB : ce chart grafana est en mode `tool.kind: SubChart`, donc les templates # helm-chart*.yaml ne rendent rien ; ce fichier, lui, est rendu tel quel et # appliqué par ArgoCD (app `grafana`, destination namespace `tools`). +# +# ⚠ NE JAMAIS ajouter `localIp@file` ici (ni sur aucun routeur `.fr`). Le trafic +# `.fr` arrive par le tunnel cloudflared, donc avec l'adresse d'un pod en +# 10.42.x.x — or `10.42.0.0/16` est dans le `sourceRange` de `localIp`, et +# `ipAllowList` juge l'adresse de la SOCKET. Le middleware « IP locale +# seulement » laisserait donc entrer Internet entier. Mesuré : doc/ce-que-traefik-voit.md §3. apiVersion: networking.k8s.io/v1 kind: Ingress metadata: diff --git a/grafana/values.yaml b/grafana/values.yaml index 9abac77..0b48119 100644 --- a/grafana/values.yaml +++ b/grafana/values.yaml @@ -285,6 +285,8 @@ grafana: &grafana_config traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt traefik.ingress.kubernetes.io/router.tls.domains.0.main: arcodange.lab traefik.ingress.kubernetes.io/router.tls.domains.0.sans: grafana.arcodange.lab + # ⚠ Valable parce que cet hôte est en `.lab` (le client arrive en direct sur + # 192.168.1.201). À NE PAS recopier sur un hôte `.fr` : cf. doc/ce-que-traefik-voit.md §3. traefik.ingress.kubernetes.io/router.middlewares: localIp@file hosts: - grafana.arcodange.lab diff --git a/hashicorp-vault/values.yaml b/hashicorp-vault/values.yaml index 459f7aa..e192089 100644 --- a/hashicorp-vault/values.yaml +++ b/hashicorp-vault/values.yaml @@ -17,6 +17,8 @@ vault: &vault_config traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt traefik.ingress.kubernetes.io/router.tls.domains.0.main: arcodange.lab traefik.ingress.kubernetes.io/router.tls.domains.0.sans: vault.arcodange.lab + # ⚠ Valable parce que cet hôte est en `.lab` (le client arrive en direct sur + # 192.168.1.201). À NE PAS recopier sur un hôte `.fr` : cf. doc/ce-que-traefik-voit.md §3. traefik.ingress.kubernetes.io/router.middlewares: localIp@file hosts: - host: vault.arcodange.lab diff --git a/minio/templates/ingress-public.yaml b/minio/templates/ingress-public.yaml index 0ab4d0d..7722dc1 100644 --- a/minio/templates/ingress-public.yaml +++ b/minio/templates/ingress-public.yaml @@ -5,6 +5,10 @@ # sa propre signature (SigV4), et un défi HTTP Basic casserait le PUT présigné # auquel le navigateur ne peut pas répondre. # +# ⚠ Et PAS de `localIp@file` non plus : sur un routeur `.fr`, ce middleware +# laisserait entrer Internet entier (le tunnel se présente en 10.42.x.x, qui est +# dans son `sourceRange`). Mesuré : doc/ce-que-traefik-voit.md §3. +# # ADR : factory/doc/adr/20260726-stockage-objet-minio.md apiVersion: networking.k8s.io/v1 kind: Ingress diff --git a/plausible/plausibleValues.yaml b/plausible/plausibleValues.yaml index 3907cfb..fc2ef6a 100644 --- a/plausible/plausibleValues.yaml +++ b/plausible/plausibleValues.yaml @@ -61,6 +61,8 @@ ingressRoute: rule: Host(`analytics.arcodange.lab`) # -- List of [middleware objects](https://doc.traefik.io/traefik/routing/providers/kubernetes-crd/#kind-middleware) for the ingress route. middlewares: + # ⚠ Valable parce que la règle ci-dessus est en `.lab` (le client arrive en direct + # sur 192.168.1.201). À NE PAS recopier sur un hôte `.fr` : cf. doc/ce-que-traefik-voit.md §3. - name: localIp@file # -- Use an existing secret containing the TLS certificate. tlsSecretName: ''