feat(host/linux): opt-in one-click updates — the pf-update root helper, group-scoped polkit grant, per-PM legs

The U2 leg of planning:host-update-from-web-console.md. A new dep-free root helper
(crates/pf-update) runs the distro package manager against the INSTALLED punktfunk
packages — apt (index refresh scoped to our list when present), dnf, rpm-ostree
(single-transaction re-resolve, reported staged), sysext (the proven signed-feed
updater), pacman only behind the explicit PACMAN_FULL_SYSUPGRADE opt-in — then the
run-the-binary gate, then a root-written result record. Zero attacker-influenceable
parameters end to end: fixed ExecStart oneshot (punktfunk-update.service), polkit rule
scoped to that one unit's start verb for the shipped-EMPTY punktfunk-update group
(joining it is the auditable opt-in; every postinst creates it, none populate it).
The host starts the unit, interprets the record (staged / nothing-newer-yet / changed),
and crosses its own restart on the same intent/reconcile machinery as the Windows leg.
Status now reports staged results and the opt-in hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 15:55:27 +02:00
co-authored by Claude Fable 5
parent bb1f93d90e
commit 06a249e49f
16 changed files with 884 additions and 55 deletions
Generated
+8
View File
@@ -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"
+1
View File
@@ -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",
+11
View File
@@ -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)."
}
}
}
+20
View File
@@ -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"
+327
View File
@@ -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<String>,
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<String, String> {
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<Vec<String>, String> {
let out = run_capture(query, what)?;
let pkgs: Vec<String> = 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<String, String> {
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<bool, String> {
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);
}
+9
View File
@@ -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<String>,
/// 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<u64>,
/// Why the last check failed, verbatim, if it did.
pub last_error: Option<String>,
/// 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<String>,
/// The apply in flight, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job: Option<UpdateJobInfo>,
@@ -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,
}),
}
}
+154 -53
View File
@@ -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<String> {
#[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<u64>| {
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<PostApply, (&'static str, String)> = {
#[cfg(target_os = "windows")]
{
let progress = |received: u64, total: Option<u64>| {
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
+5
View File
@@ -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<String>,
/// 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,
})
}
+261
View File
@@ -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<String>,
#[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::Mutex<Option<(Instant, bool)>>> =
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<bool> {
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<String> {
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(())
}
+13 -1
View File
@@ -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)
+7
View File
@@ -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
}
+3
View File
@@ -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.
+13
View File
@@ -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
+21
View File
@@ -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 <your user>
//
// 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;
}
});
+17
View File
@@ -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).
+14 -1
View File
@@ -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 || :