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>
This commit is contained in:
2026-07-30 15:55:27 +02:00
co-authored by Claude Fable 5
parent bb48225414
commit bb1f93d90e
6 changed files with 472 additions and 38 deletions
+15 -2
View File
@@ -37,8 +37,21 @@ systemctl --user restart punktfunk-host
(The Windows installer restarts the service itself; `punktfunk-sysext update` prints the same
restart hint when it's needed.)
One-click updating from the console is on the way, per install method — the card will grow an
**Apply** button where the platform supports it.
## One-click updating (Windows)
On a Windows host the card shows an **Update now** button instead of a command. It asks for the
console password again (a saved login alone can't restart your host), then the host downloads
the installer, verifies it against the signed release manifest **and** its code signature, and
runs it silently — the service restarts at the end and the page reconnects by itself. If a
stream is live you'll be warned first: updating drops it.
Every attempt leaves a result in the card (and an installer log under
`C:\ProgramData\punktfunk\logs\update-<version>.log`) — including across the restart, so a
failed update is never silent. To disable the button entirely on a host, set
`PUNKTFUNK_UPDATE_APPLY=0` in `host.env`; the card then shows the manual command instead.
One-click updating for the Linux install methods is on the way — it will be opt-in per host
(joining a `punktfunk-update` group) because it needs a narrowly-scoped root helper.
## Turning the check off
+22 -1
View File
@@ -456,5 +456,26 @@
"update_disabled": "Update-Prüfungen sind auf diesem Host deaktiviert (PUNKTFUNK_UPDATE_CHECK=0).",
"update_error": "Letzte Prüfung fehlgeschlagen:",
"update_copy": "Kopieren",
"update_copied": "Kopiert"
"update_copied": "Kopiert",
"update_apply_ready": "Version {version} kann von hier aus installiert werden — der Host lädt, prüft und startet sich selbst neu.",
"update_apply_button": "Jetzt aktualisieren",
"update_apply_confirm_title": "Host auf {version} aktualisieren?",
"update_apply_confirm_body": "Mit dem Konsolen-Passwort bestätigen. Der Host startet am Ende neu; diese Seite verbindet sich selbst wieder.",
"update_apply_force_warning": "Gerade läuft eine Streaming-Sitzung — das Update bricht sie ab (wer spielt, fliegt raus). Falls du diese Seite über den Stream siehst, verlierst du ihn ebenfalls.",
"update_apply_force_button": "Trotzdem aktualisieren (bricht den Stream ab)",
"update_apply_password_label": "Konsolen-Passwort",
"update_apply_wrong_password": "Falsches Passwort.",
"update_apply_throttled": "Zu viele Versuche — kurz warten und erneut versuchen.",
"update_apply_working": "Starte…",
"update_apply_timeout": "Der Host hat sich noch nicht zurückgemeldet. Möglicherweise installiert er noch — falls das anhält, den Dienst auf der Maschine und das Installer-Log im punktfunk-Datenverzeichnis prüfen (logs\\update-*.log).",
"update_applying_title": "Aktualisiere auf {version}",
"update_stage_downloading": "Lade den Installer… {pct}%",
"update_stage_downloading_indeterminate": "Lade den Installer…",
"update_stage_verifying": "Prüfe den Download…",
"update_stage_applying": "Übergabe an den Installer…",
"update_stage_restarting": "Der Host startet neu…",
"update_stage_reconnecting": "Warte, bis der Host wieder da ist…",
"update_result_ok": "Aktualisiert {from} → {to}.",
"update_result_failed": "Update auf {to} ist in Phase {stage} fehlgeschlagen.",
"update_result_log": "Installer-Log:"
}
+22 -1
View File
@@ -456,5 +456,26 @@
"update_disabled": "Update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0).",
"update_error": "Last check failed:",
"update_copy": "Copy",
"update_copied": "Copied"
"update_copied": "Copied",
"update_apply_ready": "Version {version} can be installed from here — the host downloads, verifies, and restarts itself.",
"update_apply_button": "Update now",
"update_apply_confirm_title": "Update the host to {version}?",
"update_apply_confirm_body": "Confirm with the console password. The host will restart at the end; this page reconnects by itself.",
"update_apply_force_warning": "A streaming session is live right now — updating will drop it (whoever is playing gets cut off). If that stream is how you're viewing this page, you'll lose it too.",
"update_apply_force_button": "Update anyway (drops the stream)",
"update_apply_password_label": "Console password",
"update_apply_wrong_password": "Wrong password.",
"update_apply_throttled": "Too many attempts — wait a moment and try again.",
"update_apply_working": "Starting…",
"update_apply_timeout": "The host hasn't come back yet. It may still be installing — if this persists, check the service on the machine and the installer log under the punktfunk data directory (logs\\update-*.log).",
"update_applying_title": "Updating to {version}",
"update_stage_downloading": "Downloading the installer… {pct}%",
"update_stage_downloading_indeterminate": "Downloading the installer…",
"update_stage_verifying": "Verifying the download…",
"update_stage_applying": "Handing over to the installer…",
"update_stage_restarting": "The host is restarting…",
"update_stage_reconnecting": "Waiting for the host to come back…",
"update_result_ok": "Updated {from} → {to}.",
"update_result_failed": "Update to {to} failed during {stage}.",
"update_result_log": "Installer log:"
}
+19
View File
@@ -4,6 +4,7 @@
// PUNKTFUNK_UI_PASSWORD is unset, so a misconfigured LAN-exposed server admits no one.
import {
defineEventHandler,
getRequestHeader,
getRequestURL,
sendRedirect,
setResponseStatus,
@@ -18,6 +19,24 @@ import {
export default defineEventHandler(async (event) => {
const { pathname } = getRequestURL(event);
// Same-origin check for every MUTATING request (defense in depth beyond SameSite=Lax,
// added with the update-apply route where CSRF ≈ code execution — design
// host-update-from-web-console.md §4.3). `Sec-Fetch-Site` is browser-set and unforgeable
// from a page; absent (curl, very old browsers) ⇒ allowed — the console's threat here is
// a BROWSER being ridden cross-site, and every riding browser sends the header.
// `same-site` is rejected too: with an IP-address origin, another port on the same box
// counts as same-site, and nothing on another port has business mutating the console.
// Applies to public paths as well (login CSRF), before any session logic.
const method = event.method?.toUpperCase?.() ?? "GET";
if (method !== "GET" && method !== "HEAD" && method !== "OPTIONS") {
const site = getRequestHeader(event, "sec-fetch-site")?.toLowerCase();
if (site && site !== "same-origin" && site !== "none") {
setResponseStatus(event, 403);
return { error: "cross-site request refused" };
}
}
if (isPublicPath(pathname)) return;
// Misconfigured: refuse everything rather than serve open on the LAN.
@@ -0,0 +1,89 @@
// POST /api/v1/update/apply — the ONE proxied route with an extra gate: the console password
// must be re-entered per apply (design host-update-from-web-console.md §4.3). A 7-day session
// cookie alone must not be able to update-and-restart the host; the password is verified HERE
// (only the BFF knows it), stripped, and never forwarded. Wrong attempts share the login
// throttle's per-IP budget, so apply can't be used as a password oracle.
//
// This specific file wins over the `[...]` catch-all (h3 route specificity) — verified in the
// U1 gate; everything else about proxying (bearer injection, loopback TLS scoping, 401→502)
// mirrors ../../[...].ts.
import {
createError,
defineEventHandler,
getRequestIP,
readBody,
setResponseHeader,
setResponseStatus,
} from "h3";
import {
isLoopbackUrl,
mgmtToken,
mgmtUrl,
timingSafeEqual,
uiPassword,
} from "../../../../util/auth";
import {
recordLoginFailure,
recordLoginSuccess,
throttleRetryAfterMs,
} from "../../../../util/loginThrottle";
export default defineEventHandler(async (event) => {
const expected = uiPassword();
if (!expected) {
throw createError({ statusCode: 503, statusMessage: "auth not configured" });
}
const ip = getRequestIP(event) ?? "unknown";
const wait = throttleRetryAfterMs(ip);
if (wait > 0) {
setResponseHeader(event, "Retry-After", Math.ceil(wait / 1000));
throw createError({
statusCode: 429,
statusMessage: "too many attempts — try again shortly",
});
}
const body = await readBody<{ password?: string; force?: boolean }>(event);
const password = String(body?.password ?? "");
if (!timingSafeEqual(password, expected)) {
recordLoginFailure(ip);
throw createError({
statusCode: 401,
statusMessage: "password confirmation failed",
});
}
recordLoginSuccess(ip);
const token = mgmtToken();
if (!token) {
setResponseStatus(event, 503);
return { error: "management token not configured" };
}
const base = mgmtUrl();
const init: RequestInit = {
method: "POST",
headers: {
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
// The password stops here — the host only ever sees the force flag.
body: JSON.stringify({ force: body?.force === true }),
};
if (isLoopbackUrl(base)) {
// Bun.fetch extension (see ../../[...].ts for why this is scoped per-request).
(init as unknown as { tls: { rejectUnauthorized: boolean } }).tls = {
rejectUnauthorized: false,
};
}
const upstream = await fetch(`${base}/api/v1/update/apply`, init);
if (upstream.status === 401) {
throw createError({
statusCode: 502,
statusMessage:
"management API rejected the host token (check PUNKTFUNK_MGMT_TOKEN)",
});
}
setResponseStatus(event, upstream.status);
setResponseHeader(event, "content-type", "application/json");
return upstream.text();
});
+305 -34
View File
@@ -10,21 +10,57 @@ import type { UpdateStatus } from "@/api/gen/model";
import { QueryState } from "@/components/query-state";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Spinner } from "@/components/ui/spinner";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
/** Give up waiting for the host to come back this long after apply started. */
const APPLY_TIMEOUT_MS = 8 * 60 * 1000;
/**
* Container: the host update-check card (U0 — notify-only). Reading status is what keeps the
* host's manifest cache warm (the host kicks its own background refresh when the cache is >6 h
* old), so a modest poll doubles as the check cadence while the console is open. Apply buttons
* arrive with the per-channel apply legs (U1 Windows, U2 Linux helper); until then the card's
* action is the exact update command for this install kind.
* Container: the host update card. Check everywhere (U0) + one-click apply where the host
* reports `apply: "full"` (U1 — Windows installer). The apply flow deliberately survives the
* console's own backends dying: once an apply is accepted, the card renders from the LAST
* status snapshot (React Query keeps data across failed polls) and treats poll errors as "the
* host is restarting", not as failures — until the target version answers or a timeout.
*/
export const UpdateSection: FC = () => {
const qc = useQueryClient();
const status = useGetUpdateStatus({ query: { refetchInterval: 60_000 } });
const [applying, setApplying] = useState<{
target: string;
startedAt: number;
} | null>(null);
const status = useGetUpdateStatus({
// Poll fast while an apply is in flight so the post-restart status lands promptly.
query: { refetchInterval: applying ? 3_000 : 60_000 },
});
const check = useForceUpdateCheck();
const s = status.data;
// The apply resolved: the host answers with the target version (success — reconcile wrote
// last_result), or reports a failed attempt at our target.
if (applying && s) {
if (
s.current_version === applying.target ||
(s.last_result &&
s.last_result.to === applying.target &&
!s.last_result.ok)
) {
setApplying(null);
}
}
const checkNow = () =>
check.mutate(undefined, {
onSuccess: (fresh) => {
@@ -32,25 +68,45 @@ export const UpdateSection: FC = () => {
},
});
return <UpdateCard state={status} onCheck={checkNow} busy={check.isPending} />;
return (
<UpdateCard
state={status}
onCheck={checkNow}
checkBusy={check.isPending}
applying={applying}
onApplied={(target) =>
setApplying({ target, startedAt: Date.now() })
}
/>
);
};
export const UpdateCard: FC<{
state: Loadable<UpdateStatus>;
onCheck: () => void;
busy: boolean;
}> = ({ state, onCheck, busy }) => {
checkBusy: boolean;
applying: { target: string; startedAt: number } | null;
onApplied: (target: string) => void;
}> = ({ state, onCheck, checkBusy, applying, onApplied }) => {
const s = state.data;
const inFlight = Boolean(applying) || Boolean(s?.job);
const timedOut =
applying !== null && Date.now() - applying.startedAt > APPLY_TIMEOUT_MS;
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle>{m.update_title()}</CardTitle>
{s?.available && <Badge>{m.update_available_badge()}</Badge>}
{s?.available && !inFlight && (
<Badge>{m.update_available_badge()}</Badge>
)}
</CardHeader>
<CardContent className="space-y-4">
<QueryState
isLoading={state.isLoading}
error={state.error}
// While an apply restarts the host (and, on Windows, this console server),
// failed polls are EXPECTED — keep rendering the last snapshot instead of
// swapping the card for an error box.
error={inFlight ? undefined : state.error}
refetch={state.refetch}
>
{s && (
@@ -94,11 +150,21 @@ export const UpdateCard: FC<{
/>
</dl>
{s.available ? (
<div className="space-y-2 rounded-md border p-4">
<p className="text-sm">{m.update_how()}</p>
<CommandLine command={s.channel_hint} />
</div>
{inFlight ? (
<ApplyProgress
status={s}
reconnecting={Boolean(state.error)}
timedOut={timedOut}
/>
) : s.available ? (
s.apply === "full" ? (
<ApplyPanel status={s} onApplied={onApplied} />
) : (
<div className="space-y-2 rounded-md border p-4">
<p className="text-sm">{m.update_how()}</p>
<CommandLine command={s.channel_hint} />
</div>
)
) : (
s.manifest && (
<p className="text-sm text-muted-foreground">
@@ -107,12 +173,16 @@ export const UpdateCard: FC<{
)
)}
{!inFlight && s.last_result && (
<LastResult result={s.last_result} />
)}
{s.manifest?.stale && (
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
{m.update_stale()}
</p>
)}
{s.last_error && (
{!inFlight && s.last_error && (
<p className="text-sm text-destructive">
{m.update_error()} {s.last_error}
</p>
@@ -122,22 +192,24 @@ export const UpdateCard: FC<{
{m.update_disabled()}
</p>
) : (
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={onCheck}
disabled={busy}
>
{busy ? m.update_checking() : m.update_check_now()}
</Button>
{s.last_checked_unix != null && (
<span className="text-xs text-muted-foreground">
{m.update_last_checked()}{" "}
{new Date(s.last_checked_unix * 1000).toLocaleString()}
</span>
)}
</div>
!inFlight && (
<div className="flex items-center gap-3">
<Button
variant="outline"
size="sm"
onClick={onCheck}
disabled={checkBusy}
>
{checkBusy ? m.update_checking() : m.update_check_now()}
</Button>
{s.last_checked_unix != null && (
<span className="text-xs text-muted-foreground">
{m.update_last_checked()}{" "}
{new Date(s.last_checked_unix * 1000).toLocaleString()}
</span>
)}
</div>
)
)}
</>
)}
@@ -147,6 +219,205 @@ export const UpdateCard: FC<{
);
};
/** The one-click leg: the Update button + its password-confirm dialog. */
const ApplyPanel: FC<{
status: UpdateStatus;
onApplied: (target: string) => void;
}> = ({ status, onApplied }) => {
const [open, setOpen] = useState(false);
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [needsForce, setNeedsForce] = useState(false);
const [busy, setBusy] = useState(false);
const target = status.manifest?.version ?? "";
const submit = async (force: boolean) => {
setBusy(true);
setError(null);
try {
// Plain fetch on purpose: apiFetch treats ANY 401 as "session expired → /login",
// but here a 401 is just a wrong password confirmation.
const res = await fetch("/api/v1/update/apply", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ password, force }),
});
if (res.status === 202) {
setOpen(false);
setPassword("");
onApplied(target);
return;
}
const body = (await res.json().catch(() => null)) as {
error?: string;
} | null;
if (res.status === 401) {
setError(m.update_apply_wrong_password());
} else if (res.status === 429) {
setError(m.update_apply_throttled());
} else if (res.status === 409 && body?.error?.includes("force")) {
// The host refused because a stream is live — escalate to the explicit
// "drop the stream" confirmation instead of showing a raw error.
setNeedsForce(true);
} else {
setError(body?.error ?? `HTTP ${res.status}`);
}
} catch {
setError(m.common_error());
} finally {
setBusy(false);
}
};
return (
<div className="space-y-2 rounded-md border p-4">
<p className="text-sm">{m.update_apply_ready({ version: target })}</p>
<div className="flex items-center gap-3">
<Button size="sm" onClick={() => setOpen(true)}>
{m.update_apply_button()}
</Button>
<CommandLine command={status.channel_hint} />
</div>
<Dialog
open={open}
onOpenChange={(o) => {
setOpen(o);
if (!o) {
setPassword("");
setError(null);
setNeedsForce(false);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{m.update_apply_confirm_title({ version: target })}
</DialogTitle>
<DialogDescription>
{needsForce
? m.update_apply_force_warning()
: m.update_apply_confirm_body()}
</DialogDescription>
</DialogHeader>
<form
className="space-y-3"
onSubmit={(e) => {
e.preventDefault();
void submit(needsForce);
}}
>
<div className="space-y-1.5">
<Label htmlFor="update-apply-password">
{m.update_apply_password_label()}
</Label>
<Input
id="update-apply-password"
type="password"
autoFocus
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button
type="submit"
variant={needsForce ? "destructive" : "default"}
disabled={busy || password.length === 0}
>
{busy
? m.update_apply_working()
: needsForce
? m.update_apply_force_button()
: m.update_apply_button()}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
);
};
/** The in-flight panel: download progress → applying → restarting → reconnecting. */
const ApplyProgress: FC<{
status: UpdateStatus;
reconnecting: boolean;
timedOut: boolean;
}> = ({ status, reconnecting, timedOut }) => {
const job = status.job;
const pct =
job?.total_bytes && job.total_bytes > 0
? Math.min(100, Math.round((job.received_bytes / job.total_bytes) * 100))
: null;
const stageLabel = (() => {
if (reconnecting) return m.update_stage_reconnecting();
switch (job?.stage) {
case "downloading":
return pct === null
? m.update_stage_downloading_indeterminate()
: m.update_stage_downloading({ pct: String(pct) });
case "verifying":
return m.update_stage_verifying();
case "applying":
return m.update_stage_applying();
default:
return m.update_stage_restarting();
}
})();
return (
<div className="space-y-3 rounded-md border p-4">
<div className="flex items-center gap-3">
<Spinner className="size-4" />
<p className="text-sm font-medium">
{m.update_applying_title({
version: job?.target_version ?? status.manifest?.version ?? "",
})}
</p>
</div>
<p className="text-sm text-muted-foreground">{stageLabel}</p>
{job?.stage === "downloading" && pct !== null && (
<div className="h-1.5 w-full overflow-hidden rounded bg-muted">
<div
className="h-full rounded bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
)}
{timedOut && (
<p className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
{m.update_apply_timeout()}
</p>
)}
</div>
);
};
/** Durable outcome of the last apply (written by the host across its own restart). */
const LastResult: FC<{
result: NonNullable<UpdateStatus["last_result"]>;
}> = ({ result }) =>
result.ok ? (
<p className="rounded-md border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
{m.update_result_ok({ from: result.from, to: result.to })}
</p>
) : (
<div className="space-y-1 rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
<p className="font-medium text-destructive">
{m.update_result_failed({ to: result.to, stage: result.stage ?? "?" })}
</p>
{result.error && <p>{result.error}</p>}
{result.log_path && (
<p className="text-xs text-muted-foreground">
{m.update_result_log()} <code>{result.log_path}</code>
</p>
)}
</div>
);
const UpdateRow: FC<{ label: string; value: ReactNode }> = ({
label,
value,
@@ -172,7 +443,7 @@ const CommandLine: FC<{ command: string }> = ({ command }) => {
});
};
return (
<div className="flex items-center gap-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<code className="min-w-0 flex-1 overflow-x-auto rounded bg-muted px-2 py-1.5 text-xs">
{command}
</code>