v0.286.0: CHANGELOG + README (R-753 visitors apart, R-772, R-773)
gates / gates (push) Successful in 27s
gates / gates (push) Successful in 27s
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:
@@ -1,3 +1,43 @@
|
||||
## v0.286.0 — the box tells visitors apart (R-753); a health check that could not run is not "healthy" (R-772); a restore keeps the sign-up lock (R-773) (2026-10-01)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). New strings: `login.msg.*` (3, both languages).
|
||||
**Catalog companion:** 19 apps whose software reads the LEFTMOST `X-Forwarded-For` carry a router middleware that removes
|
||||
the chain (app-catalog `04e9516`..`50e4fb4`), pushed BEFORE this release — without it, trusting the tunnel would let a
|
||||
stranger write the address those apps believe. **Every installed box's traefik and cloudflared are recreated once** when
|
||||
it takes this release (routing pauses a few seconds; the tunnel reconnects).
|
||||
|
||||
- **R-753 — `09` §3 decision 63 (operator ruling: extend the box's own gate; first the box tells visitors apart).**
|
||||
Measured first (`felhom.eu/documentation/audits/visitors-2026-10-01/A/`, demo-hp's real tunnel): every tunnel visitor
|
||||
reached traefik as cloudflared's one docker-assigned address; Cloudflare APPENDS the visitor to a client-written
|
||||
`X-Forwarded-For`, passes a client's `X-Forwarded-Host`/`-Port`, strips a client's `X-Real-IP`, and refuses a
|
||||
client-sent `CF-Connecting-IP` at the edge (403).
|
||||
- `felhom-tunnel` network `172.16.253.0/29` (allocation confined to `.4/30` — measured: traefik joining first was
|
||||
given `.2`): cloudflared alone at `.2`, traefik at `.3`. traefik's `websecure` trusts forwarded headers from
|
||||
`172.16.253.2/32` only; the entrypoint middleware `felhom-forwarded@file` removes `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` (`internal/infra`).
|
||||
- `EnsureBaseStack` RECONCILES a running traefik/cloudflared whose rendered files changed (recreate; refuses a rewrite
|
||||
that would drop the running certificate resolver), writes the middleware file before `traefik.yml`, and moves
|
||||
cloudflared only once traefik is on the tunnel network. No network → the old shape, nothing trusted.
|
||||
- `clientIP` (`internal/web/clientaddr.go`): believed only when the TCP peer is traefik (resolved by name); the
|
||||
rightmost `X-Forwarded-For` entry — the hop traefik saw; the tunnel hop → `CF-Connecting-IP`. `rateKey`: IPv6 per
|
||||
/64. The dashboard login, claim code, share password and escrow re-auth counters key on it: **a stranger's five
|
||||
wrong dashboard passwords lock only the stranger** (before: every tunnel visitor shared one key and the household
|
||||
was locked out of its own dashboard for a minute). The setup gate logs the visitor.
|
||||
- The dashboard login's messages are keys, informal voice, both languages (`login.msg.*`; the placeholder too).
|
||||
- Tests: `TestClientIP_*`, `TestLogin_StrangerThroughTheTunnelLocksOnlyHimself`, `TestLoginMessagesFollowTheReader`,
|
||||
`TestRateKey_IPv6Per64`, `TestPeerResolver_*`, `TestTunnelConstantsAgree`, `TestRenderTraefik_TrustsOnlyTheTunnel`,
|
||||
`TestRenderCloudflared_AloneOnTheTunnel`, `TestRenderForwardedHeaders_*`, `TestEnsureTunnelNetwork_*`,
|
||||
`TestEnsureTraefik_*`, `TestEnsureCloudflared_*`, `TestEnsureBaseStack_TunnelOrder`;
|
||||
`TestLoginRateLimit_RotatingXFF_NotLimited` reversed on purpose (a direct peer's rotating XFF no longer evades).
|
||||
Red-proofs RP-A1 (leftmost hop), RP-A2 (the tunnel hop as the key), RP-A3 (no reconcile) — each fails.
|
||||
- **R-772:** a health probe that finds no container to probe records `healthy: false, not_checked: true` (was
|
||||
`healthy: true`, then 5 minutes of silence), is looked at again on the 10-second cycle, and the app page says the
|
||||
check did not run. The state stays the containers' (`probeSaysUnhealthy`), so R-630 holds. Red-proof RP-D1.
|
||||
- **R-773:** a REMOVED app restored from its backup gets its sign-up lock back — the record (`opened_by: restore`) and
|
||||
the block written before anything starts; the loop sets the app's own switch. An installed app the household never
|
||||
closed keeps what it had (decision 49). Red-proof RP-D2.
|
||||
|
||||
## v0.285.0 — a box keeps two controller versions (decision 56); a crash in the update clean-up fixed (2026-10-01)
|
||||
|
||||
**MinAgent: 0.131.0** (unchanged). Needs hub v0.123.0 (unchanged). No new strings.
|
||||
|
||||
+15
-6
@@ -975,13 +975,15 @@ reach it explicitly. The **userdata skeleton is catalog-derived** (`DeriveUserda
|
||||
|
||||
The controller stands up its own base stack — **traefik** (reverse proxy), **cloudflared** (external tunnel), **filebrowser** — instead of relying on the bare-metal `scripts/docker-setup.sh` (which a Proxmox-provisioned guest never runs). `internal/infra` renders the compose + config files from `controller.yaml` via embedded `text/template`s (lifted from `docker-setup.sh`); image tags are **pinned constants there** (`TraefikImage`/`CloudflaredImage`/`FileBrowserImage`) and the web FileBrowser sync path delegates to the same renderers, so the pinned versions can never diverge.
|
||||
|
||||
`Manager.EnsureBaseStack()` creates the `traefik-public` network, then deploys traefik → cloudflared → filebrowser under `${stacks_dir}/<name>`. It is:
|
||||
`Manager.EnsureBaseStack()` creates the `traefik-public` network and the `felhom-tunnel` network, writes the forwarded-header clean-up, then deploys traefik → cloudflared → filebrowser under `${stacks_dir}/<name>`. It is:
|
||||
- **single-flight** (a `TryLock` guard — it's called from both first boot and every health tick, so overlapping runs must not race on the same stack dir),
|
||||
- **idempotent** (skips a stack whose container is already running; never overwrites an existing filebrowser compose, preserving the storage mounts `SyncFileBrowserMounts` manages),
|
||||
- **idempotent, and it reconciles** — filebrowser is skipped when running (never overwriting its compose, preserving the storage mounts `SyncFileBrowserMounts` manages); **traefik and cloudflared are rewritten and recreated when their rendered files differ from the ones on disk** (v0.286.0 — a release that changes their template reaches every installed box; equal files → nothing). A traefik rewrite that would drop the running certificate resolver is refused and logged.
|
||||
- **non-fatal** (logs, never crashes the controller).
|
||||
|
||||
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: <client-written…>, <real visitor>, 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 (`<router>-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/`.
|
||||
|
||||
**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.
|
||||
|
||||
> **Mount prerequisite (Section-G):** the controller writes these stacks under `/opt/docker/stacks` *inside its container*, but `docker compose up` runs on the **guest** Docker daemon. The golden's controller-bootstrap (`felhom-agent` `build-golden.sh`) therefore bind-mounts that path **same-path** (`-v /opt/docker/stacks:/opt/docker/stacks`) so the daemon resolves every relative bind source — without it, all bind-mounted stacks (base infra and customer apps) silently break.
|
||||
@@ -1055,7 +1057,10 @@ way an app that declares no check is judged: every container running and none re
|
||||
settle window. That matters most during an update — `verifying` waits on this same probe, and before
|
||||
v0.262.0 a stack with no probe target could only ever time out, so a SUCCESSFUL update ended with
|
||||
`failAndHold` stopping a working app. Such a stack also now records a probe RESULT saying why no
|
||||
check ran, instead of nothing.
|
||||
check ran, instead of nothing. **Since v0.286.0 (R-772) that record reads `healthy: false, not_checked: true`** — a
|
||||
check that did not run never says healthy — and is looked at again on the 10-second cycle; the stack's STATE is still
|
||||
the containers' (a not-checked record never overrides running → unhealthy), and the app page says „Nem futott
|
||||
egészségellenőrzés…".
|
||||
|
||||
Multiple checks per app are supported (all must pass). The probe scheduler runs every 10 seconds; per-app intervals default to 5 minutes and are configurable via `healthcheck.interval` in `.felhom.yml`. Probe results are stored in `Stack.HealthProbe` and exposed via the API. Failed probes override the stack state to `StateUnhealthy`; the override clears automatically when the next probe passes.
|
||||
|
||||
@@ -1967,6 +1972,9 @@ that folder is never a dead end, and an install never runs into it silently (R-6
|
||||
into app.yaml + one `compose up -d`, or a command) set when the gate opens, after the block. The window lifts and the
|
||||
loop re-applies it. `POST /apps/<slug>/close-signup` for an app installed before the rule: lock record
|
||||
(`opened_by: close-signup`), block, switch; never a gate. Code: `internal/stacks/after_setup.go`.
|
||||
- **A removed app restored from its backup gets its lock back (v0.286.0, R-773)** — with no app.yaml left, the restore
|
||||
(`PersistUnitRedeployConfig`) writes the lock record (`opened_by: restore`) and the block BEFORE anything starts; the
|
||||
loop sets the app's own switch. An installed app the household never closed keeps what it had (decision 49).
|
||||
- **Probes (v0.282.0, R-715)** — `field` may index lists; `done_status:` treats one non-200 status as done.
|
||||
- **R-713 (v0.281.0).** `after_install` refuses a code-bound value holding a quote, backslash, `$`, `{`, `}`, backtick
|
||||
or line break; `${NAME|base64}` passes any value safely.
|
||||
@@ -3086,9 +3094,10 @@ race where a new `felhom.<domain>` cert appears in CT logs minutes before any pa
|
||||
- **Anti-brute-force**: per-source + global counter, 5 failures → 15-minute lockout (both scopes),
|
||||
raising the allowlisted `claim_lockout` event. Pre-auth CSRF is an HMAC over `web.session_secret`
|
||||
(fixes the CTRL-007 bare-double-submit weakness). The per-source key is the client IP resolved by
|
||||
the shared `clientIP(r)` helper — XFF first-hop, else `RemoteAddr` with the ephemeral **port
|
||||
stripped** (v0.129.0 F-B; keying on the raw `RemoteAddr` let distinct direct connections evade the
|
||||
counter). The login form and the escrow wizard re-auth share the same helper/key.
|
||||
the shared `rateKey(r)` helper over `clientIP(r)` (`clientaddr.go`, v0.286.0 R-753): the hop traefik saw, or
|
||||
`CF-Connecting-IP` when that hop is the tunnel; a direct peer's own headers are never believed; the ephemeral
|
||||
**port stripped** (v0.129.0 F-B); IPv6 counted per /64. The login form (5 wrong per visitor per minute — a
|
||||
stranger locks only himself), the share password and the escrow wizard re-auth share the same key.
|
||||
- **Delivery**: the hub bakes `web.claim_code_{hash,generation,issued_at}` into the Day-0
|
||||
controller.yaml (gate-from-first-boot) and serves the freshest state in the report ACK
|
||||
(`report/claim_sync.go` caches it idempotently by generation — newer advances, same/older/nil
|
||||
|
||||
Reference in New Issue
Block a user