hub v0.152.0: operator access tokens with scopes (09 §3 decision 251, operator 2026-10-15 11:38)
gates / gates (push) Successful in 6m44s

- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once,
  stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it.
- Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only:
  secrets shown/minted, deletes/resets, password, tokens.
- R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test.
- Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open.
- 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS.
- website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push.
Not deployed — waits for the operator's yes.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
This commit is contained in:
2026-10-15 12:20:35 +02:00
parent e92501f5a1
commit d62cc08b2e
37 changed files with 2567 additions and 21 deletions
+6
View File
@@ -26,6 +26,12 @@
---
## 2026-10-15 (evening) — hub access tokens (hub v0.152.0, built, not deployed)
| Row | What | Closed | Evidence |
|---|---|---|---|
| **R-952** | **Nothing failed when a new hub route was added without deciding who may call it.** (P3) | CLOSED 2026-10-15 — **built in hub v0.152.0**, both surfaces: `/api/v1` — `internal/api/routes.go` names each route's caller (public with a reason, customer, host, global, passphrase, appliance, recovery), pinned to the switch from source; every non-public route with no credential, for a household and host that exist, answers 401/403. Web — `internal/web/routes.go` classifies every route for access tokens, pinned the same way. Full text: `git show e92501f5:documentation/backlog/OPEN-ITEMS.md` | `TestAPIRouteTable_MatchesSwitch`, `TestAPIRoutes_NoCredential`, `TestRouteTable_MatchesServeHTTPSwitch` (each red-proved, `REPORT-hub-tokens.md`); `05` §17.3–17.4 |
## 2026-10-15 (afternoon) — the floor reached 0.313.0
| Row | What | Closed | Evidence |
+6 -4
View File
@@ -197,7 +197,7 @@ stopping line that lies.
| **R-332** | Storage & devices | P3 | **The new Hiba-from-counters path has never fired on real hardware.** v0.215.0's whole point is a verdict the product could not previously reach, and it is proven only against the committed fixture's values in unit tests (12 scenario groups, 11 of 12 red-proofs failing as required). The live validation on demo-hp proved the **negative** — three healthy disks still read Rendben across the deploy, no false alert — and the **severity wire** end to end, but no live disk has actually reached Hiba. **This is the honest gap and it must not be closed by pointing at the fixture tests**: the drive that produced the fixture is in DooPlex, which is Tier 2 and never a drill target, and the demo boxes are all-flash and healthy | **WATCHING — NEW 2026-08-14, NARROWED same day.** One item originally in this gap is now PROVEN LIVE: the **persisted state surviving a controller restart**. The v0.215.0→v0.216.0 redeploy destroyed and rebuilt the container, and the new one read back a `changed_at` written by the PREVIOUS version (`2026-08-14T07:23:14.640216851Z`, still intact at 09:31:35Z) instead of re-baselining — Scenario L on real hardware, not just the production-path unit test. **What remains unproven is the verdict itself, plus the stronger restart half: an already-ALERTED disk not re-alerting** | a real degrading disk, or an injection harness | **Closing condition:** a live disk reaching Hiba from counters, OR a deliberate injection through the REAL pipeline (agent `/disks` → controller check → hub event), not a hand-set verdict | CC |
| **R-331** | Storage & devices | P4 | **Disk health Phase 3 — growth-rate detection, and retiring the static 64.** The v0.215.0 count backstop (64 unreadable sectors → Hiba) is **a judgement from ONE drive**: the observed benign excursion peaked at 16 and cleared inside an hour, and the terminal run passed 64 at 13 Aug 11:28 and never came back. It is deliberately a backstop BEHIND the sustain rule, not the primary signal, but it is still a magic number tuned on a single sample and it will be wrong for some drive. With Phase 2's history the box can ask the question that actually matters — *is this count climbing, and how fast* — which distinguishes a drive with eight stable aging sectors from one adding forty a day, something no static threshold can do. Revisit 64 when that exists | **READY (M) — NEW 2026-08-14** | R-330 | Growth-rate rule over persisted samples; re-derive or delete the static 64 | CC |
## Security & access — 20 rows (P1 2, P2 3, P3 9, P4 6)
## Security & access — 22 rows (P1 2, P2 3, P3 11, P4 6)
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
@@ -221,6 +221,8 @@ stopping line that lies.
| **R-904** | Security & access | P4 | **Cloudflare can read every household's app traffic; replacing it with our own relay is a later item.** Facts (reviewer discussion 2026-10-08; `01`): app traffic and the dashboard reach the box through the Cloudflare Tunnel (`01` §5 trust table, rows end-user ↔ apps and customer ↔ controller UI; §7), and the tunnel's public end is Cloudflare's edge, where TLS ends — so Cloudflare can technically read that traffic (the FAQ says so since 2026-10-08, R-900; „TLS ends at the edge" is not written in `01` — add it there). What Cloudflare gives today, free: inbound reach with no router setup, the CGNAT answer (`01` §4, §7); certificates (`01` §7, the free tier covers one level below a zone); the geo-WAF the hub enforces (`01` §5 last row, §7); flood protection (not in `01`). The alternative named: our own EU relay over WireGuard with TLS passthrough by SNI, certificates on the box, the geo-block on the relay. Its costs: one more machine the operator keeps up, and a single point of reach for every box; weaker flood protection; about a week of work after a spike. Operator ruling 2026-10-08 09:07 (`09` §3 decision 184): a later item. | **DEFERRED** — **2026-10-10: the asked sentence is in `01` §7** („TLS ends at Cloudflare's edge …”); the spike stays deferred. **DEFERRED — after the first customers (operator ruling 2026-10-08 09:07)** | — | A spike after the first customers (the relay's reach, cost and flood behaviour, measured) | operator |
| **R-1040** | Security & access | P4 | **Until the household makes Sonarr's or Radarr's first admin, any container on the box can read the app's API key.** Measured on the bench 2026-10-14 with the R-1030 seed in place: `AuthenticationRequired=Enabled`, `AuthenticationMethod=None` — `/initialize.json` from another container answers 200 with `apiKey`. Outsiders are blocked by the box's setup gate in that window; a compromised other app is not. | **OPEN** — filed 2026-10-14 | — | Seed the API key at install (`SONARR__AUTH__APIKEY`, measured to work) so the box knows it, and close the window by telling the household in the first steps; or seed a Forms user | CC |
| **R-1050** | Security & access | P4 | **Raise HSTS to one year on or after 2026-11-15, if no https trouble was reported.** `09` §3 decision 249 (D2 = A, operator 2026-10-15): HSTS stays `max-age=86400` now; after a month with no https trouble it becomes one year (`31536000`); never preload, never `includeSubDomains`. The value is `HSTSMaxAgeSeconds` in `felhom-controller/controller/internal/infra/infra.go`, pinned by `TestRenderForwardedHeaders_HTTPSOnly`. „Trouble” = any report of a name that will not open, or a box answering http after the floor. | **WATCHING** — filed 2026-10-15 | the date | On or after 2026-11-15: read the register and the operator's mail for https trouble since 2026-10-15; none → a controller release with one year (test updated), bake → vouch → floor | CC |
| **R-1054** | Security & access | P3 | **The hub's operator/global API key is still one bearer that can do everything on `/api/v1`, including reading every box's break-glass root password (`GET /admin/hosts/{id}/recovery-credential`).** Hub v0.152.0 gave the web surface scoped tokens (`05` §17) but tokens are refused on `/api/v1` by design. Who uses the global key today is not listed in one place. Fix: list its consumers; move each operator use to a scoped token on the web side (or a token class on the API), and keep the global key only where a box path needs it — or retire it. | OPEN — filed 2026-10-15 by the token build | — | List the global key's consumers (pod env, scripts, runbooks) | CC |
| **R-1055** | Security & access | P3 | **A stolen `customers:write` token can take over a household's dashboard: edit the household's e-mail, then „resend claim" — the claim code goes to the new address.** The operator table (`09` §3 decision 251) put „resend claim" in Customers write; the self-bind link, which mints a capability, was made session only. Nobody holds a `customers:write` token today. Fix (operator decision): keep it, or make `/configs/{id}/claim-resend` session only (one line in `hub/internal/web/routes.go`). | WAITING-ON-OPERATOR — filed 2026-10-15 by the token build | operator decision | Decide: claim resend stays in Customers write, or session only | operator |
## Box system & updates — 13 rows (P2 1, P3 9, P4 3)
@@ -257,12 +259,11 @@ stopping line that lies.
| **R-1049** | Monitoring & notifications | P3 | **A pulled drive mails the operator „unexpectedly disconnected” (error), and nothing ever mails that it came back.** MEASURED 2026-10-14 night (`audits/media-drive-events-2026-10-14/K8-hub-mails.txt`, the hub DB with its WAL from the running pod, and the mailbox as the second channel): two pulls → two `storage_disconnected` mails; the hub RECEIVED both `storage_reconnected` and both `health_recovered` (severity info) and logged no notification for any of them — they are not in `recoveredPairedDownTypes` (`hub/internal/notify/dispatcher.go`), so the severity gate drops them. The R-97a shape: told it broke, never told it healed — the operator cannot tell a returned drive from one still gone without opening the hub. **BUILT 2026-10-15 — hub v0.151.0 (`12552aca`): both pairs in `recoveredPairedDownTypes`, cooldowns ended by the next loss (`TestR1049_DriveBackMailsBothEdges`, red-proved). Not deployed: the operator's „yes” first (decision 162); the image build waits for it too.** | **WAITING-ON-OPERATOR** — hub v0.151.0 code pushed, not built, not deployed | the operator's „yes” to the hub deploy | On the „yes”: build + deploy hub v0.151.0; read a „came back” mail on the next pull | CC |
## Hub & operator — 13 rows (P3 8, P4 5)
## Hub & operator — 13 rows (P3 7, P4 6)
| ID | Category | Sev | What | State | Blocked on | Next action | Owner |
|---|---|---|---|---|---|---|---|
| **R-975** | Hub & operator | P3 | **The hub database keeps every customer's tunnel token, Cloudflare API token and git token unsealed in one column, so anyone with a copy of the database (or its nightly snapshots) gets them.** R-879 sealed the other secret columns but not this one. Fix: seal this column the same way. Security review 2026-10-12 (`audits/security-review-2026-10-12/`); detail held off-git for the operator (`/mnt/5_hdd/felhom.eu/evidence/security-review-2026-10-12-PRIVATE/`). | OPEN | — | Seal with a migration and a test | CC |
| **R-952** | Hub & operator | P3 | **Nothing fails when a new hub `/api/v1` route is added without deciding who may call it — that is how three routes stayed open with no credential until v0.148.0 (R-951).** Fix: a test that reads the API route table from source (not by text search) and fails on any route that is not classified (public with a reason, operator key, host key, customer key); every non-public route gets a request with no credential and must be refused. | OPEN — filed 2026-10-12 by the security review; not built in v0.148.0 (the session could not write it) | — | Build the test in its own task, with a red-proof | CC |
| **R-953** | Hub & operator | P3 | **A full ArgoCD sync of the `felhom` app with prune would DELETE three live Secrets and so break the hub and Umami.** Measured 2026-10-12: `Secret/gitea-creds` (read by `Deployment/hub`), `Secret/umami-config` (read by `Deployment/umami` and `Deployment/umami-db`) and `Secret/healthchecks-config` (read by nothing) are live but no longer in git since R-925 de-gitted `felhom.secret.yaml`; ArgoCD marks all three `requiresPruning`. `Deployment/umami` is also OutOfSync (not pruning). Today only the build-deploy skill's „sync only the hub" line prevents it. Fix: mark the three Secrets `argocd.argoproj.io/compare-options: IgnoreExtraneous` (or move them out of the app's scope), decide whether `healthchecks-config` is still needed, and bring `Deployment/umami` back in sync. | OPEN — filed 2026-10-12 | — | Annotate or move the three Secrets; then a whole-app diff shows nothing to prune | CC |
| **R-31** | Hub & operator | P3 | **[P2-HIGH] Offsite provisioning is synchronous with no status affordance.** Save runs the Hetzner sync in-request, so the request can hit the nginx 504 **while succeeding server-side**: the operator cannot tell failed from slow, and a retry races the first attempt. **MIGRATED FROM `ROADMAP.md` 2026-08-22 (R-369) — originally filed 2026-07-21, size M, roadmap state `idea`.** Moved verbatim; nothing added or reinterpreted. The roadmap keeps its copy as history, marked moved. | **OPEN — migrated from ROADMAP 2026-08-22, rank unchanged** **Re-ranked 2026-10-03: P2→P3: operator-only; a known workaround (click once, wait, verify) exists.** **2026-10-06 night: the race half fixed on felhom.eu main (hub, unreleased):** a second Save while the first still provisions is refused with 409 („already running — wait about a minute, then reload; do not save again"); nothing saved, nothing created; per customer, in memory. `TestProvision_R31_*`, red-proof `audits/night-burndown-2026-10-06/hub/R-31-red.txt`. LEFT: the async save with a status card (the escrow-card idiom). | — | Direction: make it async + a status card, reusing the proven **awaiting-card/poll idiom** (v0.138.0 escrow card). **Interim mitigation belongs in R-3 as an operator note: click once, wait, verify — do not re-click.** | CC |
| **R-882** | Hub & operator | P3 | **Longhorn on DooPlex could not grow a volume online: its `instance-manager` (116 days up) called a host process that no longer existed** — `nsenter: cannot open /host/proc/196610/ns/mnt` on every expansion retry, and an offline growth was blocked by the expansion's own attachment ticket (found 2026-10-05 growing `hub-data` to 2 Gi). A restart of the instance-manager (operator-approved) fixed it: 77/77 volumes back `attached/healthy` in 110 s. **Why the cached PID went stale was not established** (likely a containerd/k3s or iscsid restart after the instance-manager started), so it will recur after the next such restart and stay invisible until a volume needs to grow. `audits/hub-db-offsite-2026-10-05/partA/step1-*.txt` | **OPEN** | — | Find which host process the PID was and whether Longhorn 1.10.x re-resolves it; until then, before growing any volume, check the instance-manager's age against the last k3s/containerd restart | operator |
@@ -273,7 +274,8 @@ stopping line that lies.
| **R-844** | Hub & operator | P4 | **The household's OS-update line exists only on the hub's customer timeline.** 2026-10-04: the box itself has no event surface for agent results (the controller UI shows no timeline), so `os_update_applied` is a hub customer event (info: recorded, never mailed). Its stored text is the hub's English sentence; the hu/en bundle text (`mail.event.os_update_applied`) is used only if it is ever mailed. Fix direction: a controller-side line (the controller already polls the agent's local API) when the box gets a household timeline. `audits/os-guest-lane-2026-10-04/partG/hub-customer-timeline-demo-hp.txt` | **READY — owner: CC** **2026-10-05 (burn-down night): NEEDS A DESIGN** — a household timeline on the box does not exist yet. | — | — | CC |
| **R-1002** | Hub & operator | P4 | **A box the household switched off reads ONLINE on the hub's hosts page for its first 45 minutes** (seen live 2026-10-13, R-998 proof, `audits/poweroff-2026-10-13/B-hub-watch.log`: the „Power: Kikapcsolva…" line at 18:53, the KIKAPCSOLVA badge only at 19:38, when the host report turned stale). The customer page read „warn" in part of that gap (seen, not captured). No alarm and no mail fired; the operator page is only slow to say it. | **READY** — filed 2026-10-13 | — | CC: let `hostDisplayStatus` read `deliberate_off_at` before staleness, with a render test per state; next hub release | CC |
| **R-1011** | Hub & operator | P3 | **The Day-0 „Agent" list offers step-bundle packages (`0.156.1-step1`, `0.152.0-step1` …) as agent versions; one has no agent binary, so the hub shows the bundle JSON's sha (`FileSHA256` falls back to the first file) and a vouch would point fresh installs at a package with no binary.** Seen 2026-10-14 (`/configuration`: two „0.156.1" entries, `ac83ea41…` is the step bundle). | **READY** — filed 2026-10-14 | — | CC: list only versions whose package holds the `felhom-agent` file (refuse a missing preferred file instead of falling back), with a test; next hub release | CC |
| **R-1051** | Hub & operator | P3 | **CC cannot raise the global floor without the operator's own password.** `09` §3 decision 250 lets CC raise the floor after the three test boxes, but the only route is the operator page (`POST /configuration/global-floor`, Basic auth with the operator password, `hub/internal/web/configs.go` `handleSetGlobalFloor`); the hub's `/api/v1/` Bearer routes carry no floor. Read 2026-10-15 (no save made). The smallest grant: a hub route `POST /api/v1/operator/floor` (floor + declared MinAgent only, the same checks as the page) behind its own Bearer key stored out-of-band on DooPlex — or the operator's word that CC may use the operator password for this one save. | **WAITING-ON-OPERATOR** — filed 2026-10-15 | the operator's pick | On a scoped route: build it (a hub release, the operator's „yes"), prove a no-op save, write it into the `felhom-build-deploy` skill's release steps | operator |
| **R-1051** | Hub & operator | P3 | **CC cannot raise the global floor without the operator's own password.** `09` §3 decision 250 lets CC raise the floor after the three test boxes, but the only route was the operator page with the operator password. Operator ruling 2026-10-15 11:38 (`09` §3 decision 251): scoped tokens; until CC's token works, the password for the floor only. | **BLOCKED** — built in hub v0.152.0 (`05` §17: tokens with groups, CC = `overview:read` + `floor:write`), not deployed | the operator's „yes" to deploy v0.152.0; then the operator creates „cc-releases" and saves it to `~/.felhom-hub/cc-token` | Deploy; a no-op floor save with the token, read back; a vouch attempt → 403; a session-only route → 403; then close | operator |
| **R-1056** | Hub & operator | P4 | **Five operator routes fit no token group, so they are session only until the operator places them:** apps reset-telemetry and dismiss-issues, the Configuration asset re-seed, the off-site WireGuard endpoint save, the off-site abandon-cancel (`05` §17.3). Nothing needs them by token today. | WAITING-ON-OPERATOR — filed 2026-10-15 by the token build | operator decision | Place each in a group, or leave session only | operator |
## Business & legal — 14 rows (P2 4, P3 2, P4 8)