A verification pass re-read every finding from the original sweep against the
code on this branch rather than against the commit messages. It found that four
of them were still broken, two because the edit I made was inert. Commit
messages claim; code decides.
- **The Storybook typecheck was never on.** `tsconfig.json` listed `.storybook`
as a bare directory name, and tsc silently skips dot-prefixed directories in
that form — so the entry typechecked nothing at all. Proved it by planting
`export const __probe: string = 1` in `.storybook/preview.tsx` and watching
`bun run lint` pass. `.storybook/**/*` is what actually pulls it in; the same
probe now fails as it should.
- **The Moonlight stale-PIN reset was a no-op.** `submit.reset()` sat at the top
of `onSubmit`, immediately before `submit.mutate(...)` — which moves the status
to pending in the same update, so it cleared a flag that was already changing.
The green "PIN sent" note therefore still greeted the next pairing attempt over
an empty PIN box. It now resets on the transition that actually matters:
`pin_pending` going false → true.
- **The session⇄game controls had the enforcement flag inverted**, and I never
touched it. `enforced.length === 0 || …` reads an EMPTY list as "this build
enforces everything", when the contract says the opposite in as many words:
"Empty on a platform with no launch path (macOS), so the console can say so
instead of offering a switch that does nothing". On exactly the platform the
flag exists for, every control stayed live and reported success for an axis the
host would never act on. Absent still means "assume it acts" — that is the
compatible reading for an older host, and a different case from present-empty.
- **Logout stopped revoking after a restart.** The epoch was a module-level
counter starting at 1, so it revoked within one process run and then reset —
and since the seal key derives from the stable mgmt token, a cookie captured
before a restart unsealed fine and was accepted again for the rest of its
7-day TTL. One service restart undid the whole fix. It persists next to the
host's config now. Verified: log out, restart the console, the captured cookie
still 401s, a fresh login still works.
Two more the pass rated as partial, both worth closing:
- The plugin-UI response filter was a denylist of four header names, so
`Clear-Site-Data` sailed through — a plugin error page could wipe `pf_session`
and sign the operator out of the console, on our own origin, because the iframe
is same-origin by design. It is an allowlist now; a plugin-supplied CSP,
`X-Frame-Options` or CORS header no longer speaks for us either.
- A half-configured TLS setup now refuses to start instead of logging a warning
and serving anyway. Neither shape can work — one path missing puts the login
password on the LAN in the clear, and PUNKTFUNK_UI_SECURE without TLS marks the
cookie Secure so the browser drops it and login can never stick. Exiting with a
reason beats a console that looks fine and is not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
**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>
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>
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>
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>
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>
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>
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>
Multi-agent security review of d356d15 (4 dimensions, 2-skeptic verification):
- CRITICAL functional+security: the session cookie inherited h3's Secure=true default;
browsers DROP Secure cookies over plain http://, so login silently failed on a LAN HTTP
client (worked only on localhost, a secure context — which is why the live test passed).
Now set the cookie attributes explicitly: HttpOnly + SameSite=Lax + Path=/, and Secure
only when PUNKTFUNK_UI_SECURE=1 (behind TLS). Verified: Set-Cookie no longer has Secure.
- Gate bypass: isPublicPath allowlisted any path ending in .json/.css/.png/etc., so
/api/v1/openapi.json (served unauthenticated on the mgmt side too) leaked the whole API
schema through the token-injecting proxy. Now /api is ALWAYS gated and the generic
extension allowlist is gone (client assets are all under /assets/, still allowlisted).
Verified: /api/v1/openapi.json and /api/v1/status.json → 401.
- Session lifetime: added maxAge (7d) — bounds a stolen cookie (cookie Max-Age + iron seal
TTL); previously never expired.
- Open redirect: the post-login `next` accepted protocol-relative `//evil.com`. Hardened
client + added safeNextPath() (same-origin path only).
Re-validated end to end: login assets public (200), /api/openapi.json gated (401), authed
/api/v1/status (200), unauth /→302. tsc + build green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>