From cc015626314fc051c9afa9acdc0a22d3bcd49863 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 30 Jul 2026 14:12:08 +0200 Subject: [PATCH] =?UTF-8?q?feat(host):=20update=20check=20=E2=80=94=20sign?= =?UTF-8?q?ed=20per-channel=20manifest,=20install-kind=20detection,=20/api?= =?UTF-8?q?/v1/update=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The U0 leg of planning:host-update-from-web-console.md: a signed update manifest (Ed25519, keys pinned in the binary via the plugin-store verify path, serial floor persisted against rollback, channel-bound, 45-day stale hint) fetched lazily behind GET /update/status + rate-limited POST /update/check, admin lane only (plugin lane whole-prefix denied, absent from the cert allowlist). Install kind + channel come from root-owned facts; deb/rpm/pacman builds now stamp /usr/share/punktfunk/install-kind. Emits update.available once per discovered version. Co-Authored-By: Claude Fable 5 --- api/openapi.json | 225 +++++++++- crates/punktfunk-host/src/events.rs | 14 + crates/punktfunk-host/src/main.rs | 1 + crates/punktfunk-host/src/mgmt.rs | 6 +- crates/punktfunk-host/src/mgmt/auth.rs | 7 +- crates/punktfunk-host/src/mgmt/tests.rs | 25 ++ crates/punktfunk-host/src/mgmt/update.rs | 131 ++++++ crates/punktfunk-host/src/update.rs | 401 ++++++++++++++++++ crates/punktfunk-host/src/update/detect.rs | 420 +++++++++++++++++++ crates/punktfunk-host/src/update/manifest.rs | 245 +++++++++++ packaging/arch/PKGBUILD | 6 + packaging/debian/build-deb.sh | 10 + packaging/rpm/build-rpm.sh | 1 + packaging/rpm/punktfunk.spec | 5 + 14 files changed, 1494 insertions(+), 3 deletions(-) create mode 100644 crates/punktfunk-host/src/mgmt/update.rs create mode 100644 crates/punktfunk-host/src/update.rs create mode 100644 crates/punktfunk-host/src/update/detect.rs create mode 100644 crates/punktfunk-host/src/update/manifest.rs diff --git a/api/openapi.json b/api/openapi.json index b4a9c39f..e9fcc32f 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -10,7 +10,7 @@ "name": "MIT OR Apache-2.0", "identifier": "MIT OR Apache-2.0" }, - "version": "0.21.0" + "version": "0.22.2" }, "paths": { "/api/v1/clients": { @@ -3432,6 +3432,90 @@ } } } + }, + "/api/v1/update/check": { + "post": { + "tags": [ + "update" + ], + "summary": "Check for updates now", + "description": "Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to\none forced check per 30 s.", + "operationId": "forceUpdateCheck", + "responses": { + "200": { + "description": "Refreshed update-check state (`last_error` carries a failed check)", + "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": "Update checks are disabled on this host", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "429": { + "description": "A forced check ran less than 30 s ago", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/update/status": { + "get": { + "tags": [ + "update" + ], + "summary": "Update-check status", + "description": "How this host was installed, which channel it follows, whether a newer release is known,\nand how to update. Reading this may kick a background refresh when the cached check is\nolder than 6 h; the response never blocks on the network.", + "operationId": "getUpdateStatus", + "responses": { + "200": { + "description": "Current update-check state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateStatus" + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } } }, "components": { @@ -4799,6 +4883,36 @@ } } }, + { + "type": "object", + "description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh).", + "required": [ + "version", + "channel", + "install_kind", + "kind" + ], + "properties": { + "channel": { + "type": "string", + "description": "The channel it was announced on (`stable` | `canary`)." + }, + "install_kind": { + "type": "string", + "description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call." + }, + "kind": { + "type": "string", + "enum": [ + "update.available" + ] + }, + "version": { + "type": "string", + "description": "The newer release's version string." + } + } + }, { "type": "object", "required": [ @@ -7040,6 +7154,111 @@ "type": "string" } } + }, + "UpdateManifestInfo": { + "type": "object", + "description": "One channel's manifest facts, as much as the console renders.", + "required": [ + "version", + "serial", + "published_at", + "notes_url", + "stale" + ], + "properties": { + "notes_url": { + "type": "string", + "description": "Release-notes link (pinned to our forge by the manifest validator)." + }, + "published_at": { + "type": "string", + "description": "RFC-3339 publish time (display only)." + }, + "serial": { + "type": "integer", + "format": "int64", + "description": "Publish serial (unix seconds) — monotonic per channel.", + "minimum": 0 + }, + "stale": { + "type": "boolean", + "description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint." + }, + "version": { + "type": "string", + "description": "The released version this manifest announces." + } + } + }, + "UpdateStatus": { + "type": "object", + "description": "The full update-check state for this host.", + "required": [ + "install_kind", + "channel", + "current_version", + "apply", + "channel_hint", + "check_disabled", + "available" + ], + "properties": { + "apply": { + "type": "string", + "description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)." + }, + "available": { + "type": "boolean", + "description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)." + }, + "channel": { + "type": "string", + "description": "Release channel this install follows: `stable` | `canary`." + }, + "channel_hint": { + "type": "string", + "description": "The copy-pastable update command for this install kind." + }, + "check_disabled": { + "type": "boolean", + "description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)." + }, + "current_version": { + "type": "string", + "description": "The running host version." + }, + "install_kind": { + "type": "string", + "description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`." + }, + "last_checked_unix": { + "type": [ + "integer", + "null" + ], + "format": "int64", + "description": "When the last successful check happened (unix seconds).", + "minimum": 0 + }, + "last_error": { + "type": [ + "string", + "null" + ], + "description": "Why the last check failed, verbatim, if it did." + }, + "manifest": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/UpdateManifestInfo", + "description": "The last verified manifest, if any check has succeeded." + } + ] + } + } } }, "securitySchemes": { @@ -7110,6 +7329,10 @@ { "name": "store", "description": "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on" + }, + { + "name": "update", + "description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)" } ] } diff --git a/crates/punktfunk-host/src/events.rs b/crates/punktfunk-host/src/events.rs index a11861d9..c7ebd0b7 100644 --- a/crates/punktfunk-host/src/events.rs +++ b/crates/punktfunk-host/src/events.rs @@ -202,6 +202,19 @@ pub enum EventKind { /// API (RFC §8) lands. source: String, }, + /// A verified update manifest announced a release newer than the running host. Emitted + /// once per discovered version (a steady-state "newer exists" doesn't re-fire on every + /// refresh). + #[serde(rename = "update.available")] + UpdateAvailable { + /// The newer release's version string. + version: String, + /// The channel it was announced on (`stable` | `canary`). + channel: String, + /// This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the + /// tray render the right "how to update" hint without a second call. + install_kind: String, + }, #[serde(rename = "plugins.changed")] PluginsChanged { /// The plugin whose registration changed (registered, restarted, deregistered, or @@ -242,6 +255,7 @@ impl EventKind { EventKind::DisplayCreated { .. } => "display.created", EventKind::DisplayReleased { .. } => "display.released", EventKind::LibraryChanged { .. } => "library.changed", + EventKind::UpdateAvailable { .. } => "update.available", EventKind::PluginsChanged { .. } => "plugins.changed", EventKind::StoreChanged => "store.changed", EventKind::HostStarted { .. } => "host.started", diff --git a/crates/punktfunk-host/src/main.rs b/crates/punktfunk-host/src/main.rs index dcea7120..53c2b822 100644 --- a/crates/punktfunk-host/src/main.rs +++ b/crates/punktfunk-host/src/main.rs @@ -99,6 +99,7 @@ mod stats_recorder; // same runner CLI the `plugins` subcommand uses (design/plugin-store.md). mod store; mod stream_marker; +mod update; // `monitor_devnode::startup_recover()` (below) re-enables PnP monitor devnodes disabled by a prior // run; it lives in the `pf-win-display` leaf crate (plan §W6). #[cfg(target_os = "windows")] diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 38010bd7..171cbf98 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -45,6 +45,7 @@ mod stats; mod store; #[cfg(test)] mod tests; +mod update; /// Default management port — adjacent to the GameStream block (47984…48010), and the same /// number Sunshine users already associate with "the config UI". @@ -257,7 +258,9 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(store::get_job)) .routes(routes!(store::list_sources)) .routes(routes!(store::put_source, store::delete_source)) - .routes(routes!(store::get_runtime, store::set_runtime)), + .routes(routes!(store::get_runtime, store::set_runtime)) + .routes(routes!(update::get_update_status)) + .routes(routes!(update::force_update_check)), ) .split_for_parts() } @@ -297,6 +300,7 @@ pub fn openapi_json() -> String { (name = "hooks", description = "Operator hooks: commands and webhooks fired on lifecycle events (fire-and-forget — hooks observe, never veto)"), (name = "plugins", description = "Plugin directory: running `punktfunk-plugin-*` processes register a lease and, optionally, a loopback UI the web console proxies and adds to its nav"), (name = "store", description = "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"), + (name = "update", description = "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"), ) )] struct ApiDoc; diff --git a/crates/punktfunk-host/src/mgmt/auth.rs b/crates/punktfunk-host/src/mgmt/auth.rs index 324eda16..f71a621b 100644 --- a/crates/punktfunk-host/src/mgmt/auth.rs +++ b/crates/punktfunk-host/src/mgmt/auth.rs @@ -149,7 +149,12 @@ pub(crate) fn plugin_may_access(method: &Method, path: &str) -> bool { || (method == Method::DELETE && (path.starts_with("/api/v1/clients/") || path.starts_with("/api/v1/native/clients/"))) - || (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential")); + || (path.starts_with("/api/v1/plugins/") && path.ends_with("/ui-credential")) + // The update surface is operator business end to end: today it is only a check, but + // the same prefix will carry `apply` (running an installer / the root helper), and a + // whole-prefix deny can't be defeated by a route added later. + || path == "/api/v1/update" + || path.starts_with("/api/v1/update/"); !denied } diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 6368dc64..37a12b11 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -544,6 +544,31 @@ fn plugin_allowlist_excludes_escalation_routes() { "plugin token must not reach {path}" ); } + + // The update surface, wholesale: today a check, tomorrow `apply` (an installer / the root + // helper) — operator business end to end, denied by whole-prefix so the apply route added in + // U1/U2 is denied by default rather than by remembering to list it. And it is deliberately + // NOT on the paired-cert allowlist either: a streaming client has no business knowing or + // steering the host's update state. + for path in [ + "/api/v1/update", + "/api/v1/update/status", + "/api/v1/update/check", + "/api/v1/update/apply-does-not-exist-yet", + ] { + assert!( + !auth::plugin_may_access(&Method::GET, path), + "plugin token must not reach {path}" + ); + assert!( + !auth::plugin_may_access(&Method::POST, path), + "plugin token must not reach {path}" + ); + assert!( + !auth::cert_may_access(&Method::GET, path), + "a paired streaming cert must not reach {path}" + ); + } assert!(!auth::plugin_may_access( &Method::PUT, "/api/v1/store/sources/evil" diff --git a/crates/punktfunk-host/src/mgmt/update.rs b/crates/punktfunk-host/src/mgmt/update.rs new file mode 100644 index 00000000..ce2dc31b --- /dev/null +++ b/crates/punktfunk-host/src/mgmt/update.rs @@ -0,0 +1,131 @@ +//! `/api/v1/update/*` — the host update-check surface (design +//! `host-update-from-web-console.md` §4.2, phase U0). +//! +//! Admin lane ONLY: denied to the plugin token (`auth::plugin_may_access`) and absent from +//! the paired-cert allowlist — an update trigger is operator business. U0 exposes `status` + +//! `check`; the `apply` route arrives with the first apply leg (U1) so the API never +//! advertises a capability no code backs. + +use super::shared::*; +use crate::update::{self, detect}; + +/// One channel's manifest facts, as much as the console renders. +#[derive(Serialize, Deserialize, ToSchema)] +pub(crate) struct UpdateManifestInfo { + /// The released version this manifest announces. + pub version: String, + /// Publish serial (unix seconds) — monotonic per channel. + pub serial: u64, + /// RFC-3339 publish time (display only). + pub published_at: String, + /// Release-notes link (pinned to our forge by the manifest validator). + pub notes_url: String, + /// The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint. + pub stale: bool, +} + +/// The full update-check state for this host. +#[derive(Serialize, Deserialize, ToSchema)] +pub(crate) struct UpdateStatus { + /// How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` | + /// `dnf` | `pacman` | `steamos-source` | `nix` | `source`. + pub install_kind: String, + /// Release channel this install follows: `stable` | `canary`. + pub channel: String, + /// The running host version. + pub current_version: String, + /// What the console may offer for this install: `notify` (show the command) — later + /// phases add `full` (one-click apply) and `staged` (apply + reboot to finish). + pub apply: String, + /// The copy-pastable update command for this install kind. + pub channel_hint: String, + /// Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`). + pub check_disabled: bool, + /// A newer release than `current_version` exists for this channel (definitive + /// comparisons only — an unparseable version pair never flags). + pub available: bool, + /// The last verified manifest, if any check has succeeded. + pub manifest: Option, + /// When the last successful check happened (unix seconds). + pub last_checked_unix: Option, + /// Why the last check failed, verbatim, if it did. + pub last_error: Option, +} + +fn status_from(snap: update::Snapshot) -> UpdateStatus { + let (kind, channel) = detect::detect(); + let current = env!("PUNKTFUNK_VERSION"); + let stale = snap.stale(); + let available = snap + .checked + .as_ref() + .map(|c| detect::is_newer(&c.manifest.version, c.manifest.ci_run, current, channel)) + .unwrap_or(false); + UpdateStatus { + install_kind: kind.as_str().into(), + channel: channel.as_str().into(), + current_version: current.into(), + apply: "notify".into(), + channel_hint: detect::channel_hint(kind).into(), + check_disabled: update::check_disabled(), + available, + manifest: snap.checked.as_ref().map(|c| UpdateManifestInfo { + version: c.manifest.version.clone(), + serial: c.manifest.serial, + published_at: c.manifest.published_at.clone(), + notes_url: c.manifest.notes_url.clone(), + stale, + }), + last_checked_unix: snap.checked.as_ref().map(|c| c.fetched_unix), + last_error: snap.last_error, + } +} + +/// Update-check status +/// +/// How this host was installed, which channel it follows, whether a newer release is known, +/// and how to update. Reading this may kick a background refresh when the cached check is +/// older than 6 h; the response never blocks on the network. +#[utoipa::path( + get, + path = "/update/status", + tag = "update", + operation_id = "getUpdateStatus", + responses( + (status = OK, description = "Current update-check state", body = UpdateStatus), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn get_update_status() -> Json { + Json(status_from(update::snapshot_and_maybe_refresh())) +} + +/// Check for updates now +/// +/// Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to +/// one forced check per 30 s. +#[utoipa::path( + post, + path = "/update/check", + tag = "update", + operation_id = "forceUpdateCheck", + responses( + (status = OK, description = "Refreshed update-check state (`last_error` carries a failed check)", body = UpdateStatus), + (status = CONFLICT, description = "Update checks are disabled on this host", body = ApiError), + (status = TOO_MANY_REQUESTS, description = "A forced check ran less than 30 s ago", body = ApiError), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn force_update_check() -> Response { + match update::force_check().await { + Ok(snap) => Json(status_from(snap)).into_response(), + Err(update::ForceError::Disabled) => api_error( + StatusCode::CONFLICT, + "update checks are disabled on this host (PUNKTFUNK_UPDATE_CHECK=0)", + ), + Err(update::ForceError::TooSoon) => api_error( + StatusCode::TOO_MANY_REQUESTS, + "a forced update check ran less than 30 s ago — try again shortly", + ), + } +} diff --git a/crates/punktfunk-host/src/update.rs b/crates/punktfunk-host/src/update.rs new file mode 100644 index 00000000..0005846a --- /dev/null +++ b/crates/punktfunk-host/src/update.rs @@ -0,0 +1,401 @@ +//! Host **update check** (design `host-update-from-web-console.md`, phase U0). +//! +//! This module answers one question for the console: *does a newer host release exist for +//! this box's channel* — by fetching the per-channel signed manifest and verifying it against +//! the Ed25519 keys pinned below. It deliberately contains **no apply code**: U0 ships check +//! everywhere; apply legs land per-channel (U1 Windows, U2 Linux helper) behind the same +//! status surface. +//! +//! Shape: a process-wide cache + a lazy refresh. `GET /update/status` returns the cache and, +//! when it is older than [`AUTO_REFRESH_AFTER`], kicks a background refresh — the console +//! polls status anyway, so freshness needs no timer of its own. `POST /update/check` forces a +//! refresh, rate-limited to one per [`FORCE_MIN_INTERVAL`]. +//! +//! Trust and failure rules live in [`manifest`]; the serial floor persisted here +//! (`update-state.json`) is what makes a replayed older manifest an *error*, not a silent +//! downgrade of our knowledge. `PUNKTFUNK_UPDATE_CHECK=0` disables all network activity — +//! status then reports `check_disabled` and carries whatever identity facts need no network. + +pub(crate) mod detect; +pub(crate) mod manifest; + +use crate::store::index::PublicKey; +use manifest::Manifest; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +/// The Ed25519 public keys this binary trusts for update manifests — two slots so a key +/// rotation is "sign with the new one, ship a host trusting both, retire the old" (the +/// plugin-store `OFFICIAL_KEYS` drill). The private half is the `UPDATE_MANIFEST_KEY` CI +/// secret; it also lives in the operator's offline backup (plan U0.1 DoD). +pub(crate) const UPDATE_KEYS: [&str; 2] = [ + "ed25519:6rmlLg1aQ55cgB6icpC5BEpbMJxwPKdGaDQtDcJ0yLI=", + "", // rotation slot +]; + +/// Feed base — `//manifest.json` + `.sig`. Override for tests/dev feeds via +/// `PUNKTFUNK_UPDATE_FEED` (a base URL, not request-time input: env is operator config). +const DEFAULT_FEED_BASE: &str = "https://git.unom.io/api/packages/unom/generic/punktfunk-update"; + +/// A cache older than this is refreshed in the background on the next status read. +const AUTO_REFRESH_AFTER: Duration = Duration::from_secs(6 * 60 * 60); + +/// Forced checks (`POST /update/check`) are rate-limited to one per this interval. +pub(crate) const FORCE_MIN_INTERVAL: Duration = Duration::from_secs(30); + +/// A manifest whose publish serial is older than this is flagged stale in status — the +/// freeze-detection hint (design §3.2), not an error. +const STALE_AFTER: Duration = Duration::from_secs(45 * 24 * 60 * 60); + +/// One fetch's wall-clock budget (mirrors the store catalog fetch). +const FETCH_TIMEOUT: Duration = Duration::from_secs(15); + +/// Update checks disabled by operator config (env or `host.env`). +pub(crate) fn check_disabled() -> bool { + matches!( + std::env::var("PUNKTFUNK_UPDATE_CHECK").as_deref(), + Ok("0") | Ok("false") | Ok("off") + ) +} + +fn feed_base() -> String { + std::env::var("PUNKTFUNK_UPDATE_FEED") + .ok() + .filter(|s| s.starts_with("https://") || s.starts_with("http://127.0.0.1")) + .unwrap_or_else(|| DEFAULT_FEED_BASE.to_string()) +} + +fn pinned_keys() -> Vec { + UPDATE_KEYS + .iter() + .filter(|k| !k.is_empty()) + .filter_map(|k| PublicKey::parse(k).ok()) + .collect() +} + +// ---------------------------------------------------------------- runtime state + +/// What the last successful refresh produced. +#[derive(Clone)] +pub(crate) struct Checked { + pub manifest: Manifest, + pub fetched_unix: u64, +} + +#[derive(Default)] +struct Runtime { + checked: Option, + last_error: Option, + /// Refresh in flight (status kicks at most one). + refreshing: bool, + /// Wall-clock guard for the forced-check rate limit. + last_forced: Option, + /// Last attempt of any kind — drives the auto-refresh cadence. + last_attempt: Option, + /// 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, +} + +fn runtime() -> &'static Mutex { + static RT: OnceLock> = OnceLock::new(); + RT.get_or_init(|| Mutex::new(Runtime::default())) +} + +fn now_unix() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +// ---------------------------------------------------------------- serial floor + +/// Persisted anti-rollback state: the highest manifest serial ever accepted per channel. +#[derive(Default, serde::Serialize, serde::Deserialize)] +struct FloorFile { + #[serde(default)] + serial_floor: std::collections::BTreeMap, +} + +fn state_path() -> PathBuf { + pf_paths::config_dir().join("update-state.json") +} + +fn load_floor(path: &Path, channel: &str) -> u64 { + std::fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .and_then(|f| f.serial_floor.get(channel).copied()) + .unwrap_or(0) +} + +/// Raise (never lower) the floor; atomic tmp+rename so a power cut can't half-write it. +fn store_floor(path: &Path, channel: &str, serial: u64) { + let mut file: FloorFile = std::fs::read(path) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + let slot = file.serial_floor.entry(channel.to_string()).or_insert(0); + if serial <= *slot { + return; + } + *slot = serial; + let Ok(bytes) = serde_json::to_vec_pretty(&file) else { + return; + }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let tmp = path.with_extension("json.tmp"); + if std::fs::write(&tmp, &bytes).is_ok() { + let _ = std::fs::rename(&tmp, path); + } +} + +// ---------------------------------------------------------------- refresh + +/// Fetch + verify the channel manifest. Blocking (`ureq`) — call from a blocking thread. +fn fetch_manifest_blocking(channel: &str) -> Result { + let agent = ureq::AgentBuilder::new() + .timeout(FETCH_TIMEOUT) + // Follow the registry's 303-to-object-storage redirect; the signature is verified + // over the FINAL bytes (the sysext-feed lesson). + .redirects(3) + .user_agent(&format!( + "punktfunk-host/{} (update-check)", + env!("PUNKTFUNK_VERSION") + )) + .build(); + let base = feed_base(); + let url = format!("{base}/{channel}/manifest.json"); + let sig_url = format!("{url}.sig"); + + let body = read_capped(agent.get(&url).call().map_err(fetch_err)?)?; + let sig = read_capped(agent.get(&sig_url).call().map_err(fetch_err)?)?; + let sig_text = String::from_utf8(sig).map_err(|_| "signature file is not text".to_string())?; + + let keys = pinned_keys(); + if keys.is_empty() { + // Both slots empty would mean a build with the feature disarmed; refuse rather than + // silently skipping verification. + return Err("no update key is pinned in this build".into()); + } + manifest::verify_and_parse(&body, &sig_text, &keys, channel).map_err(|e| format!("{e:#}")) +} + +fn fetch_err(e: ureq::Error) -> String { + match e { + ureq::Error::Status(code, _) => format!("feed returned HTTP {code}"), + other => format!("feed fetch failed: {other}"), + } +} + +fn read_capped(resp: ureq::Response) -> Result, String> { + use std::io::Read as _; + let mut buf = Vec::new(); + let mut reader = resp + .into_reader() + .take(manifest::MAX_MANIFEST_BYTES as u64 + 1); + reader + .read_to_end(&mut buf) + .map_err(|e| format!("read failed: {e}"))?; + if buf.len() > manifest::MAX_MANIFEST_BYTES { + return Err("response exceeds the manifest size cap".into()); + } + Ok(buf) +} + +/// One full refresh: fetch, verify, enforce + raise the serial floor, update the cache, +/// announce a newly available release on the event bus. Returns the user-facing error string +/// on failure (also cached for status). +pub(crate) fn refresh_blocking() -> Result { + let (kind, channel) = detect::detect(); + let result = fetch_manifest_blocking(channel.as_str()).and_then(|m| { + let path = state_path(); + let floor = load_floor(&path, channel.as_str()); + if m.serial < floor { + return Err(format!( + "manifest serial {} is older than the last accepted {} — refusing rollback", + m.serial, floor + )); + } + store_floor(&path, channel.as_str(), m.serial); + Ok(m) + }); + + let mut rt = runtime().lock().unwrap(); + rt.last_attempt = Some(Instant::now()); + rt.refreshing = false; + match result { + Ok(m) => { + let checked = Checked { + manifest: m, + fetched_unix: now_unix(), + }; + let newer = detect::is_newer( + &checked.manifest.version, + checked.manifest.ci_run, + env!("PUNKTFUNK_VERSION"), + channel, + ); + if newer && rt.announced.as_deref() != Some(checked.manifest.version.as_str()) { + rt.announced = Some(checked.manifest.version.clone()); + crate::events::emit(crate::events::EventKind::UpdateAvailable { + version: checked.manifest.version.clone(), + channel: channel.as_str().to_string(), + install_kind: kind.as_str().to_string(), + }); + } + rt.last_error = None; + rt.checked = Some(checked.clone()); + Ok(checked) + } + Err(e) => { + rt.last_error = Some(e.clone()); + Err(e) + } + } +} + +/// The status handler's read: current cache + errors, kicking a background refresh when the +/// cache is cold and checks are enabled. +pub(crate) fn snapshot_and_maybe_refresh() -> Snapshot { + let mut kick = false; + let snap = { + let mut rt = runtime().lock().unwrap(); + let cold = rt + .last_attempt + .map(|t| t.elapsed() >= AUTO_REFRESH_AFTER) + .unwrap_or(true); + if cold && !rt.refreshing && !check_disabled() { + rt.refreshing = true; + rt.last_attempt = Some(Instant::now()); + kick = true; + } + Snapshot { + checked: rt.checked.clone(), + last_error: rt.last_error.clone(), + } + }; + if kick { + // Fire-and-forget; the console's next poll reads the outcome. + tokio::task::spawn_blocking(|| { + let _ = refresh_blocking(); + }); + } + snap +} + +/// A forced check (`POST /update/check`): rate-limited, blocking until the refresh finishes. +pub(crate) async fn force_check() -> Result { + if check_disabled() { + return Err(ForceError::Disabled); + } + { + let mut rt = runtime().lock().unwrap(); + if let Some(t) = rt.last_forced { + if t.elapsed() < FORCE_MIN_INTERVAL { + return Err(ForceError::TooSoon); + } + } + rt.last_forced = Some(Instant::now()); + rt.refreshing = true; + } + let _ = tokio::task::spawn_blocking(refresh_blocking).await; + let rt = runtime().lock().unwrap(); + Ok(Snapshot { + checked: rt.checked.clone(), + last_error: rt.last_error.clone(), + }) +} + +pub(crate) enum ForceError { + Disabled, + TooSoon, +} + +/// What status hands to the API layer. +pub(crate) struct Snapshot { + pub checked: Option, + pub last_error: Option, +} + +impl Snapshot { + /// 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 { + self.checked + .as_ref() + .map(|c| now_unix().saturating_sub(c.manifest.serial) > STALE_AFTER.as_secs()) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn floor_roundtrip_and_monotonicity() { + let dir = std::env::temp_dir().join(format!("pf-update-floor-{}", std::process::id())); + let path = dir.join("update-state.json"); + let _ = std::fs::remove_dir_all(&dir); + + assert_eq!(load_floor(&path, "stable"), 0); + store_floor(&path, "stable", 100); + assert_eq!(load_floor(&path, "stable"), 100); + // Lowering is a no-op. + store_floor(&path, "stable", 50); + assert_eq!(load_floor(&path, "stable"), 100); + // Channels are independent. + store_floor(&path, "canary", 7); + assert_eq!(load_floor(&path, "canary"), 7); + assert_eq!(load_floor(&path, "stable"), 100); + + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn corrupt_floor_file_reads_as_zero() { + let dir = std::env::temp_dir().join(format!("pf-update-floor2-{}", std::process::id())); + let path = dir.join("update-state.json"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(&path, b"not json").unwrap(); + assert_eq!(load_floor(&path, "stable"), 0); + // And writing over it recovers. + store_floor(&path, "stable", 5); + assert_eq!(load_floor(&path, "stable"), 5); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn pinned_keys_skip_empty_rotation_slot() { + let keys = pinned_keys(); + assert_eq!(keys.len(), 1, "one live key, one empty rotation slot"); + } + + #[test] + fn stale_math() { + let mk = |serial| Snapshot { + checked: Some(Checked { + manifest: manifest::parse_verified( + serde_json::to_vec(&serde_json::json!({ + "schema": 1, "channel": "stable", "serial": serial, + "version": "0.23.0", + "notes_url": "https://git.unom.io/unom/punktfunk/releases", + })) + .unwrap() + .as_slice(), + "stable", + ) + .unwrap(), + fetched_unix: now_unix(), + }), + last_error: None, + }; + assert!(!mk(now_unix()).stale()); + assert!(mk(now_unix() - STALE_AFTER.as_secs() - 10).stale()); + } +} diff --git a/crates/punktfunk-host/src/update/detect.rs b/crates/punktfunk-host/src/update/detect.rs new file mode 100644 index 00000000..7627c4a9 --- /dev/null +++ b/crates/punktfunk-host/src/update/detect.rs @@ -0,0 +1,420 @@ +//! **How was this host installed, and on which channel?** (design §4.1) +//! +//! The apply strategy — and, until apply lands, the command hint the console shows — hangs +//! off the install kind. Detection is a ladder over root-owned facts: packaging writes a +//! marker (`/usr/share/punktfunk/install-kind`, e.g. `apt stable`), the sysext self-identifies +//! via its merged extension-release, Nix by store path, and so on. The API only ever *reads* +//! this; nothing request-side can influence it. +//! +//! The ladder itself is a pure function over a [`Probe`] so every branch is unit-testable; +//! [`detect`] gathers the real probe once per process. + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// Where the Linux packages stamp how they were installed. First word = kind +/// (`apt`|`dnf`|`pacman`), optional second word = channel (`stable`|`canary`). +const MARKER_PATH: &str = "/usr/share/punktfunk/install-kind"; + +/// The merged sysext names itself here (written by `build-sysext.sh`); its presence means the +/// running `/usr` overlay came from the sysext image, regardless of any leftover marker. +const SYSEXT_MARKER: &str = "/usr/lib/extension-release.d/extension-release.punktfunk"; + +/// The sysext updater's own config (`CHANNEL=stable|canary`). +const SYSEXT_CONF: &str = "/etc/punktfunk-sysext.conf"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InstallKind { + WindowsInstaller, + Sysext, + RpmOstree, + Apt, + Dnf, + Pacman, + SteamosSource, + Nix, + Source, +} + +impl InstallKind { + pub(crate) fn as_str(self) -> &'static str { + match self { + InstallKind::WindowsInstaller => "windows-installer", + InstallKind::Sysext => "sysext", + InstallKind::RpmOstree => "rpm-ostree", + InstallKind::Apt => "apt", + InstallKind::Dnf => "dnf", + InstallKind::Pacman => "pacman", + InstallKind::SteamosSource => "steamos-source", + InstallKind::Nix => "nix", + InstallKind::Source => "source", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Channel { + Stable, + Canary, +} + +impl Channel { + pub(crate) fn as_str(self) -> &'static str { + match self { + Channel::Stable => "stable", + Channel::Canary => "canary", + } + } +} + +/// The root-owned facts the ladder reads, gathered once by [`gather`] (tests build these +/// directly). +#[derive(Debug, Default)] +pub(crate) struct Probe { + /// Running on Windows (cfg, not a file). + pub windows: bool, + /// The running exe's path. + pub exe: PathBuf, + /// `$HOME`, if any. + pub home: Option, + /// Contents of [`MARKER_PATH`], if present. + pub marker: Option, + /// [`SYSEXT_MARKER`] exists (merged sysext overlay). + pub sysext: bool, + /// Contents of [`SYSEXT_CONF`], if present. + pub sysext_conf: Option, + /// `/run/ostree-booted` exists (rpm-ostree / bootc family). + pub ostree_booted: bool, +} + +fn gather() -> Probe { + Probe { + windows: cfg!(target_os = "windows"), + exe: std::env::current_exe().unwrap_or_default(), + home: std::env::var_os("HOME").map(PathBuf::from), + marker: std::fs::read_to_string(MARKER_PATH).ok(), + sysext: Path::new(SYSEXT_MARKER).exists(), + sysext_conf: std::fs::read_to_string(SYSEXT_CONF).ok(), + ostree_booted: Path::new("/run/ostree-booted").exists(), + } +} + +/// The ladder (design §4.1). Order matters and each rung is a root-owned fact: +/// sysext overlay > Nix store path > dev/source tree > user-owned Deck build > package +/// marker (flipped to rpm-ostree when the box is ostree-booted) > `source` fallback. +pub(crate) fn classify(p: &Probe) -> (InstallKind, Channel) { + if p.windows { + // The installer is the only supported Windows delivery; a loose cargo build shows + // itself by not living under Program Files. Channel: canary installers carry the CI + // run as the third component (`M.m.`), see `windows_channel_of`. + let installed = p + .exe + .to_string_lossy() + .to_ascii_lowercase() + .contains("\\program files\\punktfunk"); + return if installed { + ( + InstallKind::WindowsInstaller, + windows_channel_of(env!("PUNKTFUNK_VERSION")), + ) + } else { + (InstallKind::Source, Channel::Stable) + }; + } + + if p.sysext { + let channel = p + .sysext_conf + .as_deref() + .and_then(conf_channel) + .unwrap_or(Channel::Stable); + return (InstallKind::Sysext, channel); + } + + if p.exe.starts_with("/nix/store") { + return (InstallKind::Nix, Channel::Stable); + } + + // A cargo tree anywhere (CI, dev box, the Deck checkout mid-build) is `source`; the + // Deck's install script runs the binary out of `~/punktfunk/target-steamos/`, which is + // user-owned but NOT a plain `target/` dir — that distinction is the marker here. + let exe_str = p.exe.to_string_lossy().to_string(); + if exe_str.contains("/target/") { + return (InstallKind::Source, Channel::Stable); + } + if let Some(home) = &p.home { + if p.exe.starts_with(home) { + return (InstallKind::SteamosSource, Channel::Canary); + } + } + + if let Some(marker) = &p.marker { + let mut words = marker.split_whitespace(); + let kind = words.next().unwrap_or(""); + let channel = match words.next() { + Some("canary") => Channel::Canary, + _ => Channel::Stable, + }; + let kind = match kind { + "apt" => Some(InstallKind::Apt), + // An ostree-booted box consumed the RPM by layering (or an image build); either + // way `dnf upgrade` is not how it updates. bootc-vs-layered is refined in U2 via + // `rpm-ostree status` — until then both report `rpm-ostree` (notify text is + // identical in U0). + "dnf" if p.ostree_booted => Some(InstallKind::RpmOstree), + "dnf" => Some(InstallKind::Dnf), + "pacman" => Some(InstallKind::Pacman), + _ => None, + }; + if let Some(kind) = kind { + return (kind, channel); + } + } + + (InstallKind::Source, Channel::Stable) +} + +/// `CHANNEL=canary` in `/etc/punktfunk-sysext.conf` (the sysext updater's own format). +fn conf_channel(conf: &str) -> Option { + for line in conf.lines() { + if let Some(v) = line.trim().strip_prefix("CHANNEL=") { + return Some(match v.trim() { + "canary" => Channel::Canary, + _ => Channel::Stable, + }); + } + } + None +} + +/// Windows canary installers are versioned `M.m.` where `` is a 4+ digit CI run +/// number; stable patch numbers stay small. Heuristic, documented in the plan (R10). +fn windows_channel_of(version: &str) -> Channel { + match triple(version) { + Some((_, _, patch)) if patch >= 1000 => Channel::Canary, + _ => Channel::Stable, + } +} + +/// The process-wide answer, computed once. +pub(crate) fn detect() -> (InstallKind, Channel) { + static DETECTED: OnceLock<(InstallKind, Channel)> = OnceLock::new(); + *DETECTED.get_or_init(|| classify(&gather())) +} + +// ---------------------------------------------------------------- version comparison + +/// Leading `major.minor.patch` of a version string, ignoring any suffix (`~ci…`, `-1`, `+…`). +pub(crate) fn triple(v: &str) -> Option<(u64, u64, u64)> { + let mut parts = v + .split(|c: char| !c.is_ascii_digit()) + .filter(|s| !s.is_empty()); + // Split on any non-digit: "0.23.0~ci10250.gab" → 0,23,0,10250… — take the first three + // ONLY if the string actually starts with digits (else it's not a version at all). + if !v.starts_with(|c: char| c.is_ascii_digit()) { + return None; + } + Some(( + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + parts.next()?.parse().ok()?, + )) +} + +/// The CI run number embedded in a canary version string, wherever the channel's format hid +/// it: `0.23.0~ci10250.g` (deb), `0.23.0-0.ci10250.g` (rpm), `0.23.10250` +/// (Windows/decky style, run-as-patch). A stable string yields `None`. +pub(crate) fn canary_run(version: &str) -> Option { + // `ci` immediately followed by digits, anywhere. + let mut rest = version; + while let Some(pos) = rest.find("ci") { + let digits: String = rest[pos + 2..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + if !digits.is_empty() { + return digits.parse().ok(); + } + rest = &rest[pos + 2..]; + } + match triple(version) { + Some((_, _, patch)) if patch >= 1000 => Some(patch), + _ => None, + } +} + +/// Is the manifest's release newer than what this process runs? Definitive-or-false: an +/// unparseable pair never flags (the console still shows both version strings — the badge +/// just doesn't light up on guesswork). Canary compares `(major, minor)` then the CI run, +/// because canary patch fields mean different things per channel (R10). +pub(crate) fn is_newer( + manifest_version: &str, + manifest_ci_run: Option, + current: &str, + channel: Channel, +) -> bool { + let (Some(m), Some(c)) = (triple(manifest_version), triple(current)) else { + return false; + }; + match channel { + Channel::Stable => m > c, + Channel::Canary => { + if (m.0, m.1) != (c.0, c.1) { + return (m.0, m.1) > (c.0, c.1); + } + let manifest_run = manifest_ci_run.or_else(|| canary_run(manifest_version)); + match (manifest_run, canary_run(current)) { + (Some(mr), Some(cr)) => mr > cr, + _ => false, + } + } + } +} + +/// The per-kind "how to update" command the console shows while (or instead of) an apply +/// path existing (design §5). One line, copy-pastable, no placeholders. +pub(crate) fn channel_hint(kind: InstallKind) -> &'static str { + match kind { + InstallKind::WindowsInstaller => { + "winget upgrade unom.PunktfunkHost (or re-run the newer installer)" + } + InstallKind::Sysext => "sudo punktfunk-sysext update", + InstallKind::RpmOstree => { + "sudo /usr/share/punktfunk/update-punktfunk.sh (staged; reboot to finish)" + } + InstallKind::Apt => "sudo apt update && sudo apt install --only-upgrade punktfunk-host", + InstallKind::Dnf => "sudo dnf upgrade punktfunk", + InstallKind::Pacman => "sudo pacman -Syu", + InstallKind::SteamosSource => "bash ~/punktfunk/scripts/steamdeck/update.sh --pull", + InstallKind::Nix => "nix flake update punktfunk (then rebuild your system)", + InstallKind::Source => "git pull && cargo build --release -p punktfunk-host", + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn probe() -> Probe { + Probe { + windows: false, + exe: PathBuf::from("/usr/bin/punktfunk-host"), + home: Some(PathBuf::from("/home/deck")), + ..Default::default() + } + } + + #[test] + fn ladder_sysext_beats_marker() { + let mut p = probe(); + p.sysext = true; + p.marker = Some("dnf canary".into()); + p.sysext_conf = Some("CHANNEL=canary\n".into()); + assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Canary)); + p.sysext_conf = None; + assert_eq!(classify(&p), (InstallKind::Sysext, Channel::Stable)); + } + + #[test] + fn ladder_nix_store_path() { + let mut p = probe(); + p.exe = PathBuf::from("/nix/store/abc123-punktfunk-host-0.22.2/bin/punktfunk-host"); + assert_eq!(classify(&p).0, InstallKind::Nix); + } + + #[test] + fn ladder_cargo_target_is_source_even_under_home() { + let mut p = probe(); + p.exe = PathBuf::from("/home/deck/punktfunk/target/release/punktfunk-host"); + assert_eq!(classify(&p).0, InstallKind::Source); + } + + #[test] + fn ladder_deck_build_is_steamos_source() { + let mut p = probe(); + p.exe = PathBuf::from("/home/deck/punktfunk/target-steamos/release/punktfunk-host"); + assert_eq!(classify(&p).0, InstallKind::SteamosSource); + } + + #[test] + fn ladder_markers() { + for (marker, ostree, kind, channel) in [ + ("apt stable", false, InstallKind::Apt, Channel::Stable), + ("apt canary", false, InstallKind::Apt, Channel::Canary), + ("dnf stable", false, InstallKind::Dnf, Channel::Stable), + ("dnf stable", true, InstallKind::RpmOstree, Channel::Stable), + ("pacman canary", false, InstallKind::Pacman, Channel::Canary), + ] { + let mut p = probe(); + p.marker = Some(marker.into()); + p.ostree_booted = ostree; + assert_eq!(classify(&p), (kind, channel), "marker `{marker}`"); + } + } + + #[test] + fn ladder_unknown_marker_falls_through_to_source() { + let mut p = probe(); + p.marker = Some("snap stable".into()); + assert_eq!(classify(&p).0, InstallKind::Source); + } + + #[test] + fn triples() { + assert_eq!(triple("0.23.0"), Some((0, 23, 0))); + assert_eq!(triple("0.23.0~ci10250.gab12cd34"), Some((0, 23, 0))); + assert_eq!(triple("0.23.10250"), Some((0, 23, 10250))); + assert_eq!(triple("garbage"), None); + assert_eq!(triple("1.2"), None); + } + + #[test] + fn canary_runs() { + assert_eq!(canary_run("0.23.0~ci10250.gab12cd34"), Some(10250)); + assert_eq!(canary_run("0.23.0-0.ci777.g12345678"), Some(777)); + assert_eq!(canary_run("0.23.10250"), Some(10250)); // run-as-patch (Windows/decky) + assert_eq!(canary_run("0.23.0"), None); // stable string + assert_eq!(canary_run("0.23.0-1"), None); + } + + #[test] + fn newer_stable() { + assert!(is_newer("0.23.0", None, "0.22.2", Channel::Stable)); + assert!(!is_newer("0.22.2", None, "0.22.2", Channel::Stable)); + assert!(!is_newer("0.22.1", None, "0.22.2", Channel::Stable)); // downgrade never flags + assert!(!is_newer("not-a-version", None, "0.22.2", Channel::Stable)); + } + + #[test] + fn newer_canary_compares_runs_not_patch() { + // deb canary current vs Windows-style manifest version, same run ⇒ NOT newer, + // even though a naive triple compare says 10250 > 0. + assert!(!is_newer( + "0.23.10250", + Some(10250), + "0.23.0~ci10250.gab12cd34", + Channel::Canary + )); + assert!(is_newer( + "0.23.10251", + Some(10251), + "0.23.0~ci10250.gab12cd34", + Channel::Canary + )); + // Minor bump wins outright. + assert!(is_newer( + "0.24.100", + Some(100), + "0.23.0~ci10250.g12", + Channel::Canary + )); + // No run extractable on either side ⇒ conservative false. + assert!(!is_newer("0.23.10250", None, "0.23.0", Channel::Canary)); + } + + #[test] + fn windows_channel_heuristic() { + assert_eq!(windows_channel_of("0.22.2"), Channel::Stable); + assert_eq!(windows_channel_of("0.23.10118"), Channel::Canary); + } +} diff --git a/crates/punktfunk-host/src/update/manifest.rs b/crates/punktfunk-host/src/update/manifest.rs new file mode 100644 index 00000000..92b3fc08 --- /dev/null +++ b/crates/punktfunk-host/src/update/manifest.rs @@ -0,0 +1,245 @@ +//! The signed **update manifest**: the check truth for "a newer host exists" +//! (design `host-update-from-web-console.md` §3). +//! +//! One small JSON document per channel, Ed25519-signed with keys pinned in this binary +//! ([`super::UPDATE_KEYS`]) and verified by the exact code path the plugin store already +//! trusts ([`crate::store::index::verify_signature`]). TLS and the registry that serves the +//! document are transport, never trust. +//! +//! Rules, all fail-closed (the sysext 303 lesson encoded): +//! 1. **Signature before parse** — over the exact fetched bytes, then strict JSON. An HTML +//! error page, a redirect stub, or a truncated body dies before any field is read. +//! 2. **Channel binding** — the document names its channel and it must match the one we +//! asked for, so a validly-signed canary manifest replayed onto the stable URL is refused. +//! 3. **Monotonic serial** — the publish-time serial can never go backwards for a channel +//! (the anti-downgrade/anti-replay floor, persisted by [`super`]). +//! 4. **Pinned notes origin** — the release-notes link the console renders must live on our +//! forge, so a signed-but-wrong document can't send the operator to a lookalike page. + +use crate::store::index::{verify_signature, PublicKey}; +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; + +/// The only manifest schema this host understands. A breaking change bumps this; old hosts +/// report "unsupported schema" instead of guessing. +pub(crate) const SCHEMA: u32 = 1; + +/// Hard cap on a fetched manifest (and its signature). The real document is <1 KB. +pub(crate) const MAX_MANIFEST_BYTES: usize = 64 * 1024; + +/// The only origin a manifest may point the operator at for release notes. +const NOTES_ORIGIN: &str = "https://git.unom.io/"; + +/// The signed update manifest, as served (and signed) per channel. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub(crate) struct Manifest { + /// Document schema — must equal [`SCHEMA`]. + pub schema: u32, + /// The channel this document was published for (`stable` | `canary`). Bound-checked + /// against the channel we fetched, see module docs rule 2. + pub channel: String, + /// Unix seconds at publish. Strictly increasing per channel; also drives the stale-feed + /// hint (freshness needs no date parsing and no trust in `published_at`). + pub serial: u64, + /// RFC-3339 publish time. Display only. + #[serde(default)] + pub published_at: String, + /// The released host version this manifest announces. + pub version: String, + /// Release-notes link the console renders. Must be on [`NOTES_ORIGIN`]. + #[serde(default)] + pub notes_url: String, + /// Canary only: the CI run number, the definitive "newer" axis where per-channel version + /// strings differ (`~ciN`, `0.ciN`, a padded pkgrel, `M.m.run`). + #[serde(default)] + pub ci_run: Option, + /// The Windows installer leg (design §6) — parsed and carried now so a U0 host is already + /// schema-complete, consumed by the U1 apply path. + #[serde(default)] + pub windows_host: Option, +} + +/// Where the Windows host installer for [`Manifest::version`] lives and how to verify it. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub(crate) struct WindowsHostAsset { + /// Immutable per-version download URL — never a mutable `latest/` alias, so the hash + /// below can't race an alias re-upload. + pub url: String, + /// SHA-256 (hex) of the installer bytes. + pub sha256: String, + /// Accepted Authenticode signing-leaf SHA-256 fingerprints. Living in the signed manifest + /// (not the binary) is what makes the self-signed → Trusted Signing migration a manifest + /// edit instead of a lockstep host release. + #[serde(default)] + pub authenticode_sha256: Vec, + /// Minimum Windows build (display/preflight only). + #[serde(default)] + pub min_os: String, +} + +/// Verify `sig_text` over the exact `bytes` against `keys`, then strictly parse and validate +/// the document for `expected_channel`. The only constructor — there is no unsigned path. +pub(crate) fn verify_and_parse( + bytes: &[u8], + sig_text: &str, + keys: &[PublicKey], + expected_channel: &str, +) -> Result { + verify_signature(bytes, sig_text, keys).context("update manifest signature")?; + parse_verified(bytes, expected_channel) +} + +/// Parse + validate a document whose signature has already been checked. Split out so tests +/// can exercise validation without minting signatures for every case. +pub(crate) fn parse_verified(bytes: &[u8], expected_channel: &str) -> Result { + if bytes.len() > MAX_MANIFEST_BYTES { + bail!("manifest is larger than the {MAX_MANIFEST_BYTES}-byte cap"); + } + let m: Manifest = serde_json::from_slice(bytes).context("update manifest is not valid JSON")?; + if m.schema != SCHEMA { + bail!( + "unsupported manifest schema {} (this host understands {SCHEMA})", + m.schema + ); + } + if m.channel != expected_channel { + bail!( + "manifest is for channel `{}` but this host asked for `{expected_channel}`", + m.channel + ); + } + if m.version.is_empty() || m.version.len() > 64 { + bail!("manifest version is empty or implausibly long"); + } + if m.serial == 0 { + bail!("manifest serial is zero"); + } + if !m.notes_url.is_empty() && !m.notes_url.starts_with(NOTES_ORIGIN) { + bail!("manifest notes_url is not on {NOTES_ORIGIN}"); + } + if let Some(w) = &m.windows_host { + if !w.url.starts_with("https://") { + bail!("windows_host.url must be https"); + } + if w.sha256.len() != 64 || !w.sha256.bytes().all(|b| b.is_ascii_hexdigit()) { + bail!("windows_host.sha256 is not a hex SHA-256"); + } + } + Ok(m) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn doc() -> serde_json::Value { + serde_json::json!({ + "schema": 1, + "channel": "stable", + "serial": 1785400000u64, + "published_at": "2026-07-30T12:00:00Z", + "version": "0.23.0", + "notes_url": "https://git.unom.io/unom/punktfunk/releases/tag/v0.23.0", + "windows_host": { + "url": "https://git.unom.io/unom/punktfunk/releases/download/v0.23.0/punktfunk-host-setup-0.23.0.exe", + "sha256": "aa".repeat(32), + "authenticode_sha256": ["bb".repeat(32)], + "min_os": "10.0.22621" + } + }) + } + + fn bytes(v: &serde_json::Value) -> Vec { + serde_json::to_vec(v).unwrap() + } + + /// End-to-end: sign with a fresh ring keypair, verify with the matching pinned-key + /// string — the format contract with the CI signer (raw 64-byte sig, base64; raw + /// 32-byte key, `ed25519:`). + #[test] + fn signed_roundtrip_and_tamper() { + use base64::Engine as _; + use ring::signature::KeyPair as _; + let rng = ring::rand::SystemRandom::new(); + let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&rng).unwrap(); + let kp = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key_str = format!( + "ed25519:{}", + base64::engine::general_purpose::STANDARD.encode(kp.public_key().as_ref()) + ); + let keys = vec![PublicKey::parse(&key_str).unwrap()]; + + let body = bytes(&doc()); + let sig = base64::engine::general_purpose::STANDARD.encode(kp.sign(&body)); + + let m = verify_and_parse(&body, &sig, &keys, "stable").unwrap(); + assert_eq!(m.version, "0.23.0"); + assert_eq!( + m.windows_host.as_ref().unwrap().authenticode_sha256.len(), + 1 + ); + + // One flipped byte ⇒ refused before parse. + let mut tampered = body.clone(); + tampered[10] ^= 1; + assert!(verify_and_parse(&tampered, &sig, &keys, "stable").is_err()); + + // Signed for stable, replayed as canary ⇒ refused (channel binding). + assert!(verify_and_parse(&body, &sig, &keys, "canary").is_err()); + } + + #[test] + fn html_error_page_is_not_a_manifest() { + // The Gitea 303 stub that poisoned the sysext feed — must die in strict parse. + let html = b"See Other."; + assert!(parse_verified(html, "stable").is_err()); + } + + #[test] + fn truncated_json_refused() { + let body = bytes(&doc()); + assert!(parse_verified(&body[..body.len() - 5], "stable").is_err()); + } + + #[test] + fn wrong_schema_refused() { + let mut v = doc(); + v["schema"] = serde_json::json!(2); + assert!(parse_verified(&bytes(&v), "stable").is_err()); + } + + #[test] + fn zero_serial_refused() { + let mut v = doc(); + v["serial"] = serde_json::json!(0); + assert!(parse_verified(&bytes(&v), "stable").is_err()); + } + + #[test] + fn offsite_notes_url_refused() { + let mut v = doc(); + v["notes_url"] = serde_json::json!("https://evil.example/notes"); + assert!(parse_verified(&bytes(&v), "stable").is_err()); + } + + #[test] + fn windows_asset_validation() { + let mut v = doc(); + v["windows_host"]["sha256"] = serde_json::json!("nothex"); + assert!(parse_verified(&bytes(&v), "stable").is_err()); + let mut v = doc(); + v["windows_host"]["url"] = serde_json::json!("http://git.unom.io/x.exe"); + assert!(parse_verified(&bytes(&v), "stable").is_err()); + // No windows leg at all is fine (PM channels don't need it). + let mut v = doc(); + v.as_object_mut().unwrap().remove("windows_host"); + assert!(parse_verified(&bytes(&v), "stable").is_ok()); + } + + #[test] + fn oversized_refused() { + let mut v = doc(); + v["published_at"] = serde_json::json!("x".repeat(MAX_MANIFEST_BYTES)); + assert!(parse_verified(&bytes(&v), "stable").is_err()); + } +} diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 3987e917..ed95deb6 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -173,6 +173,12 @@ package_punktfunk-host() { # operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it. install -Dm0644 "$R/scripts/punktfunk-host-desktop-session.conf" \ "$pkgdir/usr/share/punktfunk/punktfunk-host-desktop-session.conf" + # Install-kind + channel marker, read by the host's update-check surface (planning: + # host-update-from-web-console.md §4.1). A canary build's pkgrel is `0.`. + local _pf_update_channel=stable + [[ $pkgrel == 0.* ]] && _pf_update_channel=canary + printf 'pacman %s\n' "$_pf_update_channel" | \ + install -Dm0644 /dev/stdin "$pkgdir/usr/share/punktfunk/install-kind" install -Dm0644 "$R/scripts/punktfunk-kde-session.service" "$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service" sed -i 's#%h/punktfunk/scripts/headless/run-headless-kde.sh#/usr/share/punktfunk/headless/run-headless-kde.sh#' \ "$pkgdir/usr/lib/systemd/user/punktfunk-kde-session.service" diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index 64dd6c00..b19804be 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -79,6 +79,16 @@ sed -i 's#%h/punktfunk/target/release/punktfunk-host#/usr/bin/punktfunk-host#' \ # operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it. install -Dm0644 scripts/punktfunk-host-desktop-session.conf \ "$STAGE/usr/share/punktfunk-host/punktfunk-host-desktop-session.conf" +# Install-kind + channel marker, read by the host's update-check surface (planning: +# host-update-from-web-console.md §4.1). ONE canonical path across all package formats — +# /usr/share/punktfunk/, not this package's punktfunk-host/ data dir. A canary version +# carries `~ciN`; anything else is stable. +case "$VERSION" in + *~ci*) _pf_update_channel=canary ;; + *) _pf_update_channel=stable ;; +esac +printf 'apt %s\n' "$_pf_update_channel" | \ + install -Dm0644 /dev/stdin "$STAGE/usr/share/punktfunk/install-kind" # Optional headless KWin session unit (the kwin --virtual appliance), as the RPM/Arch ship. # Repoint its ExecStart from the dev source tree to the packaged script. NOT enabled by default. install -Dm0644 scripts/punktfunk-kde-session.service "$STAGE/usr/lib/systemd/user/punktfunk-kde-session.service" diff --git a/packaging/rpm/build-rpm.sh b/packaging/rpm/build-rpm.sh index c500183b..bdd87386 100755 --- a/packaging/rpm/build-rpm.sh +++ b/packaging/rpm/build-rpm.sh @@ -64,6 +64,7 @@ rpmbuild -bb --nodeps "${WEB_OPT[@]}" "${SCRIPTING_OPT[@]}" "${HOST_OPT[@]}" \ --define "_topdir $TOP" \ --define "pf_version ${PF_VERSION}" \ --define "pf_release ${PF_RELEASE}" \ + --define "pf_channel $(case "${PF_RELEASE:-1}" in 0.ci*) echo canary ;; *) echo stable ;; esac)" \ packaging/rpm/punktfunk.spec mkdir -p dist diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 595744f2..d56b1ca8 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -295,6 +295,11 @@ sed -i 's#%h/punktfunk/target/release/punktfunk-host#%{_bindir}/punktfunk-host#' # the operator copies it into ~/.config/systemd/user/punktfunk-host.service.d/ when they want it. install -Dm0644 scripts/punktfunk-host-desktop-session.conf %{buildroot}%{_datadir}/%{name}/punktfunk-host-desktop-session.conf +# Install-kind + channel marker, read by the host's update-check surface (planning: +# host-update-from-web-console.md §4.1). `pf_channel` is defined by build-rpm.sh (canary +# when the release override starts `0.ci`); a plain local rpmbuild is stable. +printf 'dnf %{?pf_channel}%{!?pf_channel:stable}\n' > %{buildroot}%{_datadir}/%{name}/install-kind + # Optional headless KDE session unit (the kwin streaming appliance): brings up `kwin --virtual` on # wayland-kde via the packaged run-headless-kde.sh, so the host's kwin backend has a session whose # privileged screencast protocol it can bind. Repoint its ExecStart from the dev source tree to the