diff --git a/REPORT-hub-tokens.md b/REPORT-hub-tokens.md new file mode 100644 index 00000000..b6dae07f --- /dev/null +++ b/REPORT-hub-tokens.md @@ -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. diff --git a/REUSE.md b/REUSE.md index dae4884a..0b0fe346 100644 --- a/REUSE.md +++ b/REUSE.md @@ -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. | diff --git a/STATUS.md b/STATUS.md index 7b402787..9bf06c2e 100644 --- a/STATUS.md +++ b/STATUS.md @@ -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. diff --git a/documentation/architecture/04-control-plane-authorization.md b/documentation/architecture/04-control-plane-authorization.md index 87b2abe7..660eae90 100644 --- a/documentation/architecture/04-control-plane-authorization.md +++ b/documentation/architecture/04-control-plane-authorization.md @@ -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 diff --git a/documentation/architecture/05-hub-architecture.md b/documentation/architecture/05-hub-architecture.md index 22c6d7f9..c1081e4b 100644 --- a/documentation/architecture/05-hub-architecture.md +++ b/documentation/architecture/05-hub-architecture.md @@ -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/` | 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. diff --git a/documentation/architecture/09-update-architecture.md b/documentation/architecture/09-update-architecture.md index b9dee1be..1b2a212a 100644 --- a/documentation/architecture/09-update-architecture.md +++ b/documentation/architecture/09-update-architecture.md @@ -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) diff --git a/documentation/architecture/13-threat-model.md b/documentation/architecture/13-threat-model.md index 14368bfe..ddcd4eaa 100644 --- a/documentation/architecture/13-threat-model.md +++ b/documentation/architecture/13-threat-model.md @@ -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) | diff --git a/documentation/audits/hub-tokens-2026-10-15/red-proof/1-route-table.txt b/documentation/audits/hub-tokens-2026-10-15/red-proof/1-route-table.txt new file mode 100644 index 00000000..04a5a7be --- /dev/null +++ b/documentation/audits/hub-tokens-2026-10-15/red-proof/1-route-table.txt @@ -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 diff --git a/documentation/audits/hub-tokens-2026-10-15/red-proof/10-api-nocred.txt b/documentation/audits/hub-tokens-2026-10-15/red-proof/10-api-nocred.txt new file mode 100644 index 00000000..d81f35a7 --- /dev/null +++ b/documentation/audits/hub-tokens-2026-10-15/red-proof/10-api-nocred.txt @@ -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 diff --git a/documentation/audits/hub-tokens-2026-10-15/red-proof/11-api-token.txt b/documentation/audits/hub-tokens-2026-10-15/red-proof/11-api-token.txt new file mode 100644 index 00000000..af0bb7bf --- /dev/null +++ b/documentation/audits/hub-tokens-2026-10-15/red-proof/11-api-token.txt @@ -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 diff --git a/documentation/audits/hub-tokens-2026-10-15/red-proof/12-main-mount.txt b/documentation/audits/hub-tokens-2026-10-15/red-proof/12-main-mount.txt new file mode 100644 index 00000000..0b00db9f --- /dev/null +++ b/documentation/audits/hub-tokens-2026-10-15/red-proof/12-main-mount.txt @@ -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 diff --git a/documentation/audits/hub-tokens-2026-10-15/red-proof/2-session-only.txt b/documentation/audits/hub-tokens-2026-10-15/red-proof/2-session-only.txt new file mode 100644 index 00000000..60fae819 --- /dev/null +++ b/documentation/audits/hub-tokens-2026-10-15/red-proof/2-session-only.txt @@ -0,0 +1,79 @@ +=== RUN TestToken_SessionOnlyRoutesRefuseEveryToken + tokens_test.go:292: POST / with an all-scope token: 200 "\n\n\n \n \n Felhom Hub — Customer Overview\n \n \n\n\n \n\n\n \n \n Apps — Felhom Hub\n \n\n\n \n\n\n \n \n Apps — Felhom Hub\n \n\n\n \n\n\n \n \n x1 — Felhom Hub\n \n \n \n + + {{if .ShowTokens}} +
+

Access tokens

+

+ A token lets a script use the hub without your password. Tick what it may do; it can do nothing else. + Never by any token: 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. +

+ {{if eq .Flash "token_revoked"}}
Token revoked. It stops working now.
{{end}} + {{if eq .Flash "token_name_invalid"}}
The token needs a name (at most 64 characters). Nothing was created.
{{end}} + {{if eq .Flash "token_no_scope"}}
Tick at least one permission. Nothing was created.
{{end}} + {{if eq .Flash "token_scope_invalid"}}
Unknown permission. Nothing was created.
{{end}} + {{if eq .Flash "token_days_invalid"}}
Expiry must be 1 to 365 days. Nothing was created.
{{end}} + {{if eq .Flash "token_create_failed"}}
The token was not saved. Nothing was created.
{{end}} + {{if eq .Flash "token_revoke_failed"}}
The token was not revoked.
{{end}} + {{with .CreatedToken}} +
+ Token {{.Name}} created. Copy it now — it is shown only this once.
+ {{.Plain}} + +
+ {{end}} + {{if .Tokens}} + + + + {{range .Tokens}} + + + + + + + + + + {{end}} + +
NamePermissionsExpiresLast usedFromState
{{.Name}}{{.Scopes}}{{.Expires}}{{.LastUsed}}{{.LastUsedFrom}}{{if .Active}}{{.State}}{{else}}{{.State}}{{end}}{{if .Active}}
{{$.CSRFField}}
{{end}}
+ {{else}} +

No tokens yet.

+ {{end}} +
+ {{.CSRFField}} +

New token

+ + + + + {{range .TokenGroups}} + + + + + + {{end}} + +
GroupReadWrite
{{.Label}}{{if .Read}}{{end}}{{if .Write}}{{end}}
+ + +
+
+ {{end}} +

Assets

diff --git a/hub/internal/web/tokens.go b/hub/internal/web/tokens.go new file mode 100644 index 00000000..4fecae23 --- /dev/null +++ b/hub/internal/web/tokens.go @@ -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)) +} diff --git a/hub/internal/web/tokens_test.go b/hub/internal/web/tokens_test.go new file mode 100644 index 00000000..87a51cc5 --- /dev/null +++ b/hub/internal/web/tokens_test.go @@ -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") + } +} diff --git a/website/CHANGELOG.md b/website/CHANGELOG.md index 819fc0e7..98426373 100644 --- a/website/CHANGELOG.md +++ b/website/CHANGELOG.md @@ -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 diff --git a/website/assets/prowlarr-logo.svg b/website/assets/prowlarr-logo.svg new file mode 100644 index 00000000..e35948c2 --- /dev/null +++ b/website/assets/prowlarr-logo.svg @@ -0,0 +1,296 @@ + +image/svg+xml \ No newline at end of file