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
|
## 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)
|
### 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
|
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"
|
> 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
|
> **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
|
> 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)
|
# REPORT — felhom-controller v0.165.0 — Indítópult megosztása (guest launcher via capability URL)
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Baselines
|
## Baselines
|
||||||
|
|
||||||
| Item | Value |
|
| Repo | main @ start | version start → target |
|
||||||
|---|---|
|
|------|--------------|------------------------|
|
||||||
| Repo | felhom-controller |
|
| felhom-controller | `8e5edb2` | v0.164.0 → **v0.165.0** |
|
||||||
| `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 |
|
|
||||||
|
|
||||||
## Files modified
|
Clean tree, `HEAD == origin/main`, verified before build.
|
||||||
|
|
||||||
- `controller/cmd/controller/main.go` — extracted `scanDeployedAppRunStates`'s pure core to
|
## What shipped
|
||||||
`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).
|
|
||||||
|
|
||||||
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:
|
**Created**
|
||||||
- **I1** — the UI stop path `Manager.StopStack` runs `docker compose down` → containers are removed,
|
- `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).
|
||||||
and a deployed stack with zero containers aggregates to `StateStopped` (refreshStatusLocked). Proven
|
- `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.
|
||||||
live: after the stop, `docker ps -a` showed **no** calibre-web container.
|
- `controller/internal/web/share_test.go` — Groups A–G (14 tests) + companion red-proofs.
|
||||||
- **I2** — the P2 restart-policy census (2026-07-21, 53 templates / 78 services) found every catalog
|
- `controller/internal/web/templates/launcher_shared.html` — standalone guest launcher (own minimal `<html>`).
|
||||||
service on `unless-stopped`, so a crash never rests at `stopped` — faults surface as
|
- `controller/internal/web/templates/launcher_share_password.html` — standalone one-field password gate.
|
||||||
`exited`/`degraded`/`restarting`/`unhealthy`.
|
|
||||||
|
|
||||||
If either invariant changes, revisit the suppression. `IsDownState` left unchanged (other callers rely
|
**Modified**
|
||||||
on stopped counting as down). Out-of-band `docker compose stop` (containers remain → `exited`) still
|
- `controller/internal/settings/settings.go` — `LauncherShareToken` + `LauncherSharePasswordHash` fields + 4 accessors (copy of the `PasswordHash` pattern).
|
||||||
alerts — acceptable. The `stopped_by_user` intent flag was considered and parked.
|
- `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 |
|
**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
|
||||||
| `TestClassifyRunStates_StoppedIsSuppressed` (Group A) | PASS — dead={immich(exited),nextcloud(degraded)}, Down flags {false,false,true,true} |
|
(`felhom-share-cookie-v1|…`, `felhom-share-csrf-v1`). It qualifies for the **reuse branch** of the
|
||||||
| `TestClassifyRunStates_FaultParity` (Group B) | PASS — both faults in dead list, both Down=true, raw state string carried |
|
decision rule: it is **persisted** (a `controller.yaml` `web.session_secret` field) and
|
||||||
| `TestClassifyRunStates_SkipsDeployingAndUndeployed` | PASS |
|
**box-scoped** (each box's own config), and it is **stable across restarts** — not per-boot and not
|
||||||
| `TestNotifyAppStartFailures_StopStartCrashSequence` (Group C) | PASS — exactly one event for the crash, zero for the stop |
|
claim-generation-scoped. It is the SAME secret the claim pre-auth CSRF already trusts
|
||||||
| Full suite `go build/vet/test ./...` | PASS (all packages green) |
|
(`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):
|
## Tests + red-proofs
|
||||||
- **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.
|
|
||||||
|
|
||||||
## 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`.
|
New tests (`share_test.go`), 14 total, all PASS:
|
||||||
- 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)`.
|
|
||||||
|
|
||||||
## 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`,
|
Companion **red-proofs** (mutate → FAIL → restore → green), all verified:
|
||||||
session cookie + `X-CSRF-Token`), driving the exact UI endpoints; banners read from rendered HTML
|
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.
|
||||||
(ASCII substring `nem fut`); event surface read from the hub SQLite `events` table (the true email
|
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.
|
||||||
trigger). Event watermark before: max id **1753**.
|
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}`;
|
Existing launcher tests (`TestBuildLauncherApps_*`, `TestLauncherTemplate_*`, `TestTileColor`,
|
||||||
`docker ps -a` → calibre-web container gone (I1 confirmed). After one health cycle:
|
`TestInitial`, `TestLauncherRoute_EndToEnd`) still green after the `launch_tile` partial extraction.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Final state: all four deployed apps (calibre-web, docmost, filebrowser, immich) healthy; system
|
Test count (internal/web): +14 (share_test.go). Full `go test ./...` green before/after.
|
||||||
restored to baseline; 0.164.0 live. In-guest helper + local credential/DB copies removed.
|
|
||||||
|
## 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
|
## Observations
|
||||||
|
|
||||||
- The one remaining launcher off-tile after Scenario C is a pre-existing non-operational app unrelated
|
- 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`.
|
||||||
to this change (the 2→1 drop is exactly calibre-web un-greying).
|
- Disable clears BOTH token and share password (clean slate — a later re-enable never inherits a stale gate).
|
||||||
- Hub event `1754` is the legitimate audit record of the Scenario-B fault injection (info severity —
|
- The token GET is deliberately NOT rate-limited or CAPTCHA'd — 160-bit entropy is the defence; the path stays fast and boring.
|
||||||
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`.
|
|
||||||
|
|||||||
@@ -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
|
are `<a target="_blank" rel="noopener">` links; stopped/degraded apps render greyed + unclickable with
|
||||||
the Hungarian state badge. Empty state links to `/stacks`.
|
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
|
#### 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.
|
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 (
|
require (
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||||
github.com/emersion/go-smtp v0.24.0
|
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
|
golang.org/x/crypto v0.31.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
modernc.org/sqlite v1.45.0
|
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/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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
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=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ type Settings struct {
|
|||||||
// Auth
|
// Auth
|
||||||
PasswordHash string `json:"password_hash,omitempty"` // bcrypt hash, overrides controller.yaml
|
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
|
// 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
|
// least once — never cleared). ClaimCode* cache the freshest hub-delivered code state (report
|
||||||
// ACK; beats controller.yaml when its generation is newer). ClaimConsumedGeneration records
|
// 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()
|
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) ──────────────────────────────────────────────
|
// ── Customer-claim arc (v0.122.0) ──────────────────────────────────────────────
|
||||||
|
|
||||||
// GetClaimed reports whether this box has completed a claim (set-only).
|
// 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
|
// 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.
|
// entry (code-gated internally). Static assets for the page too. The guest launcher share
|
||||||
if r.URL.Path == "/claim" || r.URL.Path == "/claim/request-new-code" || strings.HasPrefix(r.URL.Path, "/static/") {
|
// (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)
|
next.ServeHTTP(w, r)
|
||||||
return
|
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
|
// 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.
|
// 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)
|
next.ServeHTTP(w, r)
|
||||||
return
|
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.
|
// templateFuncMap returns the FuncMap used by all HTML templates.
|
||||||
func (s *Server) templateFuncMap() template.FuncMap {
|
func (s *Server) templateFuncMap() template.FuncMap {
|
||||||
loc := getTimezone()
|
loc := getTimezone()
|
||||||
@@ -162,14 +174,7 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
|||||||
},
|
},
|
||||||
// isOperational returns true for any state where the stack has containers
|
// isOperational returns true for any state where the stack has containers
|
||||||
// and is not stopped/exited — used by templates for showing action buttons
|
// and is not stopped/exited — used by templates for showing action buttons
|
||||||
"isOperational": func(state stacks.ContainerState) bool {
|
"isOperational": isOperationalState,
|
||||||
switch state {
|
|
||||||
case stacks.StateRunning, stacks.StateStarting, stacks.StateUnhealthy, stacks.StateRestarting, stacks.StateDegraded:
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"routeUnpublished": routeUnpublished,
|
"routeUnpublished": routeUnpublished,
|
||||||
"logoURL": func(slug string) string {
|
"logoURL": func(slug string) string {
|
||||||
return s.cfg.AppLogoURL(slug)
|
return s.cfg.AppLogoURL(slug)
|
||||||
|
|||||||
@@ -272,18 +272,39 @@ func buildLauncherApps(stackList []stacks.Stack, subdomains map[string]string) [
|
|||||||
return apps
|
return apps
|
||||||
}
|
}
|
||||||
|
|
||||||
// launcherHandler renders the Indítópult: a grid of large tappable tiles, one per openable deployed
|
// launcherApps assembles the sorted launcher tile list (deployed/protected stacks with a subdomain,
|
||||||
// app (subdomain presence is the single openability criterion — see buildLauncherApps). Behind
|
// controller excluded). Extracted from launcherHandler so the guest share page (v0.165.0) renders
|
||||||
// RequireAuth like every page; the "/" landing page stays the Vezérlőpult.
|
// the EXACT same app slice as the admin launcher.
|
||||||
func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) launcherApps() []LauncherApp {
|
||||||
var eligible []stacks.Stack
|
var eligible []stacks.Stack
|
||||||
for _, st := range s.stackMgr.GetStacks() {
|
for _, st := range s.stackMgr.GetStacks() {
|
||||||
if st.Deployed || st.Protected {
|
if st.Deployed || st.Protected {
|
||||||
eligible = append(eligible, st)
|
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 := 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)
|
s.executeTemplate(w, r, "launcher", data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ type Server struct {
|
|||||||
done chan struct{}
|
done chan struct{}
|
||||||
closeOnce sync.Once
|
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
|
// 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).
|
// (IP) + a global counter; both must be clear. claimClock is the test clock seam (nil → time.Now).
|
||||||
claimMu sync.Mutex
|
claimMu sync.Mutex
|
||||||
@@ -179,6 +185,7 @@ func NewServer(cfg *config.Config, stackMgr *stacks.Manager, cpuCollector *syste
|
|||||||
version: version,
|
version: version,
|
||||||
sessions: make(map[string]*session),
|
sessions: make(map[string]*session),
|
||||||
loginAttempts: make(map[string]*loginAttempt),
|
loginAttempts: make(map[string]*loginAttempt),
|
||||||
|
shareAttempts: make(map[string]*loginAttempt),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
}
|
}
|
||||||
s.classifyFSPath = system.ClassifyPathFSTimeout
|
s.classifyFSPath = system.ClassifyPathFSTimeout
|
||||||
@@ -331,7 +338,13 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
path := r.URL.Path
|
path := r.URL.Path
|
||||||
|
|
||||||
if s.isDebug() {
|
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 {
|
switch {
|
||||||
@@ -347,6 +360,25 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.dashboardHandler(w, r)
|
s.dashboardHandler(w, r)
|
||||||
case path == "/launcher":
|
case path == "/launcher":
|
||||||
s.launcherHandler(w, r)
|
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":
|
case path == "/stacks":
|
||||||
s.stacksHandler(w, r)
|
s.stacksHandler(w, r)
|
||||||
case path == "/backups":
|
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"}}
|
{{define "launcher"}}
|
||||||
{{template "layout_start" .}}
|
{{template "layout_start" .}}
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Indítópult</h2>
|
<h2>Indítópult</h2>
|
||||||
<span class="domain-badge">{{.Domain}}</span>
|
<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>
|
</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}}
|
{{if .Apps}}
|
||||||
<div class="launch-grid">
|
<div class="launch-grid">
|
||||||
{{range .Apps}}
|
{{range .Apps}}
|
||||||
{{if isOperational .State}}
|
{{if isOperational .State}}
|
||||||
<a class="launch-cell" href="https://{{.Subdomain}}.{{$.Domain}}{{.OpenPath}}" target="_blank" rel="noopener">
|
<a class="launch-cell" href="https://{{.Subdomain}}.{{$.Domain}}{{.OpenPath}}" target="_blank" rel="noopener">
|
||||||
<span class="launch-tile" style="background: {{tileColor .Slug .BrandColor}}">
|
{{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" false)}}
|
||||||
<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>
|
|
||||||
<span class="launch-name">{{.DisplayName}}</span>
|
<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}}
|
{{if ne (stateStr .State) "running"}}<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>{{end}}
|
||||||
</a>
|
</a>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div class="launch-cell launch-cell--off">
|
<div class="launch-cell launch-cell--off">
|
||||||
<span class="launch-tile launch-tile--off" style="background: {{tileColor .Slug .BrandColor}}">
|
{{template "launch_tile" (dict "Slug" .Slug "BrandColor" .BrandColor "DisplayName" .DisplayName "Off" true)}}
|
||||||
<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>
|
|
||||||
<span class="launch-name">{{.DisplayName}}</span>
|
<span class="launch-name">{{.DisplayName}}</span>
|
||||||
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,5 +49,87 @@
|
|||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{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"}}
|
{{template "layout_end"}}
|
||||||
{{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-cell--off { cursor: default; }
|
||||||
.launch-tile--off { opacity: .4; }
|
.launch-tile--off { opacity: .4; }
|
||||||
.launch-cell--off .launch-name { color: var(--text-3); }
|
.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 page */
|
||||||
.login-body {
|
.login-body {
|
||||||
|
|||||||
Reference in New Issue
Block a user