diff --git a/web/nitro-entry/bun-https.mjs b/web/nitro-entry/bun-https.mjs index e7d08577..36db5299 100644 --- a/web/nitro-entry/bun-https.mjs +++ b/web/nitro-entry/bun-https.mjs @@ -48,21 +48,32 @@ const tls = ? { cert: Bun.file(certPath), key: Bun.file(keyPath) } : undefined; -// Half-configured TLS is the dangerous shape: one path set and the other missing silently drops to -// plain HTTP, and if PUNKTFUNK_UI_SECURE is also set the session cookie is marked Secure — which a -// browser then refuses to store over http://, so login appears to succeed and every next request is -// unauthenticated. Both failure modes are silent, so say something. +// Half-configured TLS is not a warning, it is a refusal. +// +// Two silent failures hide here, and both end with the operator staring at a console that looks +// fine. One path set and the other missing drops to plain HTTP — the login password then crosses +// the LAN in the clear on a server the operator believes is TLS. And PUNKTFUNK_UI_SECURE without +// TLS marks the session cookie Secure, which a browser refuses to store over http://, so login +// "succeeds" and every request after it is unauthenticated, forever. +// +// Neither state can serve a working console, so exiting is strictly better than serving a broken +// one: a supervisor logs the reason and the operator sees a stopped service instead of a subtly +// wrong one. +const secureFlag = /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? ""); if (Boolean(certPath) !== Boolean(keyPath)) { console.error( `punktfunk web console: only ${certPath ? "PUNKTFUNK_UI_TLS_CERT" : "PUNKTFUNK_UI_TLS_KEY"} is set — ` + - "TLS needs BOTH. Serving plain HTTP.", + "TLS needs BOTH. Refusing to start rather than serve the login password in the clear.", ); + process.exit(1); } -if (!tls && /^(1|true)$/i.test(process.env.PUNKTFUNK_UI_SECURE ?? "")) { +if (!tls && secureFlag) { console.error( "punktfunk web console: PUNKTFUNK_UI_SECURE is set but TLS is not configured. The session " + - "cookie will be marked Secure and dropped by the browser over http:// — login will not stick.", + "cookie would be marked Secure and dropped by the browser over http://, so login could " + + "never stick. Refusing to start — set PUNKTFUNK_UI_TLS_CERT/_KEY, or unset PUNKTFUNK_UI_SECURE.", ); + process.exit(1); } const server = Bun.serve({ diff --git a/web/server/routes/plugin-ui/[...].ts b/web/server/routes/plugin-ui/[...].ts index 088f40f4..d887d96c 100644 --- a/web/server/routes/plugin-ui/[...].ts +++ b/web/server/routes/plugin-ui/[...].ts @@ -84,22 +84,41 @@ export default defineEventHandler(async (event) => { const BODY_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]); /** - * Fix up a plugin's response before it goes out on the console's origin. + * Rebuild a plugin's response before it goes out on the console's own origin. * - * - `content-encoding` / `content-length` / `transfer-encoding`: `fetch` already decoded the body, - * but the plugin's original headers survive on the Response. Re-emitting `content-encoding: gzip` - * over plaintext makes the browser fail to decode the page, and a stale `content-length` truncates - * it. The framing belongs to OUR response, so drop the plugin's and let it be recomputed. - * - `set-cookie`: a plugin runs on the console's own origin, so any cookie it sets is scoped to the - * console — it could collide with (or shadow) `pf_session`. A plugin UI has no business setting - * cookies on this origin; it authenticates with the injected per-boot bearer. + * An ALLOWLIST, not a denylist. A plugin UI is proxied same-origin by design, so any header it + * returns is asserted for the console itself — and the first version of this dropped four names it + * had thought of. `Clear-Site-Data: "*"` from a plugin's error page was not one of them: the + * browser would honour it for this origin and wipe `pf_session`, signing the operator out of the + * console because a plugin 500'd. Same shape for a plugin-supplied `Content-Security-Policy`, + * `X-Frame-Options` or `Access-Control-Allow-Origin` — all of which would speak for us. + * + * So: name what a plugin page legitimately needs, and drop the rest. Framing headers + * (content-encoding/length, transfer-encoding) are deliberately absent — `fetch` already decoded + * the body, so re-emitting the plugin's originals made compressed pages fail to decode; ours are + * recomputed. */ +const PLUGIN_HEADER_ALLOWLIST = new Set([ + "content-type", + "cache-control", + "etag", + "last-modified", + "expires", + "vary", + "content-language", + "content-disposition", + "accept-ranges", + "content-range", + "location", // its own redirects, within its own prefix + "link", // preload hints for its own assets + "x-forwarded-prefix", +]); + function sanitize(resp: Response): Response { - const headers = new Headers(resp.headers); - headers.delete("content-encoding"); - headers.delete("content-length"); - headers.delete("transfer-encoding"); - headers.delete("set-cookie"); + const headers = new Headers(); + for (const [k, v] of resp.headers) { + if (PLUGIN_HEADER_ALLOWLIST.has(k.toLowerCase())) headers.set(k, v); + } // 204/304 must not carry a body — passing one through throws in the Response constructor. const bodyless = resp.status === 204 || resp.status === 304; return new Response(bodyless ? null : resp.body, { diff --git a/web/server/util/auth.ts b/web/server/util/auth.ts index 163a165c..3d9aedca 100644 --- a/web/server/util/auth.ts +++ b/web/server/util/auth.ts @@ -1,3 +1,55 @@ +/** + * A revocation marker for issued sessions, PERSISTED across restarts. + * + * The session is stateless: everything lives inside the sealed cookie, so `session.clear()` only + * deletes the BROWSER's copy. A cookie captured beforehand stayed valid for its full 7-day TTL — + * "log out" did not log anything out. + * + * The counter has to survive a restart or it does not do its job: an in-memory `let epoch = 1` + * revokes within one process run, then resets to 1 the next time the service starts, and a cookie + * captured from that first run is accepted again for the rest of its TTL. (The seal key cannot save + * us — it is derived from the stable mgmt token, so pre-restart cookies still unseal fine.) So it + * lives in a file next to the host's own config. + * + * Best-effort by design: if the file cannot be read or written the console still works, it just + * falls back to in-memory revocation for this process. Refusing to log anyone out because a state + * file is unwritable would be the wrong trade for a LAN console. + */ +const EPOCH_FILE = (): string => + process.env.PUNKTFUNK_UI_EPOCH_FILE ?? + join( + process.env.PUNKTFUNK_CONFIG_DIR ?? join(homedir(), ".config", "punktfunk"), + "web-session-epoch", + ); + +let epochCache: number | null = null; + +/** The epoch a new session is stamped with, and the one the gate requires. */ +export function sessionEpoch(): number { + if (epochCache !== null) return epochCache; + try { + const raw = readFileSync(EPOCH_FILE(), "utf8").trim(); + const n = Number.parseInt(raw, 10); + epochCache = Number.isFinite(n) && n > 0 ? n : 1; + } catch { + epochCache = 1; // no file yet — first run + } + return epochCache; +} + +/** Invalidate every session issued so far (what logging out does). */ +export function revokeAllSessions(): void { + const next = sessionEpoch() + 1; + epochCache = next; + try { + mkdirSync(dirname(EPOCH_FILE()), { recursive: true }); + writeFileSync(EPOCH_FILE(), String(next), { mode: 0o600 }); + } catch { + // Unwritable state dir: the bump still holds for this process, which is the common case + // (log out, walk away). It is weaker than persisted, and better than refusing to log out. + } +} + // Shared auth helpers for the Nitro server (the deployed Bun server). Single-user, // shared-password gate: the user logs in with PUNKTFUNK_UI_PASSWORD, which sets a SEALED // (h3 useSession — AES-GCM) cookie; every request is gated by server/middleware/auth.ts. @@ -8,6 +60,9 @@ import { createHash, timingSafeEqual as nodeTimingSafeEqual, } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; import { getRequestHeader, getRequestIP, @@ -201,27 +256,3 @@ export interface SessionData { /** The epoch this session was sealed under — see `sessionEpoch`. */ epoch?: number; } - -/** - * A revocation counter for issued sessions. - * - * The session is stateless: everything lives inside the sealed cookie, so `session.clear()` only - * deletes the BROWSER's copy. A cookie captured beforehand (a shared machine, a shell history, a - * TLS-inspecting proxy) stayed valid for its full 7-day TTL with nothing the operator could do - * about it — "log out" did not log anything out. - * - * Bumping this invalidates every previously issued cookie, because the gate compares the stamped - * epoch against the current one. It lives in memory, so a host restart also revokes — acceptable - * for a single-user console, and the safe direction to fail. - */ -let epoch = 1; - -/** The epoch a new session is stamped with, and the one the gate requires. */ -export function sessionEpoch(): number { - return epoch; -} - -/** Invalidate every session issued so far (the "sign out everywhere" lever). */ -export function revokeAllSessions(): void { - epoch += 1; -} diff --git a/web/src/sections/Displays/SessionGameCard.tsx b/web/src/sections/Displays/SessionGameCard.tsx index ee56a51f..604b9a35 100644 --- a/web/src/sections/Displays/SessionGameCard.tsx +++ b/web/src/sections/Displays/SessionGameCard.tsx @@ -32,11 +32,18 @@ export const SessionGameCard: FC = () => { const q = useGetSessionSettings(); const save = useSetSessionSettings(); const server = q.data?.settings; - // Which axes this build acts on. Empty on a platform with no launch path (macOS), where the - // controls are shown disabled rather than hidden — "does nothing here" is information. - const enforced = q.data?.enforced ?? []; - const acts = (field: string) => - enforced.length === 0 || enforced.includes(field); + // Which axes this build acts on. An EMPTY list means the build enforces nothing — the contract + // says so outright ("Empty on a platform with no launch path (macOS), so the console can say so + // instead of offering a switch that does nothing"), and this card's own comment promises the + // controls are "shown disabled rather than hidden". + // + // The old `enforced.length === 0 || …` read empty as "enforces EVERYTHING", so on exactly the + // platform the flag exists for, every control stayed live: clicking one PUT the setting and + // toasted success for an axis the host would never act on. Absent (an older host that never + // sent the field) still means "assume it acts" — that is the compatible reading, and it is a + // different case from present-and-empty. + const enforced = q.data?.enforced; + const acts = (field: string) => !enforced || enforced.includes(field); // The grace field is free text while being typed, so it gets a local buffer; the other two axes // are discrete and go straight to the host. @@ -162,7 +169,10 @@ export const SessionGameCard: FC = () => { )} - {enforced.length === 0 && ( + {/* Present-and-empty is the "this build acts on none of it" signal; ABSENT + is an older host that never sent the field, where claiming inertness + would be a guess. Same distinction `acts()` makes above. */} + {enforced?.length === 0 && ( {m.session_game_inert()} )} {error &&

{error}

} diff --git a/web/src/sections/Pairing/MoonlightPairingCard.tsx b/web/src/sections/Pairing/MoonlightPairingCard.tsx index f8c97e2d..2d8cc6d8 100644 --- a/web/src/sections/Pairing/MoonlightPairingCard.tsx +++ b/web/src/sections/Pairing/MoonlightPairingCard.tsx @@ -1,6 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { Info, KeyRound } from "lucide-react"; -import { type FC, useState } from "react"; +import { type FC, useEffect, useRef, useState } from "react"; import { getListPairedClientsQueryKey } from "@/api/gen/clients/clients"; import type { PairingStatus } from "@/api/gen/model/pairingStatus"; import { @@ -23,10 +23,24 @@ export const MoonlightPairingSection: FC = () => { const pairing = useGetPairingStatus({ query: { refetchInterval: 2_000 } }); const submit = useSubmitPairingPin(); + // Clear the previous attempt's outcome when a NEW pairing knock arrives. + // + // The mutation's success flag outlives the form — the section never unmounts, only the inner + //
is conditional — so the green "PIN sent" note was still on screen above an empty PIN + // box the next time Moonlight asked. Resetting inside `onSubmit` (the first attempt at this) + // does nothing: `mutate` moves the status to pending in the same update, so `isSuccess` was + // already about to go false. The transition that matters is `pin_pending` going false → true. + const pending = pairing.data?.pin_pending ?? false; + const wasPending = useRef(pending); + useEffect(() => { + if (pending && !wasPending.current) { + submit.reset(); + setPin(""); + } + wasPending.current = pending; + }, [pending, submit.reset]); + const onSubmit = () => { - // The mutation's success/error flags outlive the form: without this, starting a SECOND - // pairing attempt showed the previous one's "PIN sent" confirmation before a digit was typed. - submit.reset(); submit.mutate( { data: { pin } }, { diff --git a/web/tsconfig.json b/web/tsconfig.json index 51189929..9408f771 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -23,7 +23,9 @@ "include": [ "src", "server", - ".storybook", + // A BARE directory name that starts with a dot is silently skipped by tsc, so the + // previous `.storybook` entry typechecked nothing at all. The glob is what pulls it in. + ".storybook/**/*", "vite.config.ts", "vite.storybook.config.ts", "orval.config.ts"