From b164b4c03ec889fa88b3a51c1f846797932c6178 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 01:45:12 +0200 Subject: [PATCH] feat(packaging): a client-only box can update its client without the host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .gitea/workflows/deb.yml | 11 +- crates/pf-update/src/main.rs | 174 +++++++++++++----- packaging/arch/PKGBUILD | 28 ++- packaging/arch/punktfunk-client.install | 9 + packaging/debian/build-client-deb.sh | 37 +++- .../linux/49-punktfunk-client-update.rules | 24 +++ .../linux/punktfunk-client-update.service | 23 +++ packaging/rpm/punktfunk.spec | 36 +++- 8 files changed, 284 insertions(+), 58 deletions(-) create mode 100644 packaging/linux/49-punktfunk-client-update.rules create mode 100644 packaging/linux/punktfunk-client-update.service diff --git a/.gitea/workflows/deb.yml b/.gitea/workflows/deb.yml index bc3a16df..89067dcb 100644 --- a/.gitea/workflows/deb.yml +++ b/.gitea/workflows/deb.yml @@ -147,15 +147,16 @@ jobs: PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs) run: | git config --global --add safe.directory "$PWD" - # THREE binaries ship in the client .deb, so all three are built here: the GTK shell, - # punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect), and - # punktfunk-cli (the headless `punktfunk` front-end). build-client-deb.sh installs all - # three; leaving punktfunk-cli out here made it fall over on `install: No such file or + # FOUR binaries ship in the client .deb, so all four are built here: the GTK shell, + # punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect), + # punktfunk-cli (the headless `punktfunk` front-end), and pf-update (the root helper + # behind `punktfunk-client --apply-update`). build-client-deb.sh installs all four; + # leaving punktfunk-cli out here made it fall over on `install: No such file or # directory`, because its build-if-missing guard only tested the first two and so decided # everything was already built. The HOST is built separately in the build-publish-host # job (Ubuntu 24.04 image + bundled FFmpeg 8). cargo build --release --locked \ - -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli + -p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update - name: Build + smoke-boot web console (bun preset) # Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset diff --git a/crates/pf-update/src/main.rs b/crates/pf-update/src/main.rs index c9b0123f..7d623069 100644 --- a/crates/pf-update/src/main.rs +++ b/crates/pf-update/src/main.rs @@ -1,19 +1,28 @@ -//! `pf-update` — the root helper behind web-console-triggered Linux host updates +//! `pf-update` — the root helper behind triggered Linux package 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 +//! 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. //! -//! 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 +//! 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")] @@ -22,12 +31,61 @@ mod linux_main { 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"; + + /// 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 @@ -55,12 +113,24 @@ mod linux_main { /// 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"); + 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 = std::fs::read_to_string(MARKER) - .map_err(|e| format!("no install-kind marker at {MARKER}: {e}"))?; + 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"), @@ -109,10 +179,14 @@ mod linux_main { Ok(pkgs) } - fn host_version() -> Result { + /// 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 { + let bin = mode.gate_binary(); run_capture( - Command::new(HOST_BIN).arg("--version"), - "punktfunk-host --version", + Command::new(bin).arg("--version"), + &format!("{bin} --version"), ) } @@ -211,8 +285,8 @@ mod linux_main { } } - fn write_result(result: &HelperResult) { - let path = Path::new(RESULT_PATH); + 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); } @@ -226,10 +300,17 @@ mod linux_main { 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); - } + 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. @@ -238,25 +319,28 @@ mod linux_main { std::process::exit(1); } - let kind = match detect_kind() { + let kind = match detect_kind(mode) { 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(), - }); + 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}"); - let before = host_version().unwrap_or_default(); + 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. @@ -264,7 +348,7 @@ mod linux_main { let after = if staged { before.clone() } else { - host_version() + gate_version(mode) .map_err(|e| format!("run-the-binary gate: {e} — the update did NOT stick"))? }; Ok((staged, after)) @@ -296,7 +380,7 @@ mod linux_main { } }; let ok = result.ok; - write_result(&result); + write_result(mode, &result); println!( "pf-update: {} ({} -> {}, changed: {}, staged: {})", if ok { "ok" } else { "FAILED" }, diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index b411ef0e..706d2c19 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -95,9 +95,10 @@ build() { # Pure Rust `ash` (no new lib, no link-time deps); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 # opts back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices are safe. if [ "$CARCH" = 'aarch64' ]; then - # Client-only: no host crate, so none of the encode features below apply. + # Client-only: no host crate, so none of the encode features below apply. pf-update still + # builds — the client package ships its own copy for `--apply-update`. cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session \ - -p punktfunk-cli + -p punktfunk-cli -p pf-update 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 \ @@ -164,7 +165,7 @@ package_punktfunk-host() { # 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 -Dm0755 "$T/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#' \ @@ -282,6 +283,27 @@ package_punktfunk-client() { # 32 MB UDP recv buffer (so high-bitrate streams don't overflow the kernel socket buffer). install -Dm0644 "$R/scripts/99-punktfunk-client-net.conf" \ "$pkgdir/usr/lib/sysctl.d/99-punktfunk-client-net.conf" + # One-tap client updates (`punktfunk-client --apply-update`, which is what the Decky plugin + # runs): the same root helper the host package ships, under the CLIENT's own paths — a + # client-only box (a Deck) has no host package, and two packages owning one path is a hard + # pacman conflict. Same no-libexec relocation as the host's copy, 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 "$T/pf-update" "$pkgdir/usr/lib/punktfunk/pf-update-client" + install -Dm0644 "$R/packaging/linux/punktfunk-client-update.service" \ + "$pkgdir/usr/lib/systemd/system/punktfunk-client-update.service" + sed -i 's#/usr/libexec/punktfunk/pf-update#/usr/lib/punktfunk/pf-update-client#' \ + "$pkgdir/usr/lib/systemd/system/punktfunk-client-update.service" + install -Dm0644 "$R/packaging/linux/49-punktfunk-client-update.rules" \ + "$pkgdir/usr/share/polkit-1/rules.d/49-punktfunk-client-update.rules" + # Install-kind + channel marker for the CLIENT, read by `punktfunk-client --check-update` + # (planning: host-update-from-web-console.md §4.1). Its own directory, matching the RPM (where + # the host subpackage's %{_datadir}/punktfunk/* glob would otherwise co-own a sibling file) — + # one path per product across every format, so the detector has one answer to remember. + local _pf_client_channel=stable + [[ $pkgrel == 0.* ]] && _pf_client_channel=canary + printf 'pacman %s\n' "$_pf_client_channel" | \ + install -Dm0644 /dev/stdin "$pkgdir/usr/share/punktfunk-client/install-kind" install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-client/LICENSE-MIT" install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-client/LICENSE-APACHE" } diff --git a/packaging/arch/punktfunk-client.install b/packaging/arch/punktfunk-client.install index 31446f02..9be81177 100644 --- a/packaging/arch/punktfunk-client.install +++ b/packaging/arch/punktfunk-client.install @@ -1,5 +1,13 @@ # pacman scriptlet for the GTK4 client — mirrors build-client-deb.sh's postinst. +_ensure_update_group() { + # The (empty) opt-in group for one-tap client updates — nobody is auto-added. Shared with + # punktfunk-host's scriptlet: `groupadd` is idempotent here, so whichever package lands first + # creates it and the other is a no-op. + 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=hidraw 2>/dev/null || true update-desktop-database /usr/share/applications 2>/dev/null || true @@ -14,6 +22,7 @@ MSG } post_upgrade() { + _ensure_update_group udevadm control --reload-rules 2>/dev/null || true sysctl -p /usr/lib/sysctl.d/99-punktfunk-client-net.conf >/dev/null 2>&1 || true } diff --git a/packaging/debian/build-client-deb.sh b/packaging/debian/build-client-deb.sh index 963cba0e..e053c998 100644 --- a/packaging/debian/build-client-deb.sh +++ b/packaging/debian/build-client-deb.sh @@ -45,9 +45,15 @@ CLI_BIN="$OUTDIR/punktfunk" # satisfied the guard, skipped this build, and then died on `install: No such file or directory` # for $CLI_BIN — a confusing way to say "the CLI was never built". The arm64 leg never hit it # because it pre-builds nothing, so the guard always fired there and built all three. -if [ ! -x "$BIN" ] || [ ! -x "$SESSION_BIN" ] || [ ! -x "$CLI_BIN" ]; then - echo "==> building $CRATE + punktfunk-client-session + punktfunk-cli (release${TARGET:+ for $TARGET})" - cargo build --release --locked "${CARGO_TARGET_ARGS[@]}" -p "$CRATE" -p punktfunk-client-session -p punktfunk-cli +# pf-update joins the list: the client ships its own copy of the root helper for one-tap +# updates (`punktfunk-client --apply-update`). Same guard rule as above — every binary this +# script installs is tested here, so a pre-built subset can't skip the build and then die on +# `install: No such file or directory`. +UPDATE_BIN="$OUTDIR/pf-update" +if [ ! -x "$BIN" ] || [ ! -x "$SESSION_BIN" ] || [ ! -x "$CLI_BIN" ] || [ ! -x "$UPDATE_BIN" ]; then + echo "==> building $CRATE + punktfunk-client-session + punktfunk-cli + pf-update (release${TARGET:+ for $TARGET})" + cargo build --release --locked "${CARGO_TARGET_ARGS[@]}" -p "$CRATE" -p punktfunk-client-session \ + -p punktfunk-cli -p pf-update fi STAGE="$(mktemp -d)" @@ -72,6 +78,27 @@ install -Dm0644 scripts/70-punktfunk-client.rules \ # 32 MB = 0%). systemd-sysctl applies it at boot; the postinst applies it on install. install -Dm0644 scripts/99-punktfunk-client-net.conf \ "$STAGE/usr/lib/sysctl.d/99-punktfunk-client-net.conf" +# One-tap client updates (`punktfunk-client --apply-update`, which is what the Decky plugin +# runs): the same root helper the host package ships, under the CLIENT's own paths + its own +# unit and polkit rule. Separate paths because dpkg refuses two packages owning one file, and a +# client-only box must be able to install this without punktfunk-host. Opt-in = joining the +# (shipped-empty) punktfunk-update group; postinst creates it. +install -Dm0755 "$UPDATE_BIN" "$STAGE/usr/libexec/punktfunk/pf-update-client" +install -Dm0644 packaging/linux/punktfunk-client-update.service \ + "$STAGE/usr/lib/systemd/system/punktfunk-client-update.service" +sed -i 's#/usr/libexec/punktfunk/pf-update#/usr/libexec/punktfunk/pf-update-client#' \ + "$STAGE/usr/lib/systemd/system/punktfunk-client-update.service" +install -Dm0644 packaging/linux/49-punktfunk-client-update.rules \ + "$STAGE/usr/share/polkit-1/rules.d/49-punktfunk-client-update.rules" +# Install-kind + channel marker for the CLIENT, read by `punktfunk-client --check-update` +# (planning: host-update-from-web-console.md §4.1). Its own directory, matching the RPM. A +# canary build's version carries `~ciN`; anything else is stable. +case "$VERSION" in + *~ci*) _pf_client_channel=canary ;; + *) _pf_client_channel=stable ;; +esac +printf 'apt %s\n' "$_pf_client_channel" | \ + install -Dm0644 /dev/stdin "$STAGE/usr/share/punktfunk-client/install-kind" install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT" install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE" install -Dm0644 README.md "$DOCDIR/README.md" @@ -153,6 +180,10 @@ cat > "$STAGE/DEBIAN/postinst" <<'EOF' #!/bin/sh set -e if [ "$1" = "configure" ]; then + # The (empty) opt-in group for one-tap client updates — nobody is auto-added. The host + # package's postinst creates the same group; addgroup is idempotent, so whichever is + # configured first wins and the other is a no-op. + getent group punktfunk-update >/dev/null 2>&1 || addgroup --system punktfunk-update 2>/dev/null || true # Pick up the DualSense hidraw rule without a reboot (best-effort, no-op in containers). udevadm control --reload-rules 2>/dev/null || true udevadm trigger --subsystem-match=hidraw 2>/dev/null || true diff --git a/packaging/linux/49-punktfunk-client-update.rules b/packaging/linux/49-punktfunk-client-update.rules new file mode 100644 index 00000000..333a7325 --- /dev/null +++ b/packaging/linux/49-punktfunk-client-update.rules @@ -0,0 +1,24 @@ +// Members of the `punktfunk-update` group may START exactly one more unit: the punktfunk +// root-helper oneshot that updates the punktfunk CLIENT packages (planning: +// host-update-from-web-console.md §7). Sibling of 49-punktfunk-update.rules, which grants the +// host's unit; the group is the same one, so a box that has opted in has opted in once. +// +// The group ships EMPTY — joining it is the explicit, auditable opt-in for one-tap client +// updates (the Decky plugin's "Update client" button, `punktfunk-client --apply-update`): +// +// sudo usermod -aG punktfunk-update +// +// Scope notes: `verb == "start"` keeps stop/restart/enable out of the grant, and the unit +// name pin keeps every other unit out. The unit's ExecStart is fixed and parameterless, so +// this grant authorizes "run the system's normal update for the punktfunk packages" and +// nothing else. +polkit.addRule(function (action, subject) { + if ( + action.id == "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") == "punktfunk-client-update.service" && + action.lookup("verb") == "start" && + subject.isInGroup("punktfunk-update") + ) { + return polkit.Result.YES; + } +}); diff --git a/packaging/linux/punktfunk-client-update.service b/packaging/linux/punktfunk-client-update.service new file mode 100644 index 00000000..3ecb0858 --- /dev/null +++ b/packaging/linux/punktfunk-client-update.service @@ -0,0 +1,23 @@ +# The client-triggered package update, as a root oneshot (planning: +# host-update-from-web-console.md §7). Started — never enabled — by the unprivileged client via +# `systemctl start punktfunk-client-update.service` (which is what `punktfunk-client +# --apply-update`, and so the Decky plugin's one-tap update, runs), authorised by the polkit +# rule (49-punktfunk-client-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/client-update-result.json. +# +# This is a SEPARATE unit from punktfunk-update.service, not a duplicate of it: the client and +# the host are separate packages, and no packaging format we ship lets two packages own one +# path. A client-only box — a Steam Deck, a handheld — must be able to install this without +# pulling in the host. +[Unit] +Description=punktfunk client update (root helper) +# Network is a hard prerequisite for every leg (package manager). +Wants=network-online.target +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/libexec/punktfunk/pf-update apply-client +# The helper drives the distro package manager, which needs the real system — no sandboxing +# directives here on purpose; the constraint is the FIXED ExecStart (no parameters exist). diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 3fab7273..5fe3f972 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -229,8 +229,10 @@ cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vu -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 +# Client-only (aarch64): no host crate, so none of the encode features apply. pf-update still +# builds — the client subpackage ships its own copy for `punktfunk-client --apply-update`. +cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session \ + -p punktfunk-cli -p pf-update %endif # 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 @@ -357,6 +359,24 @@ install -Dm0644 scripts/70-punktfunk-client.rules \ install -Dm0644 scripts/99-punktfunk-client-net.conf \ %{buildroot}%{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf +# One-tap client updates (`punktfunk-client --apply-update`, which is what the Decky plugin +# runs): the same root helper the host subpackage ships, under the CLIENT's own paths. Separate +# paths are not tidiness — rpm refuses two subpackages owning one file, and a client-only box +# (a Deck, an aarch64 build with %%{without host}) must be able to install this on its own. +install -Dm0755 target/release/pf-update %{buildroot}%{_libexecdir}/punktfunk/pf-update-client +install -Dm0644 packaging/linux/punktfunk-client-update.service \ + %{buildroot}%{_unitdir}/punktfunk-client-update.service +sed -i 's#%{_libexecdir}/punktfunk/pf-update#%{_libexecdir}/punktfunk/pf-update-client#' \ + %{buildroot}%{_unitdir}/punktfunk-client-update.service +install -Dm0644 packaging/linux/49-punktfunk-client-update.rules \ + %{buildroot}%{_datadir}/polkit-1/rules.d/49-punktfunk-client-update.rules +# Install-kind + channel marker for the CLIENT, read by `punktfunk-client --check-update`. Its +# own DIRECTORY, not just its own filename: the host subpackage claims %{_datadir}/%{name}/* +# with a glob, so a sibling file there would be owned by both and `dnf install punktfunk +# punktfunk-client` would fail on the conflict. +install -d %{buildroot}%{_datadir}/punktfunk-client +printf 'dnf %{?pf_channel}%{!?pf_channel:stable}\n' > %{buildroot}%{_datadir}/punktfunk-client/install-kind + %if %{with host} # Headless session helpers + example config + OpenAPI doc (reference material). install -d %{buildroot}%{_datadir}/%{name}/headless @@ -470,6 +490,14 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/ %{_datadir}/icons/hicolor/scalable/apps/io.unom.Punktfunk.svg %{_udevrulesdir}/70-punktfunk-client.rules %{_prefix}/lib/sysctl.d/99-punktfunk-client-net.conf +# Co-owned with the host subpackage (rpm allows that for DIRECTORIES, unlike files) so a +# client-only install — the aarch64 `%%{without host}` build — still owns the dir it created. +%dir %{_libexecdir}/punktfunk +%{_libexecdir}/punktfunk/pf-update-client +%{_unitdir}/punktfunk-client-update.service +%{_datadir}/polkit-1/rules.d/49-punktfunk-client-update.rules +%dir %{_datadir}/punktfunk-client +%{_datadir}/punktfunk-client/install-kind %if %{with web} %files web @@ -497,6 +525,10 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/ %endif %post client +# The (empty) opt-in group for one-tap client updates — nobody is auto-added. Also created by +# the host subpackage's %%post; groupadd is idempotent, so whichever lands first wins and the +# other is a no-op. +getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || : # Pick up the DualSense hidraw rule without a reboot (best-effort; on rpm-ostree it # applies on the next boot into the layered deployment). udevadm control --reload-rules 2>/dev/null || :