dc57aa653ca50e559c03861df7341cdf9ec0cd28
15
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dc57aa653c |
feat(web): the console can say what just happened, and hand a phone the way in
**Recent activity.** The console could describe the present — a status snapshot — but never the recent past. A client that connected and left while you were on another page left no trace anywhere you could look, and the host's own log is a developer artifact rather than a narrative. The event stream was already open for cache invalidation, so a feed costs one ring buffer next to it: every frame is recorded, labelled per kind, and rendered newest-first on the dashboard. Deliberately in-memory and bounded to 200. It starts empty on a page load and fills as things happen, which is the honest shape for a live tail — an audit trail would need the host to keep one, and pretending otherwise would be worse than not having it. **Connect a device.** The console knew the host's address and identity all along and never offered either in a form you could hand to a phone: pairing meant reading an IP off the Host page and retyping it on a couch. There is a card now with the address and a `punktfunk://connect/<uniqueid>` deep link, both copyable — the link is the shipped client grammar (clients/shared/deeplink-vectors.json), so an installed client opens straight onto this host. No QR: rendering one needs an encoder we do not bundle, and a wrong QR is worse than none. **Installable.** A web manifest and the theme/apple meta tags, so the console can live on a phone's home screen — which is where it is used from as often as from a desk. No service worker on purpose: an offline shell for a console whose every screen is live host state would only ever show stale numbers convincingly. The manifest is reachable without a session (install needs it, and it says nothing the login page doesn't); /api stays gated, verified. Verified in a browser: three host-emitted events appear in the feed with the right labels, the deep link renders and copies as `punktfunk://connect/abc123`, and the manifest serves 200 as application/manifest+json while /api/v1/host still answers 401. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b9b0df349d |
fix(web): the charts stop lying about time, and logging out logs you out
The stats charts drew every sample as an evenly-spaced slot, because recharts defaults to a category axis. A capture that idled for two minutes rendered that gap as a single step — so the one view you open specifically to find where the time went was the view least able to show it. All three charts use a numeric time axis now, so the spacing is the elapsed time. They also joined samples across a session boundary into one continuous line, implying a continuity that never existed: the stream stopped and somebody else started a new one. A capture is split at each `session_id` change now. And the live card plotted the whole capture-so-far every 2 s, re-serialising and re-plotting an unbounded series for a capture left running all evening; it plots a bounded tail and says so, with the full series still in the saved recording. Logging out only deleted the browser's copy of the cookie. The session is stateless, so a value captured beforehand — a shared machine, a shell history, a TLS-inspecting proxy — stayed valid for its full 7-day TTL and there was nothing the operator could do about it. Sessions carry an epoch now and logging out bumps it, which invalidates every cookie issued so far. Verified end to end: a captured cookie works, survives nothing across a logout, and a fresh login still works. The rest: - The update card could not show its own timeout warning. It was suppressed by a `job` field read from the last snapshot — which, when the host has gone away mid-job, is exactly the case the warning exists for. Nothing ever cleared the applying state either, so the card waited forever with no way out; there is a button now. "Check now" also surfaces the host's 429 instead of looking dead. - A running install survives a reload: the job id lived only in component state, so refreshing lost sight of an install that was still running while the Install buttons stayed armed against a host that answers 409. The host keeps the list — ask it. - An all-sources-failed catalog said "no plugins available". That is a successful request carrying nothing, not an empty store; it names the sources that failed. - The Installed tab rendered "vundefined" for a plugin with no recorded version (nullable in the contract, typed required here). - The Displays "In effect" badges were computed from the local draft, so they restated the operator's unsaved edits back to them as though the host had adopted them. They read the API's `effective` now. A failed background poll no longer replaces a form someone is editing, and leaving the page with unsaved edits prompts — the old `beforeunload` guard never fired for in-app navigation, which is how you actually leave. - Enter or Space on a preset's rename/update/delete icon applied the preset instead of running the action: keydown bubbled to the card. - Dates follow the console's locale, not the browser's. The dashboard's PIN-pending tile says "Waiting"/"None" instead of "●"/"—". - The Bun entry warns when TLS is half-configured, or when PUNKTFUNK_UI_SECURE is set without it — both of which silently break login. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4a5d4b0a71 |
feat(web): the console follows the host's events instead of asking ten times a minute
The host has published every lifecycle transition on GET /api/v1/events since the API existed — client connect/disconnect, session and stream start/end, pairing decisions, display create/release, library, store and plugin changes — and nothing consumed a byte of it. The console instead polled ten endpoints on 1-5 s timers, so a change was up to 5 s stale and two pages could disagree while you looked at them. The Library page polled not at all: install a game in Steam and it never appeared until a full reload. The console now subscribes once and invalidates exactly the queries an event affects. Events never carry data into the cache — they only say "this is stale" — so an unknown future kind costs nothing and a missed event degrades to the polling that is still there underneath, now at a slow safety-net interval. The fast ticks that remain are the ones events cannot express: the live stream numbers while streaming, and a lingering display's teardown countdown. Four things had to be true for this to work, and none of them were. Each was found by measuring, not by reading: - Nitro's `localFetch` accumulates the response and only builds it when the handler returns, so nothing streams through the deployed Bun server. Three frames sent a second apart arrived together, three seconds late, when the upstream closed — and an SSE stream never closes, so nothing would ever have arrived. /api/v1/events gets its own route that hands back a web Response wrapping the upstream stream, which passes straight through. - Hydration mounts the app shell and discards it ~15 ms later. A subscription owned by that effect opened, closed, and never came back. It is a refcounted module singleton now, with a grace period so a remount re-attaches instead of reconnecting. - `getRouter()` runs more than once in the browser, and each call built its own QueryClient. The subscription held the first, the live pages read the second, and every invalidation went to a cache nobody was reading. One client per browser session; the server still gets a fresh one per request, which it must. - `invalidateQueries` only refetches queries that currently have an observer. An event means the HOST changed, so every cached copy is wrong whether or not something is watching it. Two features fall out of the same work: - **Automation** — a page for GET/PUT /api/v1/hooks. The host has run these hooks all along and the console never showed them, so the only way to see what your machine does when a stream starts was to open the config file. Writing one means writing a shell command the host will execute, so saving re-asks for the console password, like an update or an unreviewed install. - The Host page warns when another Moonlight-compatible server (Sunshine, Apollo) is running on the same machine. The host has detected this at startup for ages and reported it in /local/summary; nothing surfaced it. It is the most common reason a host looks installed and working but no client can reach it. Verified in a real browser against a mock host: three events drive three refetches of a query with no polling timer, the conflicts card names the intruder, the hook list and its dialog render, and the console reports no errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
4575134c21 |
fix(web): one bad password from anywhere stops locking out the whole console
The console's login throttle was documented as per-IP and was not. Nitro's `localFetch` hands the app a synthetic request whose socket has no `remoteAddress`, so `getRequestIP()` returned undefined for every request and every attempt was charged to one shared "unknown" bucket. Five wrong guesses from any LAN peer locked out everyone — including the operator, and including the update-apply route, which shares that budget. The Bun entry is the only place the real peer is knowable, so it now stamps it into a header (deleting any client-supplied copy first) and `peerAddress()` reads it back. Verified on a real build bound to 0.0.0.0: seven wrong logins from 127.0.0.1 lock 127.0.0.1 out, a different peer still logs in on the first try, and a request forging the header is charged to its real address. Also on the way through: - Installing an unreviewed package and adding a catalog source now re-ask for the console password, like applying an update already did. A 7-day session cookie should not be able to run new code on the host, and `store/install` with `accept_unverified` did exactly that through the generic passthrough. The gate sits at the trust boundary — adding a source, or a raw spec — not on every install from a source the operator already chose to trust. - The ui-credential denylist is matched against the normalised path too, so `/api//v1/...` and friends can no longer walk around it. - The console serves nosniff, a no-referrer policy, and a CSP that pins frame-ancestors, object-src and base-uri. - A plugin UI's response no longer re-emits the content-encoding that `fetch` already decoded (which made compressed plugin pages fail to load), no longer sets cookies on the console's origin, and OPTIONS reaches the plugin instead of being refused 405 by us. - An unreachable host reads as 502 on these routes, matching the passthrough, instead of a bare 500. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
bb1f93d90e |
feat(web,docs): the Update-now flow — password re-entry at the BFF, restart-tolerant progress, same-origin hardening
apply.post.ts intercepts the one proxied route that restarts the host: the console password is re-verified per apply (login throttle shared, stripped before forwarding) so a 7-day cookie alone can't do it. New Sec-Fetch-Site same-origin check on every mutating request (login CSRF included). The card's apply flow: confirm dialog → live-session force escalation → download/verify/restart progress rendered from the last snapshot while polls fail (the host and, on Windows, this very server restart mid-flow) → durable success/failure from last_result. Docs: the one-click section + kill switch. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ec84b30eae |
feat(plugins): console-hosted plugin UI surface (host registry + SDK servePluginUi + console proxy/nav)
Implements planning/design/plugin-ui-surface.md (U1-U3):
- host: in-memory lease-based plugin registry (mgmt/plugins.rs) — PUT/GET/DELETE
/api/v1/plugins + GET /plugins/{id}/ui-credential; bearer+loopback only (not on
the mTLS read-only allowlist); plugins.changed event; port-only registration
(proxy always dials 127.0.0.1); secret never in the listing.
- sdk: servePluginUi — loopback ephemeral bind + per-boot secret + constant-time
check + /__health + static/SPA-fallback + register/renew(30s)/deregister via
pf.request (skew-proof, D7). Example + tests.
- console: /plugin-ui/{id}/** reverse proxy (server-side secret injection, cookie
strip, SSE streaming, stale-secret 401-retry) + credential cache; BFF denylist
for the credential endpoint; dynamic Plugins nav (desktop + mobile) fed by a
polled list; iframe-in-shell page with health probe, offline card, open-in-tab,
deep-link sync. Dev-mode /plugin-ui middleware in vite.config.ts.
OpenAPI regen for the new endpoints follows in the next commit (built on Linux).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
||
|
|
75b3c94f60 |
fix(web): console sweep — pairing, displays, stats, logs, auth, i18n
ci / rust (push) Failing after 45s
ci / web (push) Successful in 52s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
ci / docs-site (push) Successful in 1m6s
decky / build-publish (push) Successful in 33s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 10s
ci / bench (push) Successful in 6m1s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8m33s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m40s
docker / deploy-docs (push) Successful in 26s
windows-host / package (push) Successful in 15m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m24s
arch / build-publish (push) Successful in 20m32s
android / android (push) Successful in 20m50s
deb / build-publish (push) Successful in 20m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m41s
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
Pairing: - Refresh the paired-devices list after a native PIN pairing (the happy path never invalidated it, so a newly paired device stayed hidden until remount). - Moonlight PIN: a 204 means "PIN delivered to the waiting handshake", NOT paired, so it now reads "PIN sent" instead of a false "Paired successfully". - Hide the Moonlight pairing card on native-only hosts (HostInfo.gamestream) — it could never receive a PIN there. - Per-row pending on unpair/approve/deny; PIN input maxLength 16 (was 8). Displays / Library: - "Arrange displays" save refreshes the settings card (it rewrites the policy), without clobbering unsaved Custom edits (re-seed only when the draft still matches the server). - Live-display list wrapped in QueryState so errors don't read as "no displays". - "Forever" keep-alive option in the custom editor; edit-game form round-trips the logo artwork (was dropped on save); per-card delete pending. Stats: - Distinct colour for the native "queue" latency stage (it collided with "capture"). - "Not measured on this path" note on the GameStream health chart; configured-bitrate target line on throughput; host-authoritative elapsed timer; LiveCard surfaces non-404 errors. Shell / auth / i18n: - SSR-stable locale: first client render matches the base-locale SSR (no hydration mismatch), then adopts the persisted/browser locale post-hydration. - BFF proxy maps an upstream (mgmt-token) 401 to 502 so a logged-in user isn't bounced into a post-login redirect loop. - Logout checks the POST result before navigating; logs dedup by seq (StrictMode); login "next" keeps query/hash; Dashboard shows the active-session count. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
e707a962b6 |
fix(security): anti-replay, 0600 client key, open redirect, supply-chain
Address findings from a repo security review:
- core: add a sliding-window anti-replay filter over the AEAD-authenticated
sequence in Session (poll_input/poll_frame), closing the input-replay gap the
data plane previously left to the LAN/VPN trust assumption. 4096-deep window,
unit-tested; the encrypted loopback suite confirms no false drops.
- clients: write the mTLS client private key 0600 and lock the config dir 0700
on Unix (it was world-readable at the umask default), re-locking existing
stores on load. pf-client-core::trust plus the probe's own identity writer.
Windows keeps the %APPDATA% ACL; Android/Apple already wrap the key.
- web: fix a post-login open redirect — resolve `next` via URL and require it to
stay same-origin, rejecting `/\evil.com` and tab/encoding variants the old
`!startsWith("//")` guard missed. Also fixes the dead safeNextPath helper.
- ci: SHA-256-pin the BtbN FFmpeg DLLs bundled into the signed Windows installer
(were fetched from the rolling `latest` tag unverified); fails closed on a
re-roll, matching the VB-CABLE gate.
- ci: fail-open fork-guard on the Windows/Apple host-mode PR build jobs that
share runner labels with the signing jobs. Definitive fix stays server-side
(Gitea outside-collaborator approval / isolated PR runners) — see the notes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
||
|
|
f87a43f42d |
feat(web): harden login gate — throttle, scoped TLS, token-derived seal key
Remediates the two web-console residuals from the 2026-07-05 posture audit: - Brute-force throttle (loginThrottle.ts): per-IP exponential backoff after 5 free attempts, plus a global floor for spread-out floods, keyed on the socket peer IP (not spoofable X-Forwarded-For) with a size-capped map. The constant-time compare already stopped the timing leak; this bounds guess *volume* against a by-design LAN-exposed console. - Session seal key now derives from the high-entropy mgmt token instead of the low-entropy login password, so a captured cookie is no longer an offline password oracle. Falls back to the password only when no token is configured (dev/local). Rotating the token now invalidates sessions. - Replace the process-wide NODE_TLS_REJECT_UNAUTHORIZED=0 with per-request Bun TLS scoped to the loopback proxy hop; a non-loopback mgmt URL now verifies normally. Dropped the env var from the systemd unit, Steam Deck installer, Windows run scripts, env examples, and web README. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
ae51276a03 |
feat(web): consolidate paired devices, self-contained sections, docs + lint
Web console - Pairing/Library/Stats refactored into self-contained subsections that each own their own queries + mutations; a shared slot-based layout (view.tsx) is filled by the live page (containers) and Storybook (pure cards + fixtures) so the layout can't drift. - All paired devices in one list on Pairing with a protocol column (punktfunk/1 + Moonlight), routing each unpair to the right endpoint; the redundant Clients page is removed. - Library: overview grid split from the add/edit form into separate files. - Login screen links out to the docs. Docs - "Console login password" section on every host page (apt/RPM/Bazzite/SteamOS/Windows) plus a new "Forgot your Password?" troubleshooting page, linked from the login screen. - Console served as HTTP/1.1 over TLS (drop the unusable HTTP/3 advertising) across the Bun entry, launchers, systemd units, and packaging. Tooling - Biome now respects .gitignore (stops linting generated code), config migrated to 2.5.1; all lint issues fixed cleanly. Also includes this branch's in-progress host, Apple client, packaging, and CI changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
187738ed86 | improve web ui | ||
|
|
944fb9a5e0 |
feat(host/mgmt): HTTPS + token auth by default (no loopback no-auth fallback)
The mgmt API already always serves HTTPS (the host identity cert), but on a loopback bind with no token it ran unauthenticated — any local process could drive it. Make auth required ALWAYS: - new mgmt_token::load_or_generate(): token precedence is --mgmt-token > env PUNKTFUNK_MGMT_TOKEN > persisted ~/.config/punktfunk/mgmt-token > freshly generated 32-byte hex, persisted 0600 in KEY=VALUE form (so the bundled web console can source it directly as a systemd EnvironmentFile — one source of truth). config_dir() made pub(crate). - parse_serve() resolves the token via load_or_generate() when unset, so a bare `serve` Just Works with auth on and no operator step. - mgmt::run() drops the loopback no-token exemption and requires a token; require_auth()'s unauthenticated fallback now returns 401. The paired-cert (mTLS) branch is unchanged — Apple client + library auth unaffected. - web /api proxy: 503 (legible) instead of forwarding an empty bearer. - tests: test_app/test_app_native default a token, send() auto-attaches the bearer; blank-token test asserts the new "no token" refusal. 80 pass. - docs: mgmt module doc + host.env.example reflect always-on auth + auto-gen. Compiles, clippy/fmt clean, openapi no drift. Part B (bundle the web console into apt, auto-wired to this token) follows. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
f4e37ac3b3 |
fix(web): default mgmt proxy to the HTTPS self-signed mgmt API
The mgmt API serves HTTPS with the host's self-signed identity cert and requires mTLS-or-bearer auth (the mTLS work), but the web console's proxy still defaulted to `http://127.0.0.1:47990` — so a deployment copying .env.example got a plain-HTTP request to an HTTPS port (→ 502 Bad Gateway, observed live on the Bazzite box). - .env.example + server/util/auth.ts + vite.config.ts: default PUNKTFUNK_MGMT_URL to https://127.0.0.1:47990. - vite dev proxy: `secure: false` (the host cert is self-signed). - Document that the deployment needs PUNKTFUNK_MGMT_TOKEN (matching the host's) and NODE_TLS_REJECT_UNAUTHORIZED=0 — the web server's only outbound TLS is the loopback hop to the host's own self-signed cert, so disabling verify there is scoped + safe. The running Bazzite box is already fixed live (web.env → https + token + cert-skip, verified: login 200, /api/v1/status 200). This makes fresh deployments correct. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> |
||
|
|
b82eb419ee |
fix(web): harden BFF auth — adversarial-review fixes
Multi-agent security review of
|
||
|
|
d356d1534f |
feat(web): login-gated BFF auth — sealed session cookie + server-side token injection
Single-user, LAN-reachable-but-gated. The web server is a backend-for-frontend:
- Login: POST /_auth/login {password} checks PUNKTFUNK_UI_PASSWORD (constant-time) and
sets a SEALED session cookie (h3 useSession / AES-GCM). server/middleware/auth.ts gates
every request — pages 302 → /login, /api → 401 — and FAILS CLOSED (503) when
PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
- The management API stays loopback-only + token (never LAN-exposed). The proxy
(server/routes/api/[...].ts) injects PUNKTFUNK_MGMT_TOKEN server-side and drops the
browser's cookie before forwarding — the token never reaches the browser, which only
holds the session cookie.
Nitro doesn't auto-scan a server/ dir, so the Nitro plugin gets an explicit scanDirs to
pick up middleware + routes. Client: removed the localStorage token (server injects it);
the fetcher bounces to /login on 401; new /login page (bare, no shell); Settings drops the
token field and gains a Sign-out button; en/de strings.
Validated live end to end: unauth /→302, /api→401; wrong pw→401; right pw→200+cookie;
authed /api/v1/status→200 (proxied, mgmt token injected — the host required it); logout→
session cleared→401. tsc + build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|