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 ? ( -
-

{m.update_how()}

- -
+ {inFlight ? ( + + ) : s.available ? ( + s.apply === "full" ? ( + + ) : ( +
+

{m.update_how()}

+ +
+ ) ) : ( s.manifest && (

@@ -107,12 +173,16 @@ export const UpdateCard: FC<{ ) )} + {!inFlight && s.last_result && ( + + )} + {s.manifest?.stale && (

{m.update_stale()}

)} - {s.last_error && ( + {!inFlight && s.last_error && (

{m.update_error()} {s.last_error}

@@ -122,22 +192,24 @@ export const UpdateCard: FC<{ {m.update_disabled()}

) : ( -
- - {s.last_checked_unix != null && ( - - {m.update_last_checked()}{" "} - {new Date(s.last_checked_unix * 1000).toLocaleString()} - - )} -
+ !inFlight && ( +
+ + {s.last_checked_unix != null && ( + + {m.update_last_checked()}{" "} + {new Date(s.last_checked_unix * 1000).toLocaleString()} + + )} +
+ ) )} )} @@ -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(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 ( +
+

{m.update_apply_ready({ version: target })}

+
+ + +
+ { + setOpen(o); + if (!o) { + setPassword(""); + setError(null); + setNeedsForce(false); + } + }} + > + + + + {m.update_apply_confirm_title({ version: target })} + + + {needsForce + ? m.update_apply_force_warning() + : m.update_apply_confirm_body()} + + +
{ + e.preventDefault(); + void submit(needsForce); + }} + > +
+ + setPassword(e.target.value)} + autoComplete="current-password" + /> +
+ {error &&

{error}

} + + + +
+
+
+
+ ); +}; + +/** 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 ( +
+
+ +

+ {m.update_applying_title({ + version: job?.target_version ?? status.manifest?.version ?? "", + })} +

+
+

{stageLabel}

+ {job?.stage === "downloading" && pct !== null && ( +
+
+
+ )} + {timedOut && ( +

+ {m.update_apply_timeout()} +

+ )} +
+ ); +}; + +/** 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 ?? "?" })} +

+ {result.error &&

{result.error}

} + {result.log_path && ( +

+ {m.update_result_log()} {result.log_path} +

+ )} +
+ ); + const UpdateRow: FC<{ label: string; value: ReactNode }> = ({ label, value, @@ -172,7 +443,7 @@ const CommandLine: FC<{ command: string }> = ({ command }) => { }); }; return ( -
+
{command}