# Part A — the box tells visitors apart (R-753): design, measurements, decision Written 2026-10-01 evening, before the release was built (the build followed the measurements below; one bug the hand prototype found is folded in). Rule that binds every choice: **never believe an address a client can write.** ## 1. The paths, measured Two outside addresses were available: DooPlex's public IPv4 `37.191.56.193` (no IPv6), and ep0 (one request, used for the live proof in §6, not here). Venue: demo-hp's REAL tunnel (`*.enkisfelhom.hu → https://traefik`) and an echo app (`traefik/whoami:v1.11`) on demo-hp, removed afterwards. | file | what | |---|---| | `M1-status-quo.txt` | through the tunnel, today's traefik (trusts nothing): XFF and X-Real-Ip = cloudflared `172.18.0.5` for EVERY visitor; `CF-Connecting-IP` = the visitor; a client's `Forwarded`, `True-Client-Ip` pass traefik untouched; **a client-sent `CF-Connecting-IP` is refused by Cloudflare's edge with 403** | | `M2-what-cloudflared-delivers.txt` | traefik `insecure` for one minute (shows what arrives): `X-Forwarded-For: 6.6.6.6,37.191.56.193, 172.18.0.5` — **Cloudflare APPENDS the visitor to a client-written chain**; a client's `X-Real-IP` does NOT arrive (stripped); a client's **`X-Forwarded-Host: evil.example` and `X-Forwarded-Port: 8443` DO arrive**; `X-Forwarded-Proto` is overwritten (`https`) | | `M3-restored.txt` | traefik back as it was | | `M4-lan-path.txt` | LAN, forged headers: XFF / X-Real-Ip = the real LAN address (traefik drops the forged chain); **a forged `CF-Connecting-IP: 7.7.7.7` arrives** | | path | TCP peer at traefik | XFF traefik forwards today | the real visitor is in | forgeable by the visitor | |---|---|---|---|---| | tunnel | cloudflared, docker-assigned (`172.18.0.5`) | cloudflared's address, for everyone | `CF-Connecting-IP`; Cloudflare's XFF (rightmost of its part) | XFF leftmost: yes (once trusted); CF-Connecting-IP: no (edge 403) | | LAN | the LAN client | the LAN client | XFF / X-Real-Ip | no (traefik drops a forged chain); CF-Connecting-IP: YES | ## 2. Options, and the one taken **Question:** how do the box and its apps learn each visitor's own address, without believing anything a client writes? - **(a) Controller only.** No traefik change; the controller believes `CF-Connecting-IP` only when the hop traefik saw is cloudflared's address. Cost: apps keep "one address" for every tunnel visitor (R-775 Grimmory, Home Assistant's `local_only` hole, Kimai/zipline/vikunja lockouts stay); cloudflared's address must be fixed anyway. - **(b) traefik trusts cloudflared's fixed address** (the reviewer's sketch). Apps that read X-Forwarded-For from the RIGHT get the real visitor; the controller the same. Cost: (1) every app that reads the LEFTMOST entry would believe a stranger's address (the sweep found 19); (2) traefik then keeps a client's `X-Forwarded-Host`/`-Port` (M2) — host-header poisoning for apps that build links from it. - **(c) A traefik plugin or our controller as `forwardAuth` for every request** to rewrite the chain to one address. Cost: a new external dependency (plugin), or the controller in every request path (an outage takes every app down). **Taken: (b), with both of its costs paid in the same rollout.** It is the only one that gives the APPS the visitor (decision 63's purpose), needs no new dependency, and keeps the controller out of the request path. - Cost (2): an entrypoint middleware `felhom-forwarded@file` removes every header a client could write a host, path or address into (`X-Forwarded-Host/-Uri/-Method/-Prefix/-Tls-Client-Cert(-Info)`, `Forwarded`, `True-Client-Ip`, `X-Client-Ip`, `X-Cluster-Client-Ip`, `Client-Ip`, `X-Original-Forwarded-For`) and fixes `X-Forwarded-Port: 443`. An app that falls back from X-Forwarded-Host reads `Host`, which names the same app. - Cost (1): the 19 leftmost readers carry a router middleware that removes the chain (`-xff`); measured (P1) that such an app then receives NO X-Forwarded-For and reads X-Real-Ip (traefik-set) or its peer — exactly as unforgeable as today. Shipped in the catalog BEFORE the controller release (harmless without the trust). **Docs quoted.** traefik (v3.6, `doc.traefik.io/traefik/reference/install-configuration/entrypoints`): *"forwardedHeaders. trustedIPs — Trust only forwarded headers from selected IPs"*; the forwardAuth reference: *"trustForwardHeader is deprecated … configure trusted IPs at the EntryPoint level using forwardedHeaders.trustedIPs"*. traefik source v3.6.7 (`pkg/middlewares/forwardedheaders/forwarded_header.go`): an untrusted peer's `X-Forwarded-*`/`X-Real-Ip` are DELETED; a trusted peer's are KEPT and `X-Real-Ip` is set only when absent. Cloudflare's HTTP-headers page: X-Forwarded-For — *"If an X-Forwarded-For header was already present in the request to Cloudflare, Cloudflare appends the IP address of the HTTP proxy connecting to Cloudflare"* — measured in M2. ## 3. The shape built (controller v0.286.x, `internal/infra` + `internal/stacks/infra.go`) - Network `felhom-tunnel` `172.16.253.0/29`, gateway `.1`, docker's allocation confined to `--ip-range 172.16.253.4/30`; cloudflared ALONE on it at `.2`, traefik at `.3` (and on `traefik-public`). Why 172.16.x: private (apps' default proxy lists — Tomcat, Rack, remote_ip — skip it) and outside docker's default pools (172.17–172.31, 192.168). **Found by the hand prototype on 9202 (P1): without the ip-range and traefik's own fixed address, traefik joining first was given `.2`.** - traefik `websecure`: `forwardedHeaders.trustedIPs: ["172.16.253.2/32"]` and `http.middlewares: [felhom-forwarded@file]`. - `EnsureBaseStack` reconciles a RUNNING traefik/cloudflared when the rendered files differ (recreate; refuses a rewrite that would drop the running certificate resolver); writes the middleware file before `traefik.yml`; moves cloudflared only once traefik is on the tunnel network. If the network cannot be made, nothing is trusted and cloudflared stays put. - **One rule for the controller** (`internal/web/clientaddr.go`): believed only when the TCP peer is traefik (docker DNS); the RIGHTMOST X-Forwarded-For entry is the hop traefik saw; that hop being `172.16.253.2` → `CF-Connecting-IP`. It holds for the dashboard (the whole chain) and the setup gate's forwardAuth request (only traefik's hop), and with or without the trust. Readers in apps: from the RIGHT, skipping trusted proxies — **a fixed count from the right is wrong for one of the two paths** (tunnel: 2nd from the right; LAN: 1st), so count-based readers (calibre-web, tandoor, wger) are left as they are. ## 4. What it gives the apps (sweep, READ in source — `sweep/sweep-1..4.md`) - **Real visitor with no change:** actualbudget, immich, dawarich, claper (tunnel), termix, **Home Assistant** (it treated every internet visitor as "local" — a `local_only` user could sign in from the internet; fixed by this), **Grimmory** (R-775: its IP lock becomes per visitor; the per-NAME lock stays). - **Need one setting to see it** (catalog, after the release): bookstack `APP_PROXIES`, kimai `TRUSTED_PROXIES`, zipline `CORE_TRUST_PROXY`/`CORE_TRUSTED_PROXIES`, vikunja `VIKUNJA_SERVICE_IPEXTRACTIONMETHOD=xff`, nextcloud `TRUSTED_PROXIES`; Jellyfin `KnownProxies` (no env — `network.xml`). - **Chain removed on their router (19):** adventurelog, audiobookshelf, code-server, docmost, emby, ghost, gokapi, komga, mealie, opengist, outline, paperless-ngx, papra, plant-it, rallly, romm, seerr, sparkyfitness, uptime-kuma. - **Stay "one address" on the tunnel, unforgeable:** X-Real-Ip readers (vaultwarden, grafana, gitea, crafty, homebox), count readers (calibre-web, tandoor, wger), peer readers (gramps-web, navidrome, radicale, wanderer, privatebin). - **Settings that must never be turned on** (they read the leftmost): glance `proxied`, karakeep `RATE_LIMITING_ENABLED`, onlyoffice ipfilter, vaultwarden `IP_HEADER=X-Forwarded-For`, PocketBase `UseLeftmostIP`, navidrome's reverse-proxy whitelist (header login), Plex `ALLOWED_NETWORKS`. ## 5. Risks stated - A box that rolls back to ≤ 0.285 keeps the new traefik (an old controller never rewrites a running traefik); its `clientIP` takes the LEFTMOST entry, which a stranger then writes — the dashboard's counter becomes dodgeable until the box moves forward. The floor never moves back; the self-update's crash roll-back is the window. Row filed. - A NEW catalog app that reads the leftmost entry is forgeable unless its onboarding finds it — checklist row added. - Emby: every tunnel visitor is "LAN" today and stays so (its chain is removed); Jellyfin likewise until `KnownProxies`.