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
+89
View File
@@ -0,0 +1,89 @@
# REPORT — hub access tokens (hub v0.152.0), 2026-10-15
## For the operator — plain words
**What a token can do.** A token lets a script use the hub without your password. You manage tokens on
**Configuration → Access tokens**. You give a name, tick what it may do, and pick 30, 90, 180 or 365 days. The hub
shows the token once. It keeps only a scrambled form. Every use is logged with the token's name.
**What no token can do.** Show a box's emergency root password. Show a household's passphrase or controller key (so
the customer page and the config preview are password-only). Delete or reset anything. Change the password. Create or
revoke tokens. Reach the box API (`/api/v1`).
**Is CC using its token now?** No. Hub 0.152.0 is built and tested, but not deployed. It waits for your „yes".
**What you do now.**
1. Say „yes" — I deploy hub 0.152.0 (it includes 0.151.0).
2. On Configuration → Access tokens, create „cc-releases": *Overview: read* and *Releases: floor: write*, 90 days.
Save it on DooPlex as `~/.felhom-hub/cc-token` with `chmod 600`. Do not paste it to me.
3. I test it and tell you. Then change the hub password. CC's copy of the old password is the line `HUB_PW` in
`~/.config/credentials` on DooPlex — delete that line yourself.
4. Decide R-1055 (below).
**Was the hub ever open without a password?** The code allowed it: with no password set, the hub served the whole
operator UI with no login. The live hub was never open: today an unauthenticated request got the login page, and the
manifest carries a password hash. Fixed now: no password means every operator page answers „unavailable".
## What was built
- Token store `api_tokens` (SHA-256 only), the guard in `RequireAuth`, the route table (`hub/internal/web/routes.go`),
the Configuration card, the box API's refusal of tokens, the box API's route table (`hub/internal/api/routes.go`).
- Design: `documentation/architecture/05-hub-architecture.md` §17 (read first, with §16). Also `04` §6.2, `13` §4/§5,
`09` §3 decision 251.
- Go gate in `hub/`: `go build ./... && go vet ./... && go test ./...` → rc 0.
## Red-proofs — each guard broken on purpose, its test seen failing, then restored
Evidence: `documentation/audits/hub-tokens-2026-10-15/red-proof/` (one file per case; each shows `=== RUN` for the test).
| # | guard broken | test | failed with |
|---|---|---|---|
| 1 | operator-action entry removed from the route table | `TestRouteTable_MatchesServeHTTPSwitch` | route 21: switch has `/hosts/…/operator-action`, table has `/request-logs` |
| 2 | session-only allowed for tokens | `TestToken_SessionOnlyRoutesRefuseEveryToken` | `POST /` with an all-scope token: 200 |
| 3 | expiry not checked | `TestToken_NoTokenUnknownExpiredRevoked401` | expired token: 200, want 401 |
| 4 | group:read accepted on a write route | `TestToken_ScopeDecides` | floor:read token on floor save: 303; the floor changed |
| 5 | plain token stored | `TestAPIToken_StoredRowHasNoPlainToken` | t.db holds the plain token |
| 6 | old empty-password skip restored | `TestRequireAuth_NoPasswordRefusesEveryOperatorRoute` | no password: GET / → 200 |
| 7 | Basic passes without the header | `TestToken_CSRFRuleUnchangedForBasic` | Basic without X-Felhom-Operator: 303 |
| 8 | handler's own token check removed | `TestToken_CannotManageTokens` | handleTokenCreate under a token: 200; 2 rows |
| 9 | `GET /customers` removed from the API table | `TestAPIRouteTable_MatchesSwitch` | route 36 mismatch |
| 10 | `requireGlobalKey` returns true | `TestAPIRoutes_NoCredential` | GET /customers with no credential: 200 |
| 11 | `fhp_` check removed from the API | `TestAPI_AccessTokenIsRefusedEverywhere` | POST /report with an access token: 401 (not the 403 refusal) |
| 12 | main mounts `ServeHTTP` bare | `TestWebMountAlwaysAuthenticated` | 0 via Handler(), 1 other |
## Claims in the task that turned out wrong
- „Hungarian UI, same style as the page": the Configuration page and the whole hub UI are English (the design system:
operator surfaces are English). The card is English, like the page.
- „Mail hold — read: the held mails": the hub keeps no held mail; a hold drops it (`internal/mailhold`). Mail hold has
only a write tick (release). Releases: vouch has only a write tick too — the Day-0 manifest has no page of its own.
- „`dr-recipe.json` if it carries a secret": it carries none (`handleDRRecipeDownload`: „secret-free"); it is
`customers:read`.
- Not in the task, found: the customer page embeds the owner passphrase and the controller API key, and the config
preview carries the controller key — both session only.
## Recommendations not followed (one line each)
- „Customers write: resend claim" — followed as written, but a stolen `customers:write` token can then redirect a
household's claim code (edit e-mail, resend). Filed R-1055 for your decision; my pick is password-only.
- „Off-site read: key audit" — followed; it is a POST that runs the check now and changes no key.
## Scripts and runbooks that use the password (Part C4 — after the token works live)
| where | what | scope it needs |
|---|---|---|
| `skills/felhom-build-deploy/SKILL.md` release step 5 | global floor save | `floor:write` — the CC token has it |
| `skills/felhom-build-deploy/SKILL.md` ISO section | discard a proof install's appliance | **session only** — no token can; stays yours or stays the password until you place it |
| `~/git/misc-scripts/gitea-image-prune.sh` | reads floor, golden, agent from the Configuration page | `floor:read` — the CC token has it |
| `documentation/runbooks/RUNBOOK-manual-build.md` §prune | describes the script above | — |
Not moved yet — rule 3: until the token works live, nothing switches.
## Register
Before 155 · after 157 · opened 3 (R-1054 global API key's future, R-1055 claim-resend chain, R-1056 five routes
with no group) · closed 1 (R-952). R-1051 stays open (BLOCKED on the deploy and the token).
## Teardown
Provisioned nothing: no box, no host, no hub change. Tests ran on temp databases only.
+2 -2
View File
@@ -49,9 +49,9 @@
| Symbol | File | Short signature | Use for | Gotchas |
|---|---|---|---|---|
| `(*Server).RequireAuth` | hub/internal/web/server.go (~L359) | `(next http.Handler) http.Handler` | Session-cookie OR Basic-auth gate for all web routes | Empty effective hash disables auth entirely (dev mode). Browsers → /login redirect; JSON-ish requests → 401. |
| `(*Server).RequireAuth` | hub/internal/web/server.go (~L359) | `(next http.Handler) http.Handler` | Session-cookie OR Basic-auth gate for all web routes | Since v0.152.0: empty effective hash → 503 on every operator route (never open); an `fhp_` bearer is decided by the route table (`routes.go`) + its scopes. Mount it via `(*Server).Handler()`. Browsers → /login redirect; JSON-ish requests → 401. |
| `(*Server).effectivePasswordHash` | hub/internal/web/server.go (~L118) | `() string` | THE single source for the operator login hash — call this, never read `configPasswordHash` | Precedence: `hub_settings` DB override (set via Configuration UI) wins, else the hub.yaml `auth.password_hash` seed. ConfigMap = break-glass reset. Change it via `POST /configuration/password` (`handleChangePassword`). |
| `(*Server).validateCSRF` | hub/internal/web/server.go (~L446) | `(r) bool` | CSRF check — enforced centrally in `web.ServeHTTP` for every non-GET | No session cookie → returns true (Basic-auth path is exempt). New POST routes get CSRF for free; forms MUST embed `csrfField`. |
| `(*Server).validateCSRF` | hub/internal/web/server.go (~L446) | `(r) bool` | CSRF check — enforced centrally in `web.ServeHTTP` for every non-GET | A token request → true (bearer, `05` §17.1). No session cookie → Basic AND `X-Felhom-Operator` (R-135). New POST routes get CSRF for free; forms MUST embed `csrfField`. **A new route MUST get a class in `web/routes.go` (or `api/routes.go` for `/api/v1`) — the tests fail otherwise.** |
| `(*Server).csrfField` | hub/internal/web/server.go (~L483) | `(r) template.HTML` | Hidden `_csrf` input for HTML forms | Pass into template data on every form-rendering handler. |
| `(*Server).CleanupSessions` | hub/internal/web/server.go (~L110) | `(ctx)` — goroutine | Expired-session sweeper | Started once from main; 15-min tick. |
+3 -3
View File
@@ -2,7 +2,7 @@
**Ready for the first real tester (Tester-2): yes. Tester 2 (a laptop) is off; nothing was sent to it.**
**Updated 2026-10-15 (afternoon).** Agent 0.156.1 + its bundle on all three boxes; controller 0.313.0 is the floor (vouched golden 0.313.0) — demo-felhom and demo-hp run it, Tester 1 still runs the test build rc.4 (R-1053); hub 0.150.0 (0.151.0 written, not deployed). Open items: see `documentation/backlog/OPEN-ITEMS.md`. Older notes, day by day:
**Updated 2026-10-15 (evening).** Agent 0.156.1 + its bundle on all three boxes; controller 0.313.0 is the floor (vouched golden 0.313.0) — demo-felhom and demo-hp run it, Tester 1 still runs the test build rc.4 (R-1053); hub 0.150.0 (0.151.0 and 0.152.0 — access tokens — written, not deployed). Open items: see `documentation/backlog/OPEN-ITEMS.md`. Older notes, day by day:
`documentation/backlog/STATUS-HISTORY.md`. Reports: `REPORT-night-2026-10-14.md`, `REPORT-immich-rollout-2026-10-14.md`, `REPORT-seo-felho-tarhely-2026-10-14.md`, `REPORT-immich-repair-2026-10-14.md`, `REPORT-power-cut-fixes-2026-10-14.md`, `REPORT-night-power-2026-10-13.md`, `REPORT-poweroff.md`.
## What works
@@ -41,8 +41,8 @@
| D8 | R-206 | Install DooPlex's Docker settings from git (restarts Docker), and prune the build cache weekly: yes or no? | **Yes, in a quiet window** | — | a rebuild of DooPlex still has the settings in git | — |
## Waiting on you — do
- **Releases ride the floor now (your rule, 2026-10-15):** no golden per release; the golden is baked every Sunday and you vouch it then. **Decide (R-1051):** CC cannot raise the floor without your own hub password. Pick: a small hub route with its own key for CC (a hub release, my pick), or your word that CC may use your password for this one save. If you do nothing: you keep raising the floor yourself.
- **Hub 0.151.0 (a mail when a drive comes back): may I deploy it?** Say „yes". If not: you keep getting only the „disconnected" mail.
- **Hub 0.152.0 is ready: access tokens (your ruling, 11:38).** It includes 0.151.0 (a mail when a drive comes back). A token lets a script use the hub without your password; you tick what it may do. **Do, in order:** (1) say „yes" and I deploy it; (2) Configuration → Access tokens → create „cc-releases" with *Overview: read* and *Releases: floor: write*, 90 days, and save it on DooPlex as `~/.felhom-hub/cc-token` (`chmod 600`) — do not paste it to me; (3) after I report the token works, change the hub password. If you do nothing: I keep using your password, for the floor only.
- **Decide (R-1055):** a token with „Customers: write" can change a household's e-mail and resend its claim code to it. Keep that, or make „resend claim" password-only? **My pick: password-only** (one line). If you do nothing: it stays as you wrote it; nobody holds such a token now.
- **Remove the test server „plex-k6” from your Plex account** (plex.tv → Settings → Authorized Devices). The claim test passed and the server is gone from Tester 1. If not: a dead server stays listed in your Plex apps.
- System poster needs a refresh: every public name answers only over https (http → 301).
- **Clear demo-hp's own floor** (hub → Demo HP → floor: empty → Save floor). It still reads 0.311.0. If not: the next global raise passes demo-hp by.
@@ -176,6 +176,14 @@ the deletion is the hub's), `run_job` (`fill-watch`, `offsite-integrity`, `offsi
Anything else is refused at the hub's POST and again on the box. No action deletes data, starts a countdown or
shortens one (`TestOpActions_ClosedList`, `TestOperatorActions_ClosedList`). Unanswered after 24 h: expired.
### 6.2 Who may press the operator's buttons: session, Basic, or a scoped token [hub v0.152.0, `09` §3 decision 251]
The hub's operator surface has three ways in: a browser session, Basic auth with the operator password (scripts,
`05` §16.1), and — new — an **access token** with ticked scopes (`05` §17). A token is NOT a signing key and changes
nothing in this document's model: a destructive op still needs the operator's signature, which no hub credential can
make. What a token narrows is the hub side: the operator actions above are `hosts:write`; the reveal of a break-glass
credential, every delete and reset, the password and the tokens are session only.
## 7. Hardware readiness (Viktor's "build the foundation now")
Software `ssh-ed25519` now; a FIDO2 `sk-ssh-ed25519@openssh.com` key later is a **no-op on the
@@ -504,3 +504,171 @@ newest snapshot up at 02:30, checks it (`PRAGMA integrity_check`), encrypts it a
namespace (`runbooks/RUNBOOK-hub-db-offsite-backup.md`). Pinned by `internal/dbsnap/dbsnap_test.go` and
`cmd/hub/r173_wiring_test.go`.
## 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,
`05` §16.1). That password does everything: reveals every box's break-glass root password, deletes customers, changes
the floor and the password itself. A leaked copy opens the whole hub, and the log cannot tell a script from the
operator.
**The answer.** A token: `fhp_` + 32 random bytes (base32, lower case, 56 characters in all), named, with ticked
scopes and an expiry (default 90 days, at most 365, „never" not offered). Created and revoked only from a browser
session on Configuration → Access tokens; shown **once** (in that one response, `Cache-Control: no-store`, never in a
redirect URL or a log). The hub stores only its **unkeyed SHA-256** (`api_tokens.token_hash`): the token is 32 random
bytes, so there is nothing a dictionary or a slow search can find, and a slow hash would buy nothing; the hash stops a
leaked database copy from being replayed. Pinned by `TestAPIToken_StoredRowHasNoPlainToken` (the DB file and its WAL
are scanned for the plain token, with the hash as the positive control). The row keeps name, scopes, created,
created-by, expires, last used, last used from (first `X-Forwarded-For` hop — informational, never an auth input),
revoked.
### 17.1 Use, and why it needs no CSRF token
`Authorization: Bearer fhp_…` on the operator web surface. `RequireAuth` checks it **before** the session and Basic
paths and never falls through to them: a token request is decided by its scopes alone, so it is never full operator.
Unknown, revoked or expired → **401**; a scope it lacks → **403 naming the scope** (`missing scope floor:write`); a
session-only route → **403 „session only"**. Every use is logged with the token's NAME, the method, the path and the
result; a refusal too.
No CSRF token (`§16.1` against): the R-135 gate exists because a browser re-sends cached Basic credentials and cookies
on a cross-site form. A browser attaches no bearer header by itself, and a page on another site can set an
`Authorization` header only through a CORS preflight, which the hub never answers. So a request that carries the token
was written by whoever holds it. Basic auth keeps its header rule unchanged (`TestToken_CSRFRuleUnchangedForBasic`).
**A token never:** opens a session-only route (below); reaches `/api/v1` — the box API refuses any `fhp_` bearer on
every route before its switch (`TestAPI_AccessTokenIsRefusedEverywhere`); logs in a browser (`/login` takes the
password only); sees the token list (a token-authenticated read of `/configuration` renders no token card); creates or
revokes a token (the route is session-only AND both handlers check again — `TestToken_CannotManageTokens`).
### 17.2 The groups
Write includes read. A tick is offered only when it opens at least one route (`TestScopeGroups_EveryOfferedTickOpensARoute`),
so two groups have no read tick: the Day-0 manifest and the mail hold have no page of their own (the manifest shows on
Configuration, which `floor:read` opens; the mail hold is a banner, and held mail is dropped, not kept — `internal/mailhold`).
| group | read opens | write opens |
|---|---|---|
| `overview` | dashboard, customer list (`/configs`), hosts list, apps, off-site page, system page | — |
| `floor` | Configuration page, global-floor impact | global floor, per-customer floor |
| `vouch` | — | `POST /configuration/artifacts` (golden, agent, MinAgent) |
| `customers` | new/edit forms, DR recipe (secret-free) | create, edit, block/unblock, geo disable, create-config, claim resend |
| `hosts` | host page, log bundles, log tails | operator actions, log-bundle and log-tail requests |
| `offsite` | run the key check now (it reads, changes no key) | window grant, weekly windows on/off, off-site and PBS-DR re-issue, freeze |
| `os` | the fleet queue (`/os/fleet`) | approve (guest, Docker, Proxmox, kernel), ring, on/off |
| `mailhold` | — | release |
**CC's token** („cc-releases"): `overview:read` + `floor:write`. Not `vouch` — the vouch stays the operator's
(decision 250).
### 17.3 The route table — the code's single source
`hub/internal/web/routes.go` `operatorRoutes`: one entry per case of `ServeHTTP`'s switch, same order, same match;
the classifier walks it first-match-wins exactly as the switch does. `TestRouteTable_MatchesServeHTTPSwitch` reads the
switch from `server.go`'s syntax tree and fails on any added, removed, reordered or changed case (R-952, web side). A
path no entry matches, and a method an entry does not name, is session only. „—" = the route does not serve that
method (405), so a token gets 403.
| path | GET | POST | why session-only |
|---|---|---|---|
| `/` | `overview:read` | — | |
| `/style.css` | any token | — | |
| `/static/chart.min.js` | any token | — | |
| `/static/fonts/{id}` | any token | — | |
| `/configuration` | `floor:read` | **session only** | The Configuration page shows the floors; its POST is the asset re-seed, which fits no group. |
| `/apps` | `overview:read` | — | |
| `/apps/` | `overview:read` | — | |
| `/apps/{id}/reset-telemetry` | — | **session only** | Telemetry reset and issue dismissal change the fleet view; no group fits them. |
| `/apps/{id}/dismiss-issues` | — | **session only** | |
| `/apps/{id}` | `overview:read` | — | |
| `/offsite` | `overview:read` | — | |
| `/offsite/endpoints` | — | **session only** | The WireGuard endpoint record is infrastructure; no group fits a save. |
| `/offsite/endpoints/{id}/delete` | — | **session only** | |
| `/hosts` | `overview:read` | — | |
| `/hosts/` | `overview:read` | — | |
| `/system` | `overview:read` | — | |
| `/appliances/{id}/bind` | — | **session only** | A bind delivers the household's retrieval passphrase to the box that asks — a secret delivery. |
| `/appliances/{id}/discard` | — | **session only** | |
| `/hosts/{id}/delete-impact` | **session only** | — | |
| `/hosts/{id}/delete` | — | **session only** | |
| `/hosts/{id}/reveal-recovery-credential` | — | **session only** | |
| `/hosts/{id}/operator-action` | — | `hosts:write` | |
| `/hosts/{id}/request-logs` | — | `hosts:write` | |
| `/hosts/{id}/log-bundles/{x}` | `hosts:read` | — | |
| `/hosts/{id}` | `hosts:read` | — | |
| `/login` | exempt (public) | exempt (public) | |
| `/bind/<token>` | exempt (public) | exempt (public) | |
| `/customers/{id}/block` | — | `customers:write` | |
| `/customers/{id}/selfbind-link` | — | **session only** | The self-bind link is a capability that binds a box to the household — minted here. |
| `/customers/{id}/unblock` | — | `customers:write` | |
| `/customers/{id}/geo/disable` | — | `customers:write` | |
| `/customers/{id}/floor` | — | `floor:write` | |
| `/customers/{id}/create-config` | — | `customers:write` | |
| `/customers/{id}/dr-recipe.json` | `customers:read` | — | |
| `/customers/{id}/request-log-tail` | — | `hosts:write` | |
| `/customers/{id}/log-tail/{x}` | `hosts:read` | — | |
| `/customers/{id}` | **session only** | **session only** | The customer page embeds the owner passphrase and the controller API key (customer_unified.html). |
| `/configs` | `overview:read` | — | |
| `/configs/new` | `customers:read` | `customers:write` | |
| `/configuration/global-floor/impact` | `floor:read` | — | |
| `/configuration/global-floor` | — | `floor:write` | |
| `/configuration/artifacts` | — | `vouch:write` | |
| `/configuration/mail-hold/release` | — | `mailhold:write` | |
| `/configuration/password` | — | **session only** | |
| `/configuration/tokens` | — | **session only** | |
| `/configuration/tokens/{id}/revoke` | — | **session only** | |
| `/configs/{id}/delete` | **session only** | **session only** | |
| `/configs/{id}/edit` | `customers:read` | `customers:write` | |
| `/offsite/remove-unpinned/{id}` | — | **session only** | |
| `/offsite/abandon-cancel/{id}` | — | **session only** | Cancelling a pending set-aside deletion: no group names it; until the operator places it, session only. |
| `/offsite/window-grant/{id}` | — | `offsite:write` | |
| `/offsite/windows-enabled` | — | `offsite:write` | |
| `/os/{id}` | `os:read` | `os:write` | |
| `/offsite/key-audit` | — | `offsite:read` | Runs the daily key check now: it reads keys and reports, it changes none. |
| `/configs/{id}/offsite-reissue` | — | `offsite:write` | |
| `/configs/{id}/claim-resend` | — | `customers:write` | |
| `/configs/{id}/pbsdr-reissue` | — | `offsite:write` | |
| `/configs/{id}/offsite-freeze` | — | `offsite:write` | |
| `/configs/{id}/preview` | **session only** | — | The preview is the generated controller config — it carries the controller API key. |
| `/configs/{id}/regen-password` | — | **session only** | |
| `/configs/{id}/reset` | **session only** | **session only** | |
| `/configs/{id}` | `overview:read` | `overview:read` | |
**Session only, by reason:** a secret is shown or minted (reveal recovery credential; the customer page — it embeds the
owner passphrase and the controller API key; the config preview — the generated controller config carries that key;
regen-password; the self-bind link; an appliance bind — it delivers the passphrase to the box that asks); something is
deleted or reset (customer delete and its preview, reset and its preview, host delete and its impact, off-site
endpoint delete, remove-unpinned); the password; the tokens.
**Session only because no group fits — for the operator to place** (R-1056): `/apps/{id}/reset-telemetry`,
`/apps/{id}/dismiss-issues`, `POST /configuration` (asset re-seed), `POST /offsite/endpoints` (the WireGuard endpoint
record), `/offsite/abandon-cancel/{id}`.
### 17.4 The box API's route table (R-952, API side)
`hub/internal/api/routes.go` `apiRoutes` names who may call each `/api/v1` route: `public` (with its reason — only the
appliance register), `customer` (controller key or global key), `host` (host key, self-scoped, or global), `global`,
`passphrase` (X-Retrieval-Password), `appliance` (its own token), `recovery` (re-enroll — only while the operator armed
recovery mode). `TestAPIRouteTable_MatchesSwitch` pins it to `handler.go`'s switch; `TestAPIRoutes_NoCredential` sends
every non-public route a request with no credential, for a household and host that EXIST (so a 404 cannot stand in for
a refusal), and requires 401/403. The table documents and pins; each handler still makes its own check. Tokens do not
replace the global key yet (R-1054).
### 17.5 What a stolen token can do
- **CC's token:** set the global or a per-customer floor — within the floor rules the page enforces (a floor above the
vouched golden needs a declared MinAgent, R-472) — and read the overview pages. It cannot see any secret, delete or
reset anything, vouch a golden or agent, release the mail hold, or touch a box's API. Revoke on the card; it stops at
once (no cache).
- **A `customers:write` token** can change a household's e-mail and then resend its claim code — the code goes to the
new address. That is a takeover path for the household's dashboard password; it is in the group because the operator
table put „resend claim" there. Decision open (R-1055).
- **No token** can reach the break-glass password, the escrow, the off-site keys, or any destructive verb.
### 17.6 No operator password configured = closed
Until v0.152.0 an empty password **opened** the operator UI: `RequireAuth` served everything when the effective hash was
empty, and `main` did not mount `RequireAuth` at all when the ConfigMap seed was empty — even if a password was set in
the DB override. Now `main` always mounts `webServer.Handler()` (`TestWebMountAlwaysAuthenticated`) and an empty password
answers **503** on every operator route; `/bind/` stays public, `/login` renders but cannot succeed
(`TestRequireAuth_NoPasswordRefusesEveryOperatorRoute`). **The live hub was never open:** 2026-10-15, hub 0.150.0, an
unauthenticated `GET /`, `/configuration`, `/hosts` each answered 302 → `/login`, and the ConfigMap carries a hash.
@@ -925,14 +925,20 @@ its length, and both fixes cost something the household would notice — operato
2. **CC raises the floor itself**, with the release's MinAgent declared, when both guards hold: (a) the release ran on
the three test boxes (Tester 1, demo-hp, demo-felhom) through the normal update path; (b) the box walk's checks are
green on all three afterwards (`scripts/https_only_check.py` included). Not at night unless the night brief says so.
*Route:* the only way to save the global floor today is the operator page with the operator's own password; CC
does not use it for this until the operator grants a scoped route (R-1051).
*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.
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).
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
2026-10-15 11:38.* Tokens with groups (read/write per group), managed on the Configuration tab, shown once, stored
hashed, every use logged by name (`05` §17, hub v0.152.0). Never by any token: a secret shown or minted, a delete or
reset, the password, the tokens. CC gets one token, `overview:read` + `floor:write` — not the vouch (decision 250
item 3). **Until that token works live, CC may use the operator password ONLY to set the floor.** After it works,
the operator changes the password so CC no longer holds it. Amends decision 250 item 2's *Route* sentence (R-1051).
### 2026-10-15 (07:48–08:20) — bake first; Plex through its own key; HSTS one day (recorded before the media build)
@@ -46,7 +46,7 @@ scripts are held with the review's working notes.
| component | routes | no credential | listeners |
|---|---|---|---|
| controller | 231 (+8 in setup mode) | 21, of which 7 change state, each behind its own password, code or token | port 8080 on the guest's Docker networks (not published); mail relay 2525/2465/2526 |
| hub | 119 (1 health, 53 API, 65 web) | appliance register (rate-limited, by design), re-enroll (only while the operator has armed recovery mode), login, health | one ingress, path `/`, geo filter (HU, IE, LAN) |
| hub | 121 (1 health, 53 API, 67 web — v0.152.0 added the two token routes; both route tables are now pinned to the source by tests, R-952) | appliance register (rate-limited, by design), re-enroll (only while the operator has armed recovery mode), login, health | one ingress, path `/`, geo filter (HU, IE, LAN) |
| agent | 38 local-API routes (`POST /host/poweroff` added 2026-10-13, R-998) | 0 | local API on the island bridge; WireGuard; poke (tunnel only); felhom-sshd (tunnel only, measured) |
**Outbound fetches that bring code or config onto a box:** the catalog (git, `main`), app images, the four infra
@@ -65,6 +65,7 @@ OS packages (exact versions, apt-signed), the installer script (by tag, R-980).
| **added (R-1007):** controller → an app's own job queue | holds — nothing new | the `after_unclean_boot` commands run inside the app's container through the docker socket the controller already holds, and read the app's database with a read-only session: no key into the household's account, nothing a compromised controller could not already do (exec in any app, read its database). The household's lost-file names stay on the box (`app.yaml`, the app page); the operator event carries counts only, and a failing command's output is never logged (security review of controller `8d862e4`, fixed in `3b85a0c`) |
| **added:** agent ↔ root on the host | holds by the sudoers | every root verb is one regex-anchored line or a root wrapper that re-checks its input (`03` §3); agent v0.156.1 adds one delete: `felhom-vzdump-partial-clean` removes only a DEAD half-written vzdump of the named guest on a `dir` storage (no vzdump on the host, nothing holding it open) — a compromised agent gains the deletion of a leftover that is not a backup |
| agent ↔ hub | holds for destructive ops | signed jobs verified in the documented order; unsigned spec has no size bounds (R-984) |
| **added 2026-10-15:** operator script ↔ hub UI | holds from hub v0.152.0 | scripts used the operator password, which can do everything; now a scoped, expiring, hashed token (`05` §17); a token never reaches a secret, a delete or `/api/v1`. Open: a `customers:write` token can redirect a claim code (R-1055); the global API key is still one all-powerful bearer (R-1054) |
| controller ↔ hub | **did not hold until 2026-10-12** | the global key was public (R-950, rotated) and three routes needed no key (R-951, fixed); per-box keys are bound to their box (R-955) |
| box ↔ PBS | holds as written | ciphertext only; the zero-knowledge wording is stronger than a live box allows (R-985) |
| guest ↔ Proxmox host | holds | the guest has no Proxmox credentials (measured) |
@@ -0,0 +1,6 @@
=== RUN TestRouteTable_MatchesServeHTTPSwitch
tokens_test.go:193: route 21: switch has {Exact: Prefix:/hosts/ Suffix:/operator-action Contains: BindFn:false Get:{Group: Write:false SessionOnly:false AnyToken:false Exempt:false} Post:{Group: Write:false SessionOnly:false AnyToken:false Exempt:false}}, table has {Exact: Prefix:/hosts/ Suffix:/request-logs Contains: BindFn:false Get:{Group: Write:false SessionOnly:false AnyToken:false Exempt
--- FAIL: TestRouteTable_MatchesServeHTTPSwitch (0.00s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.027s
FAIL
@@ -0,0 +1,7 @@
=== RUN TestAPIRoutes_NoCredential
routes_test.go:156: GET /customers (global) with no credential: 200 "[]", want 401/403
routes_test.go:156: GET /customers/c1 (global) with no credential: 404 "404 page not found", want 401/403
--- FAIL: TestAPIRoutes_NoCredential (0.04s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/api 0.061s
FAIL
@@ -0,0 +1,57 @@
=== RUN TestAPI_AccessTokenIsRefusedEverywhere
routes_test.go:174: POST /report with an access token: 401 "Unauthorized"
routes_test.go:174: GET /wait with an access token: 401 "Unauthorized"
routes_test.go:174: POST /appliance/register with an access token: 400 "uuid and at least one MAC are required"
routes_test.go:174: GET /appliance/poll with an access token: 404 "not found"
routes_test.go:174: POST /host-report with an access token: 401 "Unauthorized"
routes_test.go:174: POST /host-enroll with an access token: 400 "Invalid payload: customer_id required"
routes_test.go:174: POST /admin/hosts with an access token: 403 "Forbidden: global key required"
routes_test.go:174: PUT /hosts/h1/escrow with an access token: 401 "Unauthorized"
routes_test.go:174: GET /hosts/h1/escrow with an access token: 401 "Unauthorized"
routes_test.go:174: GET /hosts/h1/escrow/retained with an access token: 401 "Unauthorized"
routes_test.go:174: PUT /hosts/h1/recovery-credential with an access token: 401 "Unauthorized"
routes_test.go:174: GET /admin/hosts/h1/recovery-credential with an access token: 401 "Unauthorized"
routes_test.go:174: PUT /admin/hosts/h1/recovery-mode with an access token: 403 "Forbidden: global key required"
routes_test.go:174: DELETE /admin/hosts/h1/recovery-mode with an access token: 403 "Forbidden: global key required"
routes_test.go:174: POST /hosts/h1/re-enroll with an access token: 403 "Forbidden: host not in recovery mode (operator must arm it)"
routes_test.go:174: GET /hosts/h1/restore-directive with an access token: 401 "Unauthorized"
routes_test.go:174: POST /hosts/h1/wg with an access token: 401 "Unauthorized"
routes_test.go:174: POST /hosts/h1/pbs/consume-token with an access token: 401 "Unauthorized"
routes_test.go:174: POST /hosts/h1/os-report with an access token: 401 "Unauthorized"
routes_test.go:174: GET /hosts/h1/desired-state with an access token: 401 "Unauthorized"
routes_test.go:174: GET /hosts/h1/jobs with an access token: 401 "Unauthorized"
routes_test.go:174: DELETE /hosts/h1/jobs/j1 with an access token: 401 "Unauthorized"
routes_test.go:174: PUT /admin/hosts/h1/desired-state with an access token: 403 "Forbidden: global key required"
routes_test.go:174: POST /admin/hosts/h1/jobs with an access token: 403 "Forbidden: global key required"
routes_test.go:174: PUT /admin/wg/endpoint with an access token: 403 "Forbidden: global key required"
routes_test.go:174: GET /admin/wg/endpoint with an access token: 403 "Forbidden: global key required"
routes_test.go:174: POST /admin/wg/peers with an access token: 403 "Forbidden: global key required"
routes_test.go:174: DELETE /admin/wg/peers with an access token: 403 "Forbidden: global key required"
routes_test.go:174: GET /admin/wg/peers with an access token: 403 "Forbidden: global key required"
routes_test.go:174: PUT /admin/wg/operator-peer with an access token: 403 "Forbidden: global key required"
routes_test.go:174: GET /admin/wg/operator-peer with an access token: 403 "Forbidden: global key required"
routes_test.go:174: POST /claim/reset-request with an access token: 401 "Unauthorized"
routes_test.go:174: POST /event with an access token: 401 "Unauthorized"
routes_test.go:174: POST /mail with an access token: 401 "Unauthorized"
routes_test.go:174: POST /notify with an access token: 401 "Unauthorized"
routes_test.go:174: POST /preferences with an access token: 401 "Unauthorized"
routes_test.go:174: GET /customers with an access token: 403 "Forbidden: global key required"
routes_test.go:174: GET /customers/c1 with an access token: 403 "Forbidden: global key required"
routes_test.go:174: GET /recovery/c1 with an access token: 401 "Unauthorized: X-Retrieval-Password header required"
routes_test.go:174: GET /config/c1 with an access token: 401 "Unauthorized: X-Retrieval-Password header required"
routes_test.go:174: POST /offsite/consume-password/c1 with an access token: 401 "unauthorized"
routes_test.go:174: POST /offsite/register-key/c1 with an access token: 401 "unauthorized"
routes_test.go:174: POST /offsite/confirm-key/c1 with an access token: 401 "unauthorized"
routes_test.go:174: POST /offsite/move-aside/c1 with an access token: 401 "unauthorized"
routes_test.go:174: POST /offsite/window-open/c1 with an access token: 401 "unauthorized"
routes_test.go:174: POST /offsite/window-close/c1 with an access token: 401 "unauthorized"
routes_test.go:174: POST /offsite/abandon-request/c1 with an access token: 401 "unauthorized"
routes_test.go:174: POST /offsite/abandon-cancel/c1 with an access token: 401 "unauthorized"
routes_test.go:174: POST /offsite/abandon-status/c1 with an access token: 401 "unauthorized"
routes_test.go:174: GET /artifacts/c1 with an access token: 401 "Unauthorized: X-Retrieval-Password header required"
routes_test.go:174: GET /assets/manifest with an access token: 401 "Unauthorized"
routes_test.go:174: GET /assets/file/c1 with an access token: 401 "Unauthorized"
--- FAIL: TestAPI_AccessTokenIsRefusedEverywhere (0.04s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/api 0.064s
FAIL
@@ -0,0 +1,6 @@
=== RUN TestWebMountAlwaysAuthenticated
token_wiring_test.go:43: mux.Handle("/", …): 0 via webServer.Handler(), 1 other — want exactly 1 and 0
--- FAIL: TestWebMountAlwaysAuthenticated (0.00s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/cmd/hub 0.025s
FAIL
@@ -0,0 +1,79 @@
=== RUN TestToken_SessionOnlyRoutesRefuseEveryToken
tokens_test.go:292: POST / with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Felhom Hub — Customer Overview</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n <meta http-equiv=\"refresh\" content=\"60\">\n</head>\n<body>\n <s
tokens_test.go:292: POST /style.css with an all-scope token: 200 "/* Felhom Hub — Dark theme */\n/* Felhom Hub — design system v2 (TASK-D4). Canonical tokens:\n documentation/design/design-system.md */\n\n@font-face {\n font-family: 'Plus Jakarta Sans';\n font-style: normal;\n font-weight: 400 800;\n font-display: swap;\n src: url('/static/fonts/pjs-latin.woff2') format('w
tokens_test.go:292: POST /static/chart.min.js with an all-scope token: 200 "/**\n * Skipped minification because the original files appears to be already minified.\n * Original file: /npm/chart.js@4.4.7/dist/chart.umd.js\n *\n * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files\n */\n/*!\n * Chart.js v4.4.7\n * https://www.
tokens_test.go:292: POST /static/fonts/x1 with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: POST /configuration with an all-scope token: 303 "", want 403 session only
tokens_test.go:292: POST /apps with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Apps — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"display:non
tokens_test.go:292: POST /apps/ with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Apps — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"display:no
tokens_test.go:292: GET /apps/x1/reset-telemetry with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /apps/x1/reset-telemetry with an all-scope token: 303 "", want 403 session only
tokens_test.go:292: GET /apps/x1/dismiss-issues with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /apps/x1/dismiss-issues with an all-scope token: 400 "Invalid action\n", want 403 session only
tokens_test.go:292: POST /apps/x1 with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>x1 — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n <script src=\"/static/chart.min.js\"></script>\n \n<script>\n\n\nfunction
tokens_test.go:292: POST /offsite with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Offsite — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"displ
tokens_test.go:292: GET /offsite/endpoints with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /offsite/endpoints with an all-scope token: 400 "Invalid endpoint id (use lowercase letters, digits, hyphens) — nothing stored.\n", want 403 session only
tokens_test.go:292: GET /offsite/endpoints/x1/delete with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /offsite/endpoints/x1/delete with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: POST /hosts with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Hosts — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"display:n
tokens_test.go:292: POST /hosts/ with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Hosts — Felhom Hub</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"display:
tokens_test.go:292: POST /system with an all-scope token: 503 "os updates not configured\n", want 403 session only
tokens_test.go:292: GET /appliances/x1/bind with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /appliances/x1/bind with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /appliances/x1/discard with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /appliances/x1/discard with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /hosts/x1/delete-impact with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: POST /hosts/x1/delete-impact with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /hosts/x1/delete with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /hosts/x1/delete with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: GET /hosts/x1/reveal-recovery-credential with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /hosts/x1/reveal-recovery-credential with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: GET /hosts/x1/operator-action with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /hosts/x1/request-logs with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /hosts/x1/log-bundles/y1 with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: POST /hosts/x1 with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: GET /customers/x1/block with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /customers/x1/selfbind-link with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /customers/x1/selfbind-link with an all-scope token: 502 "Self-bind mailer is not configured on this hub\n", want 403 session only
tokens_test.go:292: GET /customers/x1/unblock with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /customers/x1/geo/disable with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /customers/x1/floor with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /customers/x1/create-config with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /customers/x1/dr-recipe.json with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /customers/x1/request-log-tail with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /customers/x1/log-tail/y1 with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: GET /customers/x1 with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: POST /customers/x1 with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: POST /configs with an all-scope token: 200 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <title>Felhom Hub — Customers</title>\n <link rel=\"stylesheet\" href=\"/style.css?v=test\">\n</head>\n<body>\n <svg xmlns=\"http://www.w3.org/2000/svg\" style=\"dis
tokens_test.go:292: POST /configuration/global-floor/impact with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /configuration/global-floor with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /configuration/artifacts with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /configuration/mail-hold/release with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /configuration/password with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /configuration/password with an all-scope token: 303 "", want 403 session only
tokens_test.go:292: GET /configuration/tokens with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /configuration/tokens/x1/revoke with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /configs/x1/delete with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: POST /configs/x1/delete with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: GET /offsite/remove-unpinned/x1 with an all-scope token: 503 "unavailable\n", want 403 session only
tokens_test.go:292: POST /offsite/remove-unpinned/x1 with an all-scope token: 503 "unavailable\n", want 403 session only
tokens_test.go:292: GET /offsite/abandon-cancel/x1 with an all-scope token: 503 "unavailable\n", want 403 session only
tokens_test.go:292: POST /offsite/abandon-cancel/x1 with an all-scope token: 503 "unavailable\n", want 403 session only
tokens_test.go:292: GET /offsite/window-grant/x1 with an all-scope token: 503 "unavailable\n", want 403 session only
tokens_test.go:292: GET /offsite/windows-enabled with an all-scope token: 503 "unavailable\n", want 403 session only
tokens_test.go:292: GET /offsite/key-audit with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /configs/x1/offsite-reissue with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /configs/x1/claim-resend with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /configs/x1/pbsdr-reissue with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /configs/x1/offsite-freeze with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: GET /configs/x1/preview with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: POST /configs/x1/preview with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: GET /configs/x1/regen-password with an all-scope token: 405 "Method not allowed\n", want 403 session only
tokens_test.go:292: POST /configs/x1/regen-password with an all-scope token: 303 "", want 403 session only
tokens_test.go:292: GET /configs/x1/reset with an all-scope token: 404 "404 page not found\n", want 403 session only
tokens_test.go:292: POST /configs/x1/reset with an all-scope token: 404 "404 page not found\n", want 403 session only
--- FAIL: TestToken_SessionOnlyRoutesRefuseEveryToken (0.09s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.107s
FAIL
@@ -0,0 +1,6 @@
=== RUN TestToken_NoTokenUnknownExpiredRevoked401
tokens_test.go:326: expired token: 200, want 401
--- FAIL: TestToken_NoTokenUnknownExpiredRevoked401 (0.05s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.073s
FAIL
@@ -0,0 +1,7 @@
=== RUN TestToken_ScopeDecides
tokens_test.go:353: floor:read token on floor save: 303, want 403
tokens_test.go:356: a refused token changed the floor
--- FAIL: TestToken_ScopeDecides (0.05s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.069s
FAIL
@@ -0,0 +1,6 @@
=== RUN TestAPIToken_StoredRowHasNoPlainToken
tokens_test.go:414: t.db holds the plain token
--- FAIL: TestAPIToken_StoredRowHasNoPlainToken (0.04s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.063s
FAIL
@@ -0,0 +1,112 @@
=== RUN TestRequireAuth_NoPasswordRefusesEveryOperatorRoute
tokens_test.go:546: no password: GET / → 200, want 503
tokens_test.go:546: no password: POST / → 200, want 503
tokens_test.go:546: no password: GET /style.css → 200, want 503
tokens_test.go:546: no password: POST /style.css → 200, want 503
tokens_test.go:546: no password: GET /static/chart.min.js → 200, want 503
tokens_test.go:546: no password: POST /static/chart.min.js → 200, want 503
tokens_test.go:546: no password: GET /static/fonts/x1 → 404, want 503
tokens_test.go:546: no password: POST /static/fonts/x1 → 404, want 503
tokens_test.go:546: no password: GET /configuration → 200, want 503
tokens_test.go:546: no password: POST /configuration → 303, want 503
tokens_test.go:546: no password: GET /apps → 200, want 503
tokens_test.go:546: no password: POST /apps → 200, want 503
tokens_test.go:546: no password: GET /apps/ → 200, want 503
tokens_test.go:546: no password: POST /apps/ → 200, want 503
tokens_test.go:546: no password: GET /apps/x1/reset-telemetry → 405, want 503
tokens_test.go:546: no password: POST /apps/x1/reset-telemetry → 303, want 503
tokens_test.go:546: no password: GET /apps/x1/dismiss-issues → 405, want 503
tokens_test.go:546: no password: POST /apps/x1/dismiss-issues → 400, want 503
tokens_test.go:546: no password: GET /apps/x1 → 200, want 503
tokens_test.go:546: no password: POST /apps/x1 → 200, want 503
tokens_test.go:546: no password: GET /offsite → 200, want 503
tokens_test.go:546: no password: POST /offsite → 200, want 503
tokens_test.go:546: no password: GET /offsite/endpoints → 405, want 503
tokens_test.go:546: no password: POST /offsite/endpoints → 400, want 503
tokens_test.go:546: no password: GET /offsite/endpoints/x1/delete → 405, want 503
tokens_test.go:546: no password: POST /offsite/endpoints/x1/delete → 404, want 503
tokens_test.go:546: no password: GET /hosts → 200, want 503
tokens_test.go:546: no password: POST /hosts → 200, want 503
tokens_test.go:546: no password: GET /hosts/ → 200, want 503
tokens_test.go:546: no password: POST /hosts/ → 200, want 503
tokens_test.go:546: no password: GET /appliances/x1/bind → 405, want 503
tokens_test.go:546: no password: POST /appliances/x1/bind → 405, want 503
tokens_test.go:546: no password: GET /appliances/x1/discard → 405, want 503
tokens_test.go:546: no password: POST /appliances/x1/discard → 405, want 503
tokens_test.go:546: no password: GET /hosts/x1/delete-impact → 404, want 503
tokens_test.go:546: no password: POST /hosts/x1/delete-impact → 405, want 503
tokens_test.go:546: no password: GET /hosts/x1/delete → 405, want 503
tokens_test.go:546: no password: POST /hosts/x1/delete → 404, want 503
tokens_test.go:546: no password: GET /hosts/x1/reveal-recovery-credential → 405, want 503
tokens_test.go:546: no password: POST /hosts/x1/reveal-recovery-credential → 404, want 503
tokens_test.go:546: no password: GET /hosts/x1/operator-action → 405, want 503
tokens_test.go:546: no password: POST /hosts/x1/operator-action → 403, want 503
tokens_test.go:546: no password: GET /hosts/x1/request-logs → 405, want 503
tokens_test.go:546: no password: POST /hosts/x1/request-logs → 403, want 503
tokens_test.go:546: no password: GET /hosts/x1/log-bundles/y1 → 404, want 503
tokens_test.go:546: no password: POST /hosts/x1/log-bundles/y1 → 404, want 503
tokens_test.go:546: no password: GET /hosts/x1 → 404, want 503
tokens_test.go:546: no password: POST /hosts/x1 → 404, want 503
tokens_test.go:546: no password: GET /customers/x1/block → 405, want 503
tokens_test.go:546: no password: POST /customers/x1/block → 404, want 503
tokens_test.go:546: no password: GET /customers/x1/selfbind-link → 405, want 503
tokens_test.go:546: no password: POST /customers/x1/selfbind-link → 502, want 503
tokens_test.go:546: no password: GET /customers/x1/unblock → 405, want 503
tokens_test.go:546: no password: POST /customers/x1/unblock → 404, want 503
tokens_test.go:546: no password: GET /customers/x1/geo/disable → 405, want 503
tokens_test.go:546: no password: POST /customers/x1/geo/disable → 404, want 503
tokens_test.go:546: no password: GET /customers/x1/floor → 405, want 503
tokens_test.go:546: no password: POST /customers/x1/floor → 404, want 503
tokens_test.go:546: no password: GET /customers/x1/create-config → 405, want 503
tokens_test.go:546: no password: POST /customers/x1/create-config → 303, want 503
tokens_test.go:546: no password: GET /customers/x1/dr-recipe.json → 404, want 503
tokens_test.go:546: no password: POST /customers/x1/dr-recipe.json → 405, want 503
tokens_test.go:546: no password: GET /customers/x1/request-log-tail → 405, want 503
tokens_test.go:546: no password: POST /customers/x1/request-log-tail → 400, want 503
tokens_test.go:546: no password: GET /customers/x1/log-tail/y1 → 404, want 503
tokens_test.go:546: no password: POST /customers/x1/log-tail/y1 → 404, want 503
tokens_test.go:546: no password: GET /customers/x1 → 200, want 503
tokens_test.go:546: no password: POST /customers/x1 → 200, want 503
tokens_test.go:546: no password: GET /configs → 200, want 503
tokens_test.go:546: no password: POST /configs → 200, want 503
tokens_test.go:546: no password: GET /configs/new → 200, want 503
tokens_test.go:546: no password: POST /configs/new → 200, want 503
tokens_test.go:546: no password: GET /configuration/global-floor/impact → 200, want 503
tokens_test.go:546: no password: POST /configuration/global-floor/impact → 405, want 503
tokens_test.go:546: no password: GET /configuration/global-floor → 405, want 503
tokens_test.go:546: no password: POST /configuration/global-floor → 303, want 503
tokens_test.go:546: no password: GET /configuration/artifacts → 405, want 503
tokens_test.go:546: no password: POST /configuration/artifacts → 303, want 503
tokens_test.go:546: no password: GET /configuration/mail-hold/release → 405, want 503
tokens_test.go:546: no password: POST /configuration/mail-hold/release → 303, want 503
tokens_test.go:546: no password: GET /configuration/password → 405, want 503
tokens_test.go:546: no password: POST /configuration/password → 303, want 503
tokens_test.go:546: no password: GET /configuration/tokens → 405, want 503
tokens_test.go:546: no password: POST /configuration/tokens → 303, want 503
tokens_test.go:546: no password: GET /configuration/tokens/x1/revoke → 405, want 503
tokens_test.go:546: no password: POST /configuration/tokens/x1/revoke → 404, want 503
tokens_test.go:546: no password: GET /configs/x1/delete → 200, want 503
tokens_test.go:546: no password: POST /configs/x1/delete → 400, want 503
tokens_test.go:546: no password: GET /configs/x1/edit → 302, want 503
tokens_test.go:546: no password: POST /configs/x1/edit → 200, want 503
tokens_test.go:546: no password: GET /offsite/key-audit → 405, want 503
tokens_test.go:546: no password: GET /configs/x1/offsite-reissue → 405, want 503
tokens_test.go:546: no password: POST /configs/x1/offsite-reissue → 502, want 503
tokens_test.go:546: no password: GET /configs/x1/claim-resend → 405, want 503
tokens_test.go:546: no password: POST /configs/x1/claim-resend → 502, want 503
tokens_test.go:546: no password: GET /configs/x1/pbsdr-reissue → 405, want 503
tokens_test.go:546: no password: POST /configs/x1/pbsdr-reissue → 502, want 503
tokens_test.go:546: no password: GET /configs/x1/offsite-freeze → 405, want 503
tokens_test.go:546: no password: POST /configs/x1/offsite-freeze → 502, want 503
tokens_test.go:546: no password: GET /configs/x1/preview → 200, want 503
tokens_test.go:546: no password: POST /configs/x1/preview → 200, want 503
tokens_test.go:546: no password: GET /configs/x1/regen-password → 405, want 503
tokens_test.go:546: no password: POST /configs/x1/regen-password → 303, want 503
tokens_test.go:546: no password: GET /configs/x1/reset → 200, want 503
tokens_test.go:546: no password: POST /configs/x1/reset → 400, want 503
tokens_test.go:546: no password: GET /configs/x1 → 303, want 503
tokens_test.go:546: no password: POST /configs/x1 → 303, want 503
--- FAIL: TestRequireAuth_NoPasswordRefusesEveryOperatorRoute (0.11s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.128s
FAIL
@@ -0,0 +1,6 @@
=== RUN TestToken_CSRFRuleUnchangedForBasic
tokens_test.go:437: Basic without X-Felhom-Operator: 303, want 403
--- FAIL: TestToken_CSRFRuleUnchangedForBasic (0.06s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.080s
FAIL
@@ -0,0 +1,7 @@
=== RUN TestToken_CannotManageTokens
tokens_test.go:462: handleTokenCreate under a token: 200
tokens_test.go:466: a token was created by a token: 2 rows
--- FAIL: TestToken_CannotManageTokens (0.05s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/web 0.071s
FAIL
@@ -0,0 +1,6 @@
=== RUN TestAPIRouteTable_MatchesSwitch
routes_test.go:119: route 36: switch has {Method:GET Exact:/customers Prefix: Suffix: Contains: Auth: Why:}, table has {Method:GET Exact: Prefix:/customers/ Suffix: Contains: Auth: Why:} — classify it in routes.go in the switch's order
--- FAIL: TestAPIRouteTable_MatchesSwitch (0.01s)
FAIL
FAIL gitea.dooplex.hu/admin/felhom-hub/internal/api 0.030s
FAIL
+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)
+32
View File
@@ -1,3 +1,35 @@
## v0.152.0 — operator access tokens with scopes; an empty password no longer opens the UI; R-952 (2026-10-15)
Operator ruling 2026-10-15 11:38 (`09` §3 decision 251). Design: `documentation/architecture/05-hub-architecture.md` §17.
- **Access tokens** (Configuration → Access tokens): name, ticked scopes per group (read/write; write includes read),
expiry 30/90/180/365 days (no „never"). `fhp_` + 32 random bytes, shown once (no-store), stored as SHA-256 only
(`api_tokens`, idempotent migration). Used as `Authorization: Bearer fhp_…` on the operator web surface; checked before
the session and Basic paths and never falls through to them. Unknown/expired/revoked → 401; missing scope → 403
naming it; session-only route → 403. Every use and every refusal is logged with the token's name; last used + from
are recorded. No CSRF token needed for a bearer (reasoning in `05` §17.1); Basic keeps its R-135 header rule.
- **Never by any token:** reveal a recovery credential, the customer page (it embeds the owner passphrase and the
controller key), the config preview (it carries that key), regen-password, the self-bind link, an appliance bind or
discard, every delete and reset (and their previews), the password, creating or revoking tokens (the route AND the
handler refuse). A token never reaches `/api/v1`: the box API refuses any `fhp_` bearer on every route.
- **The route table is the code's single source** (`internal/web/routes.go`), pinned to `ServeHTTP`'s switch from
source by `TestRouteTable_MatchesServeHTTPSwitch`; a path or method it does not name is session only.
- **R-952 (closed):** `/api/v1` route table (`internal/api/routes.go`) names each route's caller; pinned to the switch
(`TestAPIRouteTable_MatchesSwitch`); every non-public route with no credential, for a household and host that exist,
answers 401/403 (`TestAPIRoutes_NoCredential`).
- **Fail-open fixed (fixed without a row):** an empty operator password served the whole operator UI — `RequireAuth`
skipped auth on an empty effective hash, and `main` did not mount `RequireAuth` at all when the ConfigMap seed was
empty, even with a DB password set. Now `main` always mounts `webServer.Handler()` and an empty password answers 503
on every operator route (`/bind/` stays public). **The live hub was never open** (2026-10-15: unauthenticated
`/`, `/configuration`, `/hosts` → 302 `/login`; the ConfigMap carries a hash).
- `r135_csrf_test.go`'s hand-kept POST list lacked `/hosts/{id}/operator-action`; added, with the two token routes.
- Tests (each red-proved — the guard broken on purpose, the test seen failing, then restored): route table ↔ switch
(web, API), session-only routes refuse an all-scope token, unknown/expired/revoked → 401, read-on-write and missing
scope → 403 with the floor unchanged, the allowed floor save lands, no plain token in the DB file or WAL, the CSRF
rule for Basic unchanged, a token cannot create a token (route and handler), no password → 503, `main` mounts only
`Handler()`, `fhp_` refused on every API route, every non-public API route refuses no credential.
- **Not deployed** — the operator's „yes" first (`09` §3 decision 162). Includes v0.151.0.
## v0.151.0 — a drive that comes back mails too (R-1049) (2026-10-15)
- **`storage_reconnected` → `storage_disconnected`** and **`health_recovered` → `health_degraded`** join
+7 -5
View File
@@ -663,12 +663,14 @@ func main() {
// API routes (no dashboard auth for report ingest)
mux.Handle("/api/v1/", apiHandler)
// Web routes (auth required)
if cfg.Auth.PasswordHash != "" {
mux.Handle("/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHTTP)))
} else {
mux.Handle("/", http.HandlerFunc(webServer.ServeHTTP))
// Web routes — ALWAYS behind RequireAuth (v0.152.0). The old `if cfg.Auth.PasswordHash != ""`
// mounted the UI with no auth at all when the ConfigMap seed was empty, even with a password set
// in the DB override. With no password anywhere, RequireAuth now closes every operator route.
// Pinned by TestWebMountAlwaysAuthenticated.
if cfg.Auth.PasswordHash == "" {
logger.Printf("[WARN] auth.password_hash is empty — the operator UI opens only if a password is set in the database; otherwise every operator route answers 503")
}
mux.Handle("/", webServer.Handler())
// Start HTTP server
server := &http.Server{
+45
View File
@@ -0,0 +1,45 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"strconv"
"testing"
)
// TestWebMountAlwaysAuthenticated (hub v0.152.0): main mounts the operator web surface at "/" ONLY as
// webServer.Handler() (RequireAuth in front, unconditionally). Before, an empty auth.password_hash mounted
// webServer.ServeHTTP bare — the whole operator UI with no login. RED-PROOF: put back
// `mux.Handle("/", http.HandlerFunc(webServer.ServeHTTP))` → FAIL.
func TestWebMountAlwaysAuthenticated(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var viaHandler, bare int
ast.Inspect(f, func(n ast.Node) bool {
c, ok := n.(*ast.CallExpr)
if !ok || len(c.Args) != 2 {
return true
}
sel, ok := c.Fun.(*ast.SelectorExpr)
if !ok || sel.Sel.Name != "Handle" {
return true
}
if bl, ok := c.Args[0].(*ast.BasicLit); !ok || func() string { v, _ := strconv.Unquote(bl.Value); return v }() != "/" {
return true
}
if call, ok := c.Args[1].(*ast.CallExpr); ok {
if s, ok := call.Fun.(*ast.SelectorExpr); ok && s.Sel.Name == "Handler" {
viaHandler++
return true
}
}
bare++
return true
})
if viaHandler != 1 || bare != 0 {
t.Fatalf(`mux.Handle("/", …): %d via webServer.Handler(), %d other — want exactly 1 and 0`, viaHandler, bare)
}
}
+9
View File
@@ -245,6 +245,15 @@ func (h *Handler) requireGlobalKey(w http.ResponseWriter, r *http.Request) bool
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
// An operator access token (`05` §17, prefix fhp_) never reaches the box API: not a box route, not a global-key
// route. Refused here, before any handler, so no route can accept one by accident. Pinned by
// TestAPI_AccessTokenIsRefusedEverywhere.
if auth := r.Header.Get("Authorization"); strings.HasPrefix(strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")), store.APITokenPrefix) {
h.logger.Printf("[WARN] access token refused on the box API: %s %s", r.Method, r.URL.Path)
http.Error(w, "Forbidden: access tokens are not accepted on /api/v1", http.StatusForbidden)
return
}
switch {
case r.Method == http.MethodPost && path == "/report":
h.handleReport(w, r)
+83
View File
@@ -0,0 +1,83 @@
package api
// The box-API route table (hub v0.152.0, R-952, `05` §17.6). EVERY case of ServeHTTP's route switch has one entry,
// in the same order and with the same match, naming WHO may call it. TestAPIRouteTable_MatchesSwitch reads the
// switch from handler.go's syntax tree and fails on a route added without an entry here; TestAPIRoutes_NoCredential
// sends every non-public route a request with no credential and requires a refusal. The table documents and pins;
// each handler still does its own check (the table is not consulted at run time).
//
// An operator access token (fhp_…) is refused on EVERY route here, before the switch (handler.go ServeHTTP).
type apiAuth string
const (
authPublic apiAuth = "public" // no credential by design — the reason is on the entry
authCustomer apiAuth = "customer" // the household's controller key (or the global key)
authHost apiAuth = "host" // the box's own host key, self-scoped (or the global key)
authGlobal apiAuth = "global" // the operator/global key only
authPassphrase apiAuth = "passphrase" // X-Retrieval-Password of that household
authAppliance apiAuth = "appliance" // the appliance's own bearer token
authRecovery apiAuth = "recovery" // only while the operator has armed recovery mode for the host
)
type apiRoute struct {
Method string
Exact, Prefix, Suffix, Contains string
Auth apiAuth
Why string // for authPublic
}
var apiRoutes = []apiRoute{
{Method: "POST", Exact: "/report", Auth: authCustomer},
{Method: "GET", Exact: "/wait", Auth: authCustomer},
{Method: "POST", Exact: "/appliance/register", Auth: authPublic, Why: "a secret-free ISO box registers itself; per-IP rate-limited, answers no enumeration"},
{Method: "GET", Exact: "/appliance/poll", Auth: authAppliance},
{Method: "POST", Exact: "/host-report", Auth: authHost},
{Method: "POST", Exact: "/host-enroll", Auth: authPassphrase},
{Method: "POST", Exact: "/admin/hosts", Auth: authGlobal},
{Method: "PUT", Prefix: "/hosts/", Suffix: "/escrow", Auth: authHost},
{Method: "GET", Prefix: "/hosts/", Suffix: "/escrow", Auth: authHost},
{Method: "GET", Prefix: "/hosts/", Suffix: "/escrow/retained", Auth: authHost},
{Method: "PUT", Prefix: "/hosts/", Suffix: "/recovery-credential", Auth: authHost},
{Method: "GET", Prefix: "/admin/hosts/", Suffix: "/recovery-credential", Auth: authGlobal},
{Method: "PUT", Prefix: "/admin/hosts/", Suffix: "/recovery-mode", Auth: authGlobal},
{Method: "DELETE", Prefix: "/admin/hosts/", Suffix: "/recovery-mode", Auth: authGlobal},
{Method: "POST", Prefix: "/hosts/", Suffix: "/re-enroll", Auth: authRecovery},
{Method: "GET", Prefix: "/hosts/", Suffix: "/restore-directive", Auth: authHost},
{Method: "POST", Prefix: "/hosts/", Suffix: "/wg", Auth: authHost},
{Method: "POST", Prefix: "/hosts/", Suffix: "/pbs/consume-token", Auth: authHost},
{Method: "POST", Prefix: "/hosts/", Suffix: "/os-report", Auth: authHost},
{Method: "GET", Prefix: "/hosts/", Suffix: "/desired-state", Auth: authHost},
{Method: "GET", Prefix: "/hosts/", Suffix: "/jobs", Auth: authHost},
{Method: "DELETE", Prefix: "/hosts/", Contains: "/jobs/", Auth: authHost},
{Method: "PUT", Prefix: "/admin/hosts/", Suffix: "/desired-state", Auth: authGlobal},
{Method: "POST", Prefix: "/admin/hosts/", Suffix: "/jobs", Auth: authGlobal},
{Method: "PUT", Exact: "/admin/wg/endpoint", Auth: authGlobal},
{Method: "GET", Exact: "/admin/wg/endpoint", Auth: authGlobal},
{Method: "POST", Exact: "/admin/wg/peers", Auth: authGlobal},
{Method: "DELETE", Exact: "/admin/wg/peers", Auth: authGlobal},
{Method: "GET", Exact: "/admin/wg/peers", Auth: authGlobal},
{Method: "PUT", Exact: "/admin/wg/operator-peer", Auth: authGlobal},
{Method: "GET", Exact: "/admin/wg/operator-peer", Auth: authGlobal},
{Method: "POST", Exact: "/claim/reset-request", Auth: authCustomer},
{Method: "POST", Exact: "/event", Auth: authCustomer},
{Method: "POST", Exact: "/mail", Auth: authCustomer},
{Method: "POST", Exact: "/notify", Auth: authCustomer},
{Method: "POST", Exact: "/preferences", Auth: authCustomer},
{Method: "GET", Exact: "/customers", Auth: authGlobal},
{Method: "GET", Prefix: "/customers/", Auth: authGlobal},
{Method: "GET", Prefix: "/recovery/", Auth: authPassphrase},
{Method: "GET", Prefix: "/config/", Auth: authPassphrase},
{Method: "POST", Prefix: "/offsite/consume-password/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/register-key/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/confirm-key/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/move-aside/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/window-open/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/window-close/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/abandon-request/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/abandon-cancel/", Auth: authCustomer},
{Method: "POST", Prefix: "/offsite/abandon-status/", Auth: authCustomer},
{Method: "GET", Prefix: "/artifacts/", Auth: authPassphrase},
{Method: "GET", Exact: "/assets/manifest", Auth: authCustomer},
{Method: "GET", Prefix: "/assets/file/", Auth: authCustomer},
}
+177
View File
@@ -0,0 +1,177 @@
package api
import (
"go/ast"
"go/parser"
"go/token"
"net/http"
"strconv"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// apiSwitchRoutes reads ServeHTTP's route switch from handler.go: one apiRoute (method + match) per case, in order.
func apiSwitchRoutes(t *testing.T) []apiRoute {
t.Helper()
f, err := parser.ParseFile(token.NewFileSet(), "handler.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var sw *ast.SwitchStmt
for _, d := range f.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && fd.Name.Name == "ServeHTTP" && fd.Recv != nil {
for _, st := range fd.Body.List {
if s, ok := st.(*ast.SwitchStmt); ok && s.Tag == nil {
sw = s
break
}
}
}
}
if sw == nil {
t.Fatal("ServeHTTP route switch not found in handler.go")
}
lit := func(e ast.Expr) string {
bl, ok := e.(*ast.BasicLit)
if !ok {
t.Fatalf("route predicate argument is not a literal at %v", e.Pos())
}
v, _ := strconv.Unquote(bl.Value)
return v
}
var conj func(e ast.Expr) []ast.Expr
conj = func(e ast.Expr) []ast.Expr {
if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.LAND {
return append(conj(b.X), conj(b.Y)...)
}
return []ast.Expr{e}
}
var out []apiRoute
for _, c := range sw.Body.List {
cc := c.(*ast.CaseClause)
if cc.List == nil {
continue // default: 404
}
if len(cc.List) != 1 {
t.Fatalf("case at %v has %d expressions; one route per case", cc.Pos(), len(cc.List))
}
var r apiRoute
for _, p := range conj(cc.List[0]) {
switch x := p.(type) {
case *ast.BinaryExpr:
if x.Op != token.EQL {
t.Fatalf("unrecognised predicate at %v", x.Pos())
}
if sel, ok := x.Y.(*ast.SelectorExpr); ok && strings.HasPrefix(sel.Sel.Name, "Method") {
r.Method = strings.ToUpper(strings.TrimPrefix(sel.Sel.Name, "Method"))
} else {
r.Exact = lit(x.Y)
}
case *ast.CallExpr:
switch x.Fun.(*ast.SelectorExpr).Sel.Name {
case "HasPrefix":
r.Prefix = lit(x.Args[1])
case "HasSuffix":
r.Suffix = lit(x.Args[1])
case "Contains":
r.Contains = lit(x.Args[1])
default:
t.Fatalf("unrecognised predicate at %v", x.Pos())
}
default:
t.Fatalf("unrecognised predicate at %v", p.Pos())
}
}
out = append(out, r)
}
return out
}
// TestAPIRouteTable_MatchesSwitch is R-952 for /api/v1: a route added to the switch without deciding who may call it
// fails here. RED-PROOF: delete the GET /customers entry from apiRoutes → FAIL at index 36.
func TestAPIRouteTable_MatchesSwitch(t *testing.T) {
sw := apiSwitchRoutes(t)
if len(sw) < 40 {
t.Fatalf("parsed only %d routes — the parser lost the switch", len(sw))
}
n := len(sw)
if len(apiRoutes) > n {
n = len(apiRoutes)
}
for i := 0; i < n; i++ {
var a, b apiRoute
if i < len(sw) {
a = sw[i]
}
if i < len(apiRoutes) {
b = apiRoutes[i]
b.Auth, b.Why = "", ""
if apiRoutes[i].Auth == "" {
t.Errorf("route %d has no Auth class", i)
}
if apiRoutes[i].Auth == authPublic && apiRoutes[i].Why == "" {
t.Errorf("public route %d has no reason", i)
}
}
if a != b {
t.Fatalf("route %d: switch has %+v, table has %+v — classify it in routes.go in the switch's order", i, a, b)
}
}
}
func apiSample(r apiRoute) string {
if r.Exact != "" {
return r.Exact
}
id := "c1"
if strings.Contains(r.Prefix, "hosts/") {
id = "h1"
}
p := r.Prefix + id
if r.Contains != "" {
p += r.Contains + "j1"
}
return p + r.Suffix
}
// TestAPIRoutes_NoCredential: every non-public route, called with no credential for a customer and a host that
// EXIST (so a 404 cannot stand in for a refusal), answers 401 or 403.
// RED-PROOF: make requireGlobalKey return true → GET /customers answers 200.
func TestAPIRoutes_NoCredential(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
n := 0
for _, r := range apiRoutes {
if r.Auth == authPublic {
continue
}
n++
// A body naming the existing household and host, so a route that reads its subject from the body
// reaches its credential check instead of a 400.
rr := do(h, r.Method, apiSample(r), "", `{"customer_id":"c1","host_id":"h1"}`)
if rr.Code != http.StatusUnauthorized && rr.Code != http.StatusForbidden {
t.Errorf("%s %s (%s) with no credential: %d %q, want 401/403", r.Method, apiSample(r), r.Auth, rr.Code, strings.TrimSpace(rr.Body.String()))
}
}
if n < 40 {
t.Fatalf("only %d routes checked", n)
}
}
// TestAPI_AccessTokenIsRefusedEverywhere: an operator access token reaches no box-API route — not a box route, not a
// global-key route, not the public one. RED-PROOF: remove the fhp_ check in ServeHTTP → the public register route
// answers something other than 403.
func TestAPI_AccessTokenIsRefusedEverywhere(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
for _, r := range apiRoutes {
rr := do(h, r.Method, apiSample(r), store.APITokenPrefix+"abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrst", "{}")
if rr.Code != http.StatusForbidden || !strings.Contains(rr.Body.String(), "access tokens are not accepted") {
t.Errorf("%s %s with an access token: %d %q", r.Method, apiSample(r), rr.Code, strings.TrimSpace(rr.Body.String()))
}
}
}
+149
View File
@@ -0,0 +1,149 @@
package store
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"fmt"
"strings"
"time"
)
// Operator access tokens (hub v0.152.0, `05` §17). A token is a scoped, expiring stand-in for the operator password,
// for scripts (Claude Code's floor step first). The plain token is shown ONCE at creation and never stored: the row
// keeps only its unkeyed SHA-256. A slow hash is not needed — the token is 32 random bytes, so there is nothing to
// guess and nothing a dictionary can find; the hash only stops a leaked database copy from being replayed as a token.
// Pinned by TestAPIToken_StoredRowHasNoPlainToken.
// APITokenPrefix starts every operator access token. The web surface accepts it; the box API refuses it.
const APITokenPrefix = "fhp_"
// APIToken is one access-token row. The plain token is never a field here.
type APIToken struct {
ID int64
Name string
Scopes []string // "<group>:read" / "<group>:write" — validated by the web package's scope list
CreatedAt time.Time
CreatedBy string
ExpiresAt time.Time
LastUsedAt *time.Time
LastUsedFrom string
RevokedAt *time.Time
}
// Expired reports whether the token's expiry has passed at now.
func (t *APIToken) Expired(now time.Time) bool { return !now.Before(t.ExpiresAt) }
// HashAPIToken is the stored lookup form of a plain token.
func HashAPIToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
func (s *Store) migrateAPITokens() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scopes TEXT NOT NULL,
created_at DATETIME NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
expires_at DATETIME NOT NULL,
last_used_at DATETIME,
last_used_from TEXT NOT NULL DEFAULT '',
revoked_at DATETIME
);`)
return err
}
// CreateAPIToken stores a new token by its hash. The caller generated plain and shows it once.
func (s *Store) CreateAPIToken(name, plain string, scopes []string, createdBy string, expiresAt time.Time) (int64, error) {
name = strings.TrimSpace(name)
if name == "" {
return 0, fmt.Errorf("token name is empty")
}
if len(scopes) == 0 {
return 0, fmt.Errorf("token has no scope")
}
res, err := s.db.Exec(`INSERT INTO api_tokens (name, token_hash, scopes, created_at, created_by, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
name, HashAPIToken(plain), strings.Join(scopes, ","), time.Now().UTC().Format(time.RFC3339),
createdBy, expiresAt.UTC().Format(time.RFC3339))
if err != nil {
return 0, err
}
return res.LastInsertId()
}
const apiTokenCols = `id, name, scopes, created_at, created_by, expires_at, last_used_at, last_used_from, revoked_at`
func scanAPIToken(sc interface{ Scan(...any) error }) (*APIToken, error) {
var t APIToken
var scopes, created, expires string
var lastUsed, revoked sql.NullString
if err := sc.Scan(&t.ID, &t.Name, &scopes, &created, &t.CreatedBy, &expires, &lastUsed, &t.LastUsedFrom, &revoked); err != nil {
return nil, err
}
if scopes != "" {
t.Scopes = strings.Split(scopes, ",")
}
t.CreatedAt = parseSQLiteTime(created)
t.ExpiresAt = parseSQLiteTime(expires)
if lastUsed.Valid && lastUsed.String != "" {
v := parseSQLiteTime(lastUsed.String)
t.LastUsedAt = &v
}
if revoked.Valid && revoked.String != "" {
v := parseSQLiteTime(revoked.String)
t.RevokedAt = &v
}
return &t, nil
}
// GetAPITokenByPlain finds a token by its plain value (hashed here). nil, nil = no such token.
func (s *Store) GetAPITokenByPlain(plain string) (*APIToken, error) {
t, err := scanAPIToken(s.db.QueryRow(`SELECT `+apiTokenCols+` FROM api_tokens WHERE token_hash = ?`, HashAPIToken(plain)))
if err == sql.ErrNoRows {
return nil, nil
}
return t, err
}
// ListAPITokens returns every token, newest first (revoked ones included — the list is the audit trail).
func (s *Store) ListAPITokens() ([]*APIToken, error) {
rows, err := s.db.Query(`SELECT ` + apiTokenCols + ` FROM api_tokens ORDER BY id DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []*APIToken
for rows.Next() {
t, err := scanAPIToken(rows)
if err != nil {
return nil, err
}
out = append(out, t)
}
return out, rows.Err()
}
// RevokeAPIToken marks a token revoked. Revoking twice is a no-op; an unknown id is an error.
func (s *Store) RevokeAPIToken(id int64) error {
res, err := s.db.Exec(`UPDATE api_tokens SET revoked_at = COALESCE(revoked_at, ?) WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return fmt.Errorf("no token with id %d", id)
}
return nil
}
// TouchAPIToken records a use. Best effort: a failure here never refuses the request.
func (s *Store) TouchAPIToken(id int64, from string) error {
_, err := s.db.Exec(`UPDATE api_tokens SET last_used_at = ?, last_used_from = ? WHERE id = ?`,
time.Now().UTC().Format(time.RFC3339), from, id)
return err
}
+4
View File
@@ -881,6 +881,10 @@ func (s *Store) migrate() error {
if err := s.migrateOperatorActions(); err != nil {
return fmt.Errorf("operator_actions: %w", err)
}
// Operator access tokens (hub v0.152.0, `05` §17, api_tokens.go).
if err := s.migrateAPITokens(); err != nil {
return fmt.Errorf("api_tokens: %w", err)
}
return nil
}
+5 -1
View File
@@ -62,6 +62,10 @@ var r135PostRoutes = []string{
"/os/approve-docker",
"/os/approve-pve",
"/os/approve-kernel",
// v0.152.0: the operator-action route was missing from this hand-kept list; the token routes are new.
"/hosts/h1/operator-action",
"/configuration/tokens",
"/configuration/tokens/1/revoke",
// Not a route: the gate must refuse BEFORE routing, so even an unknown path is 403, never 404.
"/no-such-route",
}
@@ -70,7 +74,7 @@ var r135PostRoutes = []string{
func r135Handler(t *testing.T) (*Server, http.Handler) {
t.Helper()
s, _ := serverWithPassword(t, "op-pass")
return s, s.RequireAuth(http.HandlerFunc(s.ServeHTTP))
return s, s.Handler()
}
func r135Post(h http.Handler, path string, mut func(*http.Request)) *httptest.ResponseRecorder {
+222
View File
@@ -0,0 +1,222 @@
package web
import "strings"
// The operator route table (hub v0.152.0, `05` §17). EVERY case of ServeHTTP's route switch has one entry here,
// in the SAME ORDER and with the SAME match (exact / prefix / suffix / contains), and the classifier walks the
// entries first-match-wins exactly as the switch does — so the class a request gets is the class of the handler
// that serves it. TestRouteTable_MatchesServeHTTPSwitch reads the switch from server.go's syntax tree and fails
// when a case is added, removed, reordered or changed without this table following (R-952, web side).
//
// What a class means for an ACCESS TOKEN (a session and Basic+header stay full operator, unchanged):
// - sessionOnly — never by any token: a secret is shown or minted, something is deleted or reset, the password or
// the tokens themselves change, or no group fits (listed in `05` §17 for the operator to place).
// - anyToken — any valid token (static assets of the page itself).
// - exempt — not an operator route at all (/login, the public /bind/); the token is not consulted.
// - a group + read/write — the token needs "<group>:<mode>" (write includes read).
//
// GET and HEAD use the entry's get class; every other method uses post. A method the entry does not name is
// sessionOnly — the fail-safe direction, and the handler answers 405 to it anyway.
// Token scope groups. The keys are stored in api_tokens.scopes; the labels are the Configuration card's words.
const (
grpOverview = "overview"
grpFloor = "floor"
grpVouch = "vouch"
grpCustomers = "customers"
grpHosts = "hosts"
grpOffsite = "offsite"
grpOS = "os"
grpMailHold = "mailhold"
)
// scopeGroup is one row of the Configuration card. Read/Write say which ticks it offers: a tick is offered only when
// it opens at least one route (TestScopeGroups_EveryOfferedTickOpensARoute).
type scopeGroup struct {
Key, Label, ReadHelp, WriteHelp string
Read, Write bool
}
var scopeGroups = []scopeGroup{
{grpOverview, "Overview", "dashboard, customer list, hosts list, apps, off-site, system (no secrets)", "", true, false},
{grpFloor, "Releases: floor", "the floors (Configuration page, floor impact)", "the global and the per-customer floor", true, true},
{grpVouch, "Releases: vouch", "", "golden, agent, MinAgent (the vouch)", false, true},
{grpCustomers, "Customers", "config forms, DR recipe", "create, edit, block/unblock, geo, resend claim", true, true},
{grpHosts, "Hosts & logs", "host pages, log bundles, log tails", "operator actions, log requests", true, true},
{grpOffsite, "Off-site", "run the key check", "windows, re-issue, freeze", true, true},
{grpOS, "OS updates", "the fleet queue", "approve, ring, on/off", true, true},
{grpMailHold, "Mail hold", "", "release", false, true},
}
type routeClass struct {
Group string // "" unless group-scoped
Write bool
SessionOnly bool
AnyToken bool
Exempt bool
}
var (
sessionOnly = routeClass{SessionOnly: true}
anyToken = routeClass{AnyToken: true}
exempt = routeClass{Exempt: true}
)
func rd(g string) routeClass { return routeClass{Group: g} }
func wr(g string) routeClass { return routeClass{Group: g, Write: true} }
// scope is the "<group>:<mode>" string a group-scoped class needs.
func (c routeClass) scope() string {
if c.Write {
return c.Group + ":write"
}
return c.Group + ":read"
}
// routeEntry mirrors one disjunct of one switch case. bindFn marks the isPublicBindPath case.
type routeEntry struct {
Exact, Prefix, Suffix, Contains string
BindFn bool
Get, Post routeClass
}
func (e routeEntry) matches(path string) bool {
if e.BindFn {
return isPublicBindPath(path)
}
if e.Exact != "" && path != e.Exact {
return false
}
if e.Prefix != "" && !strings.HasPrefix(path, e.Prefix) {
return false
}
if e.Suffix != "" && !strings.HasSuffix(path, e.Suffix) {
return false
}
if e.Contains != "" && !strings.Contains(path, e.Contains) {
return false
}
return true
}
var none = routeClass{} // zero: the method is not served → treated as sessionOnly
// operatorRoutes is the table. Order and match are pinned to server.go's switch by the test named above.
var operatorRoutes = []routeEntry{
{Exact: "/", Get: rd(grpOverview)},
{Exact: "/style.css", Get: anyToken},
{Exact: "/static/chart.min.js", Get: anyToken},
{Prefix: "/static/fonts/", Get: anyToken},
// The Configuration page shows the floors; its POST is the asset re-seed, which fits no group.
{Exact: "/configuration", Get: rd(grpFloor), Post: sessionOnly},
{Exact: "/apps", Get: rd(grpOverview)},
{Exact: "/apps/", Get: rd(grpOverview)},
// Telemetry reset and issue dismissal change the fleet view; no group fits them.
{Prefix: "/apps/", Suffix: "/reset-telemetry", Post: sessionOnly},
{Prefix: "/apps/", Suffix: "/dismiss-issues", Post: sessionOnly},
{Prefix: "/apps/", Get: rd(grpOverview)},
{Exact: "/offsite", Get: rd(grpOverview)},
// The WireGuard endpoint record is infrastructure; no group fits a save.
{Exact: "/offsite/endpoints", Post: sessionOnly},
{Prefix: "/offsite/endpoints/", Suffix: "/delete", Post: sessionOnly},
{Exact: "/hosts", Get: rd(grpOverview)},
{Exact: "/hosts/", Get: rd(grpOverview)},
{Exact: "/system", Get: rd(grpOverview)},
// A bind delivers the household's retrieval passphrase to the box that asks — a secret delivery.
{Prefix: "/appliances/", Suffix: "/bind", Post: sessionOnly},
{Prefix: "/appliances/", Suffix: "/discard", Post: sessionOnly},
{Prefix: "/hosts/", Suffix: "/delete-impact", Get: sessionOnly},
{Prefix: "/hosts/", Suffix: "/delete", Post: sessionOnly},
{Prefix: "/hosts/", Suffix: "/reveal-recovery-credential", Post: sessionOnly},
{Prefix: "/hosts/", Suffix: "/operator-action", Post: wr(grpHosts)},
{Prefix: "/hosts/", Suffix: "/request-logs", Post: wr(grpHosts)},
{Prefix: "/hosts/", Contains: "/log-bundles/", Get: rd(grpHosts)},
{Prefix: "/hosts/", Get: rd(grpHosts)},
{Exact: "/login", Get: exempt, Post: exempt},
{BindFn: true, Get: exempt, Post: exempt},
{Prefix: "/customers/", Suffix: "/block", Post: wr(grpCustomers)},
// The self-bind link is a capability that binds a box to the household — minted here.
{Prefix: "/customers/", Suffix: "/selfbind-link", Post: sessionOnly},
{Prefix: "/customers/", Suffix: "/unblock", Post: wr(grpCustomers)},
{Prefix: "/customers/", Suffix: "/geo/disable", Post: wr(grpCustomers)},
{Prefix: "/customers/", Suffix: "/floor", Post: wr(grpFloor)},
{Prefix: "/customers/", Suffix: "/create-config", Post: wr(grpCustomers)},
{Prefix: "/customers/", Suffix: "/dr-recipe.json", Get: rd(grpCustomers)},
{Prefix: "/customers/", Suffix: "/request-log-tail", Post: wr(grpHosts)},
{Prefix: "/customers/", Contains: "/log-tail/", Get: rd(grpHosts)},
// The customer page embeds the owner passphrase and the controller API key (customer_unified.html).
{Prefix: "/customers/", Get: sessionOnly, Post: sessionOnly},
{Exact: "/configs", Get: rd(grpOverview)},
{Exact: "/configs/new", Get: rd(grpCustomers), Post: wr(grpCustomers)},
{Exact: "/configuration/global-floor/impact", Get: rd(grpFloor)},
{Exact: "/configuration/global-floor", Post: wr(grpFloor)},
{Exact: "/configuration/artifacts", Post: wr(grpVouch)},
{Exact: "/configuration/mail-hold/release", Post: wr(grpMailHold)},
{Exact: "/configuration/password", Post: sessionOnly},
{Exact: "/configuration/tokens", Post: sessionOnly},
{Prefix: "/configuration/tokens/", Suffix: "/revoke", Post: sessionOnly},
{Prefix: "/configs/", Suffix: "/delete", Get: sessionOnly, Post: sessionOnly},
{Prefix: "/configs/", Suffix: "/edit", Get: rd(grpCustomers), Post: wr(grpCustomers)},
{Prefix: "/offsite/remove-unpinned/", Post: sessionOnly},
// Cancelling a pending set-aside deletion: no group names it; until the operator places it, session only.
{Prefix: "/offsite/abandon-cancel/", Post: sessionOnly},
{Prefix: "/offsite/window-grant/", Post: wr(grpOffsite)},
{Exact: "/offsite/windows-enabled", Post: wr(grpOffsite)},
{Prefix: "/os/", Get: rd(grpOS), Post: wr(grpOS)},
// Runs the daily key check now: it reads keys and reports, it changes none.
{Exact: "/offsite/key-audit", Post: rd(grpOffsite)},
{Prefix: "/configs/", Suffix: "/offsite-reissue", Post: wr(grpOffsite)},
{Prefix: "/configs/", Suffix: "/claim-resend", Post: wr(grpCustomers)},
{Prefix: "/configs/", Suffix: "/pbsdr-reissue", Post: wr(grpOffsite)},
{Prefix: "/configs/", Suffix: "/offsite-freeze", Post: wr(grpOffsite)},
// The preview is the generated controller config — it carries the controller API key.
{Prefix: "/configs/", Suffix: "/preview", Get: sessionOnly},
{Prefix: "/configs/", Suffix: "/regen-password", Post: sessionOnly},
{Prefix: "/configs/", Suffix: "/reset", Get: sessionOnly, Post: sessionOnly},
{Prefix: "/configs/", Get: rd(grpOverview), Post: rd(grpOverview)},
}
// classifyRoute returns the class of the handler that serves (method, path). A path no entry matches is the
// switch's 404 — sessionOnly, so an unknown path never answers a token differently from a missing scope.
func classifyRoute(method, path string) routeClass {
for _, e := range operatorRoutes {
if !e.matches(path) {
continue
}
c := e.Post
if method == "GET" || method == "HEAD" {
c = e.Get
}
if c == none {
return sessionOnly
}
return c
}
return sessionOnly
}
// validScope reports whether s is a scope the Configuration card offers.
func validScope(s string) bool {
for _, g := range scopeGroups {
if (g.Read && s == g.Key+":read") || (g.Write && s == g.Key+":write") {
return true
}
}
return false
}
// tokenAllows reports whether a token holding scopes may use class c. write includes read.
func tokenAllows(scopes []string, c routeClass) bool {
switch {
case c.Exempt, c.AnyToken:
return true
case c.SessionOnly, c.Group == "":
return false
}
for _, s := range scopes {
if s == c.scope() || (!c.Write && s == c.Group+":write") {
return true
}
}
return false
}
+52 -2
View File
@@ -712,6 +712,19 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
// Access tokens (v0.152.0, `05` §17) — session only (routes.go), each handler checks again.
case path == "/configuration/tokens":
if r.Method == http.MethodPost {
s.handleTokenCreate(w, r)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/configuration/tokens/") && strings.HasSuffix(path, "/revoke"):
if r.Method == http.MethodPost {
s.handleTokenRevoke(w, r, strings.TrimSuffix(strings.TrimPrefix(path, "/configuration/tokens/"), "/revoke"))
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
case strings.HasPrefix(path, "/configs/") && strings.HasSuffix(path, "/delete"):
// Customer DELETE cascade (v0.69.0, R-25b): GET returns the guided dialog's live inventory
// (hosts + offsite + PBS + custody + any incomplete journal), POST runs the full teardown.
@@ -861,9 +874,19 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// RequireAuth wraps a handler with session or basic authentication.
func (s *Server) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth if no password configured
// No operator password configured → every operator route is CLOSED (v0.152.0). Before, this
// branch served everything with no credential, and main.go did not even mount RequireAuth when
// the ConfigMap seed was empty — so a hub whose password lived only in the DB override, or a
// manifest that lost auth.password_hash, served the whole operator UI open. The public /bind/
// surface does not depend on the operator password and stays up; /login renders but cannot
// succeed. Pinned by TestRequireAuth_NoPasswordRefusesEveryOperatorRoute.
if s.effectivePasswordHash() == "" {
next.ServeHTTP(w, r)
if isPublicBindPath(r.URL.Path) || r.URL.Path == "/login" {
next.ServeHTTP(w, r)
return
}
s.logger.Printf("[ERROR] operator route refused: no operator password is configured — %s %s", r.Method, r.URL.Path)
http.Error(w, "Service unavailable: no operator password is configured", http.StatusServiceUnavailable)
return
}
@@ -877,6 +900,13 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler {
return
}
// An access token (v0.152.0, `05` §17) is decided by its scopes alone — it never falls through
// to the session or Basic paths, so a token request is never full operator.
if tok := bearerToken(r); tok != "" {
s.serveWithToken(w, r, next, tok)
return
}
// Check session cookie (random token stored server-side)
if cookie, err := r.Cookie(SessionCookieName); err == nil {
s.sessionsMu.RLock()
@@ -976,6 +1006,11 @@ const OperatorCLIHeader = "X-Felhom-Operator"
// Before v0.135.0 a request with no session cookie passed unconditionally (measured live: a Basic-auth
// POST with no cookie reached the handler).
func (s *Server) validateCSRF(r *http.Request) bool {
// An access token is not a credential a browser sends on its own (tokens.go explains); the token
// path in RequireAuth has already checked it and its scope for this route.
if requestToken(r) != nil {
return true
}
cookie, err := r.Cookie(SessionCookieName)
if err != nil {
_, _, basic := r.BasicAuth()
@@ -1163,6 +1198,11 @@ func statusColor(status string) string {
// handleConfiguration renders the Configuration page.
func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
s.renderConfiguration(w, r, nil)
}
// renderConfiguration renders the page; created is a token minted by this very request, shown once (tokens.go).
func (s *Server) renderConfiguration(w http.ResponseWriter, r *http.Request, created *createdToken) {
csrfToken := s.getCSRFToken(r)
assetCount := 0
@@ -1201,6 +1241,16 @@ func (s *Server) handleConfiguration(w http.ResponseWriter, r *http.Request) {
"AgentChoices": agentChoices,
"GoldenChoices": goldenChoices,
"Flash": r.URL.Query().Get("flash"),
// Access tokens (v0.152.0): the card is managed only from a session — a token-authenticated
// read of this page gets no token list (routes.go: token management is session only).
"ShowTokens": requestToken(r) == nil,
"TokenGroups": scopeGroups,
"TokenDays": tokenDaysChoices,
"TokenDefaultDays": tokenDefaultDays,
"CreatedToken": created,
}
if requestToken(r) == nil {
data["Tokens"] = s.tokenRows()
}
if err := s.templates.ExecuteTemplate(w, "configuration.html", data); err != nil {
s.logger.Printf("[ERROR] configuration.html template: %v", err)
@@ -284,6 +284,77 @@
</script>
</section>
<!-- Access tokens (v0.152.0, `05` §17): scoped, expiring stand-ins for the operator password, for scripts.
Managed only from a browser session — ShowTokens is false when a token reads this page. -->
{{if .ShowTokens}}
<section class="card" id="tokens">
<h3 style="margin-top: 0;">Access tokens</h3>
<p class="text-muted">
A token lets a script use the hub without your password. Tick what it may do; it can do nothing else.
<strong>Never by any token:</strong> showing a box's emergency root password or a household's passphrase,
deleting or resetting a customer or host, changing the password, creating or revoking tokens.
Write includes read. The hub keeps only a scrambled form of each token, and logs every use by name.
</p>
{{if eq .Flash "token_revoked"}}<div class="flash flash-success">Token revoked. It stops working now.</div>{{end}}
{{if eq .Flash "token_name_invalid"}}<div class="flash flash-error">The token needs a name (at most 64 characters). Nothing was created.</div>{{end}}
{{if eq .Flash "token_no_scope"}}<div class="flash flash-error">Tick at least one permission. Nothing was created.</div>{{end}}
{{if eq .Flash "token_scope_invalid"}}<div class="flash flash-error">Unknown permission. Nothing was created.</div>{{end}}
{{if eq .Flash "token_days_invalid"}}<div class="flash flash-error">Expiry must be 1 to 365 days. Nothing was created.</div>{{end}}
{{if eq .Flash "token_create_failed"}}<div class="flash flash-error">The token was not saved. Nothing was created.</div>{{end}}
{{if eq .Flash "token_revoke_failed"}}<div class="flash flash-error">The token was not revoked.</div>{{end}}
{{with .CreatedToken}}
<div class="flash flash-warn" id="token-created">
<strong>Token {{.Name}} created. Copy it now — it is shown only this once.</strong><br>
<code id="token-plain">{{.Plain}}</code>
<button type="button" class="btn btn-sm" onclick="navigator.clipboard.writeText(document.getElementById('token-plain').textContent)">Copy</button>
</div>
{{end}}
{{if .Tokens}}
<table class="data-table">
<thead><tr><th>Name</th><th>Permissions</th><th>Expires</th><th>Last used</th><th>From</th><th>State</th><th></th></tr></thead>
<tbody>
{{range .Tokens}}
<tr>
<td>{{.Name}}</td>
<td><code>{{.Scopes}}</code></td>
<td>{{.Expires}}</td>
<td>{{.LastUsed}}</td>
<td>{{.LastUsedFrom}}</td>
<td>{{if .Active}}<span class="badge badge-ok">{{.State}}</span>{{else}}<span class="badge badge-neutral">{{.State}}</span>{{end}}</td>
<td>{{if .Active}}<form method="POST" action="/configuration/tokens/{{.ID}}/revoke">{{$.CSRFField}}<button class="btn btn-sm" type="submit" data-confirm="Revoke the token {{.Name}}? Scripts using it stop working at once.">Revoke</button></form>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="text-muted">No tokens yet.</p>
{{end}}
<form method="POST" action="/configuration/tokens" id="token-create-form">
{{.CSRFField}}
<h4>New token</h4>
<label>Name <input type="text" name="name" maxlength="64" required placeholder="cc-releases"></label>
<table class="data-table">
<thead><tr><th>Group</th><th>Read</th><th>Write</th></tr></thead>
<tbody>
{{range .TokenGroups}}
<tr>
<td>{{.Label}}</td>
<td>{{if .Read}}<label><input type="checkbox" name="scope" value="{{.Key}}:read"> <span class="text-muted">{{.ReadHelp}}</span></label>{{end}}</td>
<td>{{if .Write}}<label><input type="checkbox" name="scope" value="{{.Key}}:write"> <span class="text-muted">{{.WriteHelp}}</span></label>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
<label>Expires after
<select name="days">
{{range .TokenDays}}<option value="{{.}}"{{if eq . $.TokenDefaultDays}} selected{{end}}>{{.}} days</option>{{end}}
</select>
</label>
<button class="btn btn-sm" type="submit">Create token</button>
</form>
</section>
{{end}}
<!-- Assets section -->
<section class="card">
<h3 style="margin-top: 0;">Assets</h3>
+259
View File
@@ -0,0 +1,259 @@
package web
import (
"context"
"crypto/rand"
"encoding/base32"
"net"
"net/http"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// Operator access tokens (hub v0.152.0, `05` §17). The token rides `Authorization: Bearer fhp_…`. It is checked
// in RequireAuth BEFORE the session and Basic paths, against the route table in routes.go.
//
// WHY NO CSRF TOKEN FOR IT (`05` §16.1): a browser never attaches a bearer header by itself — not from a cookie,
// not from a cached credential — and a page on another site can set an Authorization header only through a CORS
// preflight, which the hub never answers. So a request that carries the token was written by whoever holds it.
// Pinned by TestToken_PostWithoutCSRFPasses and TestToken_CSRFRuleUnchangedForBasic.
// TokenPrefix marks a hub operator access token (the API refuses the same prefix, api/handler.go).
const TokenPrefix = store.APITokenPrefix
const (
tokenDefaultDays = 90
tokenMaxDays = 365
)
type tokenCtxKey struct{}
// requestToken returns the access token that authenticated r, or nil (session / Basic / unauthenticated).
func requestToken(r *http.Request) *store.APIToken {
t, _ := r.Context().Value(tokenCtxKey{}).(*store.APIToken)
return t
}
// newPlainToken mints fhp_ + 32 random bytes, base32 without padding (lower case, 52 chars).
func newPlainToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return TokenPrefix + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b)), nil
}
// bearerToken returns the fhp_ token in r's Authorization header, or "".
func bearerToken(r *http.Request) string {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
return ""
}
tok := strings.TrimSpace(strings.TrimPrefix(auth, "Bearer "))
if !strings.HasPrefix(tok, TokenPrefix) {
return ""
}
return tok
}
// clientAddr is where a request came from, for "last used from": the first X-Forwarded-For hop when the ingress
// set one, else the socket peer. Informational only — never an auth input.
func clientAddr(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0])
}
if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return h
}
return r.RemoteAddr
}
// serveWithToken authenticates r by its access token and either serves it or refuses it. Every outcome is logged
// with the token's NAME (never the token): 401 for an unknown, revoked or expired token, 403 naming the missing
// scope or "session only".
func (s *Server) serveWithToken(w http.ResponseWriter, r *http.Request, next http.Handler, plain string) {
method, path := r.Method, r.URL.Path
t, err := s.store.GetAPITokenByPlain(plain)
if err != nil {
s.logger.Printf("[ERROR] access token lookup failed: %v", err)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
switch {
case t == nil:
s.logger.Printf("[WARN] access token refused: unknown token — %s %s from %s → 401", method, path, clientAddr(r))
http.Error(w, "Unauthorized: unknown access token", http.StatusUnauthorized)
return
case t.RevokedAt != nil:
s.logger.Printf("[WARN] access token %q refused: revoked — %s %s → 401", t.Name, method, path)
http.Error(w, "Unauthorized: access token revoked", http.StatusUnauthorized)
return
case t.Expired(time.Now()):
s.logger.Printf("[WARN] access token %q refused: expired %s — %s %s → 401", t.Name, t.ExpiresAt.UTC().Format(time.RFC3339), method, path)
http.Error(w, "Unauthorized: access token expired", http.StatusUnauthorized)
return
}
c := classifyRoute(method, path)
if !tokenAllows(t.Scopes, c) {
why := "session only — never by a token"
if !c.SessionOnly && c.Group != "" {
why = "missing scope " + c.scope()
}
s.logger.Printf("[WARN] access token %q refused: %s — %s %s → 403", t.Name, why, method, path)
http.Error(w, "Forbidden: "+why, http.StatusForbidden)
return
}
if err := s.store.TouchAPIToken(t.ID, clientAddr(r)); err != nil {
s.logger.Printf("[WARN] access token %q: last-used not recorded: %v", t.Name, err)
}
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r.WithContext(context.WithValue(r.Context(), tokenCtxKey{}, t)))
s.logger.Printf("[INFO] access token %q: %s %s → %d", t.Name, method, path, rec.status)
}
// statusRecorder keeps the status a handler wrote, for the token's log line.
type statusRecorder struct {
http.ResponseWriter
status int
wrote bool
}
func (s *statusRecorder) WriteHeader(code int) {
if !s.wrote {
s.status, s.wrote = code, true
}
s.ResponseWriter.WriteHeader(code)
}
func (s *statusRecorder) Write(b []byte) (int, error) {
s.wrote = true
return s.ResponseWriter.Write(b)
}
// handleTokenCreate creates a token from the Configuration card (session only — classified in routes.go, and this
// handler checks again so a table slip cannot open it). The plain token is rendered once in the response page.
func (s *Server) handleTokenCreate(w http.ResponseWriter, r *http.Request) {
if requestToken(r) != nil {
http.Error(w, "Forbidden: session only — never by a token", http.StatusForbidden)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
name := strings.TrimSpace(r.FormValue("name"))
if name == "" || len(name) > 64 {
http.Redirect(w, r, "/configuration?flash=token_name_invalid#tokens", http.StatusSeeOther)
return
}
var scopes []string
for _, sc := range r.Form["scope"] {
if !validScope(sc) {
http.Redirect(w, r, "/configuration?flash=token_scope_invalid#tokens", http.StatusSeeOther)
return
}
scopes = append(scopes, sc)
}
if len(scopes) == 0 {
http.Redirect(w, r, "/configuration?flash=token_no_scope#tokens", http.StatusSeeOther)
return
}
days, err := strconv.Atoi(strings.TrimSpace(r.FormValue("days")))
if r.FormValue("days") == "" {
days, err = tokenDefaultDays, nil
}
if err != nil || days < 1 || days > tokenMaxDays {
http.Redirect(w, r, "/configuration?flash=token_days_invalid#tokens", http.StatusSeeOther)
return
}
plain, err := newPlainToken()
if err != nil {
s.logger.Printf("[ERROR] access token: random source failed: %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
expires := time.Now().Add(time.Duration(days) * 24 * time.Hour)
if _, err := s.store.CreateAPIToken(name, plain, scopes, "operator (session)", expires); err != nil {
s.logger.Printf("[ERROR] access token %q not created: %v", name, err)
http.Redirect(w, r, "/configuration?flash=token_create_failed#tokens", http.StatusSeeOther)
return
}
s.logger.Printf("[INFO] access token %q created from %s: scopes %s, expires %s", name, clientAddr(r), strings.Join(scopes, ","), expires.UTC().Format(time.RFC3339))
// Shown once: rendered into this response only, with no-store, never put in a redirect URL or a log.
w.Header().Set("Cache-Control", "no-store")
s.renderConfiguration(w, r, &createdToken{Name: name, Plain: plain})
}
// handleTokenRevoke revokes one token (session only, same double check).
func (s *Server) handleTokenRevoke(w http.ResponseWriter, r *http.Request, idStr string) {
if requestToken(r) != nil {
http.Error(w, "Forbidden: session only — never by a token", http.StatusForbidden)
return
}
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
http.NotFound(w, r)
return
}
if err := s.store.RevokeAPIToken(id); err != nil {
s.logger.Printf("[WARN] access token %d not revoked: %v", id, err)
http.Redirect(w, r, "/configuration?flash=token_revoke_failed#tokens", http.StatusSeeOther)
return
}
s.logger.Printf("[INFO] access token %d revoked from %s", id, clientAddr(r))
http.Redirect(w, r, "/configuration?flash=token_revoked#tokens", http.StatusSeeOther)
}
// createdToken is the one-time display of a fresh token on the Configuration page.
type createdToken struct{ Name, Plain string }
// tokenRow is one line of the Access tokens card.
type tokenRow struct {
ID int64
Name string
Scopes string
Expires string
LastUsed string
LastUsedFrom string
State string // "active" / "expired" / "revoked"
Active bool
}
func (s *Server) tokenRows() []tokenRow {
list, err := s.store.ListAPITokens()
if err != nil {
s.logger.Printf("[ERROR] access tokens list: %v", err)
return nil
}
now := time.Now()
var out []tokenRow
for _, t := range list {
row := tokenRow{ID: t.ID, Name: t.Name, Scopes: strings.Join(t.Scopes, ", "),
Expires: t.ExpiresAt.Local().Format("2006-01-02"), LastUsed: "never", LastUsedFrom: t.LastUsedFrom}
if t.LastUsedAt != nil {
row.LastUsed = t.LastUsedAt.Local().Format("2006-01-02 15:04")
}
switch {
case t.RevokedAt != nil:
row.State = "revoked"
case t.Expired(now):
row.State = "expired"
default:
row.State, row.Active = "active", true
}
out = append(out, row)
}
return out
}
// tokenDaysChoices are the expiry options of the create form; "never" is not offered.
var tokenDaysChoices = []int{30, tokenDefaultDays, 180, tokenMaxDays}
// Handler is the operator web surface as main mounts it: ServeHTTP behind RequireAuth, unconditionally.
func (s *Server) Handler() http.Handler {
return s.RequireAuth(http.HandlerFunc(s.ServeHTTP))
}
+552
View File
@@ -0,0 +1,552 @@
package web
import (
"bytes"
"context"
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"golang.org/x/crypto/bcrypt"
)
// Operator access tokens (hub v0.152.0, `05` §17). Red-proofs are recorded in the hub CHANGELOG v0.152.0 entry and
// felhom.eu/REPORT-hub-tokens.md.
// tokenServer is a hub web server with an operator password, a log buffer, and its DB path.
func tokenServer(t *testing.T) (*Server, *store.Store, *bytes.Buffer, string) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "t.db")
st, err := store.New(dbPath, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
hash, _ := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
var buf bytes.Buffer
s := New(st, string(hash), "", "test", 30*time.Minute, log.New(&buf, "", 0))
return s, st, &buf, dbPath
}
// mintToken stores a token with scopes and returns its plain value.
func mintToken(t *testing.T, st *store.Store, name string, expires time.Time, scopes ...string) string {
t.Helper()
plain, err := newPlainToken()
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateAPIToken(name, plain, scopes, "test", expires); err != nil {
t.Fatal(err)
}
return plain
}
func tokenReq(method, path, plain string, form url.Values) *http.Request {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
r := httptest.NewRequest(method, path, body)
if form != nil {
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if plain != "" {
r.Header.Set("Authorization", "Bearer "+plain)
}
return r
}
func serve(s *Server, r *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
return w
}
// --- the route table is the switch -------------------------------------------------------------------------
// switchRoutes reads ServeHTTP's route switch from server.go's syntax tree: one routeEntry (match only) per
// disjunct of each case, in order.
func switchRoutes(t *testing.T) []routeEntry {
t.Helper()
f, err := parser.ParseFile(token.NewFileSet(), "server.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var sw *ast.SwitchStmt
for _, d := range f.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok || fd.Name.Name != "ServeHTTP" || fd.Recv == nil {
continue
}
for _, st := range fd.Body.List {
if s, ok := st.(*ast.SwitchStmt); ok && s.Tag == nil {
sw = s
break
}
}
}
if sw == nil {
t.Fatal("ServeHTTP route switch not found in server.go")
}
var out []routeEntry
var disjuncts func(e ast.Expr) []ast.Expr
disjuncts = func(e ast.Expr) []ast.Expr {
if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.LOR {
return append(disjuncts(b.X), disjuncts(b.Y)...)
}
return []ast.Expr{e}
}
var conjuncts func(e ast.Expr) []ast.Expr
conjuncts = func(e ast.Expr) []ast.Expr {
if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.LAND {
return append(conjuncts(b.X), conjuncts(b.Y)...)
}
return []ast.Expr{e}
}
lit := func(e ast.Expr) string {
bl, ok := e.(*ast.BasicLit)
if !ok {
t.Fatalf("route predicate argument is not a string literal: %#v", e)
}
v, _ := strconv.Unquote(bl.Value)
return v
}
for _, c := range sw.Body.List {
cc := c.(*ast.CaseClause)
for _, e := range cc.List {
for _, d := range disjuncts(e) {
var re routeEntry
for _, p := range conjuncts(d) {
switch x := p.(type) {
case *ast.BinaryExpr:
if id, ok := x.X.(*ast.Ident); !ok || id.Name != "path" || x.Op != token.EQL {
t.Fatalf("unrecognised route predicate at %v", x.Pos())
}
re.Exact = lit(x.Y)
case *ast.CallExpr:
name := ""
switch fn := x.Fun.(type) {
case *ast.SelectorExpr:
name = fn.Sel.Name
case *ast.Ident:
name = fn.Name
}
switch name {
case "HasPrefix":
re.Prefix = lit(x.Args[1])
case "HasSuffix":
re.Suffix = lit(x.Args[1])
case "Contains":
re.Contains = lit(x.Args[1])
case "isPublicBindPath":
re.BindFn = true
default:
t.Fatalf("unrecognised route predicate %s at %v", name, x.Pos())
}
default:
t.Fatalf("unrecognised route predicate at %v", p.Pos())
}
}
out = append(out, re)
}
}
}
return out
}
// TestRouteTable_MatchesServeHTTPSwitch is R-952 for the web side: a route added to the switch without a class in
// operatorRoutes — or a reorder that would change which handler a path reaches — fails here.
// RED-PROOF: delete the /hosts/{id}/operator-action entry from operatorRoutes → FAIL at index 21.
func TestRouteTable_MatchesServeHTTPSwitch(t *testing.T) {
sw := switchRoutes(t)
if len(sw) < 60 {
t.Fatalf("parsed only %d switch routes — the parser lost the switch", len(sw))
}
strip := func(e routeEntry) routeEntry {
return routeEntry{Exact: e.Exact, Prefix: e.Prefix, Suffix: e.Suffix, Contains: e.Contains, BindFn: e.BindFn}
}
n := len(sw)
if len(operatorRoutes) > n {
n = len(operatorRoutes)
}
for i := 0; i < n; i++ {
var a, b routeEntry
if i < len(sw) {
a = sw[i]
}
if i < len(operatorRoutes) {
b = strip(operatorRoutes[i])
}
if a != b {
t.Fatalf("route %d: switch has %+v, table has %+v — classify the route in routes.go in the switch's order", i, a, b)
}
}
}
// samplePath builds a path that entry e matches.
func samplePath(e routeEntry) string {
switch {
case e.BindFn:
return "/bind/tok"
case e.Exact != "":
return e.Exact
}
p := e.Prefix + "x1"
if e.Contains != "" {
p += e.Contains + "y1"
}
return p + e.Suffix
}
// every (method, path) pair the table names, with its class.
type routeSample struct {
method, path string
class routeClass
}
func routeSamples(t *testing.T) []routeSample {
var out []routeSample
for i, e := range operatorRoutes {
p := samplePath(e)
// The sample must reach THIS entry, or the class tested is not the class served.
for j, f := range operatorRoutes {
if f.matches(p) {
if j != i {
t.Fatalf("sample %s for entry %d reaches entry %d first", p, i, j)
}
break
}
}
for _, m := range []string{http.MethodGet, http.MethodPost} {
out = append(out, routeSample{m, p, classifyRoute(m, p)})
}
}
return out
}
// TestScopeGroups_EveryOfferedTickOpensARoute: a tick on the card that opens nothing would be a promise the hub
// does not keep; a group a route names but the card does not offer could never be granted.
func TestScopeGroups_EveryOfferedTickOpensARoute(t *testing.T) {
used := map[string]bool{}
for _, s := range routeSamples(t) {
if s.class.Group != "" {
used[s.class.scope()] = true
}
}
for _, g := range scopeGroups {
if g.Read && !used[g.Key+":read"] {
t.Errorf("card offers %s:read but no route needs it", g.Key)
}
if g.Write && !used[g.Key+":write"] {
t.Errorf("card offers %s:write but no route needs it", g.Key)
}
}
for sc := range used {
if !validScope(sc) && !(strings.HasSuffix(sc, ":read") && validScope(strings.TrimSuffix(sc, ":read")+":write")) {
t.Errorf("route needs %s, which the card cannot grant", sc)
}
}
}
// --- what a token may and may not do -----------------------------------------------------------------------
func allScopes() []string {
var out []string
for _, g := range scopeGroups {
if g.Read {
out = append(out, g.Key+":read")
}
if g.Write {
out = append(out, g.Key+":write")
}
}
return out
}
// TestToken_SessionOnlyRoutesRefuseEveryToken: a token holding EVERY scope gets 403 on every session-only route —
// the reveal, the deletes, the resets, the password, the token routes, the customer page with its passphrase.
// RED-PROOF: make tokenAllows return true for SessionOnly → FAIL on /hosts/x1/reveal-recovery-credential.
func TestToken_SessionOnlyRoutesRefuseEveryToken(t *testing.T) {
s, st, _, _ := tokenServer(t)
plain := mintToken(t, st, "all", time.Now().Add(time.Hour), allScopes()...)
n := 0
for _, rs := range routeSamples(t) {
if !rs.class.SessionOnly {
continue
}
n++
w := serve(s, tokenReq(rs.method, rs.path, plain, url.Values{}))
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "session only") {
t.Errorf("%s %s with an all-scope token: %d %q, want 403 session only", rs.method, rs.path, w.Code, w.Body.String())
}
}
for _, must := range []string{"/hosts/x1/reveal-recovery-credential", "/configs/x1/delete", "/configs/x1/reset",
"/hosts/x1/delete", "/configuration/password", "/configuration/tokens", "/customers/x1", "/configs/x1/preview"} {
if c := classifyRoute(http.MethodPost, must); !c.SessionOnly {
if c2 := classifyRoute(http.MethodGet, must); !c2.SessionOnly {
t.Errorf("%s must be session only", must)
}
}
}
if n < 20 {
t.Fatalf("only %d session-only samples — the table lost its classes", n)
}
}
// TestToken_NoTokenUnknownExpiredRevoked401: a token the hub does not hold, an expired one, a revoked one → 401.
// RED-PROOF: drop the Expired branch in serveWithToken → the expired case answers 200.
func TestToken_NoTokenUnknownExpiredRevoked401(t *testing.T) {
s, st, _, _ := tokenServer(t)
unknown, _ := newPlainToken()
expired := mintToken(t, st, "old", time.Now().Add(-time.Minute), "overview:read")
revoked := mintToken(t, st, "gone", time.Now().Add(time.Hour), "overview:read")
list, _ := st.ListAPITokens()
for _, tk := range list {
if tk.Name == "gone" {
if err := st.RevokeAPIToken(tk.ID); err != nil {
t.Fatal(err)
}
}
}
for name, plain := range map[string]string{"unknown": unknown, "expired": expired, "revoked": revoked} {
w := serve(s, tokenReq(http.MethodGet, "/", plain, nil))
if w.Code != http.StatusUnauthorized {
t.Errorf("%s token: %d, want 401", name, w.Code)
}
}
// No credential at all: unchanged (browser → login page, script → 401).
r := tokenReq(http.MethodGet, "/", "", nil)
r.Header.Set("Accept", "application/json")
if w := serve(s, r); w.Code != http.StatusUnauthorized {
t.Errorf("no credential: %d, want 401", w.Code)
}
}
// TestToken_ScopeDecides: a token without the group → 403 naming the scope; read on a write route → 403; the right
// scope → the handler runs AND its effect lands (the floor is stored), with no CSRF token needed.
// RED-PROOF: make tokenAllows ignore Write (accept group:read for a write route) → the read-token floor save lands.
func TestToken_ScopeDecides(t *testing.T) {
s, st, logs, _ := tokenServer(t)
overview := mintToken(t, st, "ov", time.Now().Add(time.Hour), "overview:read")
floorRead := mintToken(t, st, "fr", time.Now().Add(time.Hour), "overview:read", "floor:read")
cc := mintToken(t, st, "cc-releases", time.Now().Add(time.Hour), "overview:read", "floor:write")
form := url.Values{"min_controller_version": {"0.300.0"}, "min_agent": {"0.150.0"}}
w := serve(s, tokenReq(http.MethodPost, "/configuration/global-floor", overview, form))
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "missing scope floor:write") {
t.Errorf("overview token on floor save: %d %q", w.Code, w.Body.String())
}
w = serve(s, tokenReq(http.MethodPost, "/configuration/global-floor", floorRead, form))
if w.Code != http.StatusForbidden {
t.Errorf("floor:read token on floor save: %d, want 403", w.Code)
}
if got := st.GetGlobalMinControllerVersion(); got == "0.300.0" {
t.Fatal("a refused token changed the floor")
}
// The vouch is not CC's: floor:write does not open it.
w = serve(s, tokenReq(http.MethodPost, "/configuration/artifacts", cc, url.Values{"agent_version": {"0.1.0"}}))
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "missing scope vouch:write") {
t.Errorf("cc token on the vouch: %d %q", w.Code, w.Body.String())
}
// Allowed — and the consequence is real.
w = serve(s, tokenReq(http.MethodPost, "/configuration/global-floor", cc, form))
if w.Code != http.StatusSeeOther {
t.Fatalf("cc token on floor save: %d %q", w.Code, w.Body.String())
}
if got := st.GetGlobalMinControllerVersion(); got != "0.300.0" {
t.Fatalf("floor after the cc token's save = %q, want 0.300.0", got)
}
if got := st.GlobalFloorDeclaredMinAgent(); got != "0.150.0" {
t.Errorf("declared MinAgent = %q, want 0.150.0", got)
}
// write includes read: the Configuration page opens for floor:write.
if w := serve(s, tokenReq(http.MethodGet, "/configuration", cc, nil)); w.Code != http.StatusOK {
t.Errorf("cc token GET /configuration: %d", w.Code)
} else if strings.Contains(w.Body.String(), `action="/configuration/tokens"`) {
t.Error("a token-authenticated read of /configuration must not render the token card")
}
// The log names the token, never the token value.
out := logs.String()
if !strings.Contains(out, `access token "cc-releases": POST /configuration/global-floor → 303`) {
t.Errorf("no allowed-use log line naming the token:\n%s", out)
}
if !strings.Contains(out, `access token "ov" refused: missing scope floor:write`) {
t.Errorf("no refusal log line naming the token:\n%s", out)
}
for _, p := range []string{overview, floorRead, cc} {
if strings.Contains(out, p) || strings.Contains(out, p[len(TokenPrefix):]) {
t.Fatal("a plain token reached the log")
}
}
list, _ := st.ListAPITokens()
for _, tk := range list {
if tk.Name == "cc-releases" && (tk.LastUsedAt == nil || tk.LastUsedFrom == "") {
t.Errorf("cc-releases last-used not recorded: %+v", tk)
}
}
}
// TestAPIToken_StoredRowHasNoPlainToken: neither the row nor the database file holds the plain token.
// RED-PROOF: store plain instead of HashAPIToken(plain) → FAIL (the file holds it).
func TestAPIToken_StoredRowHasNoPlainToken(t *testing.T) {
_, st, _, dbPath := tokenServer(t)
plain := mintToken(t, st, "x", time.Now().Add(time.Hour), "overview:read")
st.Close()
secret := plain[len(TokenPrefix):]
for _, p := range []string{dbPath, dbPath + "-wal"} {
b, err := os.ReadFile(p)
if err != nil {
continue
}
if bytes.Contains(b, []byte(secret)) {
t.Fatalf("%s holds the plain token", filepath.Base(p))
}
}
// Positive control: the hash IS stored (so the scan above looked at the bytes the row lives in).
var found bool
for _, p := range []string{dbPath, dbPath + "-wal"} {
if b, err := os.ReadFile(p); err == nil && bytes.Contains(b, []byte(store.HashAPIToken(plain))) {
found = true
}
}
if !found {
t.Fatal("positive control failed: the token hash is in neither the db nor its wal")
}
}
// TestToken_CSRFRuleUnchangedForBasic: Basic without the operator header is still refused on a POST; with it, it
// passes; a session without its form token is still refused. Only the token path skips the form token.
func TestToken_CSRFRuleUnchangedForBasic(t *testing.T) {
s, st, _, _ := tokenServer(t)
form := url.Values{"min_controller_version": {"0.301.0"}, "min_agent": {"0.150.0"}}
r := tokenReq(http.MethodPost, "/configuration/global-floor", "", form)
r.SetBasicAuth("", "operator-pw")
if w := serve(s, r); w.Code != http.StatusForbidden {
t.Errorf("Basic without %s: %d, want 403", OperatorCLIHeader, w.Code)
}
r = tokenReq(http.MethodPost, "/configuration/global-floor", "", form)
r.SetBasicAuth("", "operator-pw")
r.Header.Set(OperatorCLIHeader, "cli")
if w := serve(s, r); w.Code != http.StatusSeeOther || st.GetGlobalMinControllerVersion() != "0.301.0" {
t.Errorf("Basic with %s: %d floor=%q", OperatorCLIHeader, w.Code, st.GetGlobalMinControllerVersion())
}
}
// TestToken_CannotManageTokens: even with every scope, a token cannot create or revoke one (the route is session
// only AND the handlers check again).
func TestToken_CannotManageTokens(t *testing.T) {
s, st, _, _ := tokenServer(t)
plain := mintToken(t, st, "all", time.Now().Add(time.Hour), allScopes()...)
w := serve(s, tokenReq(http.MethodPost, "/configuration/tokens", plain, url.Values{"name": {"evil"}, "scope": {"vouch:write"}}))
if w.Code != http.StatusForbidden {
t.Errorf("token creating a token: %d", w.Code)
}
// The handler's own check, bypassing the table.
r := tokenReq(http.MethodPost, "/configuration/tokens", "", url.Values{"name": {"evil"}, "scope": {"vouch:write"}})
tk, _ := st.GetAPITokenByPlain(plain)
w = httptest.NewRecorder()
s.handleTokenCreate(w, r.WithContext(contextWithToken(r, tk)))
if w.Code != http.StatusForbidden {
t.Errorf("handleTokenCreate under a token: %d", w.Code)
}
list, _ := st.ListAPITokens()
if len(list) != 1 {
t.Fatalf("a token was created by a token: %d rows", len(list))
}
}
func contextWithToken(r *http.Request, tk *store.APIToken) context.Context {
return context.WithValue(r.Context(), tokenCtxKey{}, tk)
}
// TestTokenCreate_SessionShowsItOnce: from a session the card creates a token, shows it in this one response
// (no-store), stores only its hash, and the token works with exactly the ticked scopes.
func TestTokenCreate_SessionShowsItOnce(t *testing.T) {
s, st, logs, _ := tokenServer(t)
form := url.Values{"name": {"cc-releases"}, "scope": {"overview:read", "floor:write"}, "days": {"90"}}
r := tokenReq(http.MethodPost, "/configuration/tokens", "", form)
w := httptest.NewRecorder()
s.handleTokenCreate(w, r)
if w.Code != http.StatusOK || w.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("create: %d cache=%q", w.Code, w.Header().Get("Cache-Control"))
}
body := w.Body.String()
i := strings.Index(body, TokenPrefix)
if i < 0 {
t.Fatal("the new token is not shown")
}
plain := body[i : i+len(TokenPrefix)+52]
if strings.Contains(logs.String(), plain[len(TokenPrefix):]) {
t.Fatal("the plain token reached the log")
}
tk, _ := st.GetAPITokenByPlain(plain)
if tk == nil || strings.Join(tk.Scopes, ",") != "overview:read,floor:write" {
t.Fatalf("stored token: %+v", tk)
}
if d := time.Until(tk.ExpiresAt); d < 89*24*time.Hour || d > 91*24*time.Hour {
t.Errorf("expiry %v, want ~90 days", d)
}
// A second render of the page does not show it again.
r2 := tokenReq(http.MethodGet, "/configuration", "", nil)
w2 := httptest.NewRecorder()
s.handleConfiguration(w2, r2)
if strings.Contains(w2.Body.String(), plain) {
t.Fatal("the token is shown a second time")
}
if !strings.Contains(w2.Body.String(), "cc-releases") {
t.Error("the token list does not name the token")
}
// Refusals: no scope, unknown scope, never, over a year.
for _, bad := range []url.Values{
{"name": {"a"}},
{"name": {"a"}, "scope": {"tokens:write"}},
{"name": {"a"}, "scope": {"overview:read"}, "days": {"0"}},
{"name": {"a"}, "scope": {"overview:read"}, "days": {"366"}},
{"name": {""}, "scope": {"overview:read"}},
} {
w := httptest.NewRecorder()
s.handleTokenCreate(w, tokenReq(http.MethodPost, "/configuration/tokens", "", bad))
if w.Code != http.StatusSeeOther {
t.Errorf("bad create %v: %d, want a refusal redirect", bad, w.Code)
}
}
if list, _ := st.ListAPITokens(); len(list) != 1 {
t.Errorf("refused creates stored rows: %d", len(list))
}
}
// TestRequireAuth_NoPasswordRefusesEveryOperatorRoute: with no operator password anywhere, every operator route
// answers 503 — never the page. /bind/ stays public.
// RED-PROOF: restore the old `if effectivePasswordHash() == "" { next.ServeHTTP }` → the dashboard answers 200.
func TestRequireAuth_NoPasswordRefusesEveryOperatorRoute(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
s := New(st, "", "", "test", 30*time.Minute, log.New(io.Discard, "", 0))
for _, rs := range routeSamples(t) {
if rs.class.Exempt {
continue
}
w := serve(s, tokenReq(rs.method, rs.path, "", url.Values{}))
if w.Code != http.StatusServiceUnavailable {
t.Errorf("no password: %s %s → %d, want 503", rs.method, rs.path, w.Code)
}
}
if w := serve(s, tokenReq(http.MethodGet, "/bind/sometoken", "", nil)); w.Code == http.StatusServiceUnavailable {
t.Error("the public /bind/ surface must not depend on the operator password")
}
}
+7 -1
View File
@@ -1,4 +1,10 @@
## 2026-10-14 — a new page for people who search „felhő tárhely” (R-1036, R-1034, R-1035)
## 2026-10-15 — the Prowlarr logo (unblocks the site gate)
- `website/assets/prowlarr-logo.svg` added — byte-identical to the uncommitted copy in the media work folder
(`felhom.eu-media`, untouched since 12:18). The catalog lists Prowlarr, so `site_gates.py` failed „app logos" and the
pre-push hook refused every push of this repo. The page text for Prowlarr is still that folder's unfinished work.
## 2026-10-14 — a new page for people who search „felhő tárhely” (R-1036, R-1034, R-1035)
**Why:** Search Console (3 months, read by the operator 2026-10-14) showed 48 impressions, 1 click, average
position 73.6; the top query was „felhő tárhely” (19). **No page on the site used the phrase** — it appeared 0
+296
View File
@@ -0,0 +1,296 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
id="svg10"
xml:space="preserve"
width="1024"
height="1024"
viewBox="0 0 1024 1024"
sodipodi:docname="prowlarr (1).svg"
inkscape:version="0.92.2 (5c3e80d, 2017-08-06)"
inkscape:export-filename="C:\Users\qstic\source\repos\Prowlarr\Logo\16.png"
inkscape:export-xdpi="1.5"
inkscape:export-ydpi="1.5"><metadata
id="metadata16"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title></dc:title></cc:Work></rdf:RDF></metadata><defs
id="defs14"><clipPath
clipPathUnits="userSpaceOnUse"
id="clipPath510"><path
d="m 1271.06,905.77 c 0,0 0,0 -0.01,0 0.01,0 0.01,0 0.01,0 v 0"
id="path508" /></clipPath></defs><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1680"
inkscape:window-height="997"
id="namedview12"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="0.60971844"
inkscape:cx="341.56775"
inkscape:cy="474.60369"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="1"
inkscape:current-layer="g18"
inkscape:document-rotation="0"
inkscape:pagecheckerboard="false" /><g
id="g18"
inkscape:groupmode="layer"
inkscape:label="ink_ext_XXXXXX"
transform="matrix(1.3333333,0,0,-1.3333333,198.62766,515.8368)"
style="display:inline"><g
id="g2248"
transform="matrix(4.0960001,0,0,4.0960001,425.09731,-1123.3485)"><circle
style="fill:#ffe6d5;fill-opacity:1;fill-rule:evenodd;stroke-width:1.68177998"
id="path2188"
cx="-46.40332"
cy="-274.95755"
transform="scale(1,-1)"
r="90" /><path
d="m -108.10156,258.50681 16.177,19.001 121.483,0.403 -15.767,-19.404 h -121.893"
style="fill:#83331b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path568"
inkscape:connector-curvature="0" /><path
id="path570"
style="fill:#f8a37b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.133333"
d="m -144.13477,321.99023 v 68.08399 a 24.036282,10.884353 0 0 1 21.68555,10.82031 H 3.3222656 A 18.14059,11.337869 0 0 1 1.8144531,396.37109 18.14059,11.337869 0 0 1 18.388672,385.07812 v -63.08789 z"
transform="matrix(0.75000002,0,0,-0.75000002,0,500.00002)"
inkscape:connector-curvature="0" /><path
d="m -38.32356,249.89581 c 1.962,1.06 4.991,4.244 3.122,7.428 -0.346,0.589 -0.684,0.968 -1.008,1.183 h 8.145 c -1.06,-2.333 -4.89,-5.155 -4.89,-5.155 0,0 -0.313,-0.606 -2.011,-1.941 -0.961,-0.754 -2.341,-1.237 -3.358,-1.515 m -24.041,0.131 c -1.001,0.465 -2.455,1.262 -3.478,2.348 -1.708,1.813 -2.052,4.191 -2.052,4.191 l -1.208,1.941 h 4.853 c -0.324,-0.215 -0.661,-0.594 -1.007,-1.183 -1.797,-3.062 0.935,-6.124 2.892,-7.297 m 22.287,0.961 c -1.065,0.104 -3.04,0.372 -5.015,1.03 -2.911,0.97 -4.609,3.153 -4.609,3.153 0,0 -3.275,-2.001 -7.641,-2.796 -1.458,-0.265 -2.408,-0.354 -3.028,-0.354 -0.01,0 -0.019,0 -0.029,0 -0.087,1.464 -0.387,3.029 -0.387,3.029 0,0 -0.827,2.653 -2.068,3.457 h 5.168 20.083 c -1.241,-0.804 -2.069,-3.457 -2.069,-3.457 0,0 -0.464,-2.425 -0.405,-4.062"
style="fill:#da845d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path572"
inkscape:connector-curvature="0" /><path
d="m -60.78656,255.04981 c 0,0 0.923,-4.821 0,-5.458 -0.922,-0.637 -6.926,3.548 -4.47,7.732 2.456,4.184 4.47,-2.274 4.47,-2.274"
style="fill:#dee6e3;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path574"
inkscape:connector-curvature="0" /><path
d="m -39.67256,255.04981 c 0,0 -0.922,-4.821 0,-5.458 0.923,-0.637 6.927,3.548 4.471,7.732 -2.456,4.184 -4.471,-2.274 -4.471,-2.274"
style="fill:#dee6e3;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path576"
inkscape:connector-curvature="0" /><path
d="m -75.27356,295.45981 c 1.117,-5.335 -2.302,-10.566 -7.637,-11.683 -5.335,-1.117 -10.565,2.302 -11.682,7.637 -1.117,5.335 2.302,10.566 7.637,11.683 5.335,1.117 10.565,-2.302 11.682,-7.637"
style="fill:#d4541e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path578"
inkscape:connector-curvature="0" /><path
d="m -79.13656,301.41081 c -2.176,1.581 -4.979,2.281 -7.819,1.686 -5.335,-1.117 -8.754,-6.348 -7.637,-11.683 0.243,-1.161 0.69,-2.224 1.282,-3.177 4.349,1.479 10.933,5.007 14.174,13.174"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path580"
inkscape:connector-curvature="0" /><path
d="m -79.55556,294.56281 c 0.621,-2.97 -1.282,-5.881 -4.252,-6.503 -2.969,-0.622 -5.881,1.281 -6.503,4.251 -0.622,2.97 1.282,5.881 4.251,6.503 2.97,0.622 5.882,-1.281 6.504,-4.251"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path582"
inkscape:connector-curvature="0" /><path
d="m -11.97056,297.29481 c -2.292,0 -4.427,1.456 -5.185,3.752 -0.941,2.854 0.602,5.932 3.449,6.887 1.578,0.545 9.258,3.6 6.96,10.765 -1.66,5.18 -4.8,5.934 -14.08,6.919 -1.702,0.18 -3.461,0.368 -5.289,0.629 -6.567,0.938 -11.396,3.654 -14.351,8.072 -4.312,6.445 -2.622,13.751 -2.415,14.562 0.744,2.922 3.717,4.683 6.64,3.943 2.922,-0.745 4.687,-3.718 3.942,-6.64 0.002,0 -0.74,-3.391 0.946,-5.845 1.168,-1.701 3.45,-2.804 6.783,-3.281 1.632,-0.233 3.292,-0.409 4.897,-0.58 8.623,-0.915 19.355,-2.056 23.327,-14.443 4.228,-13.187 -6.004,-21.855 -13.912,-24.464 -0.568,-0.187 -1.145,-0.276 -1.712,-0.276"
style="fill:#e2591e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path584"
inkscape:connector-curvature="0" /><path
d="m -31.35356,340.33881 -0.162,0.262 c -3.6,-2.3 -8.146,-2.791 -10.691,-2.868 0.426,-1.138 0.987,-2.289 1.74,-3.414 0.966,-1.445 2.153,-2.688 3.516,-3.766 4.784,0.793 8.636,4.38 10.757,6.815 -2.455,0.573 -4.194,1.564 -5.16,2.971"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path588"
inkscape:connector-curvature="0" /><path
d="m -19.75856,325.50081 c 3.913,-0.433 6.655,-0.862 8.622,-1.688 1.349,2.24 3.232,5.957 3.93,10.147 -2.897,1.167 -6.016,1.739 -9.03,2.126 0.263,-2.716 0.078,-7.398 -3.522,-10.585"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path590"
inkscape:connector-curvature="0" /><path
d="m 4.15644,311.99081 c 0.613,2.488 0.673,5.256 -0.025,8.278 -1.075,-0.869 -2.457,-1.657 -4.202,-2.11 -2.954,-0.768 -5.066,-0.721 -6.408,-0.509 0.581,-2.947 -0.577,-5.128 -2.15,-6.674 4.852,-2.119 9.593,-0.653 12.785,1.015"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path592"
inkscape:connector-curvature="0" /><path
d="m -6.44656,314.85681 c -0.003,10e-4 -0.006,0.003 -0.009,0.004 0.012,0.064 0.024,0.128 0.034,0.193 l 0.001,0.002 0.028,0.238 c 0.031,0.251 0.052,0.506 0.056,0.77 l 0.001,0.16 c -0.004,0.338 -0.027,0.685 -0.081,1.047 l 10e-4,-10e-4 c -0.066,0.45 -0.167,0.916 -0.322,1.404 0.111,-0.352 0.199,-0.693 0.265,-1.025 -0.002,0 -0.004,0 -0.006,10e-4 0.201,-1.021 0.194,-1.95 0.032,-2.793 m -0.292,3.82 c -0.003,0.007 -0.005,0.015 -0.008,0.022 -0.856,2.671 -2.105,4.165 -4.386,5.121 v 0 c 2.281,-0.956 3.53,-2.45 4.386,-5.121 0.003,-0.007 0.005,-0.015 0.008,-0.022 m -13.018,6.825 c -0.347,0.039 -0.704,0.077 -1.07,0.116 -1.702,0.18 -3.461,0.368 -5.29,0.629 -0.048,0.007 -0.097,0.014 -0.146,0.022 0.049,-0.008 0.098,-0.015 0.147,-0.022 1.828,-0.261 3.587,-0.449 5.289,-0.629 0.366,-0.039 0.723,-0.077 1.07,-0.116 v 0 m -6.54,0.772 c -0.007,10e-4 -0.015,0.002 -0.023,0.003 0.008,-0.001 0.015,-0.002 0.023,-0.003 m -0.075,0.011 c -0.001,0 -0.002,0 -0.003,0 0,0 0.002,0 0.003,0"
style="fill:#83bad2;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path594"
inkscape:connector-curvature="0" /><path
d="m 3.32344,309.50681 c -0.608,0.453 -1.229,0.89 -1.865,1.311 0.962,0.334 1.851,0.733 2.646,1.146 0.013,-0.008 0.025,-0.017 0.037,-0.026 -0.211,-0.844 -0.488,-1.653 -0.818,-2.431 m -7.988,8.008 c -0.71,0 -1.312,0.056 -1.808,0.134 -0.066,0.332 -0.154,0.673 -0.265,1.025 -10e-4,10e-4 -10e-4,0.002 -10e-4,0.003 -0.003,0.007 -0.005,0.015 -0.008,0.022 -0.856,2.671 -2.105,4.165 -4.386,5.121 0.494,0.82 1.059,1.839 1.612,3.006 2.749,-1.108 4.625,-2.821 6.016,-5.603 0.566,-1.131 0.757,-2.351 0.695,-3.592 -0.685,-0.082 -1.303,-0.116 -1.855,-0.116 m -15.092,7.987 c -0.347,0.039 -0.704,0.077 -1.07,0.116 -1.702,0.18 -3.461,0.368 -5.289,0.629 -0.049,0.007 -0.098,0.014 -0.147,0.022 -0.011,0.001 -0.023,0.003 -0.034,0.005 -0.008,10e-4 -0.015,0.002 -0.023,0.003 -0.018,0.003 -0.035,0.005 -0.052,0.008 -0.001,0 -0.003,0 -0.003,0 -4.878,0.737 -8.769,2.481 -11.64,5.178 0.341,-0.316 0.696,-0.619 1.064,-0.91 0.568,0.094 1.123,0.228 1.664,0.394 2.752,-0.893 6.221,-1.645 10.437,-1.887 2.795,-0.16 5.246,-0.326 7.403,-0.556 -0.54,-1.068 -1.287,-2.095 -2.31,-3.002"
style="fill:#ba4a1f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path596"
inkscape:connector-curvature="0" /><path
d="m -36.95056,330.55281 c -0.368,0.291 -0.723,0.594 -1.064,0.91 -0.306,0.287 -0.6,0.585 -0.883,0.893 1.024,-0.474 2.227,-0.959 3.611,-1.409 -0.541,-0.166 -1.096,-0.3 -1.664,-0.394"
style="fill:#6f2717;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path598"
inkscape:connector-curvature="0" /><path
d="m -11.13256,323.81981 c -1.97,0.825 -4.707,1.249 -8.624,1.682 v 0 c 1.023,0.907 1.77,1.934 2.31,3.002 3.255,-0.346 5.842,-0.838 7.926,-1.678 -0.553,-1.167 -1.118,-2.186 -1.612,-3.006 v 0"
style="fill:#6f2717;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path600"
inkscape:connector-curvature="0" /><path
d="m 1.45844,310.81781 c -2.431,1.612 -5.063,2.99 -7.905,4.039 0.162,0.843 0.169,1.772 -0.032,2.793 0.002,-10e-4 0.004,-10e-4 0.006,-10e-4 0.496,-0.078 1.098,-0.134 1.808,-0.134 0.552,0 1.17,0.034 1.855,0.116 -0.027,-0.549 -0.104,-1.101 -0.219,-1.652 2.551,-1.103 4.934,-2.454 7.133,-4.015 -0.795,-0.413 -1.684,-0.812 -2.646,-1.146"
style="fill:#6f2717;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path602"
inkscape:connector-curvature="0" /><path
d="m 1.95644,321.13981 c 0,0 -4.428,6.195 -10.044,9.266 0.233,0.715 0.442,1.456 0.617,2.217 0.927,-0.436 1.771,-0.935 2.477,-1.507 4.964,-4.019 6.95,-9.976 6.95,-9.976 m -18.127,12.572 c -3.248,1.173 -5.535,1.865 -5.535,1.865 0,0 2.427,-0.091 5.54,-0.568 0.014,-0.409 0.015,-0.844 -0.005,-1.297"
style="fill:#e66733;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path604"
inkscape:connector-curvature="0" /><path
d="m -8.08756,330.40581 c -0.478,0.261 -0.964,0.5 -1.456,0.71 -2.364,1.009 -4.67,1.89 -6.627,2.596 0.02,0.453 0.019,0.888 0.005,1.297 2.763,-0.423 6.066,-1.15 8.695,-2.386 -0.175,-0.761 -0.384,-1.502 -0.617,-2.217"
style="fill:#94401d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path606"
inkscape:connector-curvature="0" /><path
d="m -57.68656,303.78381 c 0,0 14.474,17.064 43.825,12.94 29.351,-4.124 40.267,-42.45 40.267,-42.45 l -12.614,-15.767 h -71.478 v 45.277"
style="fill:#ef5d22;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path608"
inkscape:connector-curvature="0" /><path
d="m -53.06456,307.88981 c 3.118,0.854 8.074,1.536 15.715,1.011 18.532,-1.273 42.591,-30.99 42.591,-30.99 0,0 -10.668,33.893 -44.619,36.942 -6.085,-1.951 -10.642,-4.693 -13.687,-6.963"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path610"
inkscape:connector-curvature="0" /><path
d="m 5.02844,263.29881 h -4.184 c -1.15,3.215 -6.923,18.376 -16.161,26.318 -7.613,6.544 -18.654,14.166 -32.41,14.879 0.704,0.036 1.401,0.054 2.092,0.054 9.919,0 18.501,-3.657 25.49,-8.144 0.021,0.129 0.034,0.258 0.061,0.388 0.293,1.403 0.874,2.67 1.665,3.758 5.534,-3.983 10.713,-8.764 14.808,-12.906 -0.999,-0.954 -2.191,-1.692 -3.497,-2.159 6.883,-8.215 11.163,-19.471 12.136,-22.188 m -6.338,27.706 c -2.837,4.334 -6.709,9.107 -11.819,13.25 0.149,0.042 0.3,0.08 0.451,0.116 l 0.167,0.041 c 0.259,0.056 0.521,0.099 0.786,0.134 l 0.227,0.033 c 0.249,0.027 0.501,0.04 0.754,0.048 0.091,0.003 0.181,0.012 0.273,0.012 0.008,0 0.015,0 0.023,0 0.245,0 0.492,-0.016 0.74,-0.034 0.09,-0.006 0.18,-0.006 0.271,-0.015 0.344,-0.035 0.689,-0.086 1.035,-0.159 5.335,-1.117 8.754,-6.347 7.637,-11.682 -0.117,-0.56 -0.289,-1.092 -0.492,-1.606 l -0.034,-0.091 c -0.006,-0.016 -0.012,-0.031 -0.019,-0.047"
style="fill:#c74d1f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path612"
inkscape:connector-curvature="0" /><path
d="m -3.61056,287.64581 c -4.095,4.142 -9.274,8.923 -14.808,12.906 0.616,0.847 1.361,1.585 2.198,2.193 v 0 c 0.269,0.196 0.548,0.378 0.835,0.545 l 0.017,0.01 c 0.278,0.161 0.564,0.309 0.857,0.443 l 0.053,0.025 c 0.278,0.125 0.562,0.236 0.852,0.335 l 0.105,0.038 c 0.124,0.04 0.248,0.078 0.373,0.114 5.11,-4.143 8.982,-8.916 11.819,-13.25 -0.207,-0.503 -0.456,-0.98 -0.737,-1.433 v -0.001 c -0.442,-0.711 -0.969,-1.357 -1.564,-1.925"
style="fill:#6f2717;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path614"
inkscape:connector-curvature="0" /><path
d="m -24.26856,296.79381 c -1.117,-5.335 2.303,-10.565 7.637,-11.682 5.335,-1.117 10.566,2.302 11.683,7.637 1.117,5.335 -2.302,10.565 -7.637,11.682 -5.335,1.117 -10.565,-2.302 -11.683,-7.637"
style="fill:#d4541e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path616"
inkscape:connector-curvature="0" /><path
d="m -20.40556,302.74481 c 2.176,1.581 4.98,2.281 7.82,1.686 5.335,-1.117 8.754,-6.347 7.637,-11.682 -0.243,-1.162 -0.691,-2.225 -1.282,-3.178 -4.35,1.48 -10.934,5.007 -14.175,13.174"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path618"
inkscape:connector-curvature="0" /><path
d="m -19.98556,295.89681 c -0.622,-2.969 1.282,-5.881 4.251,-6.503 2.97,-0.622 5.881,1.282 6.503,4.251 0.622,2.97 -1.281,5.882 -4.251,6.504 -2.97,0.621 -5.881,-1.282 -6.503,-4.252"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path620"
inkscape:connector-curvature="0" /><path
d="m -95.60856,258.50681 -4.852,4.94 c 0,0 15.465,31.449 36.75,38.724 21.286,7.276 38.024,-3.64 48.394,-12.554 10.37,-8.915 16.373,-26.926 16.373,-26.926 l -3.82,-4.184 h -92.845"
style="fill:#f46a2f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path622"
inkscape:connector-curvature="0" /><path
d="m -71.12356,289.57081 c 0,0 17.435,13.737 20.853,13.737 3.419,0 20.078,-12.054 20.078,-12.054 l -2.762,-3.317 c 0,0 -14.548,11.807 -17.316,11.807 -2.768,0 -16.459,-11.807 -16.459,-11.807 l -4.394,1.634"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path624"
inkscape:connector-curvature="0" /><path
d="m -62.48456,284.29281 c 0,0 10.016,8.491 12.885,8.491 2.869,0 12.401,-7.399 12.401,-7.399 l -2.597,-5.067 c 0,0 -7.579,8.22 -9.804,8.22 -2.225,0 -10.777,-7.641 -10.777,-7.641 l -2.108,3.396"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path626"
inkscape:connector-curvature="0" /><path
d="m -100.46056,263.44681 13.49,4.52 c 0,0 -4.302,14.009 8.251,20.74 12.553,6.731 18.102,-0.182 19.83,-4.184 1.729,-4.003 1.729,-14.555 1.729,-14.555 h 14.954 c 0,0 -0.157,15.707 6.271,20.194 6.428,4.488 16.095,5.094 19.873,-3.274 3.778,-8.369 0,-15.646 0,-15.646 l 17.119,-8.551 -3.82,-4.184 h -92.845 l -4.852,4.94"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path628"
inkscape:connector-curvature="0" /><path
d="m -60.78656,277.91081 c 0,-3.282 -2.66,-5.943 -5.943,-5.943 -3.282,0 -5.943,2.661 -5.943,5.943 0,3.282 2.661,5.943 5.943,5.943 3.283,0 5.943,-2.661 5.943,-5.943"
style="fill:#fddd04;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path630"
inkscape:connector-curvature="0" /><path
d="m -62.48456,277.91081 c 0,-2.344 -1.901,-4.245 -4.245,-4.245 -2.344,0 -4.244,1.901 -4.244,4.245 0,2.344 1.9,4.244 4.244,4.244 2.344,0 4.245,-1.9 4.245,-4.244"
style="fill:#391913;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path632"
inkscape:connector-curvature="0" /><path
d="m -67.62156,280.55381 c 0,-0.967 -0.784,-1.751 -1.751,-1.751 -0.967,0 -1.751,0.784 -1.751,1.751 0,0.967 0.784,1.751 1.751,1.751 0.967,0 1.751,-0.784 1.751,-1.751"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path634"
inkscape:connector-curvature="0" /><path
d="m -27.01156,277.91081 c 0,-3.282 -2.661,-5.943 -5.943,-5.943 -3.282,0 -5.943,2.661 -5.943,5.943 0,3.282 2.661,5.943 5.943,5.943 3.282,0 5.943,-2.661 5.943,-5.943"
style="fill:#fddd04;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path636"
inkscape:connector-curvature="0" /><path
d="m -28.70956,277.91081 c 0,-2.344 -1.901,-4.245 -4.245,-4.245 -2.344,0 -4.244,1.901 -4.244,4.245 0,2.344 1.9,4.244 4.244,4.244 2.344,0 4.245,-1.9 4.245,-4.244"
style="fill:#391913;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path638"
inkscape:connector-curvature="0" /><path
d="m -33.84656,280.55381 c 0,-0.967 -0.784,-1.751 -1.752,-1.751 -0.967,0 -1.751,0.784 -1.751,1.751 0,0.967 0.784,1.751 1.751,1.751 0.968,0 1.752,-0.784 1.752,-1.751"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path640"
inkscape:connector-curvature="0" /><path
d="m 13.79144,258.50681 h -12.371 l 3.821,4.184 c 0,0 -0.072,0.214 -0.213,0.608 h 8.763 l 11.536,14.197 c 0.723,-1.989 1.073,-3.206 1.078,-3.222 v 0 0 l -12.614,-15.767"
style="fill:#c74d1f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path642"
inkscape:connector-curvature="0" /><path
d="m 1.42044,258.50681 h -4.184 l 3.82,4.184 c 0,0 -0.071,0.214 -0.212,0.608 h 4.184 c 0.141,-0.394 0.213,-0.608 0.213,-0.608 l -3.821,-4.184"
style="fill:#a33f1e;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path644"
inkscape:connector-curvature="0" /><path
d="m 1.05644,262.69081 -1.217,0.608 h 1.005 c 0.141,-0.394 0.212,-0.608 0.212,-0.608 v 0"
style="fill:#de581d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path646"
inkscape:connector-curvature="0" /><path
d="m -2.76356,258.50681 h -92.845 l -4.706,4.792 h 100.154 l 1.217,-0.608 -3.82,-4.184"
style="fill:#d5d0cd;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path648"
inkscape:connector-curvature="0" /><path
d="m -58.97456,271.48281 c 0,0 -5.68,-1.462 -7.755,-6.855 -2.075,-5.394 0.745,-9.305 5.475,-10.397 4.731,-1.091 11.553,2.456 11.553,2.456 0,0 5.602,-5.458 12.352,-4.093 6.75,1.364 8.934,9.096 5.659,13.736 -3.275,4.639 -11.28,5.153 -11.28,5.153 0,0 -12.179,-3.488 -16.004,0"
style="fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path650"
inkscape:connector-curvature="0" /><path
d="m -49.70156,256.68681 c 0,0 0.282,-0.27 0.773,-0.664 v 8.605 c 0,0.37 -0.301,0.671 -0.671,0.671 -0.369,0 -0.671,-0.301 -0.671,-0.671 v -8.217 c 0.356,0.167 0.569,0.276 0.569,0.276"
style="fill:#a6b9b5;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path652"
inkscape:connector-curvature="0" /><path
d="m -42.30556,265.56381 c 0,0 4.394,5.176 1.554,7.403 -2.841,2.228 -5.535,-1.611 -9.101,-1.267 -3.566,0.344 -7.301,3.095 -8.825,0.572 -1.523,-2.522 2.303,-5.562 2.303,-5.562 0,0 0.908,2.659 3.196,1.379 2.289,-1.28 0.361,-3.99 0.361,-3.99 0,0 1.685,-1.104 3.059,-1.331 1.374,-0.228 3.509,1.549 3.509,1.549 0,0 -1.109,3.074 1.246,4.262 2.356,1.189 2.698,-3.015 2.698,-3.015"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path654"
inkscape:connector-curvature="0" /><path
d="m -85.90456,281.52781 c 0,0 -3.367,-4.374 -3.094,-10.832 0.273,-6.459 9.56,-12.189 9.56,-12.189 l -3.656,5.688 c -0.913,1.42 -0.603,3.3 0.717,4.353 l 5.659,4.513 c 0,0 -7.641,-2.456 -8.914,-0.637 -1.274,1.82 -0.272,9.104 -0.272,9.104"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path656"
inkscape:connector-curvature="0" /><path
d="m -15.45756,285.37981 c 0,0 -0.525,-5.679 -1.92,-8.498 -1.396,-2.82 -6.217,-1.547 -6.217,-1.547 0,0 5.367,-2.729 5.367,-4.457 0,-1.729 -3.639,-7.186 -3.639,-7.186 0,0 11.817,7.446 6.409,21.688"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path658"
inkscape:connector-curvature="0" /><path
d="m -86.02656,258.50681 c 0,0 -2.365,1.758 -3.882,4.245 -1.516,2.486 -2.001,4.002 -2.001,4.002 0,0 -0.182,-3.275 0.728,-5.458 0.91,-2.183 1.637,-2.789 1.637,-2.789 h 3.518"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path660"
inkscape:connector-curvature="0" /><path
d="m -13.74056,258.50681 c 0,0 2.209,1.81 4.124,5.185 1.915,3.374 2.608,7.791 2.608,7.791 0,0 0.909,-6.366 0.303,-9.095 -0.607,-2.729 -1.577,-3.881 -1.577,-3.881 h -5.458"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path662"
inkscape:connector-curvature="0" /><path
d="m -4.78756,314.17381 c -2.809,1.198 -5.822,2.093 -9.074,2.55 -2.707,0.38 -5.285,0.578 -7.739,0.628 4.835,-1.15 14.841,-4.264 22.793,-11.581 10.901,-10.029 15.146,-19.22 15.146,-19.22 0,0 -6.557,18.098 -21.126,27.623"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path664"
inkscape:connector-curvature="0" /><path
d="m 1.95644,301.95081 c 0,0 7.105,-5.586 11.35,-16.571 4.245,-10.985 4.124,-13.897 4.124,-13.897 l -18.072,22.682 12.008,-11.037 c 0,0 -0.486,6.443 -9.41,18.823"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path666"
inkscape:connector-curvature="0" /><path
id="path668"
style="fill:#f6854f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.133333"
d="m 39.412109,296.11914 -21.023437,25.87109 v 59.92579 a 9.2970522,17.233561 0 0 1 5.419922,-3.23047 9.2970522,17.233561 0 0 1 5.197265,2.94336 l 10.40625,-18.88477 z"
transform="matrix(0.75000002,0,0,-0.75000002,0,500.00002)"
inkscape:connector-curvature="0" /><path
d="m -3.30956,240.43481 h 7.034 v 5.97 h 3.188 l -6.705,7.626 -6.705,-7.626 h 3.188 v -5.97"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path670"
inkscape:connector-curvature="0" /><path
d="m 0.20744,238.61981 h -6.705 v -2.309 h 13.41 v 2.309 h -6.705 v 0"
style="fill:#852e1b;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path672"
inkscape:connector-curvature="0" /><path
d="m -4.28656,310.01981 c -3.321,1.214 -7.933,2.459 -13.091,2.58 -0.665,0.016 -1.339,0.023 -2.015,0.023 -2.736,0 -5.535,-0.12 -8.147,-0.292 -1.365,0.475 -2.782,0.899 -4.25,1.261 3.254,0.749 7.275,1.374 11.627,1.374 1.151,0 2.323,-0.044 3.511,-0.14 1.663,-0.135 3.198,-0.364 4.61,-0.658 2.489,-1.076 5.144,-2.437 7.755,-4.148"
style="fill:#f06c34;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path674"
inkscape:connector-curvature="0" /><path
d="m -39.79556,311.08381 c 0,0 3.151,1.392 8.006,2.508 1.468,-0.362 2.885,-0.786 4.25,-1.261 -6.75,-0.447 -12.256,-1.247 -12.256,-1.247"
style="fill:#94401d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path676"
inkscape:connector-curvature="0" /><path
d="m 1.05644,307.62781 c 0,0 -2.028,1.18 -5.343,2.392 -2.611,1.711 -5.266,3.072 -7.755,4.148 9.116,-1.899 13.098,-6.54 13.098,-6.54"
style="fill:#94401d;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path678"
inkscape:connector-curvature="0" /><path
id="path1131"
style="display:inline;fill:#e66001;fill-opacity:1;fill-rule:evenodd;stroke-width:0.69136697"
d="m -46.403322,368.70754 a 93.750002,93.750002 0 0 1 -93.749998,-93.75001 93.750002,93.750002 0 0 1 93.749998,-93.75 93.750002,93.750002 0 0 1 93.750003,93.75 93.750002,93.750002 0 0 1 -93.750003,93.75001 z m 0,-18.75 a 75,75 0 0 0 75.000002,-75.00001 75,75 0 0 0 -75.000002,-75 75,75 0 0 0 -74.999998,75 75,75 0 0 0 74.999998,75.00001 z"
inkscape:connector-curvature="0" /><path
d="m -31.35356,340.33881 c -1.686,2.454 -0.944,5.845 -0.946,5.845 0.745,2.922 -1.02,5.895 -3.942,6.64 -2.923,0.74 -5.896,-1.021 -6.64,-3.943 -0.2,-0.781 -1.757,-7.578 1.972,-13.834 2.907,0.545 7.163,1.779 10.204,4.534 -0.24,0.239 -0.463,0.488 -0.648,0.758"
style="display:inline;fill:#ffffff;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.1"
id="path586"
inkscape:connector-curvature="0" /></g></g></svg>

After

Width:  |  Height:  |  Size: 26 KiB