forked from unom/punktfunk
The root helper, its unit, the group-scoped polkit rule and the install-kind
marker all shipped with the HOST package. A Steam Deck or a handheld has no host
package, so the client half of one-tap updates had nothing to stand on.
The client packages now ship their own copies — deliberately under their own
paths (`pf-update-client`, `punktfunk-client-update.service`,
`49-punktfunk-client-update.rules`, `/usr/share/punktfunk-client/install-kind`)
rather than sharing the host's. Two packages owning one path is a hard conflict
in dpkg, rpm and pacman alike, and the client marker needs its own DIRECTORY
too because the host RPM claims `%{_datadir}/punktfunk/*` with a glob — a
sibling file there would break `dnf install punktfunk punktfunk-client`.
`pf-update` grows the matching `apply-client` verb. The verb comes from a
root-owned unit's fixed ExecStart, never from the caller, so the zero-parameter
invariant holds; both verbs sweep every installed punktfunk package, and what
the verb selects is which marker is read and which binary the run-the-binary
gate executes afterwards. A client sysext is refused with its reason: the signed
feed carries the host image, and installing that over a client-only box is not
an update.
The opt-in group is created by both packages' scriptlets, idempotently, so
whichever lands first wins.
Also fixes the host's own helper install: it read `$R/target/release/pf-update`,
which is the source checkout, while build() exports CARGO_TARGET_DIR="$srcdir/
target" and every other binary in the file installs from `$T`. Both now read
`$T`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
412 lines
16 KiB
Rust
412 lines
16 KiB
Rust
//! `pf-update` — the root helper behind triggered Linux package updates
|
|
//! (planning: `host-update-from-web-console.md` §7, plan U2.1).
|
|
//!
|
|
//! Two verbs, one per product:
|
|
//!
|
|
//! * `pf-update apply` — the HOST, via `punktfunk-update.service` (triggered from the web
|
|
//! console).
|
|
//! * `pf-update apply-client` — the CLIENT, via `punktfunk-client-update.service` (triggered
|
|
//! by `punktfunk-client --apply-update`, which is what the Decky plugin's one-tap runs).
|
|
//!
|
|
//! Both are started by an unprivileged process through polkit, authorised for members of the
|
|
//! `punktfunk-update` group. **The helper takes zero attacker-influenceable parameters**: no
|
|
//! versions, no URLs, no package names from the caller — the verb comes from a root-owned
|
|
//! unit's fixed `ExecStart`, the install kind 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 a trigger yields "run the system's normal update for the
|
|
//! punktfunk packages", nothing more.
|
|
//!
|
|
//! Both verbs upgrade every installed `punktfunk*` package — a box with both gets both,
|
|
//! whichever unit ran. What the verb changes is which marker is read (the two packages cannot
|
|
//! own one marker path: that is a hard conflict in deb, rpm and pacman alike) and which binary
|
|
//! the **run-the-binary gate** executes afterwards, requiring a clean exit — the
|
|
//! CI-green-on-the-wrong-program class (the 0.22.0 clobber) dies there for one binary run's
|
|
//! worth of cost. The outcome is written to `/var/lib/punktfunk/{,client-}update-result.json`
|
|
//! (root-written, world-readable) for the unprivileged caller 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 OSTREE_BOOTED: &str = "/run/ostree-booted";
|
|
const PACMAN_OPTIN_CONF: &str = "/etc/punktfunk/update.conf";
|
|
|
|
/// Which product this run was started for. It comes from the VERB in a root-owned unit's
|
|
/// fixed `ExecStart` — never from an unprivileged caller — so it stays inside the
|
|
/// zero-attacker-influenceable-parameters rule: the two units differ only in which
|
|
/// product's marker they read and which binary the run-the-binary gate executes.
|
|
///
|
|
/// Two units exist rather than one because the host and the client are separate packages
|
|
/// and every packaging format we ship treats two packages owning one path as a hard
|
|
/// conflict — a client-only box (a Steam Deck, a handheld) must be able to install the
|
|
/// helper without the host package.
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum Mode {
|
|
Host,
|
|
Client,
|
|
}
|
|
|
|
impl Mode {
|
|
fn marker(self) -> &'static str {
|
|
match self {
|
|
Mode::Host => "/usr/share/punktfunk/install-kind",
|
|
Mode::Client => "/usr/share/punktfunk-client/install-kind",
|
|
}
|
|
}
|
|
|
|
fn sysext_marker(self) -> &'static str {
|
|
match self {
|
|
Mode::Host => "/usr/lib/extension-release.d/extension-release.punktfunk",
|
|
Mode::Client => "/usr/lib/extension-release.d/extension-release.punktfunk-client",
|
|
}
|
|
}
|
|
|
|
/// The binary the run-the-binary gate executes after a package-manager run.
|
|
fn gate_binary(self) -> &'static str {
|
|
match self {
|
|
Mode::Host => "/usr/bin/punktfunk-host",
|
|
Mode::Client => "/usr/bin/punktfunk-client",
|
|
}
|
|
}
|
|
|
|
fn result_path(self) -> &'static str {
|
|
match self {
|
|
Mode::Host => "/var/lib/punktfunk/update-result.json",
|
|
Mode::Client => "/var/lib/punktfunk/client-update-result.json",
|
|
}
|
|
}
|
|
|
|
fn as_str(self) -> &'static str {
|
|
match self {
|
|
Mode::Host => "host",
|
|
Mode::Client => "client",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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(mode: Mode) -> Result<&'static str, String> {
|
|
if Path::new(mode.sysext_marker()).exists() {
|
|
return match mode {
|
|
Mode::Host => Ok("sysext"),
|
|
// `punktfunk-sysext update` pulls the HOST image from the signed feed; there
|
|
// is no client feed to pull from (a client sysext is the local
|
|
// packaging/arch/build-sysext.sh wrapper). Refusing here is the honest answer
|
|
// — the alternative would install the host over a client-only box.
|
|
Mode::Client => Err(
|
|
"this client is a sysext, and the sysext feed carries the host image only \
|
|
— rebuild and re-install the client image instead"
|
|
.to_string(),
|
|
),
|
|
};
|
|
}
|
|
let marker_path = mode.marker();
|
|
let marker = std::fs::read_to_string(marker_path)
|
|
.map_err(|e| format!("no install-kind marker at {marker_path}: {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)
|
|
}
|
|
|
|
/// The run-the-binary gate's reading: execute what we just installed and take its
|
|
/// `--version`. A binary that cannot run is an update that did NOT stick, whatever the
|
|
/// package manager reported (the 0.22.0 clobber lesson).
|
|
fn gate_version(mode: Mode) -> Result<String, String> {
|
|
let bin = mode.gate_binary();
|
|
run_capture(
|
|
Command::new(bin).arg("--version"),
|
|
&format!("{bin} --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(mode: Mode, result: &HelperResult) {
|
|
let path = Path::new(mode.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();
|
|
let mode = match arg.as_str() {
|
|
"apply" => Mode::Host,
|
|
"apply-client" => Mode::Client,
|
|
_ => {
|
|
eprintln!(
|
|
"usage: pf-update apply | apply-client (normally via \
|
|
punktfunk-update.service / punktfunk-client-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(mode) {
|
|
Ok(k) => k,
|
|
Err(e) => {
|
|
eprintln!("pf-update: {e}");
|
|
write_result(
|
|
mode,
|
|
&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}", mode.as_str());
|
|
let before = gate_version(mode).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 {
|
|
gate_version(mode)
|
|
.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(mode, &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);
|
|
}
|