diff --git a/api/openapi.json b/api/openapi.json index e9fcc32f..78aa3637 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -3433,6 +3433,58 @@ } } }, + "/api/v1/update/apply": { + "post": { + "tags": [ + "update" + ], + "summary": "Apply the available update", + "description": "Starts the one-click apply for install kinds that support it (Windows installer). The\nrequest carries no version or URL — the host installs exactly what its verified manifest\nannounced. Progress is polled via `GET /update/status` (`job`); the host restarts as part\nof the apply, and the outcome lands in `last_result` after it comes back.", + "operationId": "applyUpdate", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApplyRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "description": "Apply started — poll `GET /update/status`", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "409": { + "description": "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/update/check": { "post": { "tags": [ @@ -3848,6 +3900,15 @@ } } }, + "ApplyRequest": { + "type": "object", + "properties": { + "force": { + "type": "boolean", + "description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)." + } + } + }, "ApprovePending": { "type": "object", "description": "Approve-pending-device request body. Send `{}` to keep the device's own name.", @@ -4913,6 +4974,29 @@ } } }, + { + "type": "object", + "description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply.", + "required": [ + "from", + "to", + "kind" + ], + "properties": { + "from": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "update.applied" + ] + }, + "to": { + "type": "string" + } + } + }, { "type": "object", "required": [ @@ -7155,6 +7239,44 @@ } } }, + "UpdateJobInfo": { + "type": "object", + "description": "A running apply job (or a spawned installer that hasn't resolved yet).", + "required": [ + "target_version", + "stage", + "received_bytes", + "started_unix" + ], + "properties": { + "received_bytes": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "stage": { + "type": "string", + "description": "`downloading` | `verifying` | `applying` | `restarting`." + }, + "started_unix": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "target_version": { + "type": "string", + "description": "The version being installed." + }, + "total_bytes": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "minimum": 0 + } + } + }, "UpdateManifestInfo": { "type": "object", "description": "One channel's manifest facts, as much as the console renders.", @@ -7190,6 +7312,52 @@ } } }, + "UpdateResultInfo": { + "type": "object", + "description": "Durable outcome of the most recent apply attempt (survives the host's own restart).", + "required": [ + "ok", + "from", + "to", + "finished_unix" + ], + "properties": { + "error": { + "type": [ + "string", + "null" + ] + }, + "finished_unix": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "from": { + "type": "string" + }, + "log_path": { + "type": [ + "string", + "null" + ], + "description": "The installer's own log file on this host, for diagnosis." + }, + "ok": { + "type": "boolean" + }, + "stage": { + "type": [ + "string", + "null" + ], + "description": "The stage that failed; absent on success." + }, + "to": { + "type": "string" + } + } + }, "UpdateStatus": { "type": "object", "description": "The full update-check state for this host.", @@ -7231,6 +7399,17 @@ "type": "string", "description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`." }, + "job": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UpdateJobInfo", + "description": "The apply in flight, if any." + } + ] + }, "last_checked_unix": { "type": [ "integer", @@ -7247,6 +7426,17 @@ ], "description": "Why the last check failed, verbatim, if it did." }, + "last_result": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UpdateResultInfo", + "description": "Outcome of the most recent apply attempt." + } + ] + }, "manifest": { "oneOf": [ { diff --git a/crates/punktfunk-host/Cargo.toml b/crates/punktfunk-host/Cargo.toml index 45e7d97d..b4ff8d4e 100644 --- a/crates/punktfunk-host/Cargo.toml +++ b/crates/punktfunk-host/Cargo.toml @@ -198,6 +198,14 @@ usbip-sim = { path = "vendor/usbip-sim" } windows = { version = "0.62", features = [ "Win32_Foundation", "Win32_Security", + # WinVerifyTrust + WTHelper* — Authenticode verification of a downloaded host installer + # before the update apply spawns it (update/windows.rs). The WTHelper accessors are gated + # behind the Catalog+Sip features in windows-rs, hence the extra two. + "Win32_Security_WinTrust", + "Win32_Security_Cryptography_Catalog", + "Win32_Security_Cryptography_Sip", + # CERT_CONTEXT for the signing-leaf fingerprint pin (same file). + "Win32_Security_Cryptography", # ConvertStringSecurityDescriptorToSecurityDescriptorW — the SDDL on the virtual-DualSense # shared-memory section (inject/dualsense_windows.rs) so the UMDF host can open it. "Win32_Security_Authorization", diff --git a/crates/punktfunk-host/src/events.rs b/crates/punktfunk-host/src/events.rs index c7ebd0b7..c8b7a38b 100644 --- a/crates/punktfunk-host/src/events.rs +++ b/crates/punktfunk-host/src/events.rs @@ -215,6 +215,10 @@ pub enum EventKind { /// tray render the right "how to update" hint without a second call. install_kind: String, }, + /// A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's + /// first start after a successful apply. + #[serde(rename = "update.applied")] + UpdateApplied { from: String, to: String }, #[serde(rename = "plugins.changed")] PluginsChanged { /// The plugin whose registration changed (registered, restarted, deregistered, or @@ -256,6 +260,7 @@ impl EventKind { EventKind::DisplayReleased { .. } => "display.released", EventKind::LibraryChanged { .. } => "library.changed", EventKind::UpdateAvailable { .. } => "update.available", + EventKind::UpdateApplied { .. } => "update.applied", EventKind::PluginsChanged { .. } => "plugins.changed", EventKind::StoreChanged => "store.changed", EventKind::HostStarted { .. } => "host.started", diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 171cbf98..08cc6844 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -104,6 +104,10 @@ pub async fn run( stats: Arc, gamestream_enabled: bool, ) -> Result<()> { + // Close out any update-intent record a previous apply left behind (the update reports its + // own outcome across its own restart — update/jobs.rs). Once per boot, before serving. + crate::update::reconcile_at_boot(); + // The mgmt API is HTTPS + token-authenticated ALWAYS (even on loopback): `parse_serve` // guarantees a token (CLI flag / env / persisted ~/.config/punktfunk/mgmt-token / generated). // A blank token is treated as none — fail loudly rather than ever serve unauthenticated. @@ -260,7 +264,8 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(store::put_source, store::delete_source)) .routes(routes!(store::get_runtime, store::set_runtime)) .routes(routes!(update::get_update_status)) - .routes(routes!(update::force_update_check)), + .routes(routes!(update::force_update_check)) + .routes(routes!(update::apply_update)), ) .split_for_parts() } diff --git a/crates/punktfunk-host/src/mgmt/update.rs b/crates/punktfunk-host/src/mgmt/update.rs index ce2dc31b..5d06b7dc 100644 --- a/crates/punktfunk-host/src/mgmt/update.rs +++ b/crates/punktfunk-host/src/mgmt/update.rs @@ -24,6 +24,36 @@ pub(crate) struct UpdateManifestInfo { pub stale: bool, } +/// A running apply job (or a spawned installer that hasn't resolved yet). +#[derive(Serialize, Deserialize, ToSchema)] +pub(crate) struct UpdateJobInfo { + /// The version being installed. + pub target_version: String, + /// `downloading` | `verifying` | `applying` | `restarting`. + pub stage: String, + pub received_bytes: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total_bytes: Option, + pub started_unix: u64, +} + +/// Durable outcome of the most recent apply attempt (survives the host's own restart). +#[derive(Serialize, Deserialize, ToSchema)] +pub(crate) struct UpdateResultInfo { + pub ok: bool, + pub from: String, + pub to: String, + pub finished_unix: u64, + /// The stage that failed; absent on success. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// The installer's own log file on this host, for diagnosis. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_path: Option, +} + /// The full update-check state for this host. #[derive(Serialize, Deserialize, ToSchema)] pub(crate) struct UpdateStatus { @@ -50,6 +80,12 @@ pub(crate) struct UpdateStatus { pub last_checked_unix: Option, /// Why the last check failed, verbatim, if it did. pub last_error: Option, + /// The apply in flight, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub job: Option, + /// Outcome of the most recent apply attempt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_result: Option, } fn status_from(snap: update::Snapshot) -> UpdateStatus { @@ -61,11 +97,32 @@ fn status_from(snap: update::Snapshot) -> UpdateStatus { .as_ref() .map(|c| detect::is_newer(&c.manifest.version, c.manifest.ci_run, current, channel)) .unwrap_or(false); + // A spawned installer that hasn't resolved shows as a `restarting` job even though the + // in-process job died with the previous host (the console poller needs continuity here). + let job = snap + .job + .as_ref() + .map(|j| UpdateJobInfo { + target_version: j.target_version.clone(), + stage: j.stage.into(), + received_bytes: j.received_bytes, + total_bytes: j.total_bytes, + started_unix: j.started_unix, + }) + .or_else(|| { + snap.applying_from_intent().map(|i| UpdateJobInfo { + target_version: i.to, + stage: "restarting".into(), + received_bytes: 0, + total_bytes: None, + started_unix: i.started_unix, + }) + }); UpdateStatus { install_kind: kind.as_str().into(), channel: channel.as_str().into(), current_version: current.into(), - apply: "notify".into(), + apply: update::apply_support().into(), channel_hint: detect::channel_hint(kind).into(), check_disabled: update::check_disabled(), available, @@ -78,6 +135,16 @@ fn status_from(snap: update::Snapshot) -> UpdateStatus { }), last_checked_unix: snap.checked.as_ref().map(|c| c.fetched_unix), last_error: snap.last_error, + job, + last_result: snap.last_result.as_ref().map(|r| UpdateResultInfo { + ok: r.ok, + from: r.from.clone(), + to: r.to.clone(), + finished_unix: r.finished_unix, + stage: r.stage.clone(), + error: r.error.clone(), + log_path: r.log_path.clone(), + }), } } @@ -129,3 +196,67 @@ pub(crate) async fn force_update_check() -> Response { ), } } + +#[derive(Default, Deserialize, ToSchema)] +pub(crate) struct ApplyRequest { + /// Proceed even while a streaming session is live (the stream will drop when the host + /// restarts — the console warns before sending this). + #[serde(default)] + pub force: bool, +} + +/// Apply the available update +/// +/// Starts the one-click apply for install kinds that support it (Windows installer). The +/// request carries no version or URL — the host installs exactly what its verified manifest +/// announced. Progress is polled via `GET /update/status` (`job`); the host restarts as part +/// of the apply, and the outcome lands in `last_result` after it comes back. +#[utoipa::path( + post, + path = "/update/apply", + tag = "update", + operation_id = "applyUpdate", + request_body = ApplyRequest, + responses( + (status = ACCEPTED, description = "Apply started — poll `GET /update/status`", body = UpdateStatus), + (status = CONFLICT, description = "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn apply_update( + State(st): State>, + // The body is required (send `{}` for defaults) — axum has no optional-body extractor and + // the console always posts JSON here anyway. + ApiJson(req): ApiJson, +) -> Response { + // Same session-liveness composition as `get_status`: either plane counts. + let session_active = st.app.streaming.load(std::sync::atomic::Ordering::SeqCst) + || !crate::session_status::snapshot().is_empty(); + + match update::start_apply(req.force, session_active) { + Ok(()) => { + let snap = update::snapshot_and_maybe_refresh(); + (StatusCode::ACCEPTED, Json(status_from(snap))).into_response() + } + Err(update::ApplyError::Unsupported) => api_error( + StatusCode::CONFLICT, + "this install kind has no one-click apply — use the update command shown in status", + ), + Err(update::ApplyError::Disabled) => api_error( + StatusCode::CONFLICT, + "one-click apply is disabled on this host (PUNKTFUNK_UPDATE_APPLY=0)", + ), + Err(update::ApplyError::JobRunning) => api_error( + StatusCode::CONFLICT, + "an update is already being applied — poll GET /update/status", + ), + Err(update::ApplyError::SessionActive) => api_error( + StatusCode::CONFLICT, + "a streaming session is active — pass {\"force\": true} to update anyway (the stream will drop)", + ), + Err(update::ApplyError::NothingToApply) => api_error( + StatusCode::CONFLICT, + "no newer release is known for this channel — run a check first", + ), + } +} diff --git a/crates/punktfunk-host/src/update.rs b/crates/punktfunk-host/src/update.rs index 0005846a..ed1d0023 100644 --- a/crates/punktfunk-host/src/update.rs +++ b/crates/punktfunk-host/src/update.rs @@ -17,7 +17,10 @@ //! status then reports `check_disabled` and carries whatever identity facts need no network. pub(crate) mod detect; +pub(crate) mod jobs; pub(crate) mod manifest; +#[cfg(target_os = "windows")] +mod windows; use crate::store::index::PublicKey; use manifest::Manifest; @@ -59,6 +62,26 @@ pub(crate) fn check_disabled() -> bool { ) } +/// One-click apply disabled by operator config — the host-side kill switch (design §4.2): the +/// apply route 409s and status reports `notify` even on kinds an apply leg exists for. The +/// check surface is unaffected. +pub(crate) fn apply_disabled() -> bool { + matches!( + std::env::var("PUNKTFUNK_UPDATE_APPLY").as_deref(), + Ok("0") | Ok("false") | Ok("off") + ) +} + +/// What the console may offer for this install: `full` (one-click apply exists and is enabled) +/// or `notify` (show the command). `staged` joins with the rpm-ostree leg (U2). +pub(crate) fn apply_support() -> &'static str { + let (kind, _) = detect::detect(); + match kind { + detect::InstallKind::WindowsInstaller if !apply_disabled() => "full", + _ => "notify", + } +} + fn feed_base() -> String { std::env::var("PUNKTFUNK_UPDATE_FEED") .ok() @@ -96,6 +119,8 @@ struct Runtime { /// The manifest version an `update.available` event was already emitted for, so a /// steady-state "newer exists" doesn't re-announce every 6 h. announced: Option, + /// The live apply job, when one is running (single-flight). + job: Option, } fn runtime() -> &'static Mutex { @@ -277,6 +302,8 @@ pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot { Snapshot { checked: rt.checked.clone(), last_error: rt.last_error.clone(), + job: rt.job.clone(), + last_result: jobs::read_result(&jobs::result_path()), } }; if kick { @@ -308,6 +335,8 @@ pub(crate) async fn force_check() -> Result { Ok(Snapshot { checked: rt.checked.clone(), last_error: rt.last_error.clone(), + job: rt.job.clone(), + last_result: jobs::read_result(&jobs::result_path()), }) } @@ -316,13 +345,188 @@ pub(crate) enum ForceError { TooSoon, } +// ---------------------------------------------------------------- apply (U1: Windows) + +/// Why an apply request was refused (mapped to 409s by the API layer). +pub(crate) enum ApplyError { + /// This install kind has no one-click leg (or the operator kill switch is on) — the + /// console shows the command instead. + Unsupported, + /// `PUNKTFUNK_UPDATE_APPLY=0`. + Disabled, + /// An apply is already running (or a spawned installer hasn't resolved yet). + JobRunning, + /// A stream is live and the request didn't say `force`. + SessionActive, + /// No verified manifest announcing something newer (or it lacks the Windows asset). + NothingToApply, +} + +/// Start the (Windows) apply pipeline. The request carries **no version, url, or channel** — +/// everything comes from the verified cached manifest (invariant §0.3 of the design). +pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), ApplyError> { + if apply_disabled() { + return Err(ApplyError::Disabled); + } + let (kind, channel) = detect::detect(); + if kind != detect::InstallKind::WindowsInstaller { + return Err(ApplyError::Unsupported); + } + if session_active && !force { + return Err(ApplyError::SessionActive); + } + + let (target_version, serial, asset) = { + let mut rt = runtime().lock().unwrap(); + if rt.job.is_some() { + return Err(ApplyError::JobRunning); + } + // A spawned installer that hasn't resolved (fresh intent, old version) is still an + // apply in flight — reconcile owns it; don't start a second one under it. + if matches!( + jobs::reconcile( + jobs::read_intent(&jobs::intent_path()), + env!("PUNKTFUNK_VERSION"), + now_unix() + ), + jobs::Reconciled::StillApplying + ) { + return Err(ApplyError::JobRunning); + } + let Some(checked) = rt.checked.as_ref() else { + return Err(ApplyError::NothingToApply); + }; + let newer = detect::is_newer( + &checked.manifest.version, + checked.manifest.ci_run, + env!("PUNKTFUNK_VERSION"), + channel, + ); + let Some(asset) = checked.manifest.windows_host.clone() else { + return Err(ApplyError::NothingToApply); + }; + if !newer { + return Err(ApplyError::NothingToApply); + } + let version = checked.manifest.version.clone(); + let serial = checked.manifest.serial; + rt.job = Some(jobs::JobSnapshot { + target_version: version.clone(), + stage: "downloading", + received_bytes: 0, + total_bytes: None, + started_unix: now_unix(), + }); + (version, serial, asset) + }; + + #[cfg(target_os = "windows")] + { + tokio::task::spawn_blocking(move || { + let progress = |received: u64, total: Option| { + let mut rt = runtime().lock().unwrap(); + if let Some(job) = rt.job.as_mut() { + job.received_bytes = received; + job.total_bytes = total; + } + }; + let stage = |s: &'static str| { + let mut rt = runtime().lock().unwrap(); + if let Some(job) = rt.job.as_mut() { + job.stage = s; + } + }; + match windows::run_apply(&asset, &target_version, serial, &progress, &stage) { + Ok(()) => { + // Stage stays `restarting`; the installer is about to stop the service and + // kill this process. Boot reconciliation writes the durable outcome. + } + Err((stage_name, error)) => { + let record = jobs::ResultRecord { + ok: false, + from: env!("PUNKTFUNK_VERSION").into(), + to: target_version.clone(), + finished_unix: now_unix(), + stage: Some(stage_name.into()), + error: Some(error), + log_path: None, + }; + let _ = jobs::write_json_atomic(&jobs::result_path(), &record); + runtime().lock().unwrap().job = None; + } + } + }); + Ok(()) + } + #[cfg(not(target_os = "windows"))] + { + // Unreachable in practice (`detect` never yields WindowsInstaller off-Windows); undo + // the reservation defensively rather than leaking a stuck job. + let _ = (target_version, serial, asset); + runtime().lock().unwrap().job = None; + Err(ApplyError::Unsupported) + } +} + +/// Boot-time reconciliation (design §4.2): close out an intent record left by a previous +/// apply. Called once from `mgmt::run` before the API serves. +pub(crate) fn reconcile_at_boot() { + let path = jobs::intent_path(); + match jobs::reconcile( + jobs::read_intent(&path), + env!("PUNKTFUNK_VERSION"), + now_unix(), + ) { + jobs::Reconciled::None | jobs::Reconciled::StillApplying => {} + jobs::Reconciled::Success(record) => { + tracing::info!(from = %record.from, to = %record.to, "host update applied"); + let _ = jobs::write_json_atomic(&jobs::result_path(), &record); + let _ = std::fs::remove_file(&path); + crate::events::emit(crate::events::EventKind::UpdateApplied { + from: record.from, + to: record.to, + }); + } + jobs::Reconciled::Failed(record) => { + tracing::warn!( + from = %record.from, + to = %record.to, + error = record.error.as_deref().unwrap_or(""), + "host update did NOT stick" + ); + let _ = jobs::write_json_atomic(&jobs::result_path(), &record); + let _ = std::fs::remove_file(&path); + } + } +} + /// What status hands to the API layer. pub(crate) struct Snapshot { pub checked: Option, pub last_error: Option, + /// The live apply job, when one runs. When the process was restarted mid-apply this is + /// `None` but a fresh intent still reads as in-flight — the API layer surfaces that via + /// [`Snapshot::applying_from_intent`]. + pub job: Option, + /// Durable outcome of the most recent apply attempt. + pub last_result: Option, } impl Snapshot { + /// An apply is in flight even though no in-process job exists: a fresh intent record from + /// a spawn that hasn't resolved (this process may be the OLD host in its last seconds, or + /// a restarted host inside the grace window). The API surfaces it as a `restarting` job. + pub(crate) fn applying_from_intent(&self) -> Option { + if self.job.is_some() { + return None; + } + let intent = jobs::read_intent(&jobs::intent_path())?; + match jobs::reconcile(Some(intent.clone()), env!("PUNKTFUNK_VERSION"), now_unix()) { + jobs::Reconciled::StillApplying => Some(intent), + _ => None, + } + } + /// The stale-feed hint: last successful check is fine but the manifest itself was /// published suspiciously long ago (freeze detection, design §3.2). pub(crate) fn stale(&self) -> bool { @@ -394,6 +598,8 @@ mod tests { fetched_unix: now_unix(), }), last_error: None, + job: None, + last_result: None, }; assert!(!mk(now_unix()).stale()); assert!(mk(now_unix() - STALE_AFTER.as_secs() - 10).stale()); diff --git a/crates/punktfunk-host/src/update/jobs.rs b/crates/punktfunk-host/src/update/jobs.rs new file mode 100644 index 00000000..e33b12f2 --- /dev/null +++ b/crates/punktfunk-host/src/update/jobs.rs @@ -0,0 +1,220 @@ +//! Apply-job bookkeeping: the in-memory job snapshot the console polls, and the two on-disk +//! records that let an update **report its own outcome across its own restart** (design §4.2, +//! plan U1.1): +//! +//! - the **intent record** (`update-intent.json`) — written just before anything irreversible +//! (spawning the installer). It is the only witness once the installer kills this process. +//! - the **result record** (`update-result.json`) — the durable outcome of the *last* apply, +//! written either by the failing stage itself or by boot-time reconciliation. +//! +//! Reconciliation ([`reconcile`]) runs once per boot: intent + running-the-target-version ⇒ +//! success; intent + still the old version after the grace window ⇒ failure with the installer +//! log path attached; a fresh intent ⇒ the apply is still in flight (the installer may not have +//! stopped us yet). Pure over its inputs so every branch is table-testable. + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// How long after intent-write we still call the apply "in flight" when the running version is +/// unchanged. Beyond it, the host restarting *without* the new version is a failed apply +/// (installer aborted, rolled back, or never ran) — surfaced, never silent. +pub(crate) const APPLY_GRACE_SECS: u64 = 10 * 60; + +/// Written immediately before the point of no return. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(crate) struct IntentRecord { + /// The version that started the apply (us, at write time). + pub from: String, + /// The version being installed. + pub to: String, + /// The manifest serial that announced it. + pub serial: u64, + /// Unix seconds at write. + pub started_unix: u64, + /// SHA-256 of the verified installer (diagnostics — ties a result to exact bytes). + pub installer_sha256: String, + /// Where the installer was told to log (`/LOG=`). + pub log_path: String, +} + +/// The durable outcome of the most recent apply attempt. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct ResultRecord { + pub ok: bool, + pub from: String, + pub to: String, + pub finished_unix: u64, + /// The stage that failed (`downloading` | `verifying` | `applying` | `restarting`); + /// absent on success. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stage: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + /// The installer log, when one was in play by the time it failed (or succeeded). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_path: Option, +} + +/// The live job the console polls, mirrored into `GET /update/status`. +#[derive(Debug, Clone)] +pub(crate) struct JobSnapshot { + pub target_version: String, + /// `downloading` | `verifying` | `applying` | `restarting`. + pub stage: &'static str, + pub received_bytes: u64, + pub total_bytes: Option, + pub started_unix: u64, +} + +pub(crate) fn intent_path() -> PathBuf { + pf_paths::config_dir().join("update-intent.json") +} + +pub(crate) fn result_path() -> PathBuf { + pf_paths::config_dir().join("update-result.json") +} + +pub(crate) fn read_intent(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + // An unparseable intent (half-written before atomic-rename existed, disk fault) must not + // crash reconcile — it reads as "no intent" and the stale file is swept. + serde_json::from_slice(&bytes).ok() +} + +pub(crate) fn read_result(path: &Path) -> Option { + let bytes = std::fs::read(path).ok()?; + serde_json::from_slice(&bytes).ok() +} + +/// Atomic tmp+rename JSON write (the intent/result records must never be half-written — R12). +pub(crate) fn write_json_atomic(path: &Path, value: &T) -> std::io::Result<()> { + let bytes = serde_json::to_vec_pretty(value) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, &bytes)?; + std::fs::rename(&tmp, path) +} + +/// What boot-time reconciliation decided. +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Reconciled { + /// No intent on disk — nothing to close out. + None, + /// Intent is fresh and we still run the old version: the installer likely hasn't stopped + /// us yet (or is mid-copy). Leave the intent in place; status shows the apply in flight. + StillApplying, + /// We came back at the target version. + Success(ResultRecord), + /// We came back at the wrong version after the grace window. + Failed(ResultRecord), +} + +/// Pure reconcile: current version + wall clock vs. the intent record. +pub(crate) fn reconcile( + intent: Option, + current_version: &str, + now_unix: u64, +) -> Reconciled { + let Some(intent) = intent else { + return Reconciled::None; + }; + if current_version == intent.to { + return Reconciled::Success(ResultRecord { + ok: true, + from: intent.from, + to: intent.to, + finished_unix: now_unix, + stage: None, + error: None, + log_path: Some(intent.log_path), + }); + } + if now_unix.saturating_sub(intent.started_unix) < APPLY_GRACE_SECS { + return Reconciled::StillApplying; + } + Reconciled::Failed(ResultRecord { + ok: false, + from: intent.from.clone(), + to: intent.to.clone(), + finished_unix: now_unix, + stage: Some("restarting".into()), + error: Some(format!( + "the host restarted still running {} (expected {}) — the installer aborted or \ + rolled back; see its log", + intent.from, intent.to + )), + log_path: Some(intent.log_path), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn intent(started: u64) -> IntentRecord { + IntentRecord { + from: "0.23.100".into(), + to: "0.23.200".into(), + serial: 42, + started_unix: started, + installer_sha256: "ab".repeat(32), + log_path: "/logs/update-0.23.200.log".into(), + } + } + + #[test] + fn no_intent_is_none() { + assert_eq!(reconcile(None, "0.23.100", 1000), Reconciled::None); + } + + #[test] + fn target_version_is_success_regardless_of_age() { + for now in [1, 10_000_000] { + match reconcile(Some(intent(0)), "0.23.200", now) { + Reconciled::Success(r) => { + assert!(r.ok); + assert_eq!(r.to, "0.23.200"); + assert_eq!(r.log_path.as_deref(), Some("/logs/update-0.23.200.log")); + } + other => panic!("expected success, got {other:?}"), + } + } + } + + #[test] + fn fresh_intent_old_version_is_still_applying() { + assert_eq!( + reconcile(Some(intent(1000)), "0.23.100", 1000 + APPLY_GRACE_SECS - 1), + Reconciled::StillApplying + ); + } + + #[test] + fn stale_intent_old_version_is_failure_with_log() { + match reconcile(Some(intent(1000)), "0.23.100", 1000 + APPLY_GRACE_SECS) { + Reconciled::Failed(r) => { + assert!(!r.ok); + assert_eq!(r.stage.as_deref(), Some("restarting")); + assert!(r.error.as_deref().unwrap().contains("0.23.200")); + assert!(r.log_path.is_some()); + } + other => panic!("expected failure, got {other:?}"), + } + } + + #[test] + fn intent_and_result_roundtrip_atomically() { + let dir = std::env::temp_dir().join(format!("pf-update-jobs-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let ip = dir.join("update-intent.json"); + write_json_atomic(&ip, &intent(7)).unwrap(); + assert_eq!(read_intent(&ip).unwrap().started_unix, 7); + // Corrupt bytes read as "no intent", never a crash. + std::fs::write(&ip, b"{half").unwrap(); + assert!(read_intent(&ip).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/punktfunk-host/src/update/windows.rs b/crates/punktfunk-host/src/update/windows.rs new file mode 100644 index 00000000..daecca16 --- /dev/null +++ b/crates/punktfunk-host/src/update/windows.rs @@ -0,0 +1,380 @@ +//! The Windows apply leg (design §6, plan U1.2/U1.3): download the manifest's immutable +//! per-version installer, verify it (manifest SHA-256, then Authenticode), persist the intent +//! record, and spawn the installer detached — which stops the service and thereby kills this +//! process *by design*; boot-time reconciliation (`jobs::reconcile`) closes the loop. +//! +//! Verification order and rules: +//! 1. **SHA-256 == the signed manifest's** — the primary integrity gate (the manifest is the +//! Ed25519-verified document; this check makes the downloaded bytes those exact bytes). +//! 2. **Authenticode**: the embedded signature must be cryptographically valid, tolerating +//! `CERT_E_UNTRUSTEDROOT` while the shipping cert is self-signed (`CN=unom`); when the +//! manifest carries leaf pins, the signing leaf's SHA-256 must match one. The leaf is taken +//! from the SAME `WinVerifyTrust` state (`WTHelperGetProvSignerFromChain`), never a second +//! parse — no verify-vs-inspect gap. An empty pin list skips only the pin comparison (the +//! manifest hash already binds content; pins arrive via `AUTHENTICODE_SHA256` in CI once +//! the cert story settles — the field exists so Trusted Signing is a manifest edit). +//! +//! The spawn uses `CREATE_BREAKAWAY_FROM_JOB`: the service worker's job object is kill-on-close +//! (a stopping service would otherwise take the installer down with it) and was created +//! breakaway-ok for exactly this shape (`windows/service.rs`). Failure to break away is a hard, +//! reported error (plan R3), never a silent fallback. + +#![cfg(target_os = "windows")] + +use super::jobs::{self, IntentRecord}; +use super::manifest::WindowsHostAsset; +use std::io::{Read, Seek, Write}; +use std::path::{Path, PathBuf}; + +/// Free-space preflight: require this multiple of the download size (installer + Inno's +/// unpack scratch + headroom). +const DISK_MARGIN: u64 = 3; + +/// Keep the target + this many previous installers cached for the manual-rollback path. +const KEEP_INSTALLERS: usize = 2; + +const CREATE_BREAKAWAY_FROM_JOB: u32 = 0x0100_0000; +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// The winget-blessed silent flags (`packaging/winget/unom.PunktfunkHost.installer.yaml`). +const SILENT_ARGS: [&str; 4] = ["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART", "/SP-"]; + +fn staging_dir() -> PathBuf { + pf_paths::config_dir().join("updates") +} + +fn log_path(version: &str) -> PathBuf { + pf_paths::config_dir() + .join("logs") + .join(format!("update-{version}.log")) +} + +/// The whole pipeline, run on a blocking thread. Reports progress/stage through the callbacks +/// so this file stays free of the runtime-state lock. +pub(super) fn run_apply( + asset: &WindowsHostAsset, + target_version: &str, + serial: u64, + progress: &dyn Fn(u64, Option), + stage: &dyn Fn(&'static str), +) -> Result<(), (&'static str, String)> { + let dir = staging_dir(); + std::fs::create_dir_all(&dir) + .map_err(|e| ("downloading", format!("create staging dir: {e}")))?; + + let final_path = dir.join(format!("punktfunk-host-setup-{target_version}.exe")); + let part_path = dir.join(format!("punktfunk-host-setup-{target_version}.exe.part")); + + download(&asset.url, &part_path, progress).map_err(|e| ("downloading", e))?; + + stage("verifying"); + verify_sha256(&part_path, &asset.sha256).map_err(|e| { + quarantine(&part_path); + ("verifying", e) + })?; + verify_authenticode(&part_path, &asset.authenticode_sha256).map_err(|e| { + quarantine(&part_path); + ("verifying", e) + })?; + std::fs::rename(&part_path, &final_path) + .map_err(|e| ("verifying", format!("stage rename: {e}")))?; + prune_installers(&dir, &final_path); + + stage("applying"); + let log = log_path(target_version); + if let Some(parent) = log.parent() { + let _ = std::fs::create_dir_all(parent); + } + // The point of no return: after this record exists, boot reconciliation owns the outcome. + jobs::write_json_atomic( + &jobs::intent_path(), + &IntentRecord { + from: env!("PUNKTFUNK_VERSION").into(), + to: target_version.into(), + serial, + started_unix: super::now_unix(), + installer_sha256: asset.sha256.to_ascii_lowercase(), + log_path: log.display().to_string(), + }, + ) + .map_err(|e| ("applying", format!("write intent record: {e}")))?; + + // Let the 202 + the console's next status poll leave the box before the installer starts + // stopping the service under us (plan R4). + std::thread::sleep(std::time::Duration::from_secs(2)); + + let spawned = { + use std::os::windows::process::CommandExt as _; + std::process::Command::new(&final_path) + .args(SILENT_ARGS) + .arg(format!("/LOG={}", log.display())) + .current_dir(&dir) + .creation_flags(CREATE_BREAKAWAY_FROM_JOB | CREATE_NO_WINDOW) + .spawn() + }; + match spawned { + Ok(child) => { + // Detached on purpose: the child must outlive us. Dropping a Child does not kill it. + drop(child); + stage("restarting"); + Ok(()) + } + Err(e) => { + // Most likely: the job object stopped allowing breakaway (R3). Clear the intent — + // nothing irreversible happened — and surface the real error. + let _ = std::fs::remove_file(jobs::intent_path()); + Err(( + "applying", + format!( + "spawn installer (CREATE_BREAKAWAY_FROM_JOB — if this is ACCESS_DENIED, \ + the service job object no longer permits breakaway): {e}" + ), + )) + } + } +} + +fn quarantine(part: &Path) { + let bad = part.with_extension("bad"); + let _ = std::fs::remove_file(&bad); + let _ = std::fs::rename(part, &bad); +} + +/// Download `url` to `part`, resuming an existing partial file when the server honors Range. +fn download(url: &str, part: &Path, progress: &dyn Fn(u64, Option)) -> Result<(), String> { + if !url.starts_with("https://") { + return Err("installer url must be https".into()); + } + let agent = ureq::AgentBuilder::new() + .timeout_connect(std::time::Duration::from_secs(15)) + .redirects(3) + .user_agent(&format!( + "punktfunk-host/{} (update-apply)", + env!("PUNKTFUNK_VERSION") + )) + .build(); + + let existing = std::fs::metadata(part).map(|m| m.len()).unwrap_or(0); + let mut req = agent.get(url); + if existing > 0 { + req = req.set("Range", &format!("bytes={existing}-")); + } + let resp = req.call().map_err(|e| match e { + ureq::Error::Status(code, _) => format!("download returned HTTP {code}"), + other => format!("download failed: {other}"), + })?; + + let resumed = resp.status() == 206; + let content_len: Option = resp.header("content-length").and_then(|v| v.parse().ok()); + let total = content_len.map(|l| if resumed { existing + l } else { l }); + if let Some(t) = total { + preflight_disk(part, t.saturating_mul(DISK_MARGIN))?; + } + + let mut file = std::fs::OpenOptions::new() + .create(true) + .write(true) + .open(part) + .map_err(|e| format!("open staging file: {e}"))?; + let mut received = if resumed { + file.seek(std::io::SeekFrom::End(0)) + .map_err(|e| format!("seek: {e}"))? + } else { + file.set_len(0).map_err(|e| format!("truncate: {e}"))?; + 0 + }; + progress(received, total); + + let mut reader = resp.into_reader(); + let mut buf = [0u8; 64 * 1024]; + loop { + let n = reader.read(&mut buf).map_err(|e| format!("read: {e}"))?; + if n == 0 { + break; + } + file.write_all(&buf[..n]) + .map_err(|e| format!("write: {e}"))?; + received += n as u64; + progress(received, total); + } + file.sync_all().map_err(|e| format!("fsync: {e}"))?; + if let Some(t) = total { + if received != t { + return Err(format!("download truncated: {received} of {t} bytes")); + } + } + Ok(()) +} + +fn verify_sha256(path: &Path, expected_hex: &str) -> Result<(), String> { + let mut file = std::fs::File::open(path).map_err(|e| format!("open for hashing: {e}"))?; + let mut ctx = ring::digest::Context::new(&ring::digest::SHA256); + let mut buf = [0u8; 128 * 1024]; + loop { + let n = file + .read(&mut buf) + .map_err(|e| format!("read for hashing: {e}"))?; + if n == 0 { + break; + } + ctx.update(&buf[..n]); + } + let got = hex(ctx.finish().as_ref()); + if got != expected_hex.to_ascii_lowercase() { + return Err(format!( + "installer sha256 mismatch: got {got}, manifest says {expected_hex}" + )); + } + Ok(()) +} + +fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +fn preflight_disk(at: &Path, needed: u64) -> Result<(), String> { + use windows::core::HSTRING; + use windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + let dir = at.parent().unwrap_or(at); + let mut free: u64 = 0; + unsafe { GetDiskFreeSpaceExW(&HSTRING::from(dir.as_os_str()), Some(&mut free), None, None) } + .map_err(|e| format!("disk preflight: {e}"))?; + if free < needed { + return Err(format!( + "not enough disk space for the update: {free} bytes free, {needed} needed" + )); + } + Ok(()) +} + +/// Authenticode: valid embedded signature (untrusted root tolerated — self-signed `CN=unom`), +/// signing-leaf SHA-256 ∈ `pins` when pins are present. The leaf comes out of the same +/// `WinVerifyTrust` state via `WTHelperGetProvSignerFromChain`. +fn verify_authenticode(path: &Path, pins: &[String]) -> Result<(), String> { + use windows::core::{GUID, PCWSTR}; + use windows::Win32::Foundation::{CERT_E_UNTRUSTEDROOT, S_OK}; + use windows::Win32::Security::WinTrust::{ + WTHelperGetProvSignerFromChain, WTHelperProvDataFromStateData, WinVerifyTrust, + WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_DATA_0, WINTRUST_FILE_INFO, + WTD_CHOICE_FILE, WTD_REVOKE_NONE, WTD_STATEACTION_CLOSE, WTD_STATEACTION_VERIFY, + WTD_UI_NONE, + }; + + let wide: Vec = path + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let file_info = WINTRUST_FILE_INFO { + cbStruct: std::mem::size_of::() as u32, + pcwszFilePath: PCWSTR(wide.as_ptr()), + hFile: Default::default(), + pgKnownSubject: std::ptr::null_mut(), + }; + let mut data = WINTRUST_DATA { + cbStruct: std::mem::size_of::() as u32, + dwUIChoice: WTD_UI_NONE, + fdwRevocationChecks: WTD_REVOKE_NONE, + dwUnionChoice: WTD_CHOICE_FILE, + Anonymous: WINTRUST_DATA_0 { + pFile: &file_info as *const _ as *mut _, + }, + dwStateAction: WTD_STATEACTION_VERIFY, + ..Default::default() + }; + let mut action: GUID = WINTRUST_ACTION_GENERIC_VERIFY_V2; + + let status = unsafe { + WinVerifyTrust( + Default::default(), + &mut action, + &mut data as *mut _ as *mut core::ffi::c_void, + ) + }; + let verdict = (|| { + let ok = status == S_OK.0 || status == CERT_E_UNTRUSTEDROOT.0; + if !ok { + return Err(format!( + "installer Authenticode signature is invalid (WinVerifyTrust 0x{status:08x})" + )); + } + if pins.is_empty() { + tracing::warn!( + "update manifest carries no Authenticode leaf pins — accepting on the \ + manifest sha256 + signature validity alone" + ); + return Ok(()); + } + // Same-state leaf extraction: no second parse of the file. + let prov = unsafe { WTHelperProvDataFromStateData(data.hWVTStateData) }; + if prov.is_null() { + return Err("WinVerifyTrust returned no provider state".into()); + } + let signer = unsafe { WTHelperGetProvSignerFromChain(prov, 0, false, 0) }; + if signer.is_null() { + return Err("no signer in the Authenticode chain".into()); + } + let leaf = unsafe { + let s = &*signer; + if s.csCertChain == 0 || s.pasCertChain.is_null() { + return Err("empty Authenticode cert chain".into()); + } + // pasCertChain[0] is the SIGNING cert (leaf → root order). + &*(*s.pasCertChain).pCert + }; + let der = + unsafe { std::slice::from_raw_parts(leaf.pbCertEncoded, leaf.cbCertEncoded as usize) }; + let fp = hex(ring::digest::digest(&ring::digest::SHA256, der).as_ref()); + if !pins.iter().any(|p| p.eq_ignore_ascii_case(&fp)) { + return Err(format!( + "installer signing-leaf fingerprint {fp} matches none of the manifest's \ + {} pin(s)", + pins.len() + )); + } + Ok(()) + })(); + + // Always release the verification state. + data.dwStateAction = WTD_STATEACTION_CLOSE; + unsafe { + WinVerifyTrust( + Default::default(), + &mut action, + &mut data as *mut _ as *mut core::ffi::c_void, + ) + }; + verdict +} + +/// Keep the freshly-verified target plus the newest previous installers; sweep the rest (and +/// any stale `.part`/`.bad` from other versions). +fn prune_installers(dir: &Path, keep_newest: &Path) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + let mut exes: Vec<(std::time::SystemTime, PathBuf)> = entries + .flatten() + .map(|e| e.path()) + .filter(|p| p != keep_newest) + .filter(|p| { + p.file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("punktfunk-host-setup-")) + .unwrap_or(false) + }) + .map(|p| { + let t = p + .metadata() + .and_then(|m| m.modified()) + .unwrap_or(std::time::SystemTime::UNIX_EPOCH); + (t, p) + }) + .collect(); + exes.sort_by(|a, b| b.0.cmp(&a.0)); + for (_, p) in exes.into_iter().skip(KEEP_INSTALLERS - 1) { + let _ = std::fs::remove_file(p); + } +} + +use std::os::windows::ffi::OsStrExt as _;