diff --git a/CHANGELOG.md b/CHANGELOG.md index 774abcd..743ee9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## v0.312.0 — every public name answers only over https; HSTS one day (R-1033, P1) (2026-10-14) + +**MinAgent: 0.155.0** (unchanged — traefik's dynamic file only). **Hub:** none. **Catalog:** none. Design: felhom.eu +`09` §3 decision 244 (the operator made R-1033 P1) and the decision taken unattended that night (traefik, not +Cloudflare: the box keys lack „Zone Settings"); evidence felhom.eu `documentation/audits/https-only-2026-10-14/`. + +- **`dynamic/forwarded.yml` (`infra.RenderForwardedHeaders`):** `felhom-forwarded`, the middleware traefik's websecure + entrypoint already names, is now a CHAIN — `felhom-https-only` (redirectScheme https, permanent) then + `felhom-forwarded-headers` (the unchanged header clean-up + `Strict-Transport-Security: max-age=86400`, no subdomains, + no preload). A visitor who typed `http://` reaches Cloudflare over plain http; the tunnel delivers to :443 and says + so in `X-Forwarded-Proto: http`, which traefik keeps only from the trusted tunnel address — so the visitor gets a 301 + to the same https URL (path and query kept) BEFORE any app sees the request. An https visitor and a LAN visitor are + unchanged; a forged `X-Forwarded-Proto` from any other peer is ignored. Measured with traefik v3.7.13 on the bench. +- **`felhom-https-only-catchall`** (router, priority 1, `Header(X-Forwarded-Proto, http)`, `noop@internal`): a name no + app route matches is redirected over http too, while the same unknown name over https stays traefik's 404. +- traefik.yml is UNCHANGED (the entrypoint still names `felhom-forwarded@file`): the dynamic file is hot-loaded, no + traefik restart. The existing bring-up rewrites a drifted file on the next health tick (`ensureForwardedHeaders`). +- Tests: `TestRenderForwardedHeaders_HTTPSOnly` (red-proofed: the redirect removed from the chain → fails; HSTS a year → + fails), `TestEnsureForwardedHeaders_R1033_RewritesAnOldFile` (red-proofed: rewrite only when missing → fails); + `TestRenderForwardedHeaders_RemovesClientWritableHeaders` reads the headers through the chain. + ## v0.311.0 — after a power cut, an app repairs the files the cut left empty; the household sees lost photos (R-1007) (2026-10-14) **MinAgent: 0.155.0** (unchanged — the crash fact is the existing `GET /host/crash-guard`). **Hub:** v0.150.0 registers diff --git a/CONTEXT.md b/CONTEXT.md index c4ee237..7d4e43a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -9,6 +9,11 @@ Last updated: 2026-10-04 (v0.293.0 — the Docker-socket self-heal, R-860) +> **2026-10-14 (night) — v0.312.0, https only (R-1033, P1; `09` §3 decision 244).** DECIDED BY CC UNATTENDED — operator +> may reverse: the redirect lives in traefik on the box (`felhom-https-only` in the `felhom-forwarded` chain), not in +> Cloudflare, because every box's `cf_api_token` lacks „Zone Settings" (403, measured on all three zones). Cloudflare's +> „Always Use HTTPS" stays the operator's defence-in-depth option once the keys carry the permission. HSTS one day. + > **2026-10-08 (day) — UNRELEASED on main, ships with tomorrow's release (no release today: kernel night 8→9, `09` §3 > decision 178).** R-899 (decision 177): a daytime press never cancels the night's whole-guest backup — > `internal/quiesce/nightowed.go` ledger, owed tiers never fire the valve; a press reaches the agent as `trigger=manual`. diff --git a/REPORT.md b/REPORT.md index 99820d3..020cce3 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,15 +1,11 @@ -# REPORT — v0.311.0: after a power cut, an app repairs the files the cut left empty (R-1007, 2026-10-14) +# REPORT — v0.312.0: every public name answers only over https (R-1033, P1, 2026-10-14 night) -Baseline `acbbf4b` (0.310.0). Code `8d862e4`, security fix `3b85a0c` (before the build). Image -`felhom-controller:0.311.0` (sha256 `2a5550da…`). Design: felhom.eu `09` §3 decision 236; evidence -felhom.eu `documentation/audits/immich-repair-2026-10-14/`. +Baseline `9b95e8e` (0.311.0). Design: felhom.eu `09` §3 decision 244 + the unattended decision recorded there (traefik on +the box, because the box keys cannot change Cloudflare zone settings). Evidence: felhom.eu +`documentation/audits/https-only-2026-10-14/`. -- New template block `after_unclean_boot`; `internal/aftercrash` runs it once per start after an UNCLEAN host boot - (crashboot's verdict, after the 15-min crash grace). The household's lost-files note on the app page; the operator - event `app_repaired_after_crash` (counts only). MinAgent 0.155.0 unchanged. -- Tests: 15 guards red-proofed (each removed in turn convicted its test). Full suite and every controller gate green. -- **Security review of `8d862e4`:** compose's shared runner logs a failed command's stdout and puts stderr in the error, - which reached the event's `problem`. Fixed: a quiet runner, exit code only (`TestAUB_AFailingCommandLeaksNoOutput`). -- **Live (demo-hp 9201, a real power cut mid-upload):** 41 of 41 broken photos repaired in 10 s; 120 controls untouched. -- Open: R-1020 — the returned duration is 0 (the record's is right). Delivery to the N100 and Tester 1 waits on golden - 0.311.0 + the global floor. +- `forwarded.yml`: `felhom-forwarded` = chain [`felhom-https-only` (redirectScheme https, 301), `felhom-forwarded-headers` + (clean-up + HSTS 86400)]; catch-all router for unknown names over http. traefik.yml unchanged, no traefik restart. +- Tests red-proofed (three mutations, each seen failing). Green gate: build, vet, test all OK. +- Delivered by hand to Tester 1, demo-hp 9201 and demo-felhom 9201 for the proof (Tier 0). **Not vouched, floor not + moved** — the operator's morning step. diff --git a/controller/README.md b/controller/README.md index 3f5b3a6..b471af1 100644 --- a/controller/README.md +++ b/controller/README.md @@ -1005,7 +1005,7 @@ The controller stands up its own base stack — **traefik** (reverse proxy), **c cloudflared is only deployed when a tunnel token is configured. **Triggers**: a first-boot goroutine (after stack init) and an unconditional call on every `system-health` tick (self-heal — cheap when healthy thanks to the idempotency). `monitor.EffectiveProtected` mirrors the cloudflared condition so a LAN-only node (no tunnel token) doesn't report a perpetual "protected container not running" FAIL. -**The box tells visitors apart (v0.286.0, R-753, `09` §3 decision 63).** cloudflared sits ALONE on the `felhom-tunnel` network (`172.16.253.0/29`, docker's own allocation confined to `.4/30`) at the fixed address `172.16.253.2`; traefik joins it at `.3`. traefik's `websecure` entrypoint trusts forwarded headers from `172.16.253.2/32` only, so an app behind the tunnel receives `X-Forwarded-For: , , 172.16.253.2` and a LAN visitor's own address; every other peer's chain is dropped as before. Every websecure request passes the entrypoint middleware `felhom-forwarded@file` (`dynamic/forwarded.yml`, `RenderForwardedHeaders`), which removes the headers a client could write a host, path or address into (`X-Forwarded-Host/-Uri/-Method/-Prefix`, `Forwarded`, `True-Client-Ip`, `X-Client-Ip`, …) and fixes `X-Forwarded-Port: 443` — measured: Cloudflare passes a client's `X-Forwarded-Host`/`-Port` and appends to a client's `X-Forwarded-For`, and strips a client's `X-Real-IP`. **Readers take the visitor from the RIGHT, never the leftmost.** The controller's own rule is `internal/web/clientaddr.go` (`clientIP`): believed only when the TCP peer is traefik (docker DNS); the rightmost entry is the hop traefik saw; that hop being the tunnel address → `CF-Connecting-IP` (Cloudflare's edge refuses a client-sent one). Catalog apps that read the LEFTMOST entry carry a router middleware that removes the chain (`-xff`). If the network cannot be made, traefik keeps trusting nobody and cloudflared stays on `traefik-public`. Design + measurements: `felhom.eu/documentation/audits/visitors-2026-10-01/A/`. +**The box tells visitors apart (v0.286.0, R-753, `09` §3 decision 63).** cloudflared sits ALONE on the `felhom-tunnel` network (`172.16.253.0/29`, docker's own allocation confined to `.4/30`) at the fixed address `172.16.253.2`; traefik joins it at `.3`. traefik's `websecure` entrypoint trusts forwarded headers from `172.16.253.2/32` only, so an app behind the tunnel receives `X-Forwarded-For: , , 172.16.253.2` and a LAN visitor's own address; every other peer's chain is dropped as before. Every websecure request passes the entrypoint middleware `felhom-forwarded@file` (`dynamic/forwarded.yml`, `RenderForwardedHeaders`), which removes the headers a client could write a host, path or address into (`X-Forwarded-Host/-Uri/-Method/-Prefix`, `Forwarded`, `True-Client-Ip`, `X-Client-Ip`, …) and fixes `X-Forwarded-Port: 443` — measured: Cloudflare passes a client's `X-Forwarded-Host`/`-Port` and appends to a client's `X-Forwarded-For`, and strips a client's `X-Real-IP`. **Readers take the visitor from the RIGHT, never the leftmost.** The controller's own rule is `internal/web/clientaddr.go` (`clientIP`): believed only when the TCP peer is traefik (docker DNS); the rightmost entry is the hop traefik saw; that hop being the tunnel address → `CF-Connecting-IP` (Cloudflare's edge refuses a client-sent one). Catalog apps that read the LEFTMOST entry carry a router middleware that removes the chain (`-xff`). **https only (v0.312.0, R-1033, P1):** `felhom-forwarded` is a chain — `felhom-https-only` (301 to the same https URL when the trusted tunnel says `X-Forwarded-Proto: http`) then `felhom-forwarded-headers` (the clean-up + HSTS one day, no subdomains, no preload); the router `felhom-https-only-catchall` (priority 1, only `X-Forwarded-Proto: http`) redirects names no app route matches. An https or LAN visitor is unchanged. If the network cannot be made, traefik keeps trusting nobody and cloudflared stays on `traefik-public`. Design + measurements: `felhom.eu/documentation/audits/visitors-2026-10-01/A/`. **Backend transports — self-signed HTTPS backends (`ensureServersTransports` → `RenderServersTransports`, v0.83.0).** Traefik talks **HTTP** to app backends by default, which is correct for every catalog app that serves plain HTTP. The exception is an app that serves its **own self-signed TLS** on the internal docker bridge (the first is Crafty, HTTPS-only on `:8443`): Traefik must speak `https` to it *and* skip verifying a per-container self-signed cert (no CA to verify against; the hop never leaves the host). `insecureSkipVerify` is **not settable via Docker labels** in traefik v3 — it must live in static/file config — so `EnsureBaseStack` writes a file-provider dynamic file `dynamic/serverstransports.yml` defining a **named** transport `insecure-skip-verify` (write-if-changed; hot-loaded by the file watcher). An app **opts in per-service** via two catalog labels — `loadbalancer.server.scheme=https` + `loadbalancer.serverstransport=insecure-skip-verify@file` (the `@file` suffix is the cross-provider reference). Backend verification stays the default (ON) for every other service — there is deliberately **no global `insecureSkipVerify`** in `traefik.yml`. This write runs **outside** `ensureTraefik` (which early-returns when traefik is already up) so an established node still materializes the file on a self-heal tick. diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index 107e167..bcdc30f 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -88,6 +88,16 @@ const ( 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" + // R-1033 (P1, `09` §3 decision 244): the parts of the ForwardedMiddleware CHAIN. HTTPSOnlyMiddleware sends a visitor + // who reached Cloudflare over plain http (the tunnel's trusted X-Forwarded-Proto: http) to the same https URL; + // ForwardedHeadersMiddleware is the header clean-up plus HSTS. HTTPSOnlyCatchAllRouter does the same for a name no + // app route matches (an entrypoint middleware runs only on a matched router). + ForwardedHeadersMiddleware = "felhom-forwarded-headers" + HTTPSOnlyMiddleware = "felhom-https-only" + HTTPSOnlyCatchAllRouter = "felhom-https-only-catchall" + // HSTSMaxAgeSeconds is ONE DAY on purpose (decision 244 brief): no includeSubDomains, no preload — a longer value is + // the operator's decision (register row), because a browser keeps it even if the box is ever served without https. + HSTSMaxAgeSeconds = 86400 ) // TraefikData is the per-customer input for the traefik stack. ACMEEmail empty → no Let's Encrypt @@ -309,23 +319,49 @@ http: } // 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. +// (traefik.yml names it). Since R-1033 (P1, controller v0.312.0) ForwardedMiddleware is a CHAIN of two: +// +// 1. HTTPSOnlyMiddleware — every public name answers only over https. A visitor who typed http:// reaches Cloudflare +// over plain http; the tunnel still delivers to traefik's :443 and says so in X-Forwarded-Proto: http, which traefik +// keeps because the hop is the trusted tunnel address (any other peer's X-Forwarded-Proto is replaced by traefik's +// own — measured on the bench, audits/https-only-2026-10-14/1A-6). redirectScheme honours that header: 301 to the +// same https URL, path and query kept; an https visitor (X-Forwarded-Proto: https) and a LAN visitor are unchanged. +// The redirect happens before any app sees the request, so a login page is never served — and a password never +// sent — over http. The Cloudflare-side switch („Always Use HTTPS") needs a zone permission the box keys lack. +// 2. ForwardedHeadersMiddleware — the clean-up: 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. It also sets HSTS for HSTSMaxAgeSeconds, no subdomains, no preload. +// +// HTTPSOnlyCatchAllRouter (priority 1, only X-Forwarded-Proto: http) redirects a name no app route matches, so an +// unknown name over http is a redirect too while an unknown name over https stays traefik's 404. +// Static — no per-customer input. Pinned by TestRenderForwardedHeaders_RemovesClientWritableHeaders and +// TestRenderForwardedHeaders_HTTPSOnly (both read the chain, so removing either half fails a test). 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 + return `# Traefik dynamic config — the chain every websecure request passes: https only (R-1033), then the forwarded-header +# clean-up + HSTS. Managed by felhom-controller. +# WARNING: auto-generated at base-infra bring-up. Manual edits are overwritten. traefik.yml names ` + ForwardedMiddleware + ` 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 + `: + chain: + middlewares: + - ` + HTTPSOnlyMiddleware + ` + - ` + ForwardedHeadersMiddleware + ` + ` + HTTPSOnlyMiddleware + `: + redirectScheme: + scheme: https + permanent: true + ` + ForwardedHeadersMiddleware + `: headers: + stsSeconds: ` + fmt.Sprint(HSTSMaxAgeSeconds) + ` + stsIncludeSubdomains: false + stsPreload: false customRequestHeaders: X-Forwarded-Port: "443" X-Forwarded-Host: "" @@ -340,6 +376,14 @@ http: X-Cluster-Client-Ip: "" Client-Ip: "" X-Original-Forwarded-For: "" + routers: + ` + HTTPSOnlyCatchAllRouter + `: + rule: "Header(` + "`X-Forwarded-Proto`, `http`" + `)" + priority: 1 + entryPoints: + - websecure + service: noop@internal + tls: {} ` } diff --git a/controller/internal/infra/tunnel_test.go b/controller/internal/infra/tunnel_test.go index e774523..44e0f20 100644 --- a/controller/internal/infra/tunnel_test.go +++ b/controller/internal/infra/tunnel_test.go @@ -119,7 +119,7 @@ func TestRenderForwardedHeaders_RemovesClientWritableHeaders(t *testing.T) { if err := yaml.Unmarshal([]byte(RenderForwardedHeaders()), &doc); err != nil { t.Fatalf("not YAML: %v", err) } - h := doc["http"].(map[string]any)["middlewares"].(map[string]any)[ForwardedMiddleware].(map[string]any)["headers"].(map[string]any)["customRequestHeaders"].(map[string]any) + h := doc["http"].(map[string]any)["middlewares"].(map[string]any)[ForwardedHeadersMiddleware].(map[string]any)["headers"].(map[string]any)["customRequestHeaders"].(map[string]any) for _, name := range []string{"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"} { if v, ok := h[name]; !ok || v != "" { @@ -135,3 +135,46 @@ func TestRenderForwardedHeaders_RemovesClientWritableHeaders(t *testing.T) { } } } + +// R-1033 (P1): every websecure request passes the https-only redirect FIRST, then the clean-up + HSTS — read through the +// chain the entrypoint names, so a chain that drops either half (or names a middleware that is not defined) fails here. +// The CONSEQUENCE (an http visitor gets 301 to the same https URL, an https or LAN visitor is untouched, a forged header +// from an untrusted peer is ignored) was measured with traefik v3.7.13 on the bench: audits/https-only-2026-10-14/1A-6. +func TestRenderForwardedHeaders_HTTPSOnly(t *testing.T) { + var doc map[string]any + if err := yaml.Unmarshal([]byte(RenderForwardedHeaders()), &doc); err != nil { + t.Fatalf("not YAML: %v", err) + } + mws := doc["http"].(map[string]any)["middlewares"].(map[string]any) + chain, ok := mws[ForwardedMiddleware].(map[string]any)["chain"].(map[string]any) + if !ok { + t.Fatalf("%s must be a chain, got %v", ForwardedMiddleware, mws[ForwardedMiddleware]) + } + parts, _ := chain["middlewares"].([]any) + if len(parts) != 2 || parts[0] != HTTPSOnlyMiddleware || parts[1] != ForwardedHeadersMiddleware { + t.Fatalf("the chain must be [%s, %s] in that order, got %v", HTTPSOnlyMiddleware, ForwardedHeadersMiddleware, parts) + } + for _, p := range parts { + if _, ok := mws[p.(string)]; !ok { + t.Errorf("the chain names %v but the file does not define it — every route on the box would break", p) + } + } + rs, ok := mws[HTTPSOnlyMiddleware].(map[string]any)["redirectScheme"].(map[string]any) + if !ok || rs["scheme"] != "https" || rs["permanent"] != true { + t.Errorf("%s must be redirectScheme{scheme: https, permanent: true}, got %v", HTTPSOnlyMiddleware, mws[HTTPSOnlyMiddleware]) + } + h := mws[ForwardedHeadersMiddleware].(map[string]any)["headers"].(map[string]any) + if h["stsSeconds"] != HSTSMaxAgeSeconds || HSTSMaxAgeSeconds != 86400 { + t.Errorf("HSTS must be one day (86400) until the operator rules otherwise, got %v", h["stsSeconds"]) + } + if h["stsIncludeSubdomains"] != false || h["stsPreload"] != false { + t.Errorf("HSTS must not include subdomains nor preload, got %v / %v", h["stsIncludeSubdomains"], h["stsPreload"]) + } + r, ok := doc["http"].(map[string]any)["routers"].(map[string]any)[HTTPSOnlyCatchAllRouter].(map[string]any) + if !ok { + t.Fatalf("the catch-all router for unknown names over http is missing") + } + if r["rule"] != "Header(`X-Forwarded-Proto`, `http`)" || r["priority"] != 1 || r["service"] != "noop@internal" { + t.Errorf("catch-all must match ONLY X-Forwarded-Proto: http, at priority 1, to noop@internal; got %v", r) + } +} diff --git a/controller/internal/stacks/infra_tunnel_test.go b/controller/internal/stacks/infra_tunnel_test.go index 50f62fb..041b869 100644 --- a/controller/internal/stacks/infra_tunnel_test.go +++ b/controller/internal/stacks/infra_tunnel_test.go @@ -302,3 +302,27 @@ func TestRenderCloudflared_HasAReadinessHealthcheck(t *testing.T) { } } } + +// R-1033 (P1): a box that carries the pre-v0.312.0 forwarded.yml (headers only, no https-only redirect), or a hand-edited +// one, gets the current file back on the next bring-up — so a released template reaches every box and drift does not +// survive a tick. The file is hot-loaded by traefik's file provider (watch: true): no traefik restart. +func TestEnsureForwardedHeaders_R1033_RewritesAnOldFile(t *testing.T) { + dir := t.TempDir() + dyn := filepath.Join(dir, "dynamic") + if err := os.MkdirAll(dyn, 0o755); err != nil { + t.Fatal(err) + } + old := "http:\n middlewares:\n " + infra.ForwardedMiddleware + ":\n headers:\n customRequestHeaders:\n X-Forwarded-Port: \"443\"\n" + touch(t, filepath.Join(dyn, "forwarded.yml"), old) + m := &Manager{logger: log.New(io.Discard, "", 0)} + if err := m.ensureForwardedHeaders(dir); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(filepath.Join(dyn, "forwarded.yml")) + if string(got) != infra.RenderForwardedHeaders() { + t.Fatalf("an old forwarded.yml must be replaced by the current render (https-only chain); got:\n%s", got) + } + if !strings.Contains(string(got), infra.HTTPSOnlyMiddleware) { + t.Fatal("the rewritten file must carry the https-only redirect") + } +}