9.7 KiB
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+LauncherSharePasswordHashfields + 4 accessors (copy of thePasswordHashpattern).controller/internal/web/server.go— Server structshareAttemptsmap (+ 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;launcherHandlerwires share modal state (ShareEnabled/ShareURL/SharePasswordSet/ShareFlash).controller/internal/web/funcmap.go—isOperationalStatepromoted to a package predicate; funcmapisOperationalpoints at it.controller/internal/web/templates/launcher.html—launch_tilepartial 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:
- Token match (Group B):
subtle.ConstantTimeCompare→ prefix-accept (presented[:len(stored)] == stored) →TestShareTokenMatchesFAILS on the superstring case ("a superstring must not match"). Restored → green. - Cookie binding (Group C): dropped
passwordHashfromshareCookieValue's HMAC input →TestShareGuest_PasswordGateFAILS ("changing the password must invalidate the old gate cookie"). Restored → green. - Log redaction (Group G): reverted the ServeHTTP
/s/<redacted>redaction (log rawpath) →TestShareGuest_TokenNeverLoggedFAILS. 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).
- Enable + guest happy path (Scenario A):
GET /s/<token>(no session) →200, headersX-Robots-Tag: noindex, nofollow+Referrer-Policy: no-referrer+Cache-Control: no-store; theIndítópultheading renders; no admin chrome (class="sidebar",nav-links,/logout, version,alert-bannerall absent). - Wrong / disabled token (Scenario B):
GET /s/WRONGTOKEN…→404 [404 page not found]; an authenticatedGET /definitely-no-such-route(the mux default case) →404 [404 page not found]— byte-identical (status + body). - 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_sharegate 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). - Rotation (Scenario D):
POST /launcher/share/rotate→ old tokenGET=404, new tokenGET=200. - QR:
GET /launcher/share/qr.pngwith admin session →200 image/png(valid PNG magic89 50 4e 47); without session →302 → /login?next=/launcher/share/qr.png. Decode method: PNG validated by magic bytes + content-type; the encoded string ishttps://<host>/s/<token>by construction (the handler builds exactly that) — a phone scan was not run (no browser/scanner on DooPlex). - Token never logged (Scenario G):
docker logsshows[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 atinfo, where the redacted WARN 404 is the observable proof.) - Cleanup:
POST /launcher/share/disable→ bothlauncher_share_tokenandlauncher_share_password_hashcleared insettings.json— the box is left in the shipped default (sharing OFF). Post-rundocker ps: stillUp (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, nofollowprevents 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 isSameSite=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.