R-753: the box tells visitors apart — cloudflared at a fixed address, traefik trusts only it, the controller reads the hop traefik saw

- felhom-tunnel network 172.16.253.0/29 (ip-range .4/30): cloudflared alone at .2, traefik at .3; traefik's websecure
  trusts forwarded headers from 172.16.253.2/32 only, and every request passes felhom-forwarded@file, which removes
  the client-writable host/path/address headers (X-Forwarded-Host/-Uri/-Method/-Prefix, Forwarded, True-Client-Ip, …)
  and fixes X-Forwarded-Port to 443 (measured: Cloudflare passes a client's X-Forwarded-Host/-Port).
- EnsureBaseStack reconciles a RUNNING traefik/cloudflared whose rendered files changed (recreate), refuses a rewrite
  that would drop a certificate resolver, and moves cloudflared only once traefik is on the tunnel network.
- clientIP: believed only when the TCP peer is traefik; the rightmost X-Forwarded-For entry (the hop traefik saw);
  the tunnel hop → CF-Connecting-IP (the edge refuses a client-sent one, measured 403). rateKey: IPv6 per /64.
  Dashboard login, claim, share and escrow counters key on it; the setup gate logs it.
- Dashboard login messages: keys, informal voice, both languages.
Red-proofs: RP-A1 (leftmost hop), RP-A2 (shared tunnel key), RP-A3 (no reconcile) — felhom.eu audits/visitors-2026-10-01/A.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-01 21:01:42 +02:00
parent c1b123c649
commit 1e8d045815
19 changed files with 1006 additions and 83 deletions
+72 -4
View File
@@ -71,6 +71,25 @@ type FileSpec struct {
Mode uint32 // os.FileMode bits (e.g. 0o600); uint32 keeps this package IO-free
}
// The tunnel's own network (R-753, `09` §3 decision 63 Part A). cloudflared sits ALONE on it at a FIXED address, so
// traefik can trust forwarded headers from that one address and from nothing else; traefik joins it as the second
// member. 172.16.0.0/16 is private (RFC 1918), so apps that count private addresses as proxies (Tomcat's
// RemoteIpValve, for one) skip it, and it is OUTSIDE docker's default address pools (they begin at 172.17), so docker
// never hands it to an app network. A /29 holds the gateway, cloudflared and traefik. BOTH members take FIXED addresses
// and docker's own allocation is confined to TunnelIPRange: measured 2026-10-01 on 9202, traefik joining first was given
// .2 — cloudflared's address — by docker's allocator.
// Pinned by TestTunnelConstantsAgree and TestRenderTraefik_TrustsOnlyTheTunnel.
const (
TunnelNetwork = "felhom-tunnel"
TunnelSubnet = "172.16.253.0/29"
TunnelGateway = "172.16.253.1"
TunnelAddr = "172.16.253.2" // cloudflared — the ONLY address traefik believes forwarded headers from
TunnelTraefikAddr = "172.16.253.3" // traefik's own place on the tunnel network
TunnelIPRange = "172.16.253.4/30" // where docker may put anything else: never .2 or .3
// ForwardedMiddleware is the entrypoint middleware every websecure request passes (RenderForwardedHeaders).
ForwardedMiddleware = "felhom-forwarded"
)
// TraefikData is the per-customer input for the traefik stack. ACMEEmail empty → no Let's Encrypt
// (traefik serves self-signed); CFAPIToken empty → HTTP-01 instead of Cloudflare DNS-01, and no .env.
// (Wildcard proactive issuance is driven by the controller route, NOT here — see RenderControllerRoute:
@@ -79,21 +98,34 @@ type FileSpec struct {
type TraefikData struct {
ACMEEmail string
CFAPIToken string
// Tunnel: the felhom-tunnel network exists with its fixed subnet — traefik joins it and trusts forwarded headers
// from TunnelAddr only. False keeps the old shape (trusts nothing), so a box whose network could not be made still
// routes (a compose naming an absent external network would not start at all).
Tunnel bool
}
type traefikTmpl struct {
TraefikData
Image string
Image string
TunnelNetwork string
TunnelAddr string
TunnelTraefikAddr string
ForwardedMiddleware string
}
// CloudflaredData is the per-customer input for the cloudflared stack (just the tunnel token).
type CloudflaredData struct {
CFTunnelToken string
// Tunnel: put cloudflared on felhom-tunnel at TunnelAddr (and on nothing else). The caller sets it only once traefik
// is on that network too — otherwise cloudflared could not reach "traefik" and the tunnel would be down.
Tunnel bool
}
type cloudflaredTmpl struct {
CloudflaredData
Image string
Image string
TunnelNetwork string
TunnelAddr string
}
func render(name string, data any) (string, error) {
@@ -108,7 +140,8 @@ func render(name string, data any) (string, error) {
// — only when a Cloudflare API token is set — a 0600 .env carrying CF_DNS_API_TOKEN (kept out of the
// compose file). The orchestrator additionally creates dynamic/, certs/ and an empty 0600 acme.json.
func RenderTraefik(d TraefikData) (map[string]FileSpec, error) {
td := traefikTmpl{TraefikData: d, Image: TraefikImage}
td := traefikTmpl{TraefikData: d, Image: TraefikImage, TunnelNetwork: TunnelNetwork, TunnelAddr: TunnelAddr,
TunnelTraefikAddr: TunnelTraefikAddr, ForwardedMiddleware: ForwardedMiddleware}
yml, err := render("traefik.yml.tmpl", td)
if err != nil {
return nil, err
@@ -132,7 +165,7 @@ func RenderTraefik(d TraefikData) (map[string]FileSpec, error) {
// RenderCloudflared returns the cloudflared stack files (compose only — no bind mounts; the tunnel
// token is the entire config). Caller deploys this only when a tunnel token is configured.
func RenderCloudflared(d CloudflaredData) (map[string]FileSpec, error) {
cd := cloudflaredTmpl{CloudflaredData: d, Image: CloudflaredImage}
cd := cloudflaredTmpl{CloudflaredData: d, Image: CloudflaredImage, TunnelNetwork: TunnelNetwork, TunnelAddr: TunnelAddr}
compose, err := render("cloudflared-compose.yml.tmpl", cd)
if err != nil {
return nil, err
@@ -275,6 +308,41 @@ http:
`, ServersTransportInsecure, ServersTransportInsecure)
}
// RenderForwardedHeaders returns the dynamic file defining the entrypoint middleware every websecure request passes
// (traefik.yml names it). Once traefik trusts the tunnel's address it KEEPS the forwarded headers that hop carries,
// and Cloudflare passes a client's own X-Forwarded-Host and X-Forwarded-Port through unchanged (measured,
// audits/visitors-2026-10-01/A/M2) — so this removes every header in which a client could write a host, a path or an
// address, and fixes the port: both paths reach traefik on 443. X-Forwarded-For stays (traefik appends the hop it saw;
// readers take it from the RIGHT), X-Real-Ip stays (traefik's peer — Cloudflare strips a client's, measured M2),
// CF-Connecting-IP stays (the controller believes it only when the hop is the tunnel). A request's Host header still
// says which app it is for, so an app that falls back from X-Forwarded-Host to Host gets the same name.
// Static — no per-customer input. Pinned by TestRenderForwardedHeaders_RemovesClientWritableHeaders.
func RenderForwardedHeaders() string {
return `# Traefik dynamic config — the forwarded-header clean-up every websecure request passes. Managed by felhom-controller.
# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten. traefik.yml names this middleware on its
# websecure entrypoint, so a missing file would break every route: it is written before traefik.yml.
# An empty value REMOVES the header (traefik headers middleware).
http:
middlewares:
` + ForwardedMiddleware + `:
headers:
customRequestHeaders:
X-Forwarded-Port: "443"
X-Forwarded-Host: ""
X-Forwarded-Uri: ""
X-Forwarded-Method: ""
X-Forwarded-Prefix: ""
X-Forwarded-Tls-Client-Cert: ""
X-Forwarded-Tls-Client-Cert-Info: ""
Forwarded: ""
True-Client-Ip: ""
X-Client-Ip: ""
X-Cluster-Client-Ip: ""
Client-Ip: ""
X-Original-Forwarded-For: ""
`
}
// RenderFileBrowserConfig returns a FileBrowser Quantum config.yaml with one source per registered
// storage path (each a named sidebar entry). Empty paths → a single default /srv source. Ported
// verbatim from internal/web/handlers.go.