v0.165.0: Indítópult megosztása — guest launcher via capability URL (+ optional password, QR)

Mint a 160-bit capability URL (/s/<token>) serving a standalone read-only guest
launcher: same tiles, opens apps in new tabs, no account, no admin session.
Information only, zero control — every privilege stays behind each app's own auth.

- /s/ pre-auth pass-through (after the claim gate) + session-CSRF exemption; guest
  password POST carries its own pre-auth HMAC CSRF.
- Constant-time token match; empty stored token = disabled = byte-identical mux 404.
- Optional per-share password: separate bcrypt hash + own attempt map; signed cookie
  = HMAC(token|passwordHash) keyed with web.session_secret, so rotate/change invalidates.
- Guest labels ride the v0.164.0 ruling; never expose internal state vocabulary.
- Token redacted in logs (/s/<redacted>); never in CHANGELOG/REPORT/CONTEXT.
- Admin modal: copy-link, QR (go-qrcode), set/clear password, rotate, disable.
- Tests: Groups A-G (14) + 3 red-proofs verified red.
This commit is contained in:
2026-07-24 12:08:43 +02:00
parent 8e5edb2865
commit 15206314ab
19 changed files with 1341 additions and 115 deletions
+75 -87
View File
@@ -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://<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**.
## 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 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.
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/<redacted>` redaction.
- `controller/internal/web/auth.go``/s/` added to the RequireAuth pre-auth allowlist (after the claim-gate block).
- `controller/internal/web/csrf.go``/s/` exempted from session CSRF (guest carries pre-auth HMAC CSRF).
- `controller/internal/web/handlers.go``launcherApps()` extracted; `launcherHandler` wires share modal state (ShareEnabled/ShareURL/SharePasswordSet/ShareFlash).
- `controller/internal/web/funcmap.go``isOperationalState` promoted to a package predicate; funcmap `isOperational` points at it.
- `controller/internal/web/templates/launcher.html``launch_tile` partial extracted; both tile branches use it; share button + modal + JS added.
- `controller/internal/web/templates/style.css` — share-modal + guest-launcher CSS.
- `controller/go.mod` / `go.sum``github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e`.
- Docs: `CHANGELOG.md`, `controller/README.md`, `CONTEXT.md`.
## 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/<redacted>` 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)._
<!-- LIVE-VALIDATION -->
## Accepted residuals (no code action)
- **Link-preview crawlers** (Messenger/WhatsApp/Slack) fetch the URL once and see app names — accepted; `X-Robots-Tag: noindex, nofollow` prevents search indexing.
- **Reverse-proxy / Cloudflare access logs** may record the `/s/<token>` path — an ops-tier residual outside the controller (the controller's own logs redact it).
- **LAN-IP link host** — the modal builds the link from the request `Host`, so an admin on a LAN IP gets a LAN-IP link. Kept the UI clean; noted here only.
## Observations
- The 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.