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:
+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