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:
@@ -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://<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**. 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/<token>` 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/<redacted>`), 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
|
||||
|
||||
+23
-1
@@ -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/<token>, 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://<host>/s/<token>`, 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
|
||||
|
||||
@@ -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 A–G (14 tests) + companion red-proofs.
|
||||
- `controller/internal/web/templates/launcher_shared.html` — standalone guest launcher (own minimal `<html>`).
|
||||
- `controller/internal/web/templates/launcher_share_password.html` — standalone one-field password gate.
|
||||
|
||||
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.
|
||||
|
||||
@@ -278,6 +278,44 @@ load the monogram shows through (the launcher does NOT use the app-placeholder h
|
||||
are `<a target="_blank" rel="noopener">` 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://<host>/s/<token>`, 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/<token>` 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/<redacted>`.
|
||||
- **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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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/<token>; 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).
|
||||
|
||||
@@ -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/<token> 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
|
||||
}
|
||||
|
||||
@@ -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/<token>, 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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/<redacted>"
|
||||
}
|
||||
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/<token> 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":
|
||||
|
||||
@@ -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://<host>/s/<token> 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/<redacted>).
|
||||
// - 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)
|
||||
}
|
||||
@@ -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/<token>) 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/<redacted>", 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/<token>: 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/<token>: 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 <html>, 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/<token> — 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.")
|
||||
}
|
||||
@@ -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/<token> = %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/<token> 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/<redacted>") {
|
||||
t.Errorf("expected a redacted /s/<redacted> path in the debug log, got:\n%s", logs)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,41 @@
|
||||
{{define "launch_tile"}}
|
||||
<span class="launch-tile{{if .Off}} launch-tile--off{{end}}" style="background: {{tileColor .Slug .BrandColor}}">
|
||||
<span class="launch-mono">{{initial .DisplayName}}</span>
|
||||
<img class="launch-logo" src="{{logoURL .Slug}}" alt=""
|
||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';this.parentElement.classList.add('launch-tile--noimg');}">
|
||||
</span>
|
||||
{{end}}
|
||||
|
||||
{{define "launcher"}}
|
||||
{{template "layout_start" .}}
|
||||
|
||||
<div class="page-header">
|
||||
<h2>Indítópult</h2>
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
<button type="button" class="btn btn-outline share-open-btn" onclick="openShareModal()"><svg class="ico"><use href="#i-share"/></svg>Indítópult megosztása</button>
|
||||
</div>
|
||||
|
||||
{{if .ShareFlash}}
|
||||
<div class="alerts-container">
|
||||
<div class="alert-banner alert-banner-info">
|
||||
<span class="alert-icon"><svg class="ico"><use href="#i-info"/></svg></span>
|
||||
<span class="alert-message">{{.ShareFlash}}</span>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Apps}}
|
||||
<div class="launch-grid">
|
||||
{{range .Apps}}
|
||||
{{if isOperational .State}}
|
||||
<a class="launch-cell" href="https://{{.Subdomain}}.{{$.Domain}}{{.OpenPath}}" target="_blank" rel="noopener">
|
||||
<span class="launch-tile" style="background: {{tileColor .Slug .BrandColor}}">
|
||||
<span class="launch-mono">{{initial .DisplayName}}</span>
|
||||
<img class="launch-logo" src="{{logoURL .Slug}}" alt=""
|
||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';this.parentElement.classList.add('launch-tile--noimg');}">
|
||||
</span>
|
||||
{{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" false)}}
|
||||
<span class="launch-name">{{.DisplayName}}</span>
|
||||
{{if ne (stateStr .State) "running"}}<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>{{end}}
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="launch-cell launch-cell--off">
|
||||
<span class="launch-tile launch-tile--off" style="background: {{tileColor .Slug .BrandColor}}">
|
||||
<span class="launch-mono">{{initial .DisplayName}}</span>
|
||||
<img class="launch-logo" src="{{logoURL .Slug}}" alt=""
|
||||
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';this.parentElement.classList.add('launch-tile--noimg');}">
|
||||
</span>
|
||||
{{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" true)}}
|
||||
<span class="launch-name">{{.DisplayName}}</span>
|
||||
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
||||
</div>
|
||||
@@ -39,5 +49,87 @@
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="modal-overlay" id="share-modal" style="display:none">
|
||||
<div class="modal-card">
|
||||
<h3>Indítópult megosztása</h3>
|
||||
<p class="share-lede">Egy megosztható link, amely csak megmutatja az alkalmazásokat és új lapon megnyitja őket. Fiók nélkül, felügyeleti hozzáférés nélkül — minden alkalmazás a saját bejelentkezése mögött marad.</p>
|
||||
{{if .ShareEnabled}}
|
||||
<div class="share-field">
|
||||
<label class="share-label">Megosztási link</label>
|
||||
<div class="share-link-row">
|
||||
<input id="share-link-input" type="text" readonly value="{{.ShareURL}}">
|
||||
<button type="button" id="share-copy-btn" class="btn btn-outline" onclick="copyShareLink()">Link másolása</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="share-qr">
|
||||
<img src="/launcher/share/qr.png" alt="QR-kód a megosztási linkhez" width="200" height="200">
|
||||
<span class="share-qr-hint">Olvassa be telefonnal a gyors megnyitáshoz.</span>
|
||||
</div>
|
||||
<div class="share-field">
|
||||
{{if .SharePasswordSet}}
|
||||
<label class="share-label">Jelszó</label>
|
||||
<p class="share-note">A megosztás jelszóval védett.</p>
|
||||
<form method="POST" action="/launcher/share/password">
|
||||
{{.CSRFField}}
|
||||
<input type="hidden" name="action" value="clear">
|
||||
<button type="submit" class="btn btn-outline">Jelszó törlése</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="POST" action="/launcher/share/password">
|
||||
{{.CSRFField}}
|
||||
<label class="share-label">Jelszó (nem kötelező)</label>
|
||||
<div class="share-link-row">
|
||||
<input type="password" name="password" autocomplete="new-password" placeholder="Legalább 8 karakter">
|
||||
<button type="submit" class="btn btn-outline">Jelszó beállítása</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="share-actions">
|
||||
<form method="POST" action="/launcher/share/rotate">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-outline" data-confirm="Új link készítése? A régi link és minden korábbi belépés érvénytelenné válik.">Új link készítése</button>
|
||||
</form>
|
||||
<form method="POST" action="/launcher/share/disable">
|
||||
{{.CSRFField}}
|
||||
<button type="submit" class="btn btn-danger" data-confirm="Biztosan kikapcsolja a megosztást? A link azonnal érvénytelenné válik.">Megosztás kikapcsolása</button>
|
||||
</form>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="share-note">A megosztás jelenleg ki van kapcsolva.</p>
|
||||
<form method="POST" action="/launcher/share/enable">
|
||||
{{.CSRFField}}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeShareModal()">Bezárás</button>
|
||||
<button type="submit" class="btn btn-primary">Megosztás bekapcsolása</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
{{if .ShareEnabled}}
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-outline" onclick="closeShareModal()">Bezárás</button>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function openShareModal(){var m=document.getElementById('share-modal');if(m)m.style.display='flex';}
|
||||
function closeShareModal(){var m=document.getElementById('share-modal');if(m)m.style.display='none';}
|
||||
function copyShareLink(){
|
||||
var inp=document.getElementById('share-link-input');
|
||||
var btn=document.getElementById('share-copy-btn');
|
||||
if(!inp)return;
|
||||
function done(){if(btn){var o=btn.textContent;btn.textContent='Másolva';setTimeout(function(){btn.textContent=o;},1500);}}
|
||||
if(navigator.clipboard&&navigator.clipboard.writeText){navigator.clipboard.writeText(inp.value).then(done,function(){inp.select();done();});}
|
||||
else{inp.select();try{document.execCommand('copy');}catch(e){}done();}
|
||||
}
|
||||
(function(){
|
||||
var m=document.getElementById('share-modal');
|
||||
if(m){m.addEventListener('click',function(e){if(e.target===m)closeShareModal();});}
|
||||
{{if .ShareFlash}}openShareModal();{{end}}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{{template "layout_end"}}
|
||||
{{end}}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
{{define "launcher_share_password"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Indítópult</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="guest-body guest-gate-body">
|
||||
<main class="guest-gate">
|
||||
<div class="guest-gate-card">
|
||||
<h2>Indítópult</h2>
|
||||
<p>Ez az oldal jelszóval védett.</p>
|
||||
{{if .Error}}<div class="gate-error">{{.Error}}</div>{{end}}
|
||||
<form method="POST" action="{{.Action}}">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<input type="password" name="password" placeholder="Jelszó" autocomplete="current-password" autofocus>
|
||||
<button type="submit" class="btn btn-primary">Belépés</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,43 @@
|
||||
{{define "launcher_shared"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="hu">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<title>Indítópult</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body class="guest-body">
|
||||
<main class="guest-content">
|
||||
<div class="page-header">
|
||||
<h2>Indítópult</h2>
|
||||
<span class="domain-badge">{{.Domain}}</span>
|
||||
</div>
|
||||
{{if .Apps}}
|
||||
<div class="launch-grid">
|
||||
{{range .Apps}}
|
||||
{{if .Clickable}}
|
||||
<a class="launch-cell" href="{{.Href}}" target="_blank" rel="noopener noreferrer">
|
||||
{{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" false)}}
|
||||
<span class="launch-name">{{.DisplayName}}</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="launch-cell launch-cell--off">
|
||||
{{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" true)}}
|
||||
<span class="launch-name">{{.DisplayName}}</span>
|
||||
<span class="launch-off-label">{{.Label}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty-state">
|
||||
<p>Jelenleg nincs elérhető alkalmazás.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user