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:
+224
-1
@@ -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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user