diff --git a/docs-site/content/docs/updating.md b/docs-site/content/docs/updating.md
index 1d4a00fa..b53862e7 100644
--- a/docs-site/content/docs/updating.md
+++ b/docs-site/content/docs/updating.md
@@ -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-.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
diff --git a/web/messages/de.json b/web/messages/de.json
index 9b45849a..39b215cf 100644
--- a/web/messages/de.json
+++ b/web/messages/de.json
@@ -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:"
}
diff --git a/web/messages/en.json b/web/messages/en.json
index cad64659..f0c3ef92 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -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:"
}
diff --git a/web/server/middleware/auth.ts b/web/server/middleware/auth.ts
index 6bc7a889..51262adf 100644
--- a/web/server/middleware/auth.ts
+++ b/web/server/middleware/auth.ts
@@ -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.
diff --git a/web/server/routes/api/v1/update/apply.post.ts b/web/server/routes/api/v1/update/apply.post.ts
new file mode 100644
index 00000000..7216ff51
--- /dev/null
+++ b/web/server/routes/api/v1/update/apply.post.ts
@@ -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();
+});
diff --git a/web/src/sections/Host/UpdateCard.tsx b/web/src/sections/Host/UpdateCard.tsx
index ad0b58ad..cd5f5f99 100644
--- a/web/src/sections/Host/UpdateCard.tsx
+++ b/web/src/sections/Host/UpdateCard.tsx
@@ -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 ;
+ return (
+
+ setApplying({ target, startedAt: Date.now() })
+ }
+ />
+ );
};
export const UpdateCard: FC<{
state: Loadable;
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 (
{m.update_title()}
- {s?.available && {m.update_available_badge()}}
+ {s?.available && !inFlight && (
+ {m.update_available_badge()}
+ )}
{s && (
@@ -94,11 +150,21 @@ export const UpdateCard: FC<{
/>
- {s.available ? (
-
+ );
+};
+
+/** Durable outcome of the last apply (written by the host across its own restart). */
+const LastResult: FC<{
+ result: NonNullable;
+}> = ({ result }) =>
+ result.ok ? (
+
+ {m.update_result_ok({ from: result.from, to: result.to })}
+
+ ) : (
+
+
+ {m.update_result_failed({ to: result.to, stage: result.stage ?? "?" })}
+