Files
felhom-controller/REPORT.md
T

9.7 KiB
Raw Blame History

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.goLauncherShareToken + 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.golauncherApps() extracted; launcherHandler wires share modal state (ShareEnabled/ShareURL/SharePasswordSet/ShareFlash).
  • controller/internal/web/funcmap.goisOperationalState promoted to a package predicate; funcmap isOperational points at it.
  • controller/internal/web/templates/launcher.htmllaunch_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.sumgithub.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.