Files
felhom-controller/REPORT.md
T

128 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 AG (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.