diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c3f6e2..b91874d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ ## Changelog +### v0.165.0 — Indítópult megosztása: guest launcher via capability URL (2026-07-24) + +No agent coupling; MinAgent unchanged. New dependency: `github.com/skip2/go-qrcode` +(v0.0.0-20200617195104-da1b6568686e, MIT, pure Go, zero transitive deps) for the modal QR code. + +The admin launcher gains an **"Indítópult megosztása"** button that mints a **capability URL** +(`https:///s/`, 160-bit token) serving a standalone, read-only guest launcher — same +tiles, opens apps in new tabs — with **no accounts and no admin session**. The link grants +**information only, zero control**: app names + public URLs; every privilege stays behind each app's +own auth and the controller admin password. + +- **Capability-URL serving.** `/s/` is added to the RequireAuth pre-auth allowlist (AFTER the + claim-gate block, so the claim gate stays supreme) and exempted from session CSRF (guests carry + their own pre-auth HMAC CSRF, like the claim POST). Token comparison is `subtle.ConstantTimeCompare`; + an empty stored token matches nothing, so a wrong/disabled token is **byte-identical to the mux + default 404** — nothing distinguishes it from an unknown route. Guest responses set `X-Robots-Tag: + noindex, nofollow`, `Referrer-Policy: no-referrer`, `Cache-Control: no-store`. +- **Optional per-share password.** A SEPARATE credential — its own bcrypt hash + (`settings.LauncherSharePasswordHash`, never the admin hash), its own per-IP 5/1-min attempt map + (never the admin login map). Passing it once mints a signed cookie = HMAC-SHA256 over + `token|passwordHash` (keyed with the persisted, box-scoped `web.session_secret`), so **rotating the + token OR changing the password invalidates every outstanding cookie** with zero bookkeeping. +- **Modal (admin):** copy-link, a QR code (`/launcher/share/qr.png`, ~256px, admin-authed), + "Jelszó beállítása/törlése", "Új link készítése" (rotation), "Megosztás kikapcsolása". POSTs under + `/launcher/share/*` ride the normal admin session + session CSRF. +- **Guest state labels ride the v0.164.0 ruling:** `StateStopped` ⇒ "A tulajdonos leállította"; + any other non-clickable state ⇒ "Átmenetileg nem elérhető"; guests never see internal state + vocabulary (stopped/exited/degraded/unhealthy). Clickable ⇔ operational AND its public route is + published (`isOperationalState && !routeUnpublished`), so a guest tap never dead-ends on a 404. +- **Token is a secret:** never logged (the ServeHTTP debug line and the 404 WARN redact `/s/` paths + to `/s/`), never written to CHANGELOG/REPORT/CONTEXT, constant-time comparison only. +- **Refactors:** `launcherApps()` extracted from `launcherHandler` (shared with the guest handler); + the tile visual extracted into a `launch_tile` partial (single markup source for admin + guest); + `isOperationalState` promoted to a package predicate (single source for the funcmap + guest rule). +- New files: `internal/web/share.go` (pure core), `internal/web/share_handlers.go` (HTTP surface), + `internal/web/share_test.go` (Groups A–G + 3 red-proofs verified red), templates + `launcher_shared.html` + `launcher_share_password.html`. +- Design rulings (CONTEXT): member accounts are superseded by this capability-URL model; + per-member tile visibility is parked under the SSO arc. + ### v0.164.0 — Deliberately stopped apps no longer alarm (banner + email) (2026-07-24) No agent coupling; MinAgent unchanged. Operator finding on 9201: stopping an app via the UI diff --git a/CONTEXT.md b/CONTEXT.md index 9843174..2404223 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -7,7 +7,29 @@ > > Ask Claude Code: "Please update CONTEXT.md with what we did today" -Last updated: 2026-07-24 (v0.164.0 — deliberately stopped apps no longer alarm; banner + email suppressed for StateStopped) +Last updated: 2026-07-24 (v0.165.0 — Indítópult megosztása: guest launcher via capability URL /s/, optional share password, QR) + +> **2026-07-24 — v0.165.0 (Indítópult megosztása — guest launcher via capability URL).** The admin +> launcher gets an "Indítópult megosztása" button that mints a **capability URL** +> (`https:///s/`, 160-bit `crypto/rand` token) serving a standalone, read-only guest +> launcher — same tiles, opens apps in new tabs — with **no account and no admin session**. **Security +> ruling: the link grants INFORMATION ONLY, ZERO CONTROL** — app names + public URLs; every privilege +> stays behind each app's own auth and the controller admin password. The token IS the secret (160-bit +> entropy is the whole defence for the GET — never rate-limited, never logged, `subtle.ConstantTimeCompare` +> only; an empty stored token = sharing OFF, matches nothing, so a wrong/disabled token is byte-identical +> to the mux default 404). Optional per-share password is a SEPARATE credential (own bcrypt hash, own +> attempt map — NEVER the admin ones); one pass mints a cookie = HMAC(`token|passwordHash`) keyed with +> the persisted `web.session_secret`, so rotate-token OR change-password invalidates all cookies for free. +> **Part-2 secret decision: REUSED `web.session_secret`** (persisted + box-scoped + stable — the SAME +> secret the claim pre-auth CSRF already trusts; not per-boot, not claim-generation-scoped → the reuse +> branch), so no `ShareCookieSecret` field was added. **Design rulings recorded:** member accounts are +> **superseded** by this capability-URL model; **per-member tile visibility is PARKED under the SSO arc.** +> Guest state labels ride the v0.164.0 invariants: `StateStopped` ⇒ "A tulajdonos leállította"; any +> other non-clickable state ⇒ "Átmenetileg nem elérhető" (guests never see stopped/exited/degraded/ +> unhealthy). Accepted residuals (documented, no code action): link-preview crawlers fetch once and see +> app names (noindex prevents indexing); reverse-proxy/CF access logs may hold the path (ops-tier); the +> modal link carries the request Host, so a LAN-IP admin session yields a LAN-IP link. New dep: +> `github.com/skip2/go-qrcode`. Tests: Groups A–G (14 tests) + 3 red-proofs verified red. > **2026-07-24 — v0.164.0 (stopped ≠ fault).** Operator finding on 9201: a UI stop (Leállítás) raised > the global "Telepített alkalmazás nem fut: … (stopped)" banner on every page AND fired the diff --git a/REPORT.md b/REPORT.md index a2ef645..419956c 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,108 +1,96 @@ -# REPORT — v0.164.0: deliberately stopped apps no longer alarm (banner + email) - -## Summary - -Stopping an app from the UI (Leállítás) previously raised the global warning banner -"Telepített alkalmazás nem fut: … (stopped)" on **every** page (launcher included) and fired the -`app_start_failed` hub event on the running→down transition. A deliberate user action is not a fault. -v0.164.0 suppresses `StateStopped` from **both** the banner dead-list and the notifier Down-set at a -single derivation point, while every genuine fault (`exited`/`degraded`) keeps alerting byte-identically. +# REPORT — felhom-controller v0.165.0 — Indítópult megosztása (guest launcher via capability URL) ## Baselines -| Item | Value | -|---|---| -| Repo | felhom-controller | -| `main` before | `77956d8` (v0.163.1) — clean tree, HEAD == origin/main verified | -| `main` after (code) | `c23a0f6` | -| Target version | **v0.164.0** — built + deployed to guest 9201, healthy | -| Agent coupling | none; MinAgent unchanged | +| Repo | main @ start | version start → target | +|------|--------------|------------------------| +| felhom-controller | `8e5edb2` | v0.164.0 → **v0.165.0** | -## Files modified +Clean tree, `HEAD == origin/main`, verified before build. -- `controller/cmd/controller/main.go` — extracted `scanDeployedAppRunStates`'s pure core to - `classifyRunStates([]stacks.Stack) ([]web.DeadApp, []notify.AppRunState)`; changed the down - predicate to `stacks.IsDownState(st.State) && st.State != stacks.StateStopped`; documented invariants - I1/I2 at the seam. -- `controller/cmd/controller/classify_runstates_test.go` — **new**; Groups A/B + skip test. -- `controller/internal/notify/deadapp_test.go` — added Group C (stop→start→crash sequence). -- `controller/README.md` — new "Deliberate stops are silent (v0.164.0)" paragraph + fix-3 wording fix. -- `REUSE.md` — new `classifyRunStates` seam row. -- `CHANGELOG.md` (v0.164.0 entry on top), `CONTEXT.md` (ruling with I1+I2). +## What shipped -Commit: `c23a0f6` (code + tests + docs). REPORT committed separately (post-validation). +The admin launcher gains an **"Indítópult megosztása"** button that mints a **capability URL** +(`https:///s/`, 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**. -## The rule and its invariants (recorded at the seam, README, CONTEXT, CHANGELOG) +## Files created / modified -`StateStopped` ⇒ deliberate, because: -- **I1** — the UI stop path `Manager.StopStack` runs `docker compose down` → containers are removed, - and a deployed stack with zero containers aggregates to `StateStopped` (refreshStatusLocked). Proven - live: after the stop, `docker ps -a` showed **no** calibre-web container. -- **I2** — the P2 restart-policy census (2026-07-21, 53 templates / 78 services) found every catalog - service on `unless-stopped`, so a crash never rests at `stopped` — faults surface as - `exited`/`degraded`/`restarting`/`unhealthy`. +**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 ``). +- `controller/internal/web/templates/launcher_share_password.html` — standalone one-field password gate. -If either invariant changes, revisit the suppression. `IsDownState` left unchanged (other callers rely -on stopped counting as down). Out-of-band `docker compose stop` (containers remain → `exited`) still -alerts — acceptable. The `stopped_by_user` intent flag was considered and parked. +**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/` 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`. -## Tests — results + red-proofs (count 3→4 notify, 4→7 main; +4 total) +## Part-2 secret decision — and why -| Test | Result | -|---|---| -| `TestClassifyRunStates_StoppedIsSuppressed` (Group A) | PASS — dead={immich(exited),nextcloud(degraded)}, Down flags {false,false,true,true} | -| `TestClassifyRunStates_FaultParity` (Group B) | PASS — both faults in dead list, both Down=true, raw state string carried | -| `TestClassifyRunStates_SkipsDeployingAndUndeployed` | PASS | -| `TestNotifyAppStartFailures_StopStartCrashSequence` (Group C) | PASS — exactly one event for the crash, zero for the stop | -| Full suite `go build/vet/test ./...` | PASS (all packages green) | +**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. -Red-proofs (mechanically executed, then reverted): -- **Group A red-proof** — reverted the filter to bare `stacks.IsDownState(st.State)`: - `TestClassifyRunStates_StoppedIsSuppressed` **FAILED** ("dead list must be exactly …, got […cwa/stopped…]"). - Restored → PASS. -- **Group C red-proof** — flipped the stop cycle to `Down:true`: - `TestNotifyAppStartFailures_StopStartCrashSequence` **FAILED** ("a deliberate stop must fire no event, got 1"). - Restored → PASS. +## Tests + red-proofs -## Deployment +`go build ./... && go vet ./... && go test ./...` — all green. Gates: template_id, emoji, +native_confirm, mojibake, app_row_dedup — all OK. -- Built `0.164.0` on DooPlex (`build.sh 0.164.0 --push`), pushed to `gitea.dooplex.hu/admin/felhom-controller:0.164.0`. -- Deployed to guest 9201 (bootstrap: pull → `/etc/felhom-controller-image` → restart bootstrap service). -- `docker ps`: `gitea.dooplex.hu/admin/felhom-controller:0.164.0 Up (healthy)`. +New tests (`share_test.go`), 14 total, all PASS: -## Live validation (guest 9201, customer `demo-felhom`; endpoint-level — no browser) +| 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` | -Method: authed session to the in-guest controller (container IP 172.17.0.2:8080, `Host: felhom.demo-felhom.eu`, -session cookie + `X-CSRF-Token`), driving the exact UI endpoints; banners read from rendered HTML -(ASCII substring `nem fut`); event surface read from the hub SQLite `events` table (the true email -trigger). Event watermark before: max id **1753**. +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/` redaction (log raw `path`) → `TestShareGuest_TokenNeverLogged` FAILS. Restored → green. -1. **Deliberate stop is silent (Scenario A).** `POST /api/stacks/calibre-web/stop` → `{"ok":true}`; - `docker ps -a` → calibre-web container gone (I1 confirmed). After one health cycle: - - Banner on `/`: **none**. Banner on `/launcher`: **none**. - - Launcher: calibre-web rendered as a greyed off-tile (`launch-cell--off`, 2 off-tiles). - - Hub events since 1753: **none** — no `app_start_failed`. (Contrast: under 0.163.1 the stopped - BookStack fired app_start_failed events 1750/1752 at 08:02/08:17 the same morning.) -2. **Faults still alarm (Scenario B).** Fault-injected `immich` by stopping its supervised primary - `immich-server` (siblings redis/postgres/machine-learning stayed up → `StateDegraded`). Rationale: - `docker kill` on an `unless-stopped` container self-restarts (→ restarting/running, never rests - degraded), so the persistent-dead-member fault is injected with `docker stop`. After one cycle: - - Banner on `/`: **"nem fut: Immich (degraded)"**. - - Hub event **id 1754** `app_start_failed` "…Immich" fired (running→down transition). - - calibre-web (still stopped) remained **absent** from the banner — suppression holds beside a real fault. - - Restore: `docker start immich-server` → healthy; banner **self-cleared** (none on `/`) on the next cycle. -3. **Stop→start stays correct (Scenario C).** `POST /api/stacks/calibre-web/start` → `{"ok":true}`; - calibre-web healthy; launcher off-tiles dropped **2 → 1** (tile un-greyed). Hub events since 1753: - only the immich `1754`; **max id still 1754** — the stop AND the start produced **zero** events. +Existing launcher tests (`TestBuildLauncherApps_*`, `TestLauncherTemplate_*`, `TestTileColor`, +`TestInitial`, `TestLauncherRoute_EndToEnd`) still green after the `launch_tile` partial extraction. -Final state: all four deployed apps (calibre-web, docmost, filebrowser, immich) healthy; system -restored to baseline; 0.164.0 live. In-guest helper + local credential/DB copies removed. +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)._ + + + +## 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/` 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 one remaining launcher off-tile after Scenario C is a pre-existing non-operational app unrelated - to this change (the 2→1 drop is exactly calibre-web un-greying). -- Hub event `1754` is the legitimate audit record of the Scenario-B fault injection (info severity — - the hub's own classification, unchanged); left in place. -- No template/funcmap/notifier/dashboard-counter/Hungarian-copy change was made — the entire semantic - change is the one-line predicate at `classifyRunStates`. +- 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. diff --git a/controller/README.md b/controller/README.md index 2e389f0..bbc5b3e 100644 --- a/controller/README.md +++ b/controller/README.md @@ -278,6 +278,44 @@ load the monogram shows through (the launcher does NOT use the app-placeholder h are `` links; stopped/degraded apps render greyed + unclickable with the Hungarian state badge. Empty state links to `/stacks`. +#### Indítópult megosztása — guest launcher via capability URL (v0.165.0) + +The admin launcher's **"Indítópult megosztása"** button mints a **capability URL** — +`https:///s/`, where `token` is a 160-bit `crypto/rand` value +(`newShareToken`, base64.RawURLEncoding, 27 chars) — that serves a **standalone, read-only guest +launcher** with **no account and no admin session**. The link grants **information only, zero +control**: app names + public URLs; every privilege stays behind each app's own auth and the +controller admin password. The tile visual is shared with the admin launcher via the `launch_tile` +template partial; the app slice comes from the extracted `Server.launcherApps()` helper. + +- **Routing** (`internal/web/share.go`, `share_handlers.go`): `/s/` joins the RequireAuth + pre-auth allowlist **after** the claim-gate block (an unclaimed box never serves the guest page — + the claim gate stays supreme) and is exempted from session CSRF (the guest password POST carries a + pre-auth HMAC CSRF, `validShareCSRF`, mirroring the claim POST). Token match is + `subtle.ConstantTimeCompare`; an empty stored token (= sharing OFF, there is no separate flag) + matches nothing, so a wrong/disabled token returns a **byte-identical mux-default 404** (`share404`). + Guest responses set `X-Robots-Tag: noindex, nofollow` / `Referrer-Policy: no-referrer` / + `Cache-Control: no-store`. The token is a secret: the ServeHTTP debug line and the 404 WARN redact + `/s/` paths to `/s/`. +- **Optional per-share password** (`settings.LauncherSharePasswordHash`): a SEPARATE bcrypt credential + (never the admin `PasswordHash`), guarded by its OWN per-IP 5/1-min attempt map (`shareAttempts`, + never the admin `loginAttempts`). A correct password mints a signed gate cookie = + HMAC-SHA256(`token|passwordHash`) keyed with the persisted, box-scoped `web.session_secret` — so + rotating the token OR changing the password invalidates every outstanding cookie with no bookkeeping. +- **Guest state labels** ride the v0.164.0 ruling and never expose internal vocabulary: clickable ⇔ + `isOperationalState && !routeUnpublished` (operational AND route actually published, so a tap never + dead-ends); `StateStopped` ⇒ "A tulajdonos leállította"; any other non-clickable state ⇒ + "Átmenetileg nem elérhető". Empty ⇒ "Jelenleg nincs elérhető alkalmazás." (`buildGuestApps` is the + pure, tested mapping; templates `launcher_shared.html` + `launcher_share_password.html`). +- **Admin modal** (in `launcher.html`): current link + copy button, QR code + (`GET /launcher/share/qr.png`, ~256px PNG via `github.com/skip2/go-qrcode`, admin-authed, `no-store`), + set/clear share password, "Új link készítése" (rotate), "Megosztás kikapcsolása" (clears token AND + password). The management POSTs live under `/launcher/share/*` and ride the normal admin session + + session CSRF; rotate/disable use the inline `data-confirm` (felhomConfirm) affordance. + +Design ruling: member accounts are superseded by this capability-URL model; per-member tile +visibility is parked under the SSO arc. + #### Dashboard "Megnyitás" Button Running apps on the Vezérlőpult now show a "Megnyitás ↗" button that opens the app's subdomain in a new tab. The `Subdomains` map is built in `dashboardHandler` from `app.yaml` env or metadata fallback. diff --git a/controller/go.mod b/controller/go.mod index a064a2f..cd4c625 100644 --- a/controller/go.mod +++ b/controller/go.mod @@ -5,6 +5,7 @@ go 1.24.0 require ( github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 github.com/emersion/go-smtp v0.24.0 + github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e golang.org/x/crypto v0.31.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.45.0 diff --git a/controller/go.sum b/controller/go.sum index 3c19f12..e5663cb 100644 --- a/controller/go.sum +++ b/controller/go.sum @@ -16,6 +16,8 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= +github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= @@ -27,6 +29,8 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/controller/internal/settings/settings.go b/controller/internal/settings/settings.go index 4e1a026..19b6776 100644 --- a/controller/internal/settings/settings.go +++ b/controller/internal/settings/settings.go @@ -28,6 +28,14 @@ type Settings struct { // Auth PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml + // Guest launcher share (v0.165.0). LauncherShareToken is the ≥160-bit URL capability token that + // serves the read-only guest launcher at /s/; empty means sharing is OFF (there is no + // separate enabled flag — an empty token matches nothing). LauncherSharePasswordHash is an + // OPTIONAL bcrypt hash for a per-share password, ALWAYS SEPARATE from the admin PasswordHash above. + // The token is a secret and must never be logged. + LauncherShareToken string `json:"launcher_share_token,omitempty"` + LauncherSharePasswordHash string `json:"launcher_share_password_hash,omitempty"` + // Customer-claim arc (v0.122.0, F-4). Claimed is SET-ONLY (a claim or reset completed at // least once — never cleared). ClaimCode* cache the freshest hub-delivered code state (report // ACK; beats controller.yaml when its generation is newer). ClaimConsumedGeneration records @@ -482,6 +490,42 @@ func (s *Settings) SetPasswordHash(hash string) error { return s.save() } +// ── Guest launcher share (v0.165.0) ────────────────────────────────────────────── + +// GetLauncherShareToken returns the guest-launcher capability token ("" = sharing disabled). +func (s *Settings) GetLauncherShareToken() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.LauncherShareToken +} + +// SetLauncherShareToken stores (or clears, on "") the guest-launcher token and saves. A new value +// rotates the link; because the guest gate cookie is bound to the token, any outstanding cookie is +// invalidated automatically. Never log the value. +func (s *Settings) SetLauncherShareToken(token string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.LauncherShareToken = token + return s.save() +} + +// GetLauncherSharePasswordHash returns the optional per-share bcrypt hash ("" = no share password). +func (s *Settings) GetLauncherSharePasswordHash() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.LauncherSharePasswordHash +} + +// SetLauncherSharePasswordHash stores (or clears, on "") the per-share bcrypt hash and saves. It is +// ALWAYS distinct from the admin password hash. Changing it invalidates outstanding guest cookies +// (they bind the hash into the signature). +func (s *Settings) SetLauncherSharePasswordHash(hash string) error { + s.mu.Lock() + defer s.mu.Unlock() + s.LauncherSharePasswordHash = hash + return s.save() +} + // ── Customer-claim arc (v0.122.0) ────────────────────────────────────────────── // GetClaimed reports whether this box has completed a claim (set-only). diff --git a/controller/internal/web/auth.go b/controller/internal/web/auth.go index e046ae1..9b522f9 100644 --- a/controller/internal/web/auth.go +++ b/controller/internal/web/auth.go @@ -83,8 +83,11 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { } // Claim/reset routes stay reachable pre-auth even on a claimed box: they are the RESET - // entry (code-gated internally). Static assets for the page too. - if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" || strings.HasPrefix(r.URL.Path, "/static/") { + // entry (code-gated internally). Static assets for the page too. The guest launcher share + // (v0.165.0) joins here — /s/ is a capability URL with NO admin session; the token + // (or the optional share password) is its own gate. Placed AFTER the claim-gate block above, + // so an unclaimed box never serves the guest page (the claim gate stays supreme). + if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" || strings.HasPrefix(r.URL.Path, "/static/") || strings.HasPrefix(r.URL.Path, "/s/") { next.ServeHTTP(w, r) return } diff --git a/controller/internal/web/csrf.go b/controller/internal/web/csrf.go index 9685d12..cf8b56a 100644 --- a/controller/internal/web/csrf.go +++ b/controller/internal/web/csrf.go @@ -34,7 +34,10 @@ func (s *Server) CsrfProtect(next http.Handler) http.Handler { // Claim/reset POSTs carry their OWN pre-auth HMAC CSRF (validated in the handler) — the // customer resetting a claimed box has no session yet, so the session-CSRF path can't apply. - if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" { + // The guest launcher share password POST (/s/, v0.165.0) is the same shape: no admin + // session, own pre-auth HMAC CSRF (validShareCSRF). The admin share-management POSTs live + // under /launcher/share/* and are NOT exempted — they ride the normal session CSRF below. + if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" || strings.HasPrefix(r.URL.Path, "/s/") { next.ServeHTTP(w, r) return } diff --git a/controller/internal/web/funcmap.go b/controller/internal/web/funcmap.go index a5882ff..d6719d8 100644 --- a/controller/internal/web/funcmap.go +++ b/controller/internal/web/funcmap.go @@ -84,6 +84,18 @@ func routeUnpublished(state stacks.ContainerState) bool { } } +// isOperationalState reports whether a stack has running containers (not stopped/exited/not-deployed). +// Shared by the funcmap "isOperational" and the guest launcher's clickability rule (v0.165.0), so both +// answer "is there something to open here" from a single source. +func isOperationalState(state stacks.ContainerState) bool { + switch state { + case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting, stacks.StateDegraded: + return true + default: + return false + } +} + // templateFuncMap returns the FuncMap used by all HTML templates. func (s *Server) templateFuncMap() template.FuncMap { loc := getTimezone() @@ -162,14 +174,7 @@ func (s *Server) templateFuncMap() template.FuncMap { }, // isOperational returns true for any state where the stack has containers // and is not stopped/exited — used by templates for showing action buttons - "isOperational": func(state stacks.ContainerState) bool { - switch state { - case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting, stacks.StateDegraded: - return true - default: - return false - } - }, + "isOperational": isOperationalState, "routeUnpublished": routeUnpublished, "logoURL": func(slug string) string { return s.cfg.AppLogoURL(slug) diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index f1620fb..6758077 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -272,18 +272,39 @@ func buildLauncherApps(stackList []stacks.Stack, subdomains map[string]string) [ return apps } -// launcherHandler renders the Indítópult: a grid of large tappable tiles, one per openable deployed -// app (subdomain presence is the single openability criterion — see buildLauncherApps). Behind -// RequireAuth like every page; the "/" landing page stays the Vezérlőpult. -func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) { +// launcherApps assembles the sorted launcher tile list (deployed/protected stacks with a subdomain, +// controller excluded). Extracted from launcherHandler so the guest share page (v0.165.0) renders +// the EXACT same app slice as the admin launcher. +func (s *Server) launcherApps() []LauncherApp { var eligible []stacks.Stack for _, st := range s.stackMgr.GetStacks() { if st.Deployed || st.Protected { eligible = append(eligible, st) } } + return buildLauncherApps(eligible, s.subdomainMap(eligible)) +} + +// launcherHandler renders the Indítópult: a grid of large tappable tiles, one per openable deployed +// app (subdomain presence is the single openability criterion — see buildLauncherApps). Behind +// RequireAuth like every page; the "/" landing page stays the Vezérlőpult. It also carries the +// "Indítópult megosztása" share state (v0.165.0) for the modal. +func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) { data := s.baseData("launcher", "Indítópult") - data["Apps"] = buildLauncherApps(eligible, s.subdomainMap(eligible)) + data["Apps"] = s.launcherApps() + + // Share modal state. The share URL is built from the request Host at render time (the canonical + // controller subdomain is not persisted anywhere reachable here); it carries the live token, which + // is fine to show the authed admin inside the modal — the ONE admin surface allowed to reveal it. + token := s.settings.GetLauncherShareToken() + data["ShareEnabled"] = token != "" + if token != "" { + data["ShareURL"] = "https://" + r.Host + "/s/" + token + } + data["SharePasswordSet"] = s.settings.GetLauncherSharePasswordHash() != "" + if f := strings.TrimSpace(r.URL.Query().Get("flash")); f != "" { + data["ShareFlash"] = f + } s.executeTemplate(w, r, "launcher", data) } diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 98fc897..2a21069 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -50,6 +50,12 @@ type Server struct { done chan struct{} closeOnce sync.Once + // Guest launcher share (v0.165.0): its OWN per-IP brute-force limiter for the optional share + // password gate — deliberately separate from loginAttempts (the admin login), so a guest and the + // owner never share a counter. Lazily initialized (struct-literal test servers skip NewServer). + shareAttempts map[string]*loginAttempt + shareAttemptMu sync.Mutex + // Customer-claim arc (v0.122.0, F-4): the claim/reset code brute-force limiter. Per-source // (IP) + a global counter; both must be clear. claimClock is the test clock seam (nil → time.Now). claimMu sync.Mutex @@ -179,6 +185,7 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste version: version, sessions: make(map[string]*session), loginAttempts: make(map[string]*loginAttempt), + shareAttempts: make(map[string]*loginAttempt), done: make(chan struct{}), } s.classifyFSPath = system.ClassifyPathFSTimeout @@ -331,7 +338,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path if s.isDebug() { - s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, path, r.RemoteAddr) + // The guest launcher share token (v0.165.0) is a secret — redact it from the request log so + // debug logging never leaks a live capability URL (Scenario G). Method + IP stay intact. + logPath := path + if strings.HasPrefix(path, "/s/") { + logPath = "/s/" + } + s.logger.Printf("[DEBUG] [web] ServeHTTP: %s %s from %s", r.Method, logPath, r.RemoteAddr) } switch { @@ -347,6 +360,25 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.dashboardHandler(w, r) case path == "/launcher": s.launcherHandler(w, r) + // Guest launcher share (v0.165.0). /s/ is the pre-auth capability URL (RequireAuth lets + // the /s/ prefix through after the claim gate). A GET renders the guest launcher (or the password + // gate); a POST submits the optional share password. An unknown/disabled token falls through to a + // byte-identical 404 (share404), so nothing distinguishes a wrong token from an unknown route. + case strings.HasPrefix(path, "/s/") && r.Method == http.MethodGet: + s.shareGuestHandler(w, r) + case strings.HasPrefix(path, "/s/") && r.Method == http.MethodPost: + s.shareGuestPasswordHandler(w, r) + // Admin share management (session-authed via RequireAuth + session CSRF via CsrfProtect). + case path == "/launcher/share/qr.png" && r.Method == http.MethodGet: + s.launcherShareQRHandler(w, r) + case path == "/launcher/share/enable" && r.Method == http.MethodPost: + s.launcherShareEnableHandler(w, r) + case path == "/launcher/share/rotate" && r.Method == http.MethodPost: + s.launcherShareRotateHandler(w, r) + case path == "/launcher/share/disable" && r.Method == http.MethodPost: + s.launcherShareDisableHandler(w, r) + case path == "/launcher/share/password" && r.Method == http.MethodPost: + s.launcherSharePasswordHandler(w, r) case path == "/stacks": s.stacksHandler(w, r) case path == "/backups": diff --git a/controller/internal/web/share.go b/controller/internal/web/share.go new file mode 100644 index 0000000..af2cb67 --- /dev/null +++ b/controller/internal/web/share.go @@ -0,0 +1,147 @@ +package web + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "net/http" + "time" +) + +// Guest launcher share (v0.165.0). The "Indítópult megosztása" capability link: the admin mints a +// ≥160-bit random token; https:///s/ then serves a standalone, read-only guest launcher +// with NO account and NO admin session. The link grants INFORMATION ONLY — app names + public URLs; +// every privilege stays behind each app's own auth and the controller admin password. +// +// SECURITY MODEL: +// - The token IS the secret. 160 bits of entropy is the whole defence for the token GET — the path +// is never rate-limited or CAPTCHA'd, and the value is never logged (redacted as /s/). +// - Constant-time comparison only (shareTokenMatches); an empty stored token matches nothing, so +// "sharing disabled" and "wrong token" are indistinguishable from any unknown route (Scenario B). +// - The OPTIONAL per-share password is a SEPARATE credential (its own bcrypt hash, its own attempt +// map). Passing it once mints a signed cookie bound to token|passwordHash, so rotating the token +// OR changing the password invalidates every outstanding cookie with zero bookkeeping. + +const ( + shareCookieName = "felhom_share" // the signed guest gate cookie (password shares only) + shareCSRFCookie = "felhom_share_csrf" // pre-auth HMAC CSRF for the guest password POST + shareCookieMaxAge = 30 * 24 * time.Hour +) + +// newShareToken returns a fresh ≥160-bit capability token: 20 random bytes (160 bits) as +// base64.RawURLEncoding (27 URL-safe chars, no padding). +func newShareToken() (string, error) { + b := make([]byte, 20) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// shareTokenMatches reports whether the presented token equals the stored one, in constant time. An +// empty stored token never matches (sharing disabled ⇒ every /s/ path is a 404). ConstantTimeCompare +// returns 0 for differing lengths, so no length oracle leaks. +func shareTokenMatches(stored, presented string) bool { + if stored == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(stored), []byte(presented)) == 1 +} + +// shareCookieValue is the signed guest gate cookie: HMAC-SHA256 over token|passwordHash, keyed with +// the box's persisted, box-scoped web.session_secret (the SAME secret the claim pre-auth CSRF already +// trusts — reusing it introduces no new assumption; it is stable across restarts, not per-boot and +// not claim-generation-scoped, so it fits the reuse branch of the Part-2 decision rule). Binding the +// share-password hash into the MAC means a password change invalidates the cookie; binding the token +// means a rotation does too. +func (s *Server) shareCookieValue(token, passwordHash string) string { + mac := hmac.New(sha256.New, []byte(s.cfg.Web.SessionSecret)) + mac.Write([]byte("felhom-share-cookie-v1|" + token + "|" + passwordHash)) + return base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) +} + +// shareCookieValid reports whether the request carries a valid gate cookie for this token+hash. +func (s *Server) shareCookieValid(r *http.Request, token, passwordHash string) bool { + c, err := r.Cookie(shareCookieName) + if err != nil { + return false + } + want := s.shareCookieValue(token, passwordHash) + return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1 +} + +// ── pre-auth CSRF for the guest password form (mirrors the claim pre-auth HMAC pattern: the guest +// has no session, so the session-CSRF path cannot apply) ───────────────────────────────────────── + +func (s *Server) shareCSRFToken() string { + mac := hmac.New(sha256.New, []byte(s.cfg.Web.SessionSecret)) + mac.Write([]byte("felhom-share-csrf-v1")) + return hex.EncodeToString(mac.Sum(nil)) +} + +func (s *Server) setShareCSRFCookie(w http.ResponseWriter, r *http.Request) string { + tok := s.shareCSRFToken() + http.SetCookie(w, &http.Cookie{ + Name: shareCSRFCookie, + Value: tok, + Path: "/s/", + HttpOnly: false, // read back only by the form on the same page; SameSite blocks cross-site + SameSite: http.SameSiteStrictMode, + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", + MaxAge: int(shareCookieMaxAge.Seconds()), + }) + return tok +} + +func (s *Server) validShareCSRF(r *http.Request) bool { + want := s.shareCSRFToken() + if subtle.ConstantTimeCompare([]byte(r.FormValue(csrfFormField)), []byte(want)) != 1 { + return false + } + c, err := r.Cookie(shareCSRFCookie) + if err != nil { + return false + } + return subtle.ConstantTimeCompare([]byte(c.Value), []byte(want)) == 1 +} + +// ── the share-password brute-force limiter (own per-IP map; same 5-attempts / 1-min window as the +// admin login, copied — never shared) ───────────────────────────────────────────────────────────── + +// shareRateLimited reports whether this IP is currently blocked (>= loginMaxAttempts within the +// window). An expired window resets the counter first, so a fresh burst starts clean. +func (s *Server) shareRateLimited(ip string) bool { + s.shareAttemptMu.Lock() + defer s.shareAttemptMu.Unlock() + a := s.shareAttempts[ip] + if a != nil && time.Since(a.lastFail) > loginWindowDuration { + delete(s.shareAttempts, ip) + a = nil + } + return a != nil && a.count >= loginMaxAttempts +} + +// shareRegisterFailure bumps this IP's failed-attempt counter (lazily allocating the map so +// struct-literal test servers that skip NewServer still work). +func (s *Server) shareRegisterFailure(ip string) { + s.shareAttemptMu.Lock() + defer s.shareAttemptMu.Unlock() + if s.shareAttempts == nil { + s.shareAttempts = make(map[string]*loginAttempt) + } + if s.shareAttempts[ip] == nil { + s.shareAttempts[ip] = &loginAttempt{} + } + s.shareAttempts[ip].count++ + s.shareAttempts[ip].lastFail = time.Now() +} + +// shareClearFailures clears this IP's counter after a successful gate pass. +func (s *Server) shareClearFailures(ip string) { + s.shareAttemptMu.Lock() + defer s.shareAttemptMu.Unlock() + delete(s.shareAttempts, ip) +} diff --git a/controller/internal/web/share_handlers.go b/controller/internal/web/share_handlers.go new file mode 100644 index 0000000..f1d4252 --- /dev/null +++ b/controller/internal/web/share_handlers.go @@ -0,0 +1,294 @@ +package web + +import ( + "net/http" + "strings" + + "github.com/skip2/go-qrcode" + "golang.org/x/crypto/bcrypt" + + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" +) + +// Guest launcher share handlers (v0.165.0). See share.go for the security model. This file holds the +// HTTP surface: the pre-auth guest pages (/s/) and the admin share-management POSTs +// (/launcher/share/*, session-authed). + +// shareMinPassword is the minimum length of the OPTIONAL per-share password. +const shareMinPassword = 8 + +// setGuestHeaders stamps the guest launcher responses so search engines never index a capability URL, +// referrers never leak the token to an opened app, and no intermediary caches the page. +func (s *Server) setGuestHeaders(w http.ResponseWriter) { + h := w.Header() + h.Set("X-Robots-Tag", "noindex, nofollow") + h.Set("Referrer-Policy", "no-referrer") + h.Set("Cache-Control", "no-store") +} + +// share404 answers exactly like the mux default 404 (http.NotFound) so a wrong or disabled token is +// byte-for-byte indistinguishable from any unknown route. The token is redacted from the log. +func (s *Server) share404(w http.ResponseWriter, r *http.Request) { + s.logger.Printf("[WARN] [web] 404 Not Found: %s /s/", r.Method) + http.NotFound(w, r) +} + +// GuestLauncherApp is one tile on the standalone guest launcher. It carries ONLY what a guest may see +// — never the internal state vocabulary (stopped/exited/degraded/unhealthy). Clickable apps render as +// links to their public URL; the rest are greyed with a calm, non-technical Hungarian label. +type GuestLauncherApp struct { + DisplayName string + Slug string + BrandColor string + Clickable bool + Href string // set only when Clickable + Label string // set only when NOT Clickable +} + +// guestLauncherApps maps the shared launcherApps() slice into the guest view. Clickable ⇒ the app is +// operational AND its public route is actually published (a healthy, reachable app). isOperational +// alone would let an unhealthy/restarting/degraded app through — its URL 404s at Traefik, so a guest +// tap would dead-end; routeUnpublished screens exactly those. The label rides the v0.164.0 ruling: +// StateStopped is a deliberate owner action ("A tulajdonos leállította"); any other non-clickable +// state is a transient the guest need not understand ("Átmenetileg nem elérhető"). +func (s *Server) guestLauncherApps() []GuestLauncherApp { + return buildGuestApps(s.launcherApps(), s.cfg.Customer.Domain) +} + +// buildGuestApps is the pure mapping from the shared launcher slice to the guest view (no manager, no +// request) — the tested seam for the clickability rule and the guest label vocabulary. +func buildGuestApps(apps []LauncherApp, domain string) []GuestLauncherApp { + out := make([]GuestLauncherApp, 0, len(apps)) + for _, a := range apps { + g := GuestLauncherApp{DisplayName: a.DisplayName, Slug: a.Slug, BrandColor: a.BrandColor} + switch { + case isOperationalState(a.State) && !routeUnpublished(a.State): + g.Clickable = true + g.Href = "https://" + a.Subdomain + "." + domain + a.OpenPath + case a.State == stacks.StateStopped: + g.Label = "A tulajdonos leállította" + default: + g.Label = "Átmenetileg nem elérhető" + } + out = append(out, g) + } + return out +} + +// shareGuestHandler serves GET /s/: the standalone read-only launcher, or the password gate +// when a share password is set and no valid cookie is present. An unknown token → share404. +func (s *Server) shareGuestHandler(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.URL.Path, "/s/") + stored := s.settings.GetLauncherShareToken() + if !shareTokenMatches(stored, token) { + s.share404(w, r) + return + } + pwHash := s.settings.GetLauncherSharePasswordHash() + if pwHash != "" && !s.shareCookieValid(r, stored, pwHash) { + s.renderSharePasswordPage(w, r, "") + return + } + s.renderShareGuestPage(w, r) +} + +// shareGuestPasswordHandler handles POST /s/: the optional share-password gate. On success it +// sets the signed, ~30-day gate cookie (bound to token|passwordHash). Pre-auth HMAC CSRF + a per-IP +// 5/1-min limiter (own map) protect it. +func (s *Server) shareGuestPasswordHandler(w http.ResponseWriter, r *http.Request) { + token := strings.TrimPrefix(r.URL.Path, "/s/") + stored := s.settings.GetLauncherShareToken() + if !shareTokenMatches(stored, token) { + s.share404(w, r) + return + } + pwHash := s.settings.GetLauncherSharePasswordHash() + if pwHash == "" { + // No gate — a stray POST just returns to the page (renders directly). + s.renderShareGuestPage(w, r) + return + } + _ = r.ParseForm() + if !s.validShareCSRF(r) { + s.renderSharePasswordPage(w, r, "Érvénytelen űrlap — töltse újra az oldalt.") + return + } + ip := clientIP(r) + if s.shareRateLimited(ip) { + s.logger.Printf("[WARN] [web] share password rate limited for %s", ip) + s.renderSharePasswordPage(w, r, "Túl sok sikertelen próbálkozás, próbálja újra 1 perc múlva") + return + } + if bcrypt.CompareHashAndPassword([]byte(pwHash), []byte(r.FormValue("password"))) != nil { + s.shareRegisterFailure(ip) + s.renderSharePasswordPage(w, r, "Hibás jelszó") + return + } + s.shareClearFailures(ip) + http.SetCookie(w, &http.Cookie{ + Name: shareCookieName, + Value: s.shareCookieValue(stored, pwHash), + Path: "/s/", + MaxAge: int(shareCookieMaxAge.Seconds()), + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Secure: r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https", + }) + // Redirect back to the same URL so a reload/back does not re-POST; the cookie now passes the gate. + http.Redirect(w, r, r.URL.Path, http.StatusSeeOther) +} + +// renderShareGuestPage renders the standalone guest launcher (own minimal , no admin chrome). +func (s *Server) renderShareGuestPage(w http.ResponseWriter, r *http.Request) { + s.setGuestHeaders(w) + data := map[string]interface{}{ + "Domain": s.cfg.Customer.Domain, + "Apps": s.guestLauncherApps(), + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmpl.ExecuteTemplate(w, "launcher_shared", data); err != nil { + s.logger.Printf("[ERROR] [web] Template error (launcher_shared): %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + } +} + +// renderSharePasswordPage renders the standalone one-field password gate and sets the pre-auth CSRF +// cookie the POST validates. +func (s *Server) renderSharePasswordPage(w http.ResponseWriter, r *http.Request, errMsg string) { + s.setGuestHeaders(w) + csrf := s.setShareCSRFCookie(w, r) + data := map[string]interface{}{ + "Action": r.URL.Path, // /s/ — the guest already holds this token in their URL bar + "CSRF": csrf, + "Error": errMsg, + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := s.tmpl.ExecuteTemplate(w, "launcher_share_password", data); err != nil { + s.logger.Printf("[ERROR] [web] Template error (launcher_share_password): %v", err) + http.Error(w, "Internal error", http.StatusInternalServerError) + } +} + +// ── admin share management (session-authed via RequireAuth + session CSRF via CsrfProtect) ───────── + +// launcherShareRedirect returns to /launcher with a Hungarian flash (reuses urlQueryEscape). +func (s *Server) launcherShareRedirect(w http.ResponseWriter, r *http.Request, flash string) { + http.Redirect(w, r, "/launcher?flash="+urlQueryEscape(flash), http.StatusSeeOther) +} + +// launcherShareQRHandler serves the share link as a ~256px PNG QR code (admin-authed; not exempted, so +// an unauthenticated request → login redirect). The token is never logged. +func (s *Server) launcherShareQRHandler(w http.ResponseWriter, r *http.Request) { + token := s.settings.GetLauncherShareToken() + if token == "" { + http.NotFound(w, r) + return + } + png, err := qrcode.Encode("https://"+r.Host+"/s/"+token, qrcode.Medium, 256) + if err != nil { + s.logger.Printf("[ERROR] [web] share: QR encode failed: %v", err) + http.Error(w, "QR error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Robots-Tag", "noindex, nofollow") + _, _ = w.Write(png) +} + +// launcherShareEnableHandler mints the first token (POST /launcher/share/enable). +func (s *Server) launcherShareEnableHandler(w http.ResponseWriter, r *http.Request) { + if s.settings.GetLauncherShareToken() != "" { + s.launcherShareRedirect(w, r, "A megosztás már be van kapcsolva.") + return + } + tok, err := newShareToken() + if err != nil { + s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err) + s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.") + return + } + if err := s.settings.SetLauncherShareToken(tok); err != nil { + s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err) + s.launcherShareRedirect(w, r, "A megosztás bekapcsolása nem sikerült.") + return + } + s.logger.Printf("[INFO] [web] launcher share link enabled") + s.launcherShareRedirect(w, r, "A megosztás bekapcsolva.") +} + +// launcherShareRotateHandler mints a fresh token (POST /launcher/share/rotate). The old link 404s and +// every outstanding guest cookie is invalidated (both are bound to the token). +func (s *Server) launcherShareRotateHandler(w http.ResponseWriter, r *http.Request) { + if s.settings.GetLauncherShareToken() == "" { + s.launcherShareRedirect(w, r, "A megosztás nincs bekapcsolva.") + return + } + tok, err := newShareToken() + if err != nil { + s.logger.Printf("[ERROR] [web] share: token generation failed: %v", err) + s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.") + return + } + if err := s.settings.SetLauncherShareToken(tok); err != nil { + s.logger.Printf("[ERROR] [web] share: saving token failed: %v", err) + s.launcherShareRedirect(w, r, "Az új link készítése nem sikerült.") + return + } + s.logger.Printf("[INFO] [web] launcher share link rotated") + s.launcherShareRedirect(w, r, "Új megosztási link készült. A korábbi link és minden korábbi belépés érvénytelen.") +} + +// launcherShareDisableHandler clears the token AND the share password (POST /launcher/share/disable) — +// a clean slate so a later re-enable never inherits a stale gate. All /s/ paths then 404. +func (s *Server) launcherShareDisableHandler(w http.ResponseWriter, r *http.Request) { + if err := s.settings.SetLauncherShareToken(""); err != nil { + s.logger.Printf("[ERROR] [web] share: clearing token failed: %v", err) + s.launcherShareRedirect(w, r, "A megosztás kikapcsolása nem sikerült.") + return + } + if err := s.settings.SetLauncherSharePasswordHash(""); err != nil { + s.logger.Printf("[WARN] [web] share: clearing share password on disable failed: %v", err) + } + s.logger.Printf("[INFO] [web] launcher share link disabled") + s.launcherShareRedirect(w, r, "A megosztás kikapcsolva.") +} + +// launcherSharePasswordHandler sets or clears the OPTIONAL per-share password (POST +// /launcher/share/password). action=clear removes it; otherwise a min-length password is bcrypt-hashed +// into its OWN settings field (never the admin hash). Either change invalidates outstanding cookies. +func (s *Server) launcherSharePasswordHandler(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + if s.settings.GetLauncherShareToken() == "" { + s.launcherShareRedirect(w, r, "A megosztás nincs bekapcsolva.") + return + } + if r.FormValue("action") == "clear" { + if err := s.settings.SetLauncherSharePasswordHash(""); err != nil { + s.logger.Printf("[ERROR] [web] share: clearing share password failed: %v", err) + s.launcherShareRedirect(w, r, "A jelszó törlése nem sikerült.") + return + } + s.logger.Printf("[INFO] [web] launcher share password cleared") + s.launcherShareRedirect(w, r, "A megosztási jelszó törölve.") + return + } + pw := r.FormValue("password") + if len(pw) < shareMinPassword { + s.launcherShareRedirect(w, r, "A jelszónak legalább 8 karakter hosszúnak kell lennie.") + return + } + hash, err := bcrypt.GenerateFromPassword([]byte(pw), 10) + if err != nil { + s.logger.Printf("[ERROR] [web] share: hashing share password failed: %v", err) + s.launcherShareRedirect(w, r, "A jelszó beállítása nem sikerült.") + return + } + if err := s.settings.SetLauncherSharePasswordHash(string(hash)); err != nil { + s.logger.Printf("[ERROR] [web] share: saving share password failed: %v", err) + s.launcherShareRedirect(w, r, "A jelszó beállítása nem sikerült.") + return + } + s.logger.Printf("[INFO] [web] launcher share password set") + s.launcherShareRedirect(w, r, "A megosztási jelszó beállítva. A korábbi belépések érvénytelenek.") +} diff --git a/controller/internal/web/share_test.go b/controller/internal/web/share_test.go new file mode 100644 index 0000000..9d88fa1 --- /dev/null +++ b/controller/internal/web/share_test.go @@ -0,0 +1,400 @@ +package web + +import ( + "bytes" + "io" + "log" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" + "gitea.dooplex.hu/admin/felhom-controller/internal/stacks" + "golang.org/x/crypto/bcrypt" +) + +// Guest launcher share (v0.165.0). These tests drive the pure core (token match, guest-app mapping), +// the standalone templates, and the real routes through the production mux composition (fullMux = +// RequireAuth + CsrfProtect + ServeHTTP, exactly as main.go wires it). Each security item carries a +// companion red-proof recorded in REPORT. + +// shareTestServer builds a CLAIMED box (admin password set → authEnabled, claim gate off) with a +// SessionSecret, so the /s/ pre-auth pass-through and the admin-auth gate on /launcher/share/* are +// both exercised as they run in production. +func shareTestServer(t *testing.T) *Server { + t.Helper() + lg := log.New(io.Discard, "", 0) + dir := t.TempDir() + cfg := &config.Config{} + cfg.Customer.ID = "c1" + cfg.Customer.Name = "Teszt" + cfg.Customer.Domain = "demo-felhom.eu" + cfg.Paths.StacksDir = filepath.Join(dir, "stacks") + cfg.Paths.DataDir = filepath.Join(dir, "data") + cfg.Stacks.ComposeCommand = "docker compose" + cfg.Web.SessionSecret = "test-session-secret-share" + ph, _ := bcrypt.GenerateFromPassword([]byte("admin-pw-123456"), bcrypt.MinCost) + cfg.Web.PasswordHash = string(ph) + + sett, err := settings.Load(filepath.Join(dir, "settings.json"), lg) + if err != nil { + t.Fatalf("settings: %v", err) + } + mgr, err := stacks.NewManager(cfg, lg) + if err != nil { + t.Fatalf("stacks: %v", err) + } + s := &Server{cfg: cfg, settings: sett, stackMgr: mgr, logger: lg, version: "9.9.9-test", + sessions: map[string]*session{}, + loginAttempts: map[string]*loginAttempt{}, + shareAttempts: map[string]*loginAttempt{}} + s.loadTemplates() + return s +} + +const testShareToken = "TESTTOKEN0AAAAAAAAAAAAAAAAAA" + +// ── Group B core: constant-time token match (Scenario B) ────────────────────────────────────────── +// COMPANION red-proof (REPORT): replace subtle.ConstantTimeCompare with strings.HasPrefix(presented, +// stored) → the superstring case ("abcd" vs stored "abc") is accepted and this test FAILS. +func TestShareTokenMatches(t *testing.T) { + if !shareTokenMatches("abc", "abc") { + t.Error("exact match must pass") + } + if shareTokenMatches("", "") || shareTokenMatches("", "anything") { + t.Error("an empty stored token must never match (sharing disabled)") + } + if shareTokenMatches("abc", "ab") { + t.Error("a prefix must not match") + } + if shareTokenMatches("abc", "abcd") { + t.Error("a superstring must not match") + } + if shareTokenMatches("abc", "abx") { + t.Error("a different token must not match") + } +} + +func TestNewShareToken_EntropyAndCharset(t *testing.T) { + a, err := newShareToken() + if err != nil { + t.Fatal(err) + } + b, _ := newShareToken() + if a == b { + t.Error("two tokens collided — not random") + } + if len(a) != 27 { // 20 bytes → base64.RawURLEncoding = 27 chars + t.Errorf("token length = %d, want 27 (160 bits, no padding)", len(a)) + } + if strings.ContainsAny(a, "+/=") { + t.Errorf("token %q contains non-URL-safe chars", a) + } +} + +// ── Group A: guest happy path — headers triple, tiles, no admin chrome (Scenario A) ─────────────── +// COMPANION red-proof (REPORT): render the guest page through the admin layout template → the +// no-"nav-links"/no-version absence assertions FAIL. +func TestShareGuest_HeadersTilesNoAdminChrome(t *testing.T) { + s := shareTestServer(t) + if err := s.settings.SetLauncherShareToken(testShareToken); err != nil { + t.Fatal(err) + } + writeStack(t, s.cfg.Paths.StacksDir, "recept-app", "display_name: Receptek\nsubdomain: recept\n", true) + _ = s.stackMgr.ScanStacks() + + rr := httptest.NewRecorder() + s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("GET /s/ = %d: %s", rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("X-Robots-Tag"); got != "noindex, nofollow" { + t.Errorf("X-Robots-Tag = %q", got) + } + if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" { + t.Errorf("Referrer-Policy = %q", got) + } + if got := rr.Header().Get("Cache-Control"); got != "no-store" { + t.Errorf("Cache-Control = %q", got) + } + body := rr.Body.String() + if !strings.Contains(body, "Receptek") { + t.Error("guest page must render the openable app tile") + } + for _, chrome := range []string{`class="sidebar"`, "nav-links", "/logout", "9.9.9-test", "alert-banner"} { + if strings.Contains(body, chrome) { + t.Errorf("guest page leaked admin chrome: %q", chrome) + } + } +} + +// ── Group B: wrong / disabled / empty token → byte-identical to the mux default 404 (Scenario B) ── +func TestShareGuest_WrongTokenIs404LikeDefault(t *testing.T) { + s := shareTestServer(t) + if err := s.settings.SetLauncherShareToken(testShareToken); err != nil { + t.Fatal(err) + } + // Reference: the mux default case. /s/ bypasses auth, so both reach ServeHTTP's switch directly; + // calling ServeHTTP is the apples-to-apples comparison against the default 404 branch. + ref := httptest.NewRecorder() + s.ServeHTTP(ref, httptest.NewRequest(http.MethodGet, "/no-such-route-xyz", nil)) + wantCode, wantBody := ref.Code, ref.Body.String() + + cases := []string{"/s/WRONGTOKEN", "/s/"} + for _, p := range cases { + rr := httptest.NewRecorder() + s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) + if rr.Code != wantCode || rr.Body.String() != wantBody { + t.Errorf("GET %s = %d %q; want default-404 %d %q", p, rr.Code, rr.Body.String(), wantCode, wantBody) + } + } + // Disabled (empty stored token): the previously-valid token now 404s identically. + if err := s.settings.SetLauncherShareToken(""); err != nil { + t.Fatal(err) + } + rr := httptest.NewRecorder() + s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil)) + if rr.Code != wantCode || rr.Body.String() != wantBody { + t.Errorf("disabled-token GET = %d %q; want default-404 %d %q", rr.Code, rr.Body.String(), wantCode, wantBody) + } +} + +// sharePOST posts the guest password form with a valid pre-auth HMAC CSRF pair (form field + cookie). +func (s *Server) sharePOST(mux http.Handler, path, password string, extra ...*http.Cookie) *httptest.ResponseRecorder { + csrf := s.shareCSRFToken() + form := url.Values{"_csrf": {csrf}, "password": {password}} + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(&http.Cookie{Name: shareCSRFCookie, Value: csrf}) + for _, c := range extra { + req.AddCookie(c) + } + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + return rr +} + +func cookieNamed(rr *httptest.ResponseRecorder, name string) *http.Cookie { + for _, c := range rr.Result().Cookies() { + if c.Name == name { + return c + } + } + return nil +} + +// ── Group C: optional password gate (Scenario C) ────────────────────────────────────────────────── +// COMPANION red-proof (REPORT): drop passwordHash from shareCookieValue's HMAC input → the +// "changing the password invalidates the cookie" assertion FAILS. +func TestShareGuest_PasswordGate(t *testing.T) { + s := shareTestServer(t) + mux := s.fullMux() + s.settings.SetLauncherShareToken(testShareToken) + hash, _ := bcrypt.GenerateFromPassword([]byte("guest-secret"), bcrypt.MinCost) + s.settings.SetLauncherSharePasswordHash(string(hash)) + path := "/s/" + testShareToken + + // GET with no cookie → the password gate, not the launcher. + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "Ez az oldal jelszóval védett") { + t.Fatalf("expected password gate, got %d: %s", rr.Code, rr.Body.String()) + } + + // 5 wrong attempts → "Hibás jelszó"; the 6th within the window → rate-limited. + for i := 0; i < 5; i++ { + rr := s.sharePOST(mux, path, "wrong") + if !strings.Contains(rr.Body.String(), "Hibás jelszó") { + t.Fatalf("wrong attempt %d: want 'Hibás jelszó', got: %s", i+1, rr.Body.String()) + } + } + rr = s.sharePOST(mux, path, "wrong") + if !strings.Contains(rr.Body.String(), "Túl sok sikertelen") { + t.Fatalf("6th attempt must be rate-limited, got: %s", rr.Body.String()) + } + + // New IP, correct password → 303 + a signed gate cookie is set. + s2 := shareTestServer(t) + mux2 := s2.fullMux() + s2.settings.SetLauncherShareToken(testShareToken) + s2.settings.SetLauncherSharePasswordHash(string(hash)) + rr = s2.sharePOST(mux2, path, "guest-secret") + if rr.Code != http.StatusSeeOther { + t.Fatalf("correct password: want 303, got %d: %s", rr.Code, rr.Body.String()) + } + gate := cookieNamed(rr, shareCookieName) + if gate == nil || gate.Value == "" { + t.Fatal("correct password must set the signed gate cookie") + } + + // The cookie lets a subsequent GET through directly (no gate). + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(gate) + rr = httptest.NewRecorder() + mux2.ServeHTTP(rr, req) + if strings.Contains(rr.Body.String(), "jelszóval védett") { + t.Error("a valid gate cookie must skip the password page") + } + + // Changing the password invalidates the outstanding cookie (it binds the hash). + newHash, _ := bcrypt.GenerateFromPassword([]byte("new-secret"), bcrypt.MinCost) + s2.settings.SetLauncherSharePasswordHash(string(newHash)) + req = httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(gate) + rr = httptest.NewRecorder() + mux2.ServeHTTP(rr, req) + if !strings.Contains(rr.Body.String(), "jelszóval védett") { + t.Error("changing the password must invalidate the old gate cookie") + } +} + +// ── Group D: rotation + disable (Scenario D) ────────────────────────────────────────────────────── +func TestShareGuest_RotateAndDisable(t *testing.T) { + s := shareTestServer(t) + s.settings.SetLauncherShareToken(testShareToken) + hash, _ := bcrypt.GenerateFromPassword([]byte("pw"), bcrypt.MinCost) + s.settings.SetLauncherSharePasswordHash(string(hash)) + oldGate := &http.Cookie{Name: shareCookieName, Value: s.shareCookieValue(testShareToken, string(hash))} + + // Rotate: mint a new token; the old one 404s, the old gate cookie no longer passes. + newTok, _ := newShareToken() + s.settings.SetLauncherShareToken(newTok) + + rr := httptest.NewRecorder() + s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil)) + if rr.Code != http.StatusNotFound { + t.Errorf("old token after rotation: want 404, got %d", rr.Code) + } + // New token + old cookie → the cookie is bound to the OLD token, so the gate re-prompts. + req := httptest.NewRequest(http.MethodGet, "/s/"+newTok, nil) + req.AddCookie(oldGate) + rr = httptest.NewRecorder() + s.ServeHTTP(rr, req) + if !strings.Contains(rr.Body.String(), "jelszóval védett") { + t.Error("rotation must invalidate the old gate cookie") + } + + // Disable: every /s/ path 404s. + s.settings.SetLauncherShareToken("") + rr = httptest.NewRecorder() + s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+newTok, nil)) + if rr.Code != http.StatusNotFound { + t.Errorf("after disable: want 404, got %d", rr.Code) + } +} + +// ── Group E: guest state labels (Scenario E) ────────────────────────────────────────────────────── +func TestBuildGuestApps_Labels(t *testing.T) { + apps := []LauncherApp{ + {Name: "a", DisplayName: "Alpha", Slug: "a", Subdomain: "a", State: stacks.StateRunning}, + {Name: "b", DisplayName: "Beta", Slug: "b", Subdomain: "b", State: stacks.StateStopped}, + {Name: "c", DisplayName: "Gamma", Slug: "c", Subdomain: "c", State: stacks.StateExited}, + {Name: "d", DisplayName: "Delta", Slug: "d", Subdomain: "d", State: stacks.StateDegraded}, + } + g := buildGuestApps(apps, "demo-felhom.eu") + if !g[0].Clickable || g[0].Href != "https://a.demo-felhom.eu" { + t.Errorf("running app must be clickable with a public href, got %+v", g[0]) + } + if g[1].Clickable || g[1].Label != "A tulajdonos leállította" { + t.Errorf("stopped app: want greyed 'A tulajdonos leállította', got %+v", g[1]) + } + if g[2].Clickable || g[2].Label != "Átmenetileg nem elérhető" { + t.Errorf("exited app: want 'Átmenetileg nem elérhető', got %+v", g[2]) + } + if g[3].Clickable || g[3].Label != "Átmenetileg nem elérhető" { + t.Errorf("degraded app: want 'Átmenetileg nem elérhető', got %+v", g[3]) + } +} + +// The rendered guest page shows the calm labels and NEVER the internal state vocabulary. +func TestShareGuestTemplate_LabelsNoInternalWords(t *testing.T) { + g := buildGuestApps([]LauncherApp{ + {DisplayName: "Alpha", Slug: "a", Subdomain: "a", State: stacks.StateRunning}, + {DisplayName: "Beta", Slug: "b", Subdomain: "b", State: stacks.StateStopped}, + {DisplayName: "Gamma", Slug: "c", Subdomain: "c", State: stacks.StateExited}, + }, "demo-felhom.eu") + html := renderBackupPage(t, "launcher_shared", map[string]interface{}{"Domain": "demo-felhom.eu", "Apps": g}) + if !strings.Contains(html, "A tulajdonos leállította") || !strings.Contains(html, "Átmenetileg nem elérhető") { + t.Error("guest labels missing from rendered page") + } + for _, word := range []string{"stopped", "exited", "degraded", "unhealthy"} { + if strings.Contains(html, word) { + t.Errorf("internal state word %q leaked to the guest page", word) + } + } + // Empty state. + empty := renderBackupPage(t, "launcher_shared", map[string]interface{}{"Domain": "demo-felhom.eu", "Apps": []GuestLauncherApp{}}) + if !strings.Contains(empty, "Jelenleg nincs elérhető alkalmazás.") { + t.Error("empty guest launcher must show the calm empty-state copy") + } +} + +// ── Group F: claim gate supreme + admin surfaces stay admin (Scenario F) ────────────────────────── +func TestShare_ClaimGateInterceptsGuestPage(t *testing.T) { + s, _, _ := claimTestServer(t) // unclaimed: claim code, no password → claimGateActive + s.settings.SetLauncherShareToken(testShareToken) + rr := httptest.NewRecorder() + s.fullMux().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/s/"+testShareToken, nil)) + if rr.Code != http.StatusFound || rr.Header().Get("Location") != "/claim" { + t.Errorf("on an unclaimed box /s/ must hit the claim gate: got %d loc=%q", rr.Code, rr.Header().Get("Location")) + } +} + +func TestShare_AdminSurfacesRequireAuthAndCSRF(t *testing.T) { + s := shareTestServer(t) + mux := s.fullMux() + + // Unauthenticated QR + share POSTs → login redirect. + for _, tc := range []struct { + method, path string + }{ + {http.MethodGet, "/launcher/share/qr.png"}, + {http.MethodPost, "/launcher/share/enable"}, + {http.MethodPost, "/launcher/share/rotate"}, + {http.MethodPost, "/launcher/share/disable"}, + } { + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil)) + if rr.Code != http.StatusFound || !strings.HasPrefix(rr.Header().Get("Location"), "/login") { + t.Errorf("%s %s unauthenticated: want login redirect, got %d loc=%q", tc.method, tc.path, rr.Code, rr.Header().Get("Location")) + } + } + + // Authenticated but CSRF-missing POST → 403. + sessTok := s.createSession() + req := httptest.NewRequest(http.MethodPost, "/launcher/share/enable", nil) + req.AddCookie(&http.Cookie{Name: sessionCookieName, Value: sessTok}) + rr := httptest.NewRecorder() + mux.ServeHTTP(rr, req) + if rr.Code != http.StatusForbidden { + t.Errorf("CSRF-missing admin POST: want 403, got %d", rr.Code) + } +} + +// ── Group G: the token never reaches the logs (Scenario G) ──────────────────────────────────────── +// COMPANION red-proof (REPORT): revert the /s/ redaction in ServeHTTP (log the raw path) → the +// "token absent from logs" assertion FAILS. +func TestShareGuest_TokenNeverLogged(t *testing.T) { + s := shareTestServer(t) + var buf bytes.Buffer + s.logger = log.New(&buf, "", 0) + s.cfg.Logging.Level = "debug" + s.settings.SetLauncherShareToken(testShareToken) + + // A valid GET (debug ServeHTTP line) and a wrong-token 404 must both keep the token out of logs. + for _, p := range []string{"/s/" + testShareToken, "/s/WRONGSECRET123"} { + rr := httptest.NewRecorder() + s.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, p, nil)) + } + logs := buf.String() + if strings.Contains(logs, testShareToken) || strings.Contains(logs, "WRONGSECRET123") { + t.Errorf("a share token leaked into the logs:\n%s", logs) + } + if !strings.Contains(logs, "/s/") { + t.Errorf("expected a redacted /s/ path in the debug log, got:\n%s", logs) + } +} diff --git a/controller/internal/web/templates/launcher.html b/controller/internal/web/templates/launcher.html index cf84581..c743f08 100644 --- a/controller/internal/web/templates/launcher.html +++ b/controller/internal/web/templates/launcher.html @@ -1,31 +1,41 @@ +{{define "launch_tile"}} + + {{initial .DisplayName}} + + +{{end}} + {{define "launcher"}} {{template "layout_start" .}} +{{if .ShareFlash}} +
+
+ + {{.ShareFlash}} +
+
+{{end}} + {{if .Apps}}
{{range .Apps}} {{if isOperational .State}} - - {{initial .DisplayName}} - - + {{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" false)}} {{.DisplayName}} {{if ne (stateStr .State) "running"}}{{stateLabel .State}}{{end}} {{else}}
- - {{initial .DisplayName}} - - + {{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" true)}} {{.DisplayName}} {{stateLabel .State}}
@@ -39,5 +49,87 @@
{{end}} + + + + {{template "layout_end"}} {{end}} diff --git a/controller/internal/web/templates/launcher_share_password.html b/controller/internal/web/templates/launcher_share_password.html new file mode 100644 index 0000000..c12f833 --- /dev/null +++ b/controller/internal/web/templates/launcher_share_password.html @@ -0,0 +1,27 @@ +{{define "launcher_share_password"}} + + + + + + + Indítópult + + + + +
+
+

Indítópult

+

Ez az oldal jelszóval védett.

+ {{if .Error}}
{{.Error}}
{{end}} +
+ + + +
+
+
+ + +{{end}} diff --git a/controller/internal/web/templates/launcher_shared.html b/controller/internal/web/templates/launcher_shared.html new file mode 100644 index 0000000..5fa3a65 --- /dev/null +++ b/controller/internal/web/templates/launcher_shared.html @@ -0,0 +1,43 @@ +{{define "launcher_shared"}} + + + + + + + Indítópult + + + + +
+ + {{if .Apps}} +
+ {{range .Apps}} + {{if .Clickable}} + + {{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" false)}} + {{.DisplayName}} + + {{else}} +
+ {{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" true)}} + {{.DisplayName}} + {{.Label}} +
+ {{end}} + {{end}} +
+ {{else}} +
+

Jelenleg nincs elérhető alkalmazás.

+
+ {{end}} +
+ + +{{end}} diff --git a/controller/internal/web/templates/style.css b/controller/internal/web/templates/style.css index 16d7d73..da1222d 100644 --- a/controller/internal/web/templates/style.css +++ b/controller/internal/web/templates/style.css @@ -1181,6 +1181,28 @@ a.stat-card:hover { .launch-cell--off { cursor: default; } .launch-tile--off { opacity: .4; } .launch-cell--off .launch-name { color: var(--text-3); } +.launch-off-label { font-size: .8rem; color: var(--text-3); text-align: center; } + +/* Launcher share modal (v0.165.0) */ +.share-open-btn { margin-left: auto; } +.share-lede { color: var(--text-2); font-size: .9rem; margin-bottom: 1rem; } +.share-field { margin-bottom: 1rem; } +.share-label { display: block; font-size: .85rem; color: var(--text-2); margin-bottom: .35rem; } +.share-note { color: var(--text-2); font-size: .9rem; } +.share-link-row { display: flex; gap: .5rem; align-items: center; } +.share-link-row input { flex: 1; min-width: 0; } +.share-qr { display: flex; flex-direction: column; align-items: center; gap: .35rem; margin-bottom: 1rem; } +.share-qr img { border: 1px solid var(--line); border-radius: var(--radius); background: #fff; padding: .35rem; } +.share-qr-hint { color: var(--text-3); font-size: .8rem; } +.share-actions { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .5rem; } + +/* Standalone guest launcher + password gate (v0.165.0) — no sidebar/nav */ +.guest-content { max-width: 960px; margin: 0 auto; padding: 2rem 1.25rem; } +.guest-gate-body { display: flex; align-items: center; justify-content: center; min-height: 100vh; } +.guest-gate { width: 100%; display: flex; justify-content: center; padding: 1.25rem; } +.guest-gate-card { width: 100%; max-width: 360px; background: var(--bg-1); border: 1px solid var(--line); border-radius: var(--radius); padding: 1.75rem; text-align: center; } +.guest-gate-card form { display: flex; flex-direction: column; gap: .75rem; margin-top: 1rem; } +.gate-error { color: var(--crit); font-size: .9rem; margin-top: .5rem; } /* Login page */ .login-body {