Files
felhom-controller/REPORT.md
T
admin 15206314ab v0.165.0: Indítópult megosztása — guest launcher via capability URL (+ optional password, QR)
Mint a 160-bit capability URL (/s/<token>) serving a standalone read-only guest
launcher: same tiles, opens apps in new tabs, no account, no admin session.
Information only, zero control — every privilege stays behind each app's own auth.

- /s/ pre-auth pass-through (after the claim gate) + session-CSRF exemption; guest
  password POST carries its own pre-auth HMAC CSRF.
- Constant-time token match; empty stored token = disabled = byte-identical mux 404.
- Optional per-share password: separate bcrypt hash + own attempt map; signed cookie
  = HMAC(token|passwordHash) keyed with web.session_secret, so rotate/change invalidates.
- Guest labels ride the v0.164.0 ruling; never expose internal state vocabulary.
- Token redacted in logs (/s/<redacted>); never in CHANGELOG/REPORT/CONTEXT.
- Admin modal: copy-link, QR (go-qrcode), set/clear password, rotate, disable.
- Tests: Groups A-G (14) + 3 red-proofs verified red.
2026-07-24 12:08:43 +02:00

97 lines
7.2 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)
_Filled after build + deploy to guest 9201 — see the transcripts below (token redacted throughout)._
<!-- LIVE-VALIDATION -->
## 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.