Hub v0.158.0 code (built, NOT deployed): agent-only vouch, R-895 snapshot alarms, the hub's own 90-day access log (R-1140 A); installer --rotate-recovery-only (R-1141); 02/03/05/09 + break-glass
gates / gates (push) Successful in 6m45s

Hub: the R-120 golden guard judges only a CHANGED golden selection; offsite_snapshots_vanished /
_unexplained (operator-only, hub-origin) on the 07:10 listing; internal/accesslog writes path-only
lines to /data/access-log, 90 days. Installer: --rotate-recovery-only runs step 4b alone (rides
the next installer tag). 03: the orphan check runs after a failed backup (agent 6545ccc). 02: the
allow-list is empty, appdata_folders (controller v0.329.0). All red-proofed.
This commit is contained in:
admin committed 2026-10-17 23:31:34 +02:00
1 parent 5a5486104a
commit 7d64038f02
27 files changed
+1813 -77

No files matched your search

@@ -738,7 +738,8 @@ this app's service has EXACTLY that setting on the allow-list: `privileged`, `ca
`include`, ANY `extends`, a `provider` service, `post_start`/`pre_stop` hooks, an `env_file`/`configs`/`secrets` file
outside the stack dir, a named volume with `driver`/`driver_opts`/`external`/`name`, a `${VAR}` in any of those keys,
and binds: the Docker socket anywhere, mount propagation, a `${VAR}` in a bind's options, and any source outside the
data roots the box itself gives — `${HDD_PATH}/appdata/`, `${USERDATA_PATH}`, `${IMPORT_PATH}` (a bind source may use
data roots the box itself gives — `${HDD_PATH}/appdata/<the app's own folder>/` (v0.329.0, R-1142), `${USERDATA_PATH}`,
`${IMPORT_PATH}` (a bind source may use
only those variables, as its leading token; each root must lie under `/mnt/felhom-drives/` or the system data path; on
the box a source is judged where it really points, through its nearest existing ancestor, so a symlink cannot carry it
out).
@@ -747,9 +748,17 @@ out).
Gitea — the threat R-971 names (whoever can push to the catalog can take every guest) — cannot add to it; a new entry
needs a controller release. An entry is app + service + exact setting, and holds only while that service runs the
entry's image REPOSITORY (a literal reference), with the image's own entrypoint and command, and the exact
`healthcheck.test` the entry names — so a push cannot keep the allowed right and change what runs with it. Today: one
entry, homepage's read-only Docker socket (the survey of all 123 catalog files found nothing else,
`audits/agent-allowlist-rotation-2026-10-17/P2/survey.md`). The catalog's CI gate `check-privileges.py` mirrors the
`healthcheck.test` the entry names — so a push cannot keep the allowed right and change what runs with it. **Since
v0.329.0 the list has NO entry** (R-981): homepage's read-only Docker socket — the one entry of v0.328.0 — served no
Felhom feature (its rendered `docker.yaml` is empty, nothing carries `homepage.*` labels; measured on demo-hp,
`audits/night-2026-10-18/P1/`), so the template dropped it and so did the list. The entry-binding rules stay pinned on a
test list, so a future entry is held to them. **The app's own folder (R-1142):** the list's `appdata_folders` map names
an app's folder under `${HDD_PATH}/appdata/` when it is not the app's name — one entry, `paperless-ngx → paperless` (the
survey of every template, the drill catalog and the three test boxes found no other). It is compiled in with the list,
because the catalog is the threat and must not name the folder; an app NAMED like a folder the map gives to another app
gets no `HDD_PATH` root, and one folder for two apps refuses to load. `USERDATA_PATH` is always the controller's own
derivation from `HDD_PATH`, never the app's env — a deploy field a catalog push adds would otherwise name any folder
(both found by the security review of the pushed commit, 2026-10-18). The catalog's CI gate `check-privileges.py` mirrors the
check with a byte-identical copy of the list (pinned from both sides); the BOX is the authority.
**What a refusal does.** Nothing is written. A refused correction or update leaves the app on its last good compose,
@@ -760,8 +769,8 @@ that cannot read the compose refuses. The sync never copies a catalog template n
**Out of scope, stated.** The controller's own infra stacks (traefik, cloudflared, filebrowser, samba) and its override
files (setup gate, family gate, install hold, media library) are rendered by the controller, not the catalog. Published
host ports are not a class (crafty-controller's game ports). Known limit: under `${HDD_PATH}/appdata/` one app can
name another app's folder (a per-app rule needs a name map — paperless-ngx keeps `appdata/paperless`), R-1142.
host ports are not a class (crafty-controller's game ports). `${USERDATA_PATH}` and `${IMPORT_PATH}` are shared by design:
they are the household's own folders, which every media app reads.
**Pinned by** `internal/privpolicy/privpolicy_test.go` (each class refused, with positive controls),
`stacks.TestAllowList_IsWiredIntoEveryComposeWriter` (AST: every compose writer and every env decision point asks
+1 -1
View File
@@ -113,7 +113,7 @@ fixed file, delivered by the signed config bundle):
| `FELHOM_CONTROLLERSWAP` | the managed controller update | exact vmid; image ref pinned to `gitea.dooplex.hu/admin/felhom-controller:X.Y.Z` for the image check; the inspect template stays free text; **the write goes through `felhom-priv-apply controller-image <vmid>`** (the ref on stdin, re-checked as root against the same pattern — R-861 (a) A1, `09` §3 decision 165, agent after v0.150.0) | guest-scoped: a compromised agent can restart the guest's bootstrap and choose an OLDER version of OUR controller image (a downgrade); it can no longer hand the guest any other image. Until the A1 bundle reached a box, the agent's in-guest `tee` let it write any ref (sudo cannot see stdin — the 2026-10-06 night correction, `audits/night-burndown-2026-10-06/design-R-861.md`) |
| `FELHOM_STALELOCK` / `FELHOM_SCRATCH_TEARDOWN` | stale-lock clear; failed restore-test scratch | exact vmid; the scratch band `99000[0-9]` was already exact | — |
| `FELHOM_VZPARTIAL` | remove a dead half-written vzdump (R-1006, agent v0.156.1) | ONE line: `felhom-vzdump-partial-clean <storage> <vmid> <half-written name of that vmid>`; the root helper (a config-bundle file) re-checks: a `dir` storage from `storage.cfg`, the name of that vmid (`.tar.dat`, `.vma.dat`, `.tmp`), not a symlink, directly in `<path>/dump`, no vzdump on the host, nothing holds it open, dead (before this boot or quiet 120 s) | a compromised agent can delete a dead partial — never a finished archive, never a live one |
| `FELHOM_THINCHECK` | name an ORPHAN thin device around a whole-guest backup (R-1012, agent v0.159.0) | ONE line: `felhom-thin-check <vg> <pool>`; the root helper (a config-bundle file) lists LVM's thin ids before and after and the pool's ids through a metadata snapshot it reserves and releases; it refuses (exit 3) when someone else holds a metadata snapshot and sends no other pool message. The agent reports an id in the pool and in neither LVM list (two reads) in the backup failure's text; the repair (`runbooks/whole-guest-backup-orphan-thin.md`) stays human | a compromised agent can READ the pool's device ids — it cannot delete, create or change a thin device |
| `FELHOM_THINCHECK` | name an ORPHAN thin device when a whole-guest backup has failed (R-1012, agent v0.159.0) | ONE line: `felhom-thin-check <vg> <pool>`; the root helper (a config-bundle file) lists LVM's thin ids before and after and the pool's ids through a metadata snapshot it reserves and releases; it refuses (exit 3) when someone else holds a metadata snapshot and sends no other pool message. The agent runs it ONLY when a snapshot-mode vzdump has failed (start or task) — the moment the answer is needed; a successful backup never takes the pool's metadata snapshot (v0.159.0/v0.159.1 also ran it before every snapshot backup; the next agent release after v0.159.1 drops that pre-run check). It reports an id in the pool and in neither LVM list (two reads) in the backup failure's text; the repair (`runbooks/whole-guest-backup-orphan-thin.md`) stays human | a compromised agent can READ the pool's device ids — it cannot delete, create or change a thin device |
| `FELHOM_MOUNT` `unit-forget` | forget a decommissioned drive that is gone (R-1124, agent v0.159.0) | `felhom-priv-apply unit-forget mnt-<…>.mount` removes ONE unit file, only a felhom by-UUID drive unit (not a network unit) that is disabled, not mounted, and whose `/dev/disk/by-uuid` link is absent; the agent calls it at start and on the 20-s tick for drives whose intent is `decommissioned` | a compromised agent can delete the unit of an ABSENT, unmounted drive — the same effect as unplugging it; a present drive keeps its unit |
| `FELHOM_WG` | the off-site tunnel | conf via `felhom-priv-apply wg` (only the keys `renderConf` writes; no `PostUp`/`PreUp`/`DNS`/`Table`; `/32` only) | — |
| `FELHOM_SELFUPDATE` | commit / rollback of the A/B flip | **`apply` removed**: the flip runs only inside `felhom-os-apply agent_update`, after the operator signature, host, window and nonce are checked as root and the staged bytes are hashed ONCE and copied to a root-owned dir (`/var/lib/felhom-os-apply/agent-update/`); the wrapper accepts only that dir | — |
@@ -132,7 +132,9 @@ touches WAF.
the floor carries it to every box (MinAgent declared), raised after the release ran on the three test boxes and the box
walk is green; the golden is baked weekly and vouched by the operator; a new box installs the vouched golden and updates
itself to the floor (R-1052: not yet proven end to end). Setting the global floor today needs the operator page with the
operator's password — there is no scoped route for CC (R-1051).
operator's password — there is no scoped route for CC (R-1051). **An agent can be vouched mid-week; the golden stays
weekly** (hub v0.158.0): the R-120 golden guard judges only a CHANGED golden selection, so a Day-0 save that keeps the
vouched golden (same version and sha) saves a new agent even while that golden is behind the fleet.
**The controller floor and its agent requirement (hub v0.112.0, R-472).** The managed controller floor
rides the report ACK. `store.ResolveManagedFloor` decides per box whether to serve it, from three
@@ -505,6 +507,21 @@ namespace (`runbooks/RUNBOOK-hub-db-offsite-backup.md`). Pinned by `internal/dbs
`cmd/hub/r173_wiring_test.go`.
### 16.4 The hub's own access log [DESIGN — hub v0.158.0, operator decision A on R-1140, 2026-10-17 22:26]
**Why:** R-954 asked „did anyone use the old hub key before 2026-10-12?" and nobody could answer — the ingress
controller's container log covers about half a day (k3s keeps 10 MiB × 5) and the hub logged no requests. **What is
kept:** one line per request the hub serves — time (UTC), method, the PATH (the query string is dropped; the self-bind
capability in `/bind/<token>` and any path segment of 32+ characters are written as `[redacted]`), status, duration,
the socket peer, the X-Forwarded-For hops that parse as IP addresses (recorded, never trusted), the KIND of credential
presented and the identity kind the hub's auth established (customer, host, global, session, basic, token) — never a
key, token, password, cookie or passphrase value (`TestAccessLog_NeverWritesASecret`). kube-probe's own `/healthz`
is not logged. **Where and how long:** `<data_dir>/access-log/access-YYYY-MM-DD.log` on the hub's own volume (`/data`,
the `hub-data` PVC; no new env var, no manifest change), one file per UTC day, mode 0600; files older than **90 days**
are deleted at start-up and at each new day (`TestAccessLog_Keeps90DaysThenDeletes`). Measured 2026-10-17 from the
ingress log: ≈ 1,900 hub requests a day at ≈ 110 bytes a line → ≈ 0.2 MB a day, ≈ 19 MB for 90 days. A logging
failure never fails or delays a request: lines go through a non-blocking buffer to one writer (`internal/accesslog`).
## 17. Operator access tokens [DESIGN — hub v0.152.0, operator ruling 2026-10-15 11:38 (`09` §3 decision 251)]
**The problem.** Scripts — Claude Code's floor step first — drove the hub with the operator's own password (HTTP Basic,
@@ -969,9 +969,13 @@ its length, and both fixes cost something the household would notice — operato
*Route:* the floor save on the operator page, with CC's scoped token (decision 251, `05` §17); until the token
works live, the operator password for the floor only (R-1051).
3. **Agent releases (and their bundles) still stop for the operator's vouch;** a floor whose MinAgent is above the
vouched agent is never set.
vouched agent is never set. **An agent can be vouched mid-week:** a Day-0 save that leaves the golden as vouched
(same version and sha) is not refused by the R-120 golden guard even while the golden is behind the fleet (hub
v0.158.0, `TestVouchAgentOnlyWithStaleGoldenIsSaved`).
4. **The golden is baked weekly** (Sunday night, `runbooks/nightly-rotation.md`); the operator vouches it. A new box
installs the vouched golden and updates itself to the floor (not yet proven end to end — R-1052).
installs the vouched golden and updates itself to the floor (not yet proven end to end — R-1052). **The golden
stays weekly:** a CHANGED golden selection is still refused when it is older than the fleet's newest controller
(`TestVouchGoldenChangedToOlderIsStillRefused`).
5. The push gate judges the golden by its AGE (older than 7 days while the floor is above it), not by the newest
CHANGELOG heading (`scripts/golden_currency_gate.py`).
251. **Scripts use scoped hub tokens, not the operator password; CC's floor step uses its own.** *Operator ruling
+13 -4
View File
@@ -86,16 +86,25 @@ Fix the cause; the three layers are the safety net, not the cure.
- **Keep the build-server PVE token fresh.** In the spike incident the build-server-cached PVE token
(`kisfenyo@192.168.0.180:~/felhom-agent-demo.json`) was **stale (401)** and left no non-SSH lever
before the web-console path was used. A working break-glass path independent of sshd must always exist.
- **Rotate the console credential** with `felhom-host-install … --rotate-recovery` (regenerates +
re-vaults; a plain re-run does NOT rotate, to avoid stranding your saved copy). Full auto-rotation is
a future item.
- **Rotate the console credential ALONE** with `sudo bash felhom-host-install.sh --rotate-recovery-only`
(R-1141; try it first with `--dry-run`). It runs step 4b and nothing else: a new random `root@pam`
password, set with `chpasswd` and re-vaulted to the hub with the host key from the agent's own config
(`/etc/felhom-agent/agent.json`). It does NOT reinstall or change the agent, its config or the root
files, needs no passphrase or customer id, checks that the hub answers (`/healthz` 200) before the
password changes, and refuses a byo host (also an agent config whose `deployment_mode` is not
`appliance`). Proof after a rotation: the new vaulted value gets a PVE ticket (200), the old one
gets 401. The flag ships with the first `installer-v…` tag after installer 1.33.0 — until then the
served installer does not know it. The older `--rotate-recovery` flag only works inside a FULL
installer re-run, which also reinstalls the vouched agent and the root files (on a box ahead of the
vouched agent it moves the agent back) — do not use it for a password change. A plain re-run does NOT
rotate, to avoid stranding your saved copy. Full auto-rotation is a future item.
- **Operator-set FIXED credential (e.g. demo-felhom-01, pinned 2026-07-12).** A host's `root@pam` may
be deliberately set to a *known operator-chosen* password instead of the random day-0 one. Do it the
same way step 4b does, just with your password: set it on the box (`printf 'root:<pw>\n' | chpasswd`),
then re-vault with `PUT /api/v1/hosts/<host_id>/recovery-credential` (host `api_key` from
`/etc/felhom-agent/agent.json`; expect HTTP 200), and ensure `break_glass` is in
`/var/lib/felhom-install/state.json`'s `completed[]`. This **survives plain installer re-runs**
(step 4b skips on the marker). Two caveats: (a) **never `--rotate-recovery` such a host** — it
(step 4b skips on the marker). Two caveats: (a) **never `--rotate-recovery` or `--rotate-recovery-only` such a host** — it
replaces your known copy with a random one; (b) a **full from-scratch reinstall** (uninstall→install)
wipes `state.json`, so step 4b re-randomizes — re-run the set-and-vault afterward. Background: on each
full reinstall this is exactly why `root@pam` "kept changing" until it was pinned.