feat(host): update check — signed per-channel manifest, install-kind detection, /api/v1/update surface

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:34:46 +02:00
co-authored by Claude Fable 5
parent 940bd0b7ec
commit cc01562631
14 changed files with 1494 additions and 3 deletions
+224 -1
View File
@@ -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)"
}
]
}
+14
View File
@@ -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",
+1
View File
@@ -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")]
+5 -1
View File
@@ -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<Arc<MgmtState>>, 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;
+6 -1
View File
@@ -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
}
+25
View File
@@ -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"
+131
View File
@@ -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<UpdateManifestInfo>,
/// When the last successful check happened (unix seconds).
pub last_checked_unix: Option<u64>,
/// Why the last check failed, verbatim, if it did.
pub last_error: Option<String>,
}
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<UpdateStatus> {
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",
),
}
}
+401
View File
@@ -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 — `<base>/<channel>/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<PublicKey> {
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<Checked>,
last_error: Option<String>,
/// Refresh in flight (status kicks at most one).
refreshing: bool,
/// Wall-clock guard for the forced-check rate limit.
last_forced: Option<Instant>,
/// Last attempt of any kind — drives the auto-refresh cadence.
last_attempt: Option<Instant>,
/// 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<String>,
}
fn runtime() -> &'static Mutex<Runtime> {
static RT: OnceLock<Mutex<Runtime>> = 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<String, u64>,
}
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::<FloorFile>(&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<Manifest, String> {
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<Vec<u8>, 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<Checked, String> {
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<Snapshot, ForceError> {
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<Checked>,
pub last_error: Option<String>,
}
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());
}
}
+420
View File
@@ -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<PathBuf>,
/// Contents of [`MARKER_PATH`], if present.
pub marker: Option<String>,
/// [`SYSEXT_MARKER`] exists (merged sysext overlay).
pub sysext: bool,
/// Contents of [`SYSEXT_CONF`], if present.
pub sysext_conf: Option<String>,
/// `/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.<run>`), 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<Channel> {
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.<run>` where `<run>` 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<sha>` (deb), `0.23.0-0.ci10250.g<sha>` (rpm), `0.23.10250`
/// (Windows/decky style, run-as-patch). A stable string yields `None`.
pub(crate) fn canary_run(version: &str) -> Option<u64> {
// `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<u64>,
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);
}
}
@@ -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<u64>,
/// 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<WindowsHostAsset>,
}
/// 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<String>,
/// 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<Manifest> {
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<Manifest> {
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<u8> {
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:<base64>`).
#[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"<a href=\"https://objects.example/x\">See Other</a>.";
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());
}
}
+6
View File
@@ -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.<zero-padded run>`.
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"
+10
View File
@@ -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"
+1
View File
@@ -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
+5
View File
@@ -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