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
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:
@@ -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.
|
||||
@@ -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. |
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
Reference in New Issue
Block a user