128 lines
9.7 KiB
Markdown
128 lines
9.7 KiB
Markdown
# REPORT — felhom-controller v0.165.0 — Indítópult megosztása (guest launcher via capability URL)
|
||
|
||
## Baselines
|
||
|
||
| Repo | main @ start | version start → target |
|
||
|------|--------------|------------------------|
|
||
| felhom-controller | `8e5edb2` | v0.164.0 → **v0.165.0** |
|
||
|
||
Clean tree, `HEAD == origin/main`, verified before build.
|
||
|
||
## What shipped
|
||
|
||
The admin launcher gains an **"Indítópult megosztása"** button that mints a **capability URL**
|
||
(`https://<host>/s/<token>`, 160-bit token) serving a standalone, read-only guest launcher — same
|
||
tiles, opens apps in new tabs — with **no accounts and no admin session**. Optional per-share
|
||
password (separate credential); modal offers copy-link, QR, rotate, disable. The link grants
|
||
**information only, zero control**.
|
||
|
||
## Files created / modified
|
||
|
||
**Created**
|
||
- `controller/internal/web/share.go` — pure core: `newShareToken` (20 rand bytes → base64.RawURLEncoding, 27 chars), `shareTokenMatches` (constant-time; empty stored never matches), `shareCookieValue`/`shareCookieValid` (HMAC guest cookie), `shareCSRFToken`/`setShareCSRFCookie`/`validShareCSRF` (pre-auth HMAC CSRF), `shareRateLimited`/`shareRegisterFailure`/`shareClearFailures` (own attempt map).
|
||
- `controller/internal/web/share_handlers.go` — HTTP surface: guest GET/POST handlers, `share404`, `setGuestHeaders`, `GuestLauncherApp` + `buildGuestApps` (pure mapping), render helpers, admin `/launcher/share/*` handlers (enable/rotate/disable/password) + QR handler.
|
||
- `controller/internal/web/share_test.go` — Groups A–G (14 tests) + companion red-proofs.
|
||
- `controller/internal/web/templates/launcher_shared.html` — standalone guest launcher (own minimal `<html>`).
|
||
- `controller/internal/web/templates/launcher_share_password.html` — standalone one-field password gate.
|
||
|
||
**Modified**
|
||
- `controller/internal/settings/settings.go` — `LauncherShareToken` + `LauncherSharePasswordHash` fields + 4 accessors (copy of the `PasswordHash` pattern).
|
||
- `controller/internal/web/server.go` — Server struct `shareAttempts` map (+ NewServer init); ServeHTTP `/s/` mux cases (GET/POST) + `/launcher/share/*` cases; ServeHTTP debug-line `/s/<redacted>` redaction.
|
||
- `controller/internal/web/auth.go` — `/s/` added to the RequireAuth pre-auth allowlist (after the claim-gate block).
|
||
- `controller/internal/web/csrf.go` — `/s/` exempted from session CSRF (guest carries pre-auth HMAC CSRF).
|
||
- `controller/internal/web/handlers.go` — `launcherApps()` extracted; `launcherHandler` wires share modal state (ShareEnabled/ShareURL/SharePasswordSet/ShareFlash).
|
||
- `controller/internal/web/funcmap.go` — `isOperationalState` promoted to a package predicate; funcmap `isOperational` points at it.
|
||
- `controller/internal/web/templates/launcher.html` — `launch_tile` partial extracted; both tile branches use it; share button + modal + JS added.
|
||
- `controller/internal/web/templates/style.css` — share-modal + guest-launcher CSS.
|
||
- `controller/go.mod` / `go.sum` — `github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e`.
|
||
- Docs: `CHANGELOG.md`, `controller/README.md`, `CONTEXT.md`.
|
||
|
||
## Part-2 secret decision — and why
|
||
|
||
**REUSED `web.session_secret`** (via `s.cfg.Web.SessionSecret`) as the HMAC key for both the guest
|
||
gate cookie and the guest CSRF, with per-purpose domain-separation labels
|
||
(`felhom-share-cookie-v1|…`, `felhom-share-csrf-v1`). It qualifies for the **reuse branch** of the
|
||
decision rule: it is **persisted** (a `controller.yaml` `web.session_secret` field) and
|
||
**box-scoped** (each box's own config), and it is **stable across restarts** — not per-boot and not
|
||
claim-generation-scoped. It is the SAME secret the claim pre-auth CSRF already trusts
|
||
(`claim.go:claimCSRFToken`), so reusing it introduces **no new security assumption** beyond what the
|
||
box already relies on. No `ShareCookieSecret` field was added. Binding the cookie to
|
||
`token|passwordHash` makes rotation and password-change invalidate cookies with zero bookkeeping.
|
||
|
||
## Tests + red-proofs
|
||
|
||
`go build ./... && go vet ./... && go test ./...` — all green. Gates: template_id, emoji,
|
||
native_confirm, mojibake, app_row_dedup — all OK.
|
||
|
||
New tests (`share_test.go`), 14 total, all PASS:
|
||
|
||
| Group | Scenario | Test |
|
||
|---|---|---|
|
||
| B(core) | constant-time token match | `TestShareTokenMatches`, `TestNewShareToken_EntropyAndCharset` |
|
||
| A | guest 200 + 3 headers + tiles + no admin chrome | `TestShareGuest_HeadersTilesNoAdminChrome` |
|
||
| B | wrong/disabled/empty = byte-identical mux 404 | `TestShareGuest_WrongTokenIs404LikeDefault` |
|
||
| C | password gate: 5 wrong → 6th rate-limited; correct → cookie; change pw invalidates | `TestShareGuest_PasswordGate` |
|
||
| D | rotate → old 404 + old cookie invalid; disable → all 404 | `TestShareGuest_RotateAndDisable` |
|
||
| E | guest labels + no internal state words + empty state | `TestBuildGuestApps_Labels`, `TestShareGuestTemplate_LabelsNoInternalWords` |
|
||
| F | claim gate intercepts guest page; admin surfaces need auth + CSRF | `TestShare_ClaimGateInterceptsGuestPage`, `TestShare_AdminSurfacesRequireAuthAndCSRF` |
|
||
| G | token never logged (valid + wrong), path redacted | `TestShareGuest_TokenNeverLogged` |
|
||
|
||
Companion **red-proofs** (mutate → FAIL → restore → green), all verified:
|
||
1. **Token match** (Group B): `subtle.ConstantTimeCompare` → prefix-accept (`presented[:len(stored)] == stored`) → `TestShareTokenMatches` FAILS on the superstring case ("a superstring must not match"). Restored → green.
|
||
2. **Cookie binding** (Group C): dropped `passwordHash` from `shareCookieValue`'s HMAC input → `TestShareGuest_PasswordGate` FAILS ("changing the password must invalidate the old gate cookie"). Restored → green.
|
||
3. **Log redaction** (Group G): reverted the ServeHTTP `/s/<redacted>` redaction (log raw `path`) → `TestShareGuest_TokenNeverLogged` FAILS. Restored → green.
|
||
|
||
Existing launcher tests (`TestBuildLauncherApps_*`, `TestLauncherTemplate_*`, `TestTileColor`,
|
||
`TestInitial`, `TestLauncherRoute_EndToEnd`) still green after the `launch_tile` partial extraction.
|
||
|
||
Test count (internal/web): +14 (share_test.go). Full `go test ./...` green before/after.
|
||
|
||
## Deployed version + live validation (§13)
|
||
|
||
Built `0.165.0` on DooPlex (digest `sha256:df3b5920…`), pushed, deployed to guest 9201 via the
|
||
bootstrap service. `docker ps`: `gitea.dooplex.hu/admin/felhom-controller:0.165.0 Up (healthy)`.
|
||
|
||
**Method (stated per the no-browser rule):** all checks invoke the exact endpoints the UI invokes,
|
||
via `docker exec felhom-controller curl http://localhost:8080/…` — `localhost` passes
|
||
CatchAllMiddleware, so the full server pipeline runs (only rendering is skipped). Admin steps use the
|
||
real `/login` (302 + session cookie); the guest surface is hit with NO session. **The live token is
|
||
redacted throughout (first 4 chars + length only).**
|
||
|
||
1. **Enable + guest happy path (Scenario A):** `GET /s/<token>` (no session) → `200`, headers
|
||
`X-Robots-Tag: noindex, nofollow` + `Referrer-Policy: no-referrer` + `Cache-Control: no-store`;
|
||
the `Indítópult` heading renders; **no admin chrome** (`class="sidebar"`, `nav-links`, `/logout`,
|
||
version, `alert-banner` all absent).
|
||
2. **Wrong / disabled token (Scenario B):** `GET /s/WRONGTOKEN…` → `404 [404 page not found]`;
|
||
an authenticated `GET /definitely-no-such-route` (the mux default case) → `404 [404 page not found]`
|
||
— **byte-identical** (status + body).
|
||
3. **Optional password gate (Scenario C):** setting the password stored `launcher_share_password_hash`
|
||
(separate field). `GET /s/<token>` (no cookie) → the password gate. Correct password → `303` +
|
||
`felhom_share` gate cookie → a subsequent cookie-bearing GET renders the launcher directly (no
|
||
gate). Five wrong POSTs → `Hibás jelszó` each; the **6th → `Túl sok sikertelen…` (rate-limited)**.
|
||
4. **Rotation (Scenario D):** `POST /launcher/share/rotate` → old token `GET` = `404`, new token
|
||
`GET` = `200`.
|
||
5. **QR:** `GET /launcher/share/qr.png` **with** admin session → `200 image/png` (valid PNG magic
|
||
`89 50 4e 47`); **without** session → `302 → /login?next=/launcher/share/qr.png`. Decode method:
|
||
PNG validated by magic bytes + content-type; the encoded string is `https://<host>/s/<token>` **by
|
||
construction** (the handler builds exactly that) — a phone scan was not run (no browser/scanner on
|
||
DooPlex).
|
||
6. **Token never logged (Scenario G):** `docker logs` shows `[WARN] [web] 404 Not Found: GET
|
||
/s/<redacted>` (2 lines); a scan of the full log for any raw `/s/<20+ char token>` returned
|
||
**EMPTY**. (The DEBUG ServeHTTP-line redaction is unit-proven in Group G; the live box runs at
|
||
`info`, where the redacted WARN 404 is the observable proof.)
|
||
7. **Cleanup:** `POST /launcher/share/disable` → both `launcher_share_token` and
|
||
`launcher_share_password_hash` cleared in `settings.json` — the box is left in the shipped default
|
||
(sharing OFF). Post-run `docker ps`: still `Up (healthy)`.
|
||
|
||
## Accepted residuals (no code action)
|
||
|
||
- **Link-preview crawlers** (Messenger/WhatsApp/Slack) fetch the URL once and see app names — accepted; `X-Robots-Tag: noindex, nofollow` prevents search indexing.
|
||
- **Reverse-proxy / Cloudflare access logs** may record the `/s/<token>` path — an ops-tier residual outside the controller (the controller's own logs redact it).
|
||
- **LAN-IP link host** — the modal builds the link from the request `Host`, so an admin on a LAN IP gets a LAN-IP link. Kept the UI clean; noted here only.
|
||
|
||
## Observations
|
||
|
||
- The guest gate cookie is scoped `Path=/s/` + `HttpOnly` + `SameSite=Lax` (Lax so a first click from an external app still sends it on top-level GET). The CSRF cookie is `SameSite=Strict`.
|
||
- Disable clears BOTH token and share password (clean slate — a later re-enable never inherits a stale gate).
|
||
- The token GET is deliberately NOT rate-limited or CAPTCHA'd — 160-bit entropy is the defence; the path stays fast and boring.
|