diff --git a/Cargo.lock b/Cargo.lock index 7567b9ee..35154c1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3047,6 +3047,14 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "pf-update" +version = "0.22.2" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "pf-vdisplay" version = "0.22.3" diff --git a/Cargo.toml b/Cargo.toml index 2bf10132..a86d73e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ members = [ "crates/pf-ffvk", "crates/pf-driver-proto", "crates/pf-paths", + "crates/pf-update", "crates/pf-host-config", "crates/pf-gpu", "crates/pf-zerocopy", diff --git a/api/openapi.json b/api/openapi.json index 78aa3637..825668d1 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -7353,6 +7353,10 @@ ], "description": "The stage that failed; absent on success." }, + "staged": { + "type": "boolean", + "description": "Applied but activates on the next reboot (rpm-ostree)." + }, "to": { "type": "string" } @@ -7447,6 +7451,13 @@ "description": "The last verified manifest, if any check has succeeded." } ] + }, + "opt_in_hint": { + "type": [ + "string", + "null" + ], + "description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)." } } } diff --git a/crates/pf-update/Cargo.toml b/crates/pf-update/Cargo.toml new file mode 100644 index 00000000..26ba05cc --- /dev/null +++ b/crates/pf-update/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "pf-update" +description = "Root helper for web-console-triggered host updates: runs the distro package manager against the punktfunk packages, gates on the new binary actually running, and reports a result record. Deliberately tiny — root runs these few hundred lines, never the host's dependency tree." +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true + +[[bin]] +name = "pf-update" +path = "src/main.rs" + +# The zero-parameter invariant lives in the DEPENDENCY LIST too: no HTTP client, no TLS, no +# argument parsing beyond one subcommand — the helper fetches nothing and parses no remote +# data; the package manager's own signature chain gates every payload. +[target.'cfg(target_os = "linux")'.dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/crates/pf-update/src/main.rs b/crates/pf-update/src/main.rs new file mode 100644 index 00000000..c9b0123f --- /dev/null +++ b/crates/pf-update/src/main.rs @@ -0,0 +1,327 @@ +//! `pf-update` — the root helper behind web-console-triggered Linux host updates +//! (planning: `host-update-from-web-console.md` §7, plan U2.1). +//! +//! Invoked as `pf-update apply`, normally via the `punktfunk-update.service` oneshot that a +//! `punktfunk-update`-group member may start through polkit. **It takes zero +//! attacker-influenceable parameters**: no versions, no URLs, no package names from the +//! caller — the install kind comes from root-owned markers, the package list from the local +//! package database, and every payload from the distro package manager's own signed +//! repositories. Compromising the trigger yields "run the system's normal update for the +//! punktfunk packages", nothing more. +//! +//! After a successful package-manager run, the **run-the-binary gate** executes the newly +//! installed `/usr/bin/punktfunk-host --version` and requires it to exit cleanly — the +//! CI-green-on-the-wrong-program class (the 0.22.0 clobber) dies here for one binary run's +//! worth of cost. The outcome is written to `/var/lib/punktfunk/update-result.json` +//! (root-written, world-readable) for the unprivileged host to read; stdout/stderr land in +//! the unit's journal. + +#[cfg(target_os = "linux")] +mod linux_main { + use serde::Serialize; + use std::path::Path; + use std::process::Command; + + const MARKER: &str = "/usr/share/punktfunk/install-kind"; + const SYSEXT_MARKER: &str = "/usr/lib/extension-release.d/extension-release.punktfunk"; + const OSTREE_BOOTED: &str = "/run/ostree-booted"; + const PACMAN_OPTIN_CONF: &str = "/etc/punktfunk/update.conf"; + const RESULT_PATH: &str = "/var/lib/punktfunk/update-result.json"; + const HOST_BIN: &str = "/usr/bin/punktfunk-host"; + + /// What the host reads back. Field meanings mirror the mgmt API's `UpdateResultInfo` + /// where they overlap; `changed=false` is the "your package source has nothing newer + /// yet" case (not an error), `staged=true` means a reboot finishes the update + /// (rpm-ostree). + #[derive(Serialize)] + struct HelperResult { + ok: bool, + kind: String, + before_version: String, + after_version: String, + changed: bool, + staged: bool, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, + finished_unix: u64, + } + + fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) + } + + /// Root-owned facts → the apply strategy. Mirrors the host's ladder for the kinds a + /// root helper serves (the helper decides for ITSELF — never trusts its caller). + fn detect_kind() -> Result<&'static str, String> { + if Path::new(SYSEXT_MARKER).exists() { + return Ok("sysext"); + } + let marker = std::fs::read_to_string(MARKER) + .map_err(|e| format!("no install-kind marker at {MARKER}: {e}"))?; + match marker.split_whitespace().next() { + Some("apt") => Ok("apt"), + Some("dnf") if Path::new(OSTREE_BOOTED).exists() => Ok("rpm-ostree"), + Some("dnf") => Ok("dnf"), + Some("pacman") => Ok("pacman"), + other => Err(format!( + "install-kind marker says {other:?} — no root apply leg for it" + )), + } + } + + fn run(cmd: &mut Command, what: &str) -> Result<(), String> { + println!("pf-update: running {cmd:?}"); + let status = cmd + .status() + .map_err(|e| format!("{what}: failed to launch: {e}"))?; + if !status.success() { + return Err(format!("{what}: exited {status}")); + } + Ok(()) + } + + fn run_capture(cmd: &mut Command, what: &str) -> Result { + let out = cmd + .output() + .map_err(|e| format!("{what}: failed to launch: {e}"))?; + if !out.status.success() { + return Err(format!("{what}: exited {}", out.status)); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } + + /// The installed punktfunk packages, from the LOCAL package database — upgrade exactly + /// what this box has (host-only installs don't grow a web console out of nowhere). + fn installed_packages(query: &mut Command, what: &str) -> Result, String> { + let out = run_capture(query, what)?; + let pkgs: Vec = out + .lines() + .map(str::trim) + .filter(|l| l.starts_with("punktfunk")) + .map(str::to_string) + .collect(); + if pkgs.is_empty() { + return Err(format!("{what}: no installed punktfunk packages found")); + } + Ok(pkgs) + } + + fn host_version() -> Result { + run_capture( + Command::new(HOST_BIN).arg("--version"), + "punktfunk-host --version", + ) + } + + /// The per-kind command tables (design §5). Returns `staged` (activation needs a reboot). + fn apply_for_kind(kind: &str) -> Result { + match kind { + "apt" => { + // Refresh only OUR index when the documented list file exists (S5); + // otherwise a full refresh — normal admin behavior, just slower. + let ours = "/etc/apt/sources.list.d/punktfunk.list"; + let mut update = Command::new("apt-get"); + update.env("DEBIAN_FRONTEND", "noninteractive"); + if Path::new(ours).exists() { + update.args([ + "update", + "-o", + &format!("Dir::Etc::sourcelist={ours}"), + "-o", + "Dir::Etc::sourceparts=-", + ]); + } else { + update.arg("update"); + } + run(&mut update, "apt-get update")?; + let pkgs = installed_packages( + Command::new("dpkg-query").args(["-W", "-f", "${Package}\n", "punktfunk*"]), + "dpkg-query", + )?; + let mut install = Command::new("apt-get"); + install + .env("DEBIAN_FRONTEND", "noninteractive") + .args(["install", "--only-upgrade", "-y"]) + .args(&pkgs); + run(&mut install, "apt-get install --only-upgrade")?; + Ok(false) + } + "dnf" => { + let pkgs = installed_packages( + Command::new("rpm").args(["-qa", "--qf", "%{NAME}\n", "punktfunk*"]), + "rpm -qa", + )?; + let mut upgrade = Command::new("dnf"); + upgrade.args(["-y", "upgrade"]).args(&pkgs); + run(&mut upgrade, "dnf upgrade")?; + Ok(false) + } + "rpm-ostree" => { + // A layered package only re-resolves when forced — the single-transaction + // uninstall+install dance (packaging/bazzite/update-punktfunk.sh). Staged; + // a reboot activates it. + let pkgs = installed_packages( + Command::new("rpm").args(["-qa", "--qf", "%{NAME}\n", "punktfunk*"]), + "rpm -qa", + )?; + run( + Command::new("rpm-ostree").args(["refresh-md", "--force"]), + "rpm-ostree refresh-md", + )?; + let mut update = Command::new("rpm-ostree"); + update.arg("update"); + for p in &pkgs { + update.args(["--uninstall", p, "--install", p]); + } + run(&mut update, "rpm-ostree update (re-resolve)")?; + Ok(true) + } + "sysext" => { + // The proven signed-feed updater; it refreshes the merged /usr in place. + run( + Command::new("punktfunk-sysext").arg("update"), + "punktfunk-sysext update", + )?; + Ok(false) + } + "pacman" => { + // Arch doctrine: partial upgrades break boxes, so the ONLY thing this + // helper will run is a full -Syu — and only when the operator opted into + // that explicitly (root-owned config, not the API). + let optin = std::fs::read_to_string(PACMAN_OPTIN_CONF) + .ok() + .map(|c| c.lines().any(|l| l.trim() == "PACMAN_FULL_SYSUPGRADE=1")) + .unwrap_or(false); + if !optin { + return Err(format!( + "pacman full-sysupgrade is not opted in — set PACMAN_FULL_SYSUPGRADE=1 \ + in {PACMAN_OPTIN_CONF} (this runs `pacman -Syu` for the WHOLE system)" + )); + } + run( + Command::new("pacman").args(["-Syu", "--noconfirm"]), + "pacman -Syu", + )?; + Ok(false) + } + other => Err(format!("no apply leg for install kind {other}")), + } + } + + fn write_result(result: &HelperResult) { + let path = Path::new(RESULT_PATH); + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let tmp = path.with_extension("json.tmp"); + if let Ok(bytes) = serde_json::to_vec_pretty(result) { + if std::fs::write(&tmp, &bytes).is_ok() { + let _ = std::fs::rename(&tmp, path); + } + } + } + + pub fn main() { + let arg = std::env::args().nth(1).unwrap_or_default(); + if arg != "apply" { + eprintln!("usage: pf-update apply (normally via punktfunk-update.service)"); + std::process::exit(2); + } + // Effective root is required for every leg; refuse early with a clear message + // rather than half-running. + // SAFETY: geteuid has no preconditions. + if unsafe { libc_geteuid() } != 0 { + eprintln!("pf-update: must run as root (start punktfunk-update.service)"); + std::process::exit(1); + } + + let kind = match detect_kind() { + Ok(k) => k, + Err(e) => { + eprintln!("pf-update: {e}"); + write_result(&HelperResult { + ok: false, + kind: "unknown".into(), + before_version: String::new(), + after_version: String::new(), + changed: false, + staged: false, + error: Some(e), + finished_unix: now_unix(), + }); + std::process::exit(1); + } + }; + println!("pf-update: install kind {kind}"); + let before = host_version().unwrap_or_default(); + + let outcome = apply_for_kind(kind).and_then(|staged| { + // The run-the-binary gate: the freshly installed binary must actually run. + // Skipped for staged (rpm-ostree) — the new binary isn't in /usr until reboot. + let after = if staged { + before.clone() + } else { + host_version() + .map_err(|e| format!("run-the-binary gate: {e} — the update did NOT stick"))? + }; + Ok((staged, after)) + }); + + let result = match outcome { + Ok((staged, after)) => HelperResult { + ok: true, + kind: kind.into(), + changed: staged || after != before, + staged, + before_version: before, + after_version: after, + error: None, + finished_unix: now_unix(), + }, + Err(e) => { + eprintln!("pf-update: {e}"); + HelperResult { + ok: false, + kind: kind.into(), + before_version: before.clone(), + after_version: before, + changed: false, + staged: false, + error: Some(e), + finished_unix: now_unix(), + } + } + }; + let ok = result.ok; + write_result(&result); + println!( + "pf-update: {} ({} -> {}, changed: {}, staged: {})", + if ok { "ok" } else { "FAILED" }, + result.before_version, + result.after_version, + result.changed, + result.staged, + ); + std::process::exit(if ok { 0 } else { 1 }); + } + + // One libc symbol, declared directly — not worth a libc dependency in a root helper. + extern "C" { + #[link_name = "geteuid"] + fn libc_geteuid() -> u32; + } +} + +#[cfg(target_os = "linux")] +fn main() { + linux_main::main(); +} + +#[cfg(not(target_os = "linux"))] +fn main() { + eprintln!("pf-update is a Linux-only root helper"); + std::process::exit(2); +} diff --git a/crates/punktfunk-host/src/mgmt/update.rs b/crates/punktfunk-host/src/mgmt/update.rs index 5d06b7dc..fb552045 100644 --- a/crates/punktfunk-host/src/mgmt/update.rs +++ b/crates/punktfunk-host/src/mgmt/update.rs @@ -52,6 +52,9 @@ pub(crate) struct UpdateResultInfo { /// The installer's own log file on this host, for diagnosis. #[serde(default, skip_serializing_if = "Option::is_none")] pub log_path: Option, + /// Applied but activates on the next reboot (rpm-ostree). + #[serde(default)] + pub staged: bool, } /// The full update-check state for this host. @@ -80,6 +83,10 @@ pub(crate) struct UpdateStatus { pub last_checked_unix: Option, /// Why the last check failed, verbatim, if it did. pub last_error: Option, + /// This install could one-click apply, but the operator hasn't opted in yet — the + /// command to run (Linux: join the `punktfunk-update` group). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opt_in_hint: Option, /// The apply in flight, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub job: Option, @@ -135,6 +142,7 @@ fn status_from(snap: update::Snapshot) -> UpdateStatus { }), last_checked_unix: snap.checked.as_ref().map(|c| c.fetched_unix), last_error: snap.last_error, + opt_in_hint: update::opt_in_hint(), job, last_result: snap.last_result.as_ref().map(|r| UpdateResultInfo { ok: r.ok, @@ -144,6 +152,7 @@ fn status_from(snap: update::Snapshot) -> UpdateStatus { stage: r.stage.clone(), error: r.error.clone(), log_path: r.log_path.clone(), + staged: r.staged, }), } } diff --git a/crates/punktfunk-host/src/update.rs b/crates/punktfunk-host/src/update.rs index ed1d0023..9b335db0 100644 --- a/crates/punktfunk-host/src/update.rs +++ b/crates/punktfunk-host/src/update.rs @@ -18,6 +18,8 @@ pub(crate) mod detect; pub(crate) mod jobs; +#[cfg(target_os = "linux")] +mod linux; pub(crate) mod manifest; #[cfg(target_os = "windows")] mod windows; @@ -72,16 +74,58 @@ pub(crate) fn apply_disabled() -> bool { ) } -/// What the console may offer for this install: `full` (one-click apply exists and is enabled) -/// or `notify` (show the command). `staged` joins with the rpm-ostree leg (U2). +/// What the console may offer for this install: `full` (one-click apply), `staged` (apply + +/// reboot to finish — rpm-ostree), or `notify` (show the command). Linux legs additionally +/// require the packaged root helper AND the operator's group opt-in; pacman also the +/// root-owned full-sysupgrade config (design §5). pub(crate) fn apply_support() -> &'static str { + if apply_disabled() { + return "notify"; + } let (kind, _) = detect::detect(); match kind { - detect::InstallKind::WindowsInstaller if !apply_disabled() => "full", + detect::InstallKind::WindowsInstaller => "full", + #[cfg(target_os = "linux")] + detect::InstallKind::Apt | detect::InstallKind::Dnf | detect::InstallKind::Sysext + if linux::helper_installed() && linux::opted_in() => + { + "full" + } + #[cfg(target_os = "linux")] + detect::InstallKind::RpmOstree if linux::helper_installed() && linux::opted_in() => { + "staged" + } + #[cfg(target_os = "linux")] + detect::InstallKind::Pacman + if linux::helper_installed() && linux::opted_in() && linux::pacman_opted_in() => + { + "full" + } _ => "notify", } } +/// The opt-in instruction for status: this install COULD one-click apply (helper shipped) +/// but the operator hasn't joined the `punktfunk-update` group yet. +pub(crate) fn opt_in_hint() -> Option { + #[cfg(target_os = "linux")] + { + let (kind, _) = detect::detect(); + let capable = matches!( + kind, + detect::InstallKind::Apt + | detect::InstallKind::Dnf + | detect::InstallKind::Sysext + | detect::InstallKind::RpmOstree + | detect::InstallKind::Pacman + ); + if capable && !apply_disabled() && linux::helper_installed() && !linux::opted_in() { + return Some(linux::opt_in_hint()); + } + } + None +} + fn feed_base() -> String { std::env::var("PUNKTFUNK_UPDATE_FEED") .ok() @@ -369,7 +413,31 @@ pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), Apply return Err(ApplyError::Disabled); } let (kind, channel) = detect::detect(); - if kind != detect::InstallKind::WindowsInstaller { + let windows_leg = kind == detect::InstallKind::WindowsInstaller; + let linux_leg = matches!( + kind, + detect::InstallKind::Apt + | detect::InstallKind::Dnf + | detect::InstallKind::Sysext + | detect::InstallKind::RpmOstree + | detect::InstallKind::Pacman + ); + if !windows_leg && !linux_leg { + return Err(ApplyError::Unsupported); + } + #[cfg(target_os = "linux")] + if linux_leg { + if !linux::helper_installed() { + return Err(ApplyError::Unsupported); + } + // The pacman leg additionally requires the root-owned full-sysupgrade opt-in — the + // helper enforces it too; refusing here keeps the console honest before any spawn. + if kind == detect::InstallKind::Pacman && !linux::pacman_opted_in() { + return Err(ApplyError::Unsupported); + } + } + #[cfg(not(target_os = "linux"))] + if linux_leg { return Err(ApplyError::Unsupported); } if session_active && !force { @@ -402,17 +470,24 @@ pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), Apply env!("PUNKTFUNK_VERSION"), channel, ); - let Some(asset) = checked.manifest.windows_host.clone() else { - return Err(ApplyError::NothingToApply); - }; if !newer { return Err(ApplyError::NothingToApply); } + // Only the Windows leg needs the manifest's installer asset; the Linux legs resolve + // artifacts through the package manager. + let asset = checked.manifest.windows_host.clone(); + if windows_leg && asset.is_none() { + return Err(ApplyError::NothingToApply); + } let version = checked.manifest.version.clone(); let serial = checked.manifest.serial; rt.job = Some(jobs::JobSnapshot { target_version: version.clone(), - stage: "downloading", + stage: if windows_leg { + "downloading" + } else { + "applying" + }, received_bytes: 0, total_bytes: None, started_unix: now_unix(), @@ -420,52 +495,78 @@ pub(crate) fn start_apply(force: bool, session_active: bool) -> Result<(), Apply (version, serial, asset) }; - #[cfg(target_os = "windows")] - { - tokio::task::spawn_blocking(move || { - let progress = |received: u64, total: Option| { - let mut rt = runtime().lock().unwrap(); - if let Some(job) = rt.job.as_mut() { - job.received_bytes = received; - job.total_bytes = total; - } - }; - let stage = |s: &'static str| { - let mut rt = runtime().lock().unwrap(); - if let Some(job) = rt.job.as_mut() { - job.stage = s; - } - }; - match windows::run_apply(&asset, &target_version, serial, &progress, &stage) { - Ok(()) => { - // Stage stays `restarting`; the installer is about to stop the service and - // kill this process. Boot reconciliation writes the durable outcome. - } - Err((stage_name, error)) => { - let record = jobs::ResultRecord { - ok: false, - from: env!("PUNKTFUNK_VERSION").into(), - to: target_version.clone(), - finished_unix: now_unix(), - stage: Some(stage_name.into()), - error: Some(error), - log_path: None, - }; - let _ = jobs::write_json_atomic(&jobs::result_path(), &record); - runtime().lock().unwrap().job = None; - } + tokio::task::spawn_blocking(move || { + let stage = |s: &'static str| { + let mut rt = runtime().lock().unwrap(); + if let Some(job) = rt.job.as_mut() { + job.stage = s; } - }); - Ok(()) - } - #[cfg(not(target_os = "windows"))] - { - // Unreachable in practice (`detect` never yields WindowsInstaller off-Windows); undo - // the reservation defensively rather than leaking a stuck job. - let _ = (target_version, serial, asset); - runtime().lock().unwrap().job = None; - Err(ApplyError::Unsupported) - } + }; + let outcome: Result = { + #[cfg(target_os = "windows")] + { + let progress = |received: u64, total: Option| { + let mut rt = runtime().lock().unwrap(); + if let Some(job) = rt.job.as_mut() { + job.received_bytes = received; + job.total_bytes = total; + } + }; + let asset = asset.expect("windows leg reserved with an asset"); + windows::run_apply(&asset, &target_version, serial, &progress, &stage) + .map(|()| PostApply::AwaitRestart) + } + #[cfg(target_os = "linux")] + { + let _ = &asset; // the Linux legs resolve through the package manager + linux::run_apply(&target_version, serial, &stage).map(|()| { + // Staged / nothing-to-do wrote a durable result and this process lives + // on; an in-place change wrote the intent and our restart is queued. + // Either way the in-process job is finished. + PostApply::Done + }) + } + #[cfg(not(any(target_os = "windows", target_os = "linux")))] + { + let _ = (&asset, &target_version, serial, &stage); + Err(("applying", "no apply leg for this platform".to_string())) + } + }; + match outcome { + Ok(PostApply::AwaitRestart) => { + // Stage stays `restarting`; the installer is about to stop the service and + // kill this process. Boot reconciliation writes the durable outcome. + } + Ok(PostApply::Done) => { + runtime().lock().unwrap().job = None; + } + Err((stage_name, error)) => { + let record = jobs::ResultRecord { + ok: false, + from: env!("PUNKTFUNK_VERSION").into(), + to: target_version.clone(), + finished_unix: now_unix(), + stage: Some(stage_name.into()), + error: Some(error), + log_path: None, + staged: false, + }; + let _ = jobs::write_json_atomic(&jobs::result_path(), &record); + runtime().lock().unwrap().job = None; + } + } + }); + Ok(()) +} + +/// What an apply leg leaves behind for the spawn wrapper. (Each platform constructs only +/// its own variant; the other is matched-but-never-built there.) +#[allow(dead_code)] +enum PostApply { + /// The process is about to die (installer / self-restart); reconcile owns the outcome. + AwaitRestart, + /// The leg finished in-process (staged, nothing-to-do) — clear the job. + Done, } /// Boot-time reconciliation (design §4.2): close out an intent record left by a previous diff --git a/crates/punktfunk-host/src/update/jobs.rs b/crates/punktfunk-host/src/update/jobs.rs index e33b12f2..0ff32eb2 100644 --- a/crates/punktfunk-host/src/update/jobs.rs +++ b/crates/punktfunk-host/src/update/jobs.rs @@ -53,6 +53,9 @@ pub(crate) struct ResultRecord { /// The installer log, when one was in play by the time it failed (or succeeded). #[serde(default, skip_serializing_if = "Option::is_none")] pub log_path: Option, + /// The update is applied but activates on the next reboot (rpm-ostree). + #[serde(default)] + pub staged: bool, } /// The live job the console polls, mirrored into `GET /update/status`. @@ -130,6 +133,7 @@ pub(crate) fn reconcile( stage: None, error: None, log_path: Some(intent.log_path), + staged: false, }); } if now_unix.saturating_sub(intent.started_unix) < APPLY_GRACE_SECS { @@ -147,6 +151,7 @@ pub(crate) fn reconcile( intent.from, intent.to )), log_path: Some(intent.log_path), + staged: false, }) } diff --git a/crates/punktfunk-host/src/update/linux.rs b/crates/punktfunk-host/src/update/linux.rs new file mode 100644 index 00000000..2b3d0806 --- /dev/null +++ b/crates/punktfunk-host/src/update/linux.rs @@ -0,0 +1,261 @@ +//! The Linux apply leg (design §7, plan U2.3): ask systemd to run the `pf-update` root +//! oneshot, read its result record, and — when the on-disk binary actually changed — hand +//! the outcome across our own restart via the same intent/reconcile machinery the Windows +//! leg uses. +//! +//! Privilege model: this process is unprivileged; `systemctl start punktfunk-update.service` +//! is authorized by polkit for members of the `punktfunk-update` group (an explicit, +//! auditable opt-in — the packaged group is empty). The request we send carries **nothing**: +//! the unit's ExecStart is fixed, and the helper derives everything from root-owned state. +//! polkit resolves group membership via NSS at request time, so a fresh +//! `usermod -aG punktfunk-update` counts without re-login (the *hint* probe in +//! [`opted_in`] uses the same NSS route for the same reason). + +#![cfg(target_os = "linux")] + +use super::jobs::{self, IntentRecord}; +use std::path::Path; +use std::process::Command; +use std::time::{Duration, Instant}; + +/// Where the root helper writes its outcome (mirrors `pf-update`'s `HelperResult`). +const HELPER_RESULT: &str = "/var/lib/punktfunk/update-result.json"; + +/// The unit the polkit rule scopes to. Its presence is the "helper is installed" probe. +const UNIT_PATH: &str = "/usr/lib/systemd/system/punktfunk-update.service"; + +/// The pacman escape hatch (root-owned; see the design's §5 pacman stance). +const PACMAN_OPTIN_CONF: &str = "/etc/punktfunk/update.conf"; + +/// Wall-clock cap on one helper run (a full `pacman -Syu` on a stale box is slow; a stuck +/// package manager should still surface as an error eventually). +const HELPER_TIMEOUT: Duration = Duration::from_secs(30 * 60); + +#[derive(serde::Deserialize)] +struct HelperResult { + ok: bool, + #[serde(default)] + before_version: String, + #[serde(default)] + after_version: String, + #[serde(default)] + changed: bool, + #[serde(default)] + staged: bool, + #[serde(default)] + error: Option, + #[serde(default)] + finished_unix: u64, +} + +/// The root helper (+ its unit) is installed on this box. +pub(super) fn helper_installed() -> bool { + Path::new(UNIT_PATH).exists() +} + +/// The pacman full-sysupgrade escape hatch is explicitly enabled (root-owned config). +pub(super) fn pacman_opted_in() -> bool { + std::fs::read_to_string(PACMAN_OPTIN_CONF) + .map(|c| c.lines().any(|l| l.trim() == "PACMAN_FULL_SYSUPGRADE=1")) + .unwrap_or(false) +} + +/// Is this process's user in the `punktfunk-update` group, **by NSS** (matching how polkit +/// will decide) — not by our (possibly stale) process credentials. Cached briefly; the +/// status endpoint polls this. +pub(super) fn opted_in() -> bool { + static CACHE: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + let cache = CACHE.get_or_init(|| std::sync::Mutex::new(None)); + { + let cached = cache.lock().unwrap(); + if let Some((at, val)) = *cached { + if at.elapsed() < Duration::from_secs(60) { + return val; + } + } + } + let val = probe_group_membership().unwrap_or(false); + *cache.lock().unwrap() = Some((Instant::now(), val)); + val +} + +fn probe_group_membership() -> Option { + let user = capture(Command::new("id").arg("-un"))?; + let groups = capture(Command::new("id").args(["-nG", user.trim()]))?; + Some(groups.split_whitespace().any(|g| g == "punktfunk-update")) +} + +fn capture(cmd: &mut Command) -> Option { + let out = cmd.output().ok()?; + out.status + .success() + .then(|| String::from_utf8_lossy(&out.stdout).into_owned()) +} + +/// The console-facing opt-in instruction, shown instead of an Apply button. +pub(super) fn opt_in_hint() -> String { + "sudo usermod -aG punktfunk-update $USER # enables web-triggered updates for this host" + .to_string() +} + +/// Run the whole Linux apply: start the oneshot, wait, interpret its result record, and for +/// an in-place binary change, write the intent and restart ourselves (boot reconciliation +/// reports the outcome). Blocking — run on a blocking thread. +pub(super) fn run_apply( + target_version: &str, + serial: u64, + stage: &dyn Fn(&'static str), +) -> Result<(), (&'static str, String)> { + let started_unix = super::now_unix(); + + let mut child = Command::new("systemctl") + .args(["start", "punktfunk-update.service"]) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| ("applying", format!("launch systemctl: {e}")))?; + + let deadline = Instant::now() + HELPER_TIMEOUT; + let status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) if Instant::now() > deadline => { + let _ = child.kill(); + return Err(( + "applying", + format!( + "the update helper is still running after {} min — see \ + `journalctl -u punktfunk-update.service`", + HELPER_TIMEOUT.as_secs() / 60 + ), + )); + } + Ok(None) => std::thread::sleep(Duration::from_millis(500)), + Err(e) => return Err(("applying", format!("wait for systemctl: {e}"))), + } + }; + + if !status.success() { + let mut err = String::new(); + if let Some(mut stderr) = child.stderr.take() { + use std::io::Read as _; + let _ = stderr.read_to_string(&mut err); + } + let denial = err.contains("interactive authentication") + || err.contains("Access denied") + || err.contains("Permission denied"); + return Err(( + "applying", + if denial { + format!( + "not authorized to start the update helper — enable web-triggered \ + updates first: {}", + opt_in_hint() + ) + } else { + format!( + "update helper failed ({status}) — see \ + `journalctl -u punktfunk-update.service`. {err}" + ) + }, + )); + } + + // The unit succeeded; its record is the ground truth. Refuse a stale record (an old run's + // leftovers with a fresh exit 0 would mean the helper never wrote — surface that). + let result: HelperResult = std::fs::read(HELPER_RESULT) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .ok_or(( + "applying", + format!("the update helper wrote no readable result at {HELPER_RESULT}"), + ))?; + if result.finished_unix + 5 < started_unix { + return Err(( + "applying", + format!( + "the update helper's result record predates this run \ + ({} < {started_unix}) — it never wrote one", + result.finished_unix + ), + )); + } + if !result.ok { + return Err(( + "applying", + result + .error + .unwrap_or_else(|| "the update helper reported failure without detail".into()), + )); + } + + let current = env!("PUNKTFUNK_VERSION"); + if result.staged { + // rpm-ostree: the new deployment activates on reboot — durable outcome now, no restart. + let _ = jobs::write_json_atomic( + &jobs::result_path(), + &jobs::ResultRecord { + ok: true, + from: current.into(), + to: target_version.into(), + finished_unix: super::now_unix(), + stage: None, + error: None, + log_path: None, + staged: true, + }, + ); + return Ok(()); + } + if !result.changed { + // The channel had nothing newer than what's installed (announce leads the mirrors) — + // a real answer, not an error. from == to renders as "already up to date". + let _ = jobs::write_json_atomic( + &jobs::result_path(), + &jobs::ResultRecord { + ok: true, + from: current.into(), + to: current.into(), + finished_unix: super::now_unix(), + stage: None, + error: None, + log_path: None, + staged: false, + }, + ); + return Ok(()); + } + + // The on-disk binary changed (and the helper's run-the-binary gate already proved it + // runs). Cross the restart on the intent record, exactly like the Windows leg. + let to = result + .after_version + .split_whitespace() + .last() + .unwrap_or(target_version) + .to_string(); + jobs::write_json_atomic( + &jobs::intent_path(), + &IntentRecord { + from: current.into(), + to, + serial, + started_unix: super::now_unix(), + installer_sha256: String::new(), + log_path: "journalctl -u punktfunk-update.service".into(), + }, + ) + .map_err(|e| ("restarting", format!("write intent record: {e}")))?; + + stage("restarting"); + // Web console first (its own unit; a brief console blip the frontend rides out), then + // ourselves — --no-block, because this very process is what's being restarted. + let _ = Command::new("systemctl") + .args(["--user", "--no-block", "restart", "punktfunk-web.service"]) + .status(); + let _ = Command::new("systemctl") + .args(["--user", "--no-block", "restart", "punktfunk-host.service"]) + .status(); + Ok(()) +} diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index ed95deb6..b411ef0e 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -100,7 +100,8 @@ build() { -p punktfunk-cli else cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \ - -p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli + -p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli \ + -p pf-update # The status tray in its OWN cargo invocation — load-bearing, not tidiness. Cargo unifies features # across everything in one build, so co-building the tray with the host pulls the host's # ashpd -> zbus/tokio onto the tray's shared zbus; the tray (ksni async-io + blocking, no tokio @@ -159,6 +160,17 @@ package_punktfunk-host() { "$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy" sed -i 's#/usr/libexec/punktfunk/pf-dm-helper#/usr/lib/punktfunk/pf-dm-helper#' \ "$pkgdir/usr/share/polkit-1/actions/io.unom.punktfunk.dm-helper.policy" + # Web-console-triggered updates (host-update-from-web-console.md §7): root helper + oneshot + # unit + group-scoped polkit rule. Same no-libexec relocation as pf-dm-helper, with the + # unit's ExecStart rewritten to match. On pacman the helper additionally requires the + # explicit PACMAN_FULL_SYSUPGRADE opt-in (partial upgrades are against Arch doctrine). + install -Dm0755 "$R/target/release/pf-update" "$pkgdir/usr/lib/punktfunk/pf-update" + install -Dm0644 "$R/packaging/linux/punktfunk-update.service" \ + "$pkgdir/usr/lib/systemd/system/punktfunk-update.service" + sed -i 's#/usr/libexec/punktfunk/pf-update#/usr/lib/punktfunk/pf-update#' \ + "$pkgdir/usr/lib/systemd/system/punktfunk-update.service" + install -Dm0644 "$R/packaging/linux/49-punktfunk-update.rules" \ + "$pkgdir/usr/share/polkit-1/rules.d/49-punktfunk-update.rules" # vhci-hcd autoload — usbip transport for the virtual Steam Deck pad (Steam only adopts USB pads) install -Dm0644 "$R/scripts/punktfunk-modules.conf" "$pkgdir/usr/lib/modules-load.d/punktfunk.conf" # 32 MB UDP socket buffers (send-side headroom at high bitrate) diff --git a/packaging/arch/punktfunk-host.install b/packaging/arch/punktfunk-host.install index 18b7e7c0..efea5722 100644 --- a/packaging/arch/punktfunk-host.install +++ b/packaging/arch/punktfunk-host.install @@ -1,5 +1,11 @@ # pacman install scriptlet — mirrors the RPM %post / deb postinst. +_ensure_update_group() { + # The (empty) opt-in group for web-console-triggered updates — nobody is auto-added. + getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || true +} + post_install() { + _ensure_update_group udevadm control --reload-rules 2>/dev/null || true udevadm trigger --subsystem-match=misc 2>/dev/null || true # Apply the UDP socket-buffer tuning now (also auto-applied at boot by systemd-sysctl). @@ -48,6 +54,7 @@ MSG } post_upgrade() { + _ensure_update_group udevadm control --reload-rules 2>/dev/null || true sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true } diff --git a/packaging/bazzite/punktfunk-sysext.sh b/packaging/bazzite/punktfunk-sysext.sh index 0e41a87f..61468e1d 100644 --- a/packaging/bazzite/punktfunk-sysext.sh +++ b/packaging/bazzite/punktfunk-sysext.sh @@ -164,6 +164,9 @@ post_merge() { install -Dm0644 /usr/lib/modules-load.d/punktfunk.conf /etc/modules-load.d/punktfunk.conf 2>/dev/null || : install -Dm0644 /usr/lib/udev/rules.d/60-punktfunk.rules /etc/udev/rules.d/60-punktfunk.rules 2>/dev/null || : udevadm control --reload 2>/dev/null || : + # The (empty) opt-in group for web-console-triggered updates (the sysext ships the pf-update + # helper + unit + polkit rule in its /usr; the group can't ride an image) — nobody is auto-added. + getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || : modprobe vhci-hcd 2>/dev/null || : # Re-fire the vhci rule against the (possibly already-present) controller so attach/detach pick up # the input-group ownership even when the module's original add event predated the reloaded rule. diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index b19804be..75030405 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -49,6 +49,9 @@ TRAY_BIN="target/release/punktfunk-tray" # when the existing artifact was already resolved that way, and rebuilds it when it wasn't. echo "==> building punktfunk-tray (release, own invocation — see comment above)" cargo build --release -p punktfunk-tray --locked +# The web-console-update root helper (dep-free; see crates/pf-update). +echo "==> building pf-update (release)" +cargo build --release -p pf-update --locked STAGE="$(mktemp -d)" trap 'rm -rf "$STAGE"' EXIT @@ -57,6 +60,14 @@ SHAREDIR="$STAGE/usr/share/$PKG" # --- file layout (matches the RPM %install) ---------------------------------- install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG" +# Web-console-triggered updates (host-update-from-web-console.md §7): root helper + its +# oneshot unit + the polkit rule scoping `systemctl start punktfunk-update.service` to the +# (shipped-empty) punktfunk-update group. Opt-in = joining the group; postinst creates it. +install -Dm0755 target/release/pf-update "$STAGE/usr/libexec/punktfunk/pf-update" +install -Dm0644 packaging/linux/punktfunk-update.service \ + "$STAGE/usr/lib/systemd/system/punktfunk-update.service" +install -Dm0644 packaging/linux/49-punktfunk-update.rules \ + "$STAGE/usr/share/polkit-1/rules.d/49-punktfunk-update.rules" install -Dm0644 scripts/60-punktfunk.rules "$STAGE/usr/lib/udev/rules.d/60-punktfunk.rules" # Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can # stop/restore the display manager for the stream (the helper derives the DM unit itself). @@ -276,6 +287,8 @@ cat > "$STAGE/DEBIAN/postinst" <<'EOF' #!/bin/sh set -e if [ "$1" = "configure" ]; then + # The (empty) opt-in group for web-console-triggered updates — nobody is auto-added. + getent group punktfunk-update >/dev/null 2>&1 || addgroup --system punktfunk-update 2>/dev/null || true # Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers). udevadm control --reload-rules 2>/dev/null || true udevadm trigger --subsystem-match=misc 2>/dev/null || true diff --git a/packaging/linux/49-punktfunk-update.rules b/packaging/linux/49-punktfunk-update.rules new file mode 100644 index 00000000..d1ae2988 --- /dev/null +++ b/packaging/linux/49-punktfunk-update.rules @@ -0,0 +1,21 @@ +// Members of the `punktfunk-update` group may START exactly one unit: the punktfunk +// root-helper oneshot that updates the punktfunk packages (planning: +// host-update-from-web-console.md §7). The group ships EMPTY — joining it is the explicit, +// auditable opt-in for web-console-triggered updates: +// +// sudo usermod -aG punktfunk-update +// +// Scope notes: `verb == "start"` keeps stop/restart/enable out of the grant, and the unit +// name pin keeps every other unit out. The unit's ExecStart is fixed and parameterless, so +// this grant authorizes "run the system's normal update for the punktfunk packages" and +// nothing else. +polkit.addRule(function (action, subject) { + if ( + action.id == "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") == "punktfunk-update.service" && + action.lookup("verb") == "start" && + subject.isInGroup("punktfunk-update") + ) { + return polkit.Result.YES; + } +}); diff --git a/packaging/linux/punktfunk-update.service b/packaging/linux/punktfunk-update.service new file mode 100644 index 00000000..746a3df9 --- /dev/null +++ b/packaging/linux/punktfunk-update.service @@ -0,0 +1,17 @@ +# The web-console-triggered host update, as a root oneshot (planning: +# host-update-from-web-console.md §7). Started — never enabled — by the unprivileged host via +# `systemctl start punktfunk-update.service`, authorized by the polkit rule +# (49-punktfunk-update.rules) for members of the `punktfunk-update` group. systemd gives us +# single-flight (a second start while active fails) and journal capture for free; the helper +# writes its machine-readable outcome to /var/lib/punktfunk/update-result.json. +[Unit] +Description=punktfunk host update (root helper) +# Network is a hard prerequisite for every leg (package manager / sysext feed). +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/libexec/punktfunk/pf-update apply +# The helper drives the distro package manager, which needs the real system — no sandboxing +# directives here on purpose; the constraint is the FIXED ExecStart (no parameters exist). diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index d56b1ca8..3fab7273 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -226,7 +226,8 @@ export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}" # back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices degrade gracefully. %if %{with host} cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \ - -p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli + -p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli \ + -p pf-update %else # Client-only (aarch64): no host crate, so none of the encode features apply. cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli @@ -284,6 +285,13 @@ install -Dm0644 scripts/punktfunk-modules.conf %{buildroot}%{_prefix}/lib/module # and high-bitrate frames overflow it (send-side loss). systemd-sysctl applies it at boot. install -Dm0644 scripts/99-punktfunk-net.conf %{buildroot}%{_prefix}/lib/sysctl.d/99-punktfunk-net.conf +# Web-console-triggered updates (host-update-from-web-console.md §7): the dep-free root +# helper + its oneshot system unit + the polkit rule scoping it to the (shipped-empty) +# punktfunk-update group. Also rides into the Bazzite sysext image via rpm2cpio. +install -Dm0755 target/release/pf-update %{buildroot}%{_libexecdir}/punktfunk/pf-update +install -Dm0644 packaging/linux/punktfunk-update.service %{buildroot}%{_unitdir}/punktfunk-update.service +install -Dm0644 packaging/linux/49-punktfunk-update.rules %{buildroot}%{_datadir}/polkit-1/rules.d/49-punktfunk-update.rules + # systemd *user* unit (the host runs in the graphical session, not as root). install -Dm0644 scripts/punktfunk-host.service %{buildroot}%{_userunitdir}/punktfunk-host.service # The source unit's ExecStart points at the dev source tree; a packaged install has the binary at @@ -432,6 +440,9 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/ %{_udevrulesdir}/60-punktfunk.rules %dir %{_libexecdir}/punktfunk %{_libexecdir}/punktfunk/pf-dm-helper +%{_libexecdir}/punktfunk/pf-update +%{_unitdir}/punktfunk-update.service +%{_datadir}/polkit-1/rules.d/49-punktfunk-update.rules %{_datadir}/polkit-1/actions/io.unom.punktfunk.dm-helper.policy %{_prefix}/lib/modules-load.d/punktfunk.conf %{_prefix}/lib/sysctl.d/99-punktfunk-net.conf @@ -500,6 +511,8 @@ update-desktop-database %{_datadir}/applications >/dev/null 2>&1 || : %if %{with host} %post +# The (empty) opt-in group for web-console-triggered updates — nobody is auto-added. +getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || : # Reload udev so /dev/uinput picks up the new rule without a reboot (best-effort). udevadm control --reload-rules 2>/dev/null || : udevadm trigger --subsystem-match=misc 2>/dev/null || :