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:
Generated
+8
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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).
|
||||
@@ -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 || :
|
||||
|
||||
Reference in New Issue
Block a user