From 4f8cce67514a3f25f814268d89d8abe516b40ef5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 9 Aug 2026 12:50:47 +0200 Subject: [PATCH] feat(packaging): grant CAP_SYS_NICE to the encode worker on all six channels, and assert the host never gets it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 767e67ca's per-channel mechanics were correct; they were aimed at the wrong binary. Each one is restored here pointed at punktfunk-encode-worker, and every host-side removal from #136 stays verbatim. All grants remain best-effort — an uncapped worker still encodes, at default priority, so a failed setcap must never fail an install. * Arch: setcap in post_install AND post_upgrade (a replaced binary is a new inode). * RPM: %caps(cap_sys_nice=ep) in %files, never a %post setcap — %caps applies, restores and verifies, and covers Fedora as well as Bazzite via rpm-ostree layering. * Bazzite + Arch sysext: setcap on the staging tree before mksquashfs, which does record security.capability. The assertion is amended, not removed: host EMPTY is still a hard fail, and the worker must carry exactly cap_sys_nice=ep — missing is fine, anything else is not. * deb: setcap in postinst. * NixOS: security.wrappers for the WORKER plus PUNKTFUNK_ENCODE_WORKER in the unit. A file capability cannot live on a store path, and an ambient grant is right here precisely because nothing ever identifies the worker. The host's ExecStart stays on the store path. * Steam Deck: setcap the worker; the .desktop the script writes stays valid this time. Four things the plan's channel table missed: * packaging/arch/build-sysext.sh had no capability handling at all, and a sysext can never run a pacman scriptlet — the SteamOS image would have shipped the lever permanently inert. * scripts/steamdeck/update.sh had none either. It rebuilds both binaries, so a new inode drops the grant, and it is the documented steady-state path: the lever would have died on the first update. It also never healed a Deck already capped by 0.26.0-1. * A capped worker is AT_SECURE, and glibc drops $ORIGIN-expanded RPATH entries for secure binaries unless they normalise into a trusted system dir. Copying the host's rpath under BUNDLE_FFMPEG=1 would have left the capped worker unable to find libavcodec on exactly the channel that bundles it. Absolute DT_RPATH instead. * Nix crane scopes by -p, so the worker would not have been built at all, and it needs its own addDriverRunpath. scripts/ci/assert-cap-matrix.sh mechanizes the lesson from 0.26.0-1 — verify the PACKAGE, never the board. It unpacks the built Arch package, the deb, the rpm and the mounted sysext raw and asserts one matrix: the host carries NOTHING (hard fail), the worker exactly cap_sys_nice=ep. The sysext reader first proves it can round-trip a capability through mksquashfs/unsquashfs at all, so an unreadable artifact fails rather than issuing a blind PASS, and --self-test red-teams the assertions themselves. Red-teaming the leg found a real bug: setcap originally ran BEFORE the assertion, so "the worker arrived carrying something unexpected" was unreachable and a stray %caps would have been silently overwritten. Both sysext scripts now assert, then grant, then assert again. --- .gitea/workflows/arch.yml | 15 ++ .gitea/workflows/deb.yml | 19 +- .gitea/workflows/rpm.yml | 33 ++- packaging/arch/PKGBUILD | 14 +- packaging/arch/README.md | 7 +- packaging/arch/build-sysext.sh | 92 ++++++- packaging/arch/punktfunk-host.install | 34 +++ packaging/bazzite/build-sysext.sh | 102 ++++++-- packaging/bootc/Containerfile | 18 ++ packaging/debian/build-deb.sh | 57 +++- packaging/nix/nixos-module.nix | 46 +++- packaging/nix/packages.nix | 17 +- packaging/rpm/punktfunk.spec | 35 ++- scripts/ci/assert-cap-matrix.sh | 357 ++++++++++++++++++++++++++ scripts/steamdeck/install.sh | 38 ++- scripts/steamdeck/update.sh | 36 ++- 16 files changed, 878 insertions(+), 42 deletions(-) create mode 100755 scripts/ci/assert-cap-matrix.sh diff --git a/.gitea/workflows/arch.yml b/.gitea/workflows/arch.yml index a21b1764..b8f5ae2a 100644 --- a/.gitea/workflows/arch.yml +++ b/.gitea/workflows/arch.yml @@ -280,6 +280,21 @@ jobs: done echo "OK: $(echo "$DEPS" | grep -E '^libav|^libsw' | tr '\n' ' ')" + # 0.26.0-1 setcap'd `cap_sys_nice=ep` on the host from this package's .INSTALL scriptlet and + # killed desktop streaming on every KDE box — with a green board, because nothing here ever + # looked at what the built package would DO. The lesson recorded then was "verify the + # PACKAGE, never the board"; this is that, and pacman is the channel where it matters most, + # since capabilities live in the scriptlet rather than in package metadata. + # + # Host must carry NOTHING, the worker exactly cap_sys_nice=ep. `--self-test` runs first so a + # guard that has quietly lost the ability to fail takes the job down rather than approving a + # release. (Only the host package is checked: the client/web/scripting packages ship neither + # binary and the script skips them by itself.) + - name: Assert the capability matrix (Arch package) + run: | + bash scripts/ci/assert-cap-matrix.sh --self-test + bash scripts/ci/assert-cap-matrix.sh "$GITHUB_WORKSPACE"/dist/punktfunk-host-*.pkg.tar.zst + # The optional HDR gamescope companion (packaging/gamescope) — a separate pkgbase with a # completely different dependency set, published into the same repo so `pacman -S # punktfunk-gamescope` is all an Arch/SteamOS box needs for 10-bit BT.2020 PQ. diff --git a/.gitea/workflows/deb.yml b/.gitea/workflows/deb.yml index ea349dff..69911c8e 100644 --- a/.gitea/workflows/deb.yml +++ b/.gitea/workflows/deb.yml @@ -310,8 +310,14 @@ jobs: # with "there is no reactor running, must be called from the context of a Tokio 1.x runtime". # It WAS listed here, which is why only the .deb shipped a crashing tray while the RPM and # Arch packages — which already split it — were fine. + # + # punktfunk-encode-worker IS in this invocation: it is the capability-carrying PyroWave + # encode worker that ships next to the host in /usr/bin, and build-deb.sh only builds it + # if the artifact is missing — building it here keeps it on the same sccache pass as the + # host. Unlike the tray it shares the host's dependency graph by design (v1 accepts that + # the worker links the same FFmpeg), so feature unification here is harmless. cargo build --release --locked --features punktfunk-host/nvenc,punktfunk-host/vulkan-encode \ - -p punktfunk-host + -p punktfunk-host -p punktfunk-encode-worker - name: Build host .deb (FFmpeg bundled) # BUNDLE_FFMPEG=1 copies the image's /opt/ffmpeg libav* into the package and repoints the @@ -320,6 +326,17 @@ jobs: run: | VERSION="$VERSION" BUNDLE_FFMPEG=1 bash packaging/debian/build-deb.sh + # Read the capability matrix out of the BUILT .deb before it is published. dpkg carries no + # capability metadata — the postinst applies them — so this reads the postinst that will + # actually run on a user's box, plus the payload. 0.26.0-1 granted the host cap_sys_nice=ep + # from exactly that postinst and killed every KDE desktop session while every board stayed + # green: host must carry NOTHING, worker exactly cap_sys_nice=ep. `--self-test` first so a + # guard that can no longer fail takes the job down instead of waving the release through. + - name: Assert the capability matrix (host .deb) + run: | + bash scripts/ci/assert-cap-matrix.sh --self-test + bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-host_*.deb + # punktfunk-gamescope for apt. Same reasoning as the RPM leg in rpm.yml: without a packaged # build, a Debian/Ubuntu box has no route to the patched gamescope except compiling it, and a # stock gamescope streams SDR, cursorless, and tells every game its display is 60 Hz. diff --git a/.gitea/workflows/rpm.yml b/.gitea/workflows/rpm.yml index 507bcab0..210893f4 100644 --- a/.gitea/workflows/rpm.yml +++ b/.gitea/workflows/rpm.yml @@ -103,7 +103,11 @@ jobs: # gamescope`.) Matches packaging/rpm/punktfunk.spec, which dropped its BuildRequires too. dnf -y install gtk4-devel libadwaita-devel SDL3-devel # sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling. - dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted + # libcap = setcap/getcap: the sysext is the ONLY place the image can acquire + # cap_sys_nice=ep on punktfunk-encode-worker (a merged /usr is read-only squashfs and no + # scriptlet ever runs), and it is also what the build's host-must-be-uncapped assertion + # and the capability-matrix CI leg read with. Without it the image ships the lever inert. + dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted libcap # Fedora's own gamescope, for its RUNTIME libraries only — never shipped, never run. The # sysext folds in our punktfunk-gamescope and verifies it by executing `--version`, and # on a cache hit (the common case) nothing else in this job would have pulled libavif / @@ -155,6 +159,20 @@ jobs: RPM_GPG_PASSPHRASE: ${{ secrets.RPM_GPG_PASSPHRASE }} run: bash packaging/rpm/sign-rpms.sh + # Read the file-capability matrix out of the BUILT rpm, before anything is signed or + # published. 0.26.0-1 shipped `%caps(cap_sys_nice=ep)` on the host through this very spec — + # on Fedora and, via rpm-ostree layering, on Bazzite — and every board was green while every + # KDE desktop session died in the field. The lesson recorded then was "verify the PACKAGE, + # never the board"; this is that. Host must carry NOTHING; the worker must carry exactly + # cap_sys_nice=ep. `--self-test` first, so a guard that has quietly stopped being able to + # fail takes the job down instead of waving the release through. + - name: Assert the capability matrix (rpm) + run: | + bash scripts/ci/assert-cap-matrix.sh --self-test + # Only the main host package carries binaries; -debuginfo/-debugsource and the + # client/web/scripting subpackages ship neither and are skipped by the script itself. + bash scripts/ci/assert-cap-matrix.sh dist/punktfunk-[0-9]*.rpm + - name: Publish to the Gitea RPM registry env: TOKEN: ${{ secrets.REGISTRY_TOKEN }} @@ -302,6 +320,19 @@ jobs: dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm \ dist/punktfunk-scripting-"${PF_VERSION}-${PF_RELEASE}"*.rpm + # Read the capability matrix back OUT of the image that is about to be published — the one + # channel where getting it wrong is unrepairable, because a merged sysext's /usr is read-only + # squashfs and the only fix is a new image plus a feed republish. 0.26.0-1's Bazzite breakage + # was confirmed exactly this way, after the fact, by mounting the published .raw and running + # getcap on it. Doing it here means the .raw never reaches the feed. + # + # The script proves its own reader first (cap a file, squash it, unsquash it, read it back) + # so a runner that cannot see file capabilities FAILS the leg instead of blessing the image. + - name: Assert the capability matrix (sysext image) + run: | + bash scripts/ci/assert-cap-matrix.sh \ + "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" + # The feed's SHA256SUMS is OpenPGP-signed with the same packages@unom.io key as the RPMs, and # punktfunk-sysext(8) refuses a feed it can't verify — the checksums alone never proved # anything, sitting on the same registry as the images they describe. diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index b2892318..5fb9b71c 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -106,8 +106,16 @@ build() { cargo build --release --locked -p punktfunk-client-linux -p punktfunk-client-session \ -p punktfunk-cli -p pf-update else + # `-p punktfunk-encode-worker`: the capability-carrying PyroWave encode worker, shipped next to + # the host in /usr/bin and setcap'd by punktfunk-host.install. It MUST be its own file — the host + # can never carry a capability (KWin identification; see the scriptlet) — and it must ship in the + # SAME package as the host, because host and worker version-check each other over their socket + # and fall back to the in-process encoder on any mismatch. Co-built here on purpose: v1 accepts + # that the worker links the same FFmpeg the host does (same package, same sonames, no new break + # class), so cargo's feature unification across this one invocation is harmless. 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-encode-worker \ + -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 @@ -183,6 +191,10 @@ package_punktfunk-host() { local R; R="$(_repo)"; local T="$srcdir/target/release" install -Dm0755 "$T/punktfunk-host" "$pkgdir/usr/bin/punktfunk-host" + # The PyroWave encode worker — a SEPARATE file in the same bindir (the host resolves it as a + # sibling of /proc/self/exe). punktfunk-host.install setcaps this one, and only this one; the + # host must stay capability-free or KWin cannot identify it and desktop streaming dies. + install -Dm0755 "$T/punktfunk-encode-worker" "$pkgdir/usr/bin/punktfunk-encode-worker" # /dev/uinput + /dev/uhid -> input group (virtual gamepads + DualSense UHID) install -Dm0644 "$R/scripts/60-punktfunk.rules" "$pkgdir/usr/lib/udev/rules.d/60-punktfunk.rules" # Managed gamescope takeover on DM-autologin boxes: root helper + polkit action so the host can diff --git a/packaging/arch/README.md b/packaging/arch/README.md index b9bd78f2..22f12966 100644 --- a/packaging/arch/README.md +++ b/packaging/arch/README.md @@ -174,8 +174,11 @@ systemctl --user enable --now punktfunk-host # the user unit is now under /u The udev rule, sysctl, and systemd **user** unit all live under `/usr/lib`, so the merged sysext exposes them. `systemd-sysext refresh` re-merges after a reboot. (One HDR nuance of the sysext path: the image ships gamescope without `CAP_SYS_NICE`, so its frame pacing is marginally worse — -everything works. Note the host binary carries no capability on *either* path, deliberately: one -would make the host unidentifiable to KWin and break desktop streaming, see +everything works. Capabilities inside the image: `punktfunk-host` carries **none**, on *either* +path, deliberately — one would make it unidentifiable to KWin and break desktop streaming; +`punktfunk-encode-worker` carries `cap_sys_nice=ep`, applied by `build-sysext.sh` because pacman +scriptlets never run for a sysext and a merged `/usr` is read-only. Both are asserted at build +time. See [Running as a service](https://punktfunk.io/docs/running-as-a-service#gpu-scheduling-priority).) ## Steam Deck — the client (what the Decky plugin launches) diff --git a/packaging/arch/build-sysext.sh b/packaging/arch/build-sysext.sh index 817494c9..980dee6f 100755 --- a/packaging/arch/build-sysext.sh +++ b/packaging/arch/build-sysext.sh @@ -16,15 +16,23 @@ # its `+pfhdr` banner, never trusted by filename. Omit it and the image is exactly what it was — # the host then stays SDR on that backend, by design. # -# No CAP_SYS_NICE inside the image, for either binary. ⚠ NOT because capabilities are lost on the -# way in — that was this comment's earlier claim and it is false: mksquashfs records -# security.capability, and the published Bazzite 0.26.0-1 image really did carry `cap_sys_nice=ep` -# on usr/bin/punktfunk-host. It is left out on purpose. A capability on the HOST binary makes it -# unidentifiable to KWin (which resolves a client's /proc//exe to match it against a .desktop, -# and cannot read it for a capability-carrying process) and kills every Desktop-mode session — see -# packaging/bazzite/build-sysext.sh, which now hard-fails if one is staged. `punktfunk-gamescope` -# is a compositor, not a KWin client, so it is unaffected by that rule and simply runs without the -# capability here, pacing slightly worse. +# Capabilities in the image: NEVER on usr/bin/punktfunk-host, `cap_sys_nice=ep` on +# usr/bin/punktfunk-encode-worker (best-effort), and none on punktfunk-gamescope. +# +# ⚠ Capabilities are NOT lost on the way in — that was this comment's earlier claim and it is +# false: mksquashfs records security.capability, and the published Bazzite 0.26.0-1 image really +# did carry `cap_sys_nice=ep` on usr/bin/punktfunk-host. The host is left uncapped on purpose. A +# capability on the HOST binary makes it unidentifiable to KWin (which resolves a client's +# /proc//exe to match it against a .desktop, and cannot read it for a capability-carrying +# process) and kills every Desktop-mode session. +# +# ⚠ And it is NOT enough to leave it out here: pacman scriptlets never run for a sysext, so the +# `setcap` in punktfunk-host.install cannot reach this image either way. The encode worker is +# therefore capped on the staging tree below — this is the only place a sysext can acquire it — and +# both halves of the matrix are asserted before mksquashfs, exactly as +# packaging/bazzite/build-sysext.sh does. `punktfunk-gamescope` is a compositor, not a KWin client, +# so it is unaffected by the host rule and simply runs without a capability here, pacing slightly +# worse. set -euo pipefail GAMESCOPE="" @@ -80,6 +88,72 @@ ID=_any ARCHITECTURE=x86-64 EOF +# CAP_SYS_NICE on the encode worker (see the header). A pacman payload carries no capabilities and +# no scriptlet ever runs for a sysext, so without this the SteamOS image ships the lever inert — +# on the box with the smallest GPU shared between game and encode. Needs CAP_SETFCAP, i.e. root or +# fakeroot; a plain-user build simply ships without it, which is a pacing loss and nothing more. +# +# `getcap` on an uncapped file exits 0 and prints nothing, so an empty read is unambiguous; the +# output form differs across libcap versions ("path cap_sys_nice=ep" since ~2.36, "path = +# cap_sys_nice+ep" before), hence the normalizer. +_pf_caps_of() { + local raw; raw="$(getcap "$1" 2>/dev/null || true)" + [ -n "$raw" ] || { printf ''; return 0; } + printf '%s' "${raw#* }" | sed -e 's/^= *//' -e 's/+/=/' -e 's/[[:space:]]*$//' +} + +# BEFORE granting: refuse a capability that arrived from somewhere else. The setcap below would +# overwrite it and ship a correct-looking image while the surprise went unreported everywhere else. +# Order matters: assert first, then grant, or the "anything else" arm can never fire. +if command -v getcap >/dev/null 2>&1 && [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then + arrived_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-encode-worker")" + case "$arrived_caps" in + ''|cap_sys_nice=ep) : ;; + *) + echo "ERROR: staged usr/bin/punktfunk-encode-worker ARRIVED carrying '$arrived_caps'." >&2 + echo " A pacman payload carries no capabilities, so something else granted it — find" >&2 + echo " out what, because it is doing the same on the plain package path, unchecked." >&2 + exit 1 ;; + esac +fi + +if [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then + if setcap 'cap_sys_nice=ep' "$STAGE/usr/bin/punktfunk-encode-worker" 2>/dev/null; then + echo "granted CAP_SYS_NICE to usr/bin/punktfunk-encode-worker (GPU-priority lever active)" + else + echo "WARNING: could not setcap CAP_SYS_NICE on usr/bin/punktfunk-encode-worker (need" >&2 + echo " root/CAP_SETFCAP) — the image ships without it and PyroWave encodes at" >&2 + echo " default GPU priority." >&2 + fi +fi + +# Assert the final matrix before it is sealed into a read-only squashfs: host EMPTY (hard fail), +# worker exactly cap_sys_nice=ep or nothing at all (missing is fine — the grant is best-effort). +if command -v getcap >/dev/null 2>&1; then + if [ -f "$STAGE/usr/bin/punktfunk-host" ]; then + staged_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-host")" + if [ -n "$staged_caps" ]; then + echo "ERROR: staged usr/bin/punktfunk-host carries capabilities: $staged_caps" >&2 + echo " A capability makes the host unidentifiable to KWin and breaks every Desktop-mode" >&2 + echo " session on a merged image, which cannot be repaired on the box (read-only /usr)." >&2 + echo " The GPU-priority capability belongs on usr/bin/punktfunk-encode-worker, never here." >&2 + exit 1 + fi + fi + if [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then + worker_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-encode-worker")" + case "$worker_caps" in + '') echo "note: usr/bin/punktfunk-encode-worker ships uncapped — PyroWave encodes at default GPU priority" ;; + cap_sys_nice=ep) : ;; + *) + echo "ERROR: staged usr/bin/punktfunk-encode-worker carries '$worker_caps'," >&2 + echo " expected exactly 'cap_sys_nice=ep' (or nothing at all)." >&2 + echo " Refusing to bake an unexpected capability into a read-only image." >&2 + exit 1 ;; + esac + fi +fi + OUT="$NAME.raw" rm -f "$OUT" mksquashfs "$STAGE" "$OUT" -all-root -noappend -quiet diff --git a/packaging/arch/punktfunk-host.install b/packaging/arch/punktfunk-host.install index 0890f62c..654329a7 100644 --- a/packaging/arch/punktfunk-host.install +++ b/packaging/arch/punktfunk-host.install @@ -50,10 +50,40 @@ _revoke_sched_capability() { setcap -r usr/bin/punktfunk-host 2>/dev/null || true } +# CAP_SYS_NICE on the ENCODE WORKER — the same GPU-scheduling grant 0.26.0-1 aimed at the wrong +# binary, now on a binary that can carry it. +# +# punktfunk-encode-worker is a separate executable (never a hardlink or a subcommand of the host — +# a shared inode would share the file capability and silently re-create the breakage above). It is +# spawned per PyroWave session, speaks one socketpair to its parent, and never connects to Wayland, +# D-Bus or the network — so it is not a KWin client, nothing ever resolves its /proc//exe, and +# a capability on it is invisible to the identification path that the host must keep clear. +# +# What it buys: PyroWave encodes on the same GPU shader cores the game saturates, and an elevated +# VK_KHR_global_priority queue is the preemption lever for that. Every driver tested (NVIDIA and +# RADV alike) refuses EVERY priority class without CAP_SYS_NICE, so without this line the lever is +# decoration. Measured on .21 (RTX 5070 Ti, GRID 2 loop): encode p99 6.4 -> 4.4 ms. +# +# NARROW: CAP_SYS_NICE permits raising scheduling priority only (nice/ioprio/affinity/RT class). No +# filesystem, network or user-switching privilege, and it is NOT setuid. +# +# BEST-EFFORT, always: an uncapped worker still encodes, at default priority. A box without libcap, +# or a filesystem that cannot store capabilities, must never fail an install over a pacing lever. +# +# Two consequences worth knowing before debugging the WORKER (they do not apply to the host): +# * a file capability makes the process AT_SECURE, so the loader ignores LD_LIBRARY_PATH and +# LD_PRELOAD for it — a library-path shim that rescues the host will NOT reach the worker. +# * core dumps are suppressed for capability-carrying binaries by default (fs.suid_dumpable). +_grant_worker_sched_capability() { + [ -f usr/bin/punktfunk-encode-worker ] || return 0 + setcap 'cap_sys_nice=ep' usr/bin/punktfunk-encode-worker 2>/dev/null || true +} + post_install() { _ensure_update_group _ensure_punktfunk_group _revoke_sched_capability + _grant_worker_sched_capability 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). @@ -114,6 +144,10 @@ post_upgrade() { _ensure_punktfunk_group # Strip the cap_sys_nice 0.26.0-1 granted: it makes the host unidentifiable to KWin (see above). _revoke_sched_capability + # And (re-)grant it to the encode worker. On UPGRADE too, and this one is not belt-and-braces: + # pacman writes a REPLACED binary as a new inode, file capabilities live on the inode, so the + # grant is gone after every single upgrade unless it is re-applied here. + _grant_worker_sched_capability udevadm control --reload-rules 2>/dev/null || true sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true _warn_stale_firewall_ports diff --git a/packaging/bazzite/build-sysext.sh b/packaging/bazzite/build-sysext.sh index 8a8423b5..9d9fa5e4 100644 --- a/packaging/bazzite/build-sysext.sh +++ b/packaging/bazzite/build-sysext.sh @@ -130,9 +130,9 @@ SYSEXT_VERSION_ID=$PF_VR EXTENSION_RELOAD_MANAGER=1 EOF -# NO CAP_SYS_NICE in the image — and an assertion that none crept back in. +# CAP_SYS_NICE on the ENCODE WORKER, never on the host — and an assertion of BOTH halves. # -# 0.26.0-1 setcap'd the staged binary here for the GPU-priority lever. mksquashfs records +# 0.26.0-1 setcap'd the staged HOST binary here for the GPU-priority lever. mksquashfs records # security.capability, so the capability really did ship: verified by mounting the published # punktfunk-0.26.0-1-x86-64.raw, where `getcap usr/bin/punktfunk-host` reports `cap_sys_nice=ep`. # That broke desktop streaming on every Bazzite KDE box, field-reported as @@ -143,22 +143,92 @@ EOF # /proc//exe and matching it against an installed .desktop's Exec= — the image ships # usr/share/applications/io.unom.Punktfunk.Host.desktop for exactly that. The kernel refuses that # readlink to any reader whose effective set is not a superset of the target's PERMITTED set -# (cap_ptrace_access_check), and KWin holds no capabilities. So a capability in this image makes the -# host unidentifiable and every Desktop-mode session dies. Full matrix, including why neither -# prctl(PR_SET_DUMPABLE, 1) nor systemd AmbientCapabilities= rescues it, in +# (cap_ptrace_access_check), and KWin holds no capabilities. So a capability on the HOST in this +# image makes it unidentifiable and every Desktop-mode session dies. Full matrix, including why +# neither prctl(PR_SET_DUMPABLE, 1) nor systemd AmbientCapabilities= rescues it, in # packaging/arch/punktfunk-host.install. # -# A merged sysext's /usr is a read-only squashfs, so this cannot be repaired on the box — the image -# is the only place it can be got right. Assert it rather than trust it: the RPM payload arrives via -# `rpm2cpio | cpio`, which carries no capabilities today, but the spec is one `%caps()` away from -# changing that and this build would silently bake it in. -if [ -f "$STAGE/usr/bin/punktfunk-host" ] && command -v getcap >/dev/null 2>&1; then - staged_caps="$(getcap "$STAGE/usr/bin/punktfunk-host" 2>/dev/null || true)" - if [ -n "$staged_caps" ]; then - echo "ERROR: staged usr/bin/punktfunk-host carries capabilities: $staged_caps" >&2 - echo " A capability makes the host unidentifiable to KWin and breaks every Desktop-mode" >&2 - echo " session on a merged image, which cannot be repaired on the box (read-only /usr)." >&2 - exit 1 +# usr/bin/punktfunk-encode-worker is the OTHER binary: a separate executable (never a hardlink or a +# host subcommand — a shared inode shares the capability and re-creates the above), spawned per +# PyroWave session, speaking one socketpair to its parent and touching neither Wayland nor D-Bus +# nor the network. Nothing resolves ITS /proc//exe, so it can carry the capability the lever +# needs. This is the ONLY place the sysext can acquire it: a merged sysext's /usr is a read-only +# squashfs, and it cannot ride in from the RPM either — the spec declares %caps(cap_sys_nice=ep), +# but rpm keeps capabilities in its own header and `rpm2cpio | cpio` carries only the payload, so +# the staged file arrives with none. mksquashfs DOES record security.capability (only +# security.selinux is excluded below), so a setcap on the staging tree is what lands in the image. +# +# Needs CAP_SETFCAP, i.e. root (or fakeroot). A plain-user build simply cannot, and that is NOT +# fatal: an uncapped worker still encodes, at default priority. Warn and carry on rather than fail +# a release over a pacing lever. +# +# `getcap` on a file with no capability exits 0 and prints nothing, so an empty read is unambiguous. +# The output form differs across libcap versions ("path cap_sys_nice=ep" since ~2.36, "path = +# cap_sys_nice+ep" before), hence the normalizer. +_pf_caps_of() { + # -> canonical "cap_sys_nice=ep", or "" when the file carries no capability. + local raw; raw="$(getcap "$1" 2>/dev/null || true)" + [ -n "$raw" ] || { printf ''; return 0; } + printf '%s' "${raw#* }" | sed -e 's/^= *//' -e 's/+/=/' -e 's/[[:space:]]*$//' +} + +# BEFORE granting: refuse a capability that arrived from somewhere else. The setcap below would +# overwrite it and ship a correct-looking image while the surprise — a stray %caps() in the spec, a +# payload from an unexpected source — went unreported on every other channel. Order matters: assert +# first, then grant, or the "anything else" arm can never fire. +if command -v getcap >/dev/null 2>&1 && [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then + arrived_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-encode-worker")" + case "$arrived_caps" in + ''|cap_sys_nice=ep) : ;; + *) + echo "ERROR: staged usr/bin/punktfunk-encode-worker ARRIVED carrying '$arrived_caps'." >&2 + echo " Nothing upstream of this script should grant it anything: rpm keeps capabilities" >&2 + echo " in its own header and 'rpm2cpio | cpio' carries only the payload. Find out what" >&2 + echo " did — it is granting the same thing on the plain RPM path, unchecked." >&2 + exit 1 ;; + esac +fi + +if [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then + if setcap 'cap_sys_nice=ep' "$STAGE/usr/bin/punktfunk-encode-worker" 2>/dev/null; then + echo "granted CAP_SYS_NICE to usr/bin/punktfunk-encode-worker (GPU-priority lever active)" + else + echo "WARNING: could not setcap CAP_SYS_NICE on usr/bin/punktfunk-encode-worker (need" >&2 + echo " root/CAP_SETFCAP) — the image ships without it and PyroWave encodes at" >&2 + echo " default GPU priority." >&2 + fi +fi + +# Assert the final matrix rather than trust it. A merged sysext's /usr is a read-only squashfs, so +# a bad image cannot be repaired on the box — the image is the only place this can be got right. +# +# host -> MUST be empty. Hard fail. (The RPM payload carries no capabilities today, but the +# spec is one `%caps()` away from changing that and this build would bake it in.) +# worker -> MUST be exactly cap_sys_nice=ep if it carries anything at all. MISSING IS NOT AN +# ERROR (a plain-user build cannot setcap; best-effort by design), but a DIFFERENT or +# WIDER capability is — and a read-only image is not the place to discover it. +if command -v getcap >/dev/null 2>&1; then + if [ -f "$STAGE/usr/bin/punktfunk-host" ]; then + staged_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-host")" + if [ -n "$staged_caps" ]; then + echo "ERROR: staged usr/bin/punktfunk-host carries capabilities: $staged_caps" >&2 + echo " A capability makes the host unidentifiable to KWin and breaks every Desktop-mode" >&2 + echo " session on a merged image, which cannot be repaired on the box (read-only /usr)." >&2 + echo " The GPU-priority capability belongs on usr/bin/punktfunk-encode-worker, never here." >&2 + exit 1 + fi + fi + if [ -f "$STAGE/usr/bin/punktfunk-encode-worker" ]; then + worker_caps="$(_pf_caps_of "$STAGE/usr/bin/punktfunk-encode-worker")" + case "$worker_caps" in + '') echo "note: usr/bin/punktfunk-encode-worker ships uncapped — PyroWave encodes at default GPU priority" ;; + cap_sys_nice=ep) : ;; + *) + echo "ERROR: staged usr/bin/punktfunk-encode-worker carries '$worker_caps'," >&2 + echo " expected exactly 'cap_sys_nice=ep' (or nothing at all)." >&2 + echo " Refusing to bake an unexpected capability into a read-only image." >&2 + exit 1 ;; + esac fi fi diff --git a/packaging/bootc/Containerfile b/packaging/bootc/Containerfile index 84fe4cf7..e8a127a8 100644 --- a/packaging/bootc/Containerfile +++ b/packaging/bootc/Containerfile @@ -45,5 +45,23 @@ RUN printf '%s\n' \ # time (host + console run per-user in the graphical session, enabled after first boot with # `systemctl --user enable --now punktfunk-host punktfunk-web`). +# NO `setcap` here — deliberately, in BOTH directions, and this file must stay that way. +# +# /usr/bin/punktfunk-host must carry NO capability. A capability-carrying process +# cannot have its /proc//exe read, so KWin cannot identify +# it, never advertises zkde_screencast_unstable_v1, and every +# KDE desktop session dies. That is the 0.26.0-1 incident; a +# layered/bootc image is as unrepairable in place as a sysext. +# /usr/bin/punktfunk-encode-worker carries cap_sys_nice=ep, declared with %caps in +# packaging/rpm/punktfunk.spec. rpm applies file capabilities +# from package metadata during the dnf5 install above and the +# ostree commit preserves the security.capability xattr — so it +# arrives correctly without anything to do here, and the +# capability matrix is asserted on the .rpm in CI +# (scripts/ci/assert-cap-matrix.sh, .gitea/workflows/rpm.yml). +# +# If a capability is ever wanted in this image, change the SPEC, never this file: a setcap here +# would apply to one channel and drift from the other four. + # bootc image hygiene: the container build must leave a clean ostree commit. RUN ostree container commit diff --git a/packaging/debian/build-deb.sh b/packaging/debian/build-deb.sh index 5ebf9d10..28c9638c 100755 --- a/packaging/debian/build-deb.sh +++ b/packaging/debian/build-deb.sh @@ -38,6 +38,16 @@ if [ ! -x "$BIN" ]; then echo "==> building $PKG (release)" PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p "$PKG" --locked # stamp --version (build.rs) fi +# The PyroWave encode worker — the capability-carrying half. A SEPARATE executable, never a +# hardlink or a host subcommand: a shared inode would share the file capability and make the host +# unidentifiable to KWin all over again (see the postinst note below). It ships in this same .deb +# because host and worker version-check each other over their socket and fall back to the +# in-process encoder on any mismatch, so they must move in lockstep. +WORKER_BIN="target/release/punktfunk-encode-worker" +if [ ! -x "$WORKER_BIN" ]; then + echo "==> building punktfunk-encode-worker (release)" + PUNKTFUNK_BUILD_VERSION="$VERSION" cargo build --release -p punktfunk-encode-worker --locked +fi TRAY_BIN="target/release/punktfunk-tray" # ALWAYS built here, in its OWN cargo invocation — load-bearing, not tidiness, and deliberately not # skipped when the artifact already exists. Cargo unifies features across everything in one build, @@ -60,6 +70,9 @@ SHAREDIR="$STAGE/usr/share/$PKG" # --- file layout (matches the RPM %install) ---------------------------------- install -Dm0755 "$BIN" "$STAGE/usr/bin/$PKG" +# Next to the host in the SAME bindir — the host resolves the worker as a sibling of +# /proc/self/exe. postinst grants this one (and only this one) cap_sys_nice=ep. +install -Dm0755 "$WORKER_BIN" "$STAGE/usr/bin/punktfunk-encode-worker" # 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. @@ -195,13 +208,27 @@ if [ "$BUNDLE_FFMPEG" = "1" ]; then patchelf --set-rpath '$ORIGIN' "$so" done patchelf --force-rpath --set-rpath "\$ORIGIN/../lib/$PKG" "$STAGE/usr/bin/$PKG" + # The encode worker gets an ABSOLUTE rpath, not the $ORIGIN one the host uses — and this is + # load-bearing, not style. postinst grants the worker cap_sys_nice=ep, which makes it AT_SECURE, + # and glibc DROPS any $ORIGIN-expanded RPATH entry for a secure binary unless it normalizes into + # a system-trusted directory (/lib, /usr/lib — /usr/lib/punktfunk-host is not one). So a capped + # worker with `$ORIGIN/../lib/punktfunk-host` would find no libavcodec at all on Ubuntu 24.04 and + # fail to exec — the host would fall back inline (never a dead session, by the ladder's design) + # but the lever would be silently dead on exactly the channel that bundles FFmpeg. An absolute + # DT_RPATH is honoured under AT_SECURE, and because it is DT_RPATH (--force-rpath) it is searched + # transitively, so it also resolves libavutil for the bundled libavcodec — whose own $ORIGIN + # RUNPATH is subject to the same AT_SECURE rule inside this process. + patchelf --force-rpath --set-rpath "/usr/lib/$PKG" "$STAGE/usr/bin/punktfunk-encode-worker" BUNDLED_LIBS="$(printf '%s ' "$DEST"/*.so.*)" echo "==> bundled FFmpeg from $FFMPEG_PREFIX into /$LIBDIR_REL" fi # --- dependencies ------------------------------------------------------------ -# Auto: the binary's directly-linked shared libs (libcuda ignored, see header). In bundle mode the -# bundled .so's are appended so their external deps (libva2/libdrm2/…) are captured too. +# Auto: the binaries' directly-linked shared libs (libcuda ignored, see header). In bundle mode the +# bundled .so's are appended so their external deps (libva2/libdrm2/…) are captured too. The encode +# worker is scanned alongside the host: its link set is a subset today, but it is a shipped +# executable in this package and a future divergence must show up as a Depends, not as a worker +# that silently fails to exec on a fresh install. SHLIB_TMP="$(mktemp -d)" mkdir -p "$SHLIB_TMP/debian" cat > "$SHLIB_TMP/debian/control" <"$SHLIB_TMP/err" \ + dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" "$ROOTDIR/$WORKER_BIN" $BUNDLED_LIBS 2>"$SHLIB_TMP/err" \ | sed -n 's/^shlibs:Depends=//p' )" || { echo "dpkg-shlibdeps failed (exit $?):" >&2; sed 's/^/ /' "$SHLIB_TMP/err" >&2; rm -rf "$SHLIB_TMP"; exit 1; } rm -rf "$SHLIB_TMP" @@ -311,6 +338,30 @@ if [ "$1" = "configure" ]; then # postinst runs on upgrade too, so this heals boxes that installed 0.26.0-1. `setcap -r` exits # non-zero on a file that has no capability, hence the redirect and `|| true`. setcap -r /usr/bin/punktfunk-host 2>/dev/null || true + # CAP_SYS_NICE on the ENCODE WORKER — the same grant, on the binary that can carry it. + # + # punktfunk-encode-worker is a SEPARATE executable (never a hardlink or a host subcommand: a + # shared inode shares the capability and re-creates the breakage above). It is spawned per + # PyroWave session, speaks one socketpair to its parent, and never connects to Wayland, D-Bus + # or the network — so nothing ever resolves ITS /proc//exe and the KWin identification + # path above stays clear. + # + # Why it is worth a capability at all: PyroWave encodes on the GPU shader cores the game + # saturates, and an elevated VK_KHR_global_priority queue is the preemption lever. Every driver + # tested (NVIDIA and RADV) refuses EVERY class without CAP_SYS_NICE. Measured on an RTX 5070 + # Ti under load: encode p99 6.4 -> 4.4 ms. Narrow — scheduling priority only, no filesystem, + # network or user-switching privilege, not setuid. + # + # Best-effort, always: an uncapped worker still encodes at default priority, so a box without + # libcap or a filesystem that cannot store capabilities must not fail this install. postinst + # runs on upgrade too, which is what re-applies the grant to the replaced (new-inode) file. + # + # Debugging the WORKER: a capability makes it AT_SECURE — the loader ignores LD_LIBRARY_PATH + # and LD_PRELOAD for it, and core dumps are suppressed. (On a bundled-FFmpeg build the worker + # carries an ABSOLUTE rpath for exactly that reason; see build-deb.sh.) + if [ -x /usr/bin/punktfunk-encode-worker ]; then + setcap 'cap_sys_nice=ep' /usr/bin/punktfunk-encode-worker 2>/dev/null || true + fi # Pick up the /dev/uinput rule without a reboot (best-effort, no-op in containers). udevadm control --reload-rules 2>/dev/null || true udevadm trigger --subsystem-match=misc 2>/dev/null || true diff --git a/packaging/nix/nixos-module.nix b/packaging/nix/nixos-module.nix index 38418a60..f7dfef8f 100644 --- a/packaging/nix/nixos-module.nix +++ b/packaging/nix/nixos-module.nix @@ -356,7 +356,8 @@ in allowedUDPPorts = nativeUDP ++ optionals cfg.host.gamestream gamestreamUDP; }; - # NO CAP_SYS_NICE wrapper here — deliberately. 0.26.0-1 gave the host a + # NO CAP_SYS_NICE wrapper for the HOST — deliberately, and note there is deliberately one for + # the WORKER just below; the difference is the whole point. 0.26.0-1 gave the host a # `security.wrappers.punktfunk-host` carrying `cap_sys_nice=ep` for the GPU-priority lever, # and that broke desktop streaming on every KDE box. # @@ -373,8 +374,36 @@ in # ambient-only grant (dumpable=1, CapPrm set) is refused exactly like a file capability. See # packaging/arch/punktfunk-host.install for the full matrix. # - # Costs pacing only: pf-zerocopy walks REALTIME -> HIGH -> default when a priority class is - # refused, and pf-frame's thread nice is a best-effort no-op — 0.25.0's behaviour exactly. + # The capability lives on the ENCODE WORKER instead — a different binary, and one nothing + # ever has to identify. + # + # punktfunk-encode-worker is spawned per PyroWave session, speaks one socketpair to its + # parent, and never connects to Wayland, D-Bus or the network. Nothing resolves ITS + # /proc//exe, so the ambient grant a NixOS wrapper performs — the very thing that makes + # a wrapper useless for the host — is exactly right here. (It must also stay a SEPARATE file: + # a hardlink or a host subcommand would share the inode, hence the capability, and re-create + # the breakage above on every file-capability channel.) + # + # A file capability cannot live on a store path (read-only, and shared by every generation), + # so `security.wrappers` is the only mechanism NixOS has — which is why the unit below points + # PUNKTFUNK_ENCODE_WORKER at `config.security.wrapperDir` rather than the store path. The + # host's own ExecStart stays on the store path and must never move. + # + # Best-effort by construction: if the wrapper is absent or the operator overrides the env, + # the host falls back to its in-process encoder at default GPU priority — one warn, never a + # dead session. What the capability buys: PyroWave encodes on the GPU shader cores a game + # saturates, and every driver tested (NVIDIA and RADV) refuses EVERY elevated + # VK_KHR_global_priority class without CAP_SYS_NICE. Measured on an RTX 5070 Ti under load: + # encode p99 6.4 -> 4.4 ms. + # + # Narrow: CAP_SYS_NICE permits raising scheduling priority only — no filesystem, network or + # user-switching privilege, and the wrapper is capability-based, NOT setuid. + security.wrappers.punktfunk-encode-worker = { + source = "${cfg.host.package}/bin/punktfunk-encode-worker"; + capabilities = "cap_sys_nice=ep"; + owner = "root"; + group = "root"; + }; systemd.user.services.punktfunk-host = { description = "punktfunk GameStream + punktfunk/1 streaming host"; @@ -393,6 +422,17 @@ in # The HDR-capable gamescope, if enabled. On PATH rather than pinned through # PUNKTFUNK_GAMESCOPE_BIN so an operator's own override of that env still wins. ++ optional cfg.host.gamescopeHdr cfg.host.gamescopePackage; + # Point the host at the WRAPPED encode worker (see `security.wrappers` above). The host's + # own resolution order is PUNKTFUNK_ENCODE_WORKER -> alongside /proc/self/exe -> PATH, and + # on NixOS the sibling of the store binary is the UNCAPPED store copy — it would run, and + # be refused every priority class, silently. This env is the whole reason the override + # exists. `config.security.wrapperDir` rather than a hard-coded /run/wrappers/bin so an + # operator who has moved it is still correct. + # + # NixOS renders `Environment=` before `EnvironmentFile=`, so `settings`/`environmentFile` + # can still override this (or set it to `off` to force the in-process encoder) — the same + # "an operator's own override still wins" posture as PUNKTFUNK_GAMESCOPE_BIN above. + environment.PUNKTFUNK_ENCODE_WORKER = "${config.security.wrapperDir}/punktfunk-encode-worker"; serviceConfig = { # The store path DIRECTLY — not a capability wrapper. /proc//exe then resolves to the # very path packages.nix substituted into io.unom.Punktfunk.Host.desktop's Exec=, which is diff --git a/packaging/nix/packages.nix b/packaging/nix/packages.nix index 06cbc4ee..53f17b49 100644 --- a/packaging/nix/packages.nix +++ b/packaging/nix/packages.nix @@ -141,9 +141,17 @@ in commonArgs // { pname = "punktfunk-host"; - # HOST ONLY — the tray is a separate derivation (see the note above; co-building crashes it). + # HOST + ENCODE WORKER — the tray is a separate derivation (see the note above; co-building + # crashes it), but punktfunk-encode-worker belongs here: it is the capability-carrying half of + # the PyroWave encode path, it shares the host's dependency graph by design, and host and + # worker version-check each other over their socket, so they must be built and shipped + # lockstep. It is a SEPARATE executable, never a hardlink or a host subcommand — on + # file-capability channels a shared inode would share the capability and make the host + # unidentifiable to KWin (see the note in nixos-module.nix). Without `-p` here crane never + # builds it and `$out/bin` simply would not contain it. cargoExtraArgs = - "--locked -p punktfunk-host " + "--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode"; + "--locked -p punktfunk-host -p punktfunk-encode-worker " + + "--features punktfunk-host/nvenc,punktfunk-host/vulkan-encode"; PUNKTFUNK_BUILD_VERSION = buildVersion; @@ -203,6 +211,11 @@ in postFixup = '' # Only the host dlopens the GPU stack; the tray (its own derivation, copied in above) does not. addDriverRunpath "$out/bin/punktfunk-host" + # The encode worker owns a Vulkan device of its own (PyroWave encodes through ash, which + # dlopens the loader and the vendor ICD), so it needs the same driver runpath. Without it + # the worker starts and then finds no usable device — the host falls back to the in-process + # encoder, so nothing breaks, but the GPU-priority lever this binary exists for is dead. + addDriverRunpath "$out/bin/punktfunk-encode-worker" ''; meta = meta // { diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 15eb3370..6315a702 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -237,9 +237,17 @@ export PUNKTFUNK_BUILD_VERSION="%{version}-%{release}" # with real RFI (clean P-frame recovery anchor via DPB reference slots; design/linux-vulkan-video-encode.md). # Pure Rust `ash` (no new lib / no link-time dep); default on for HEVC (PUNKTFUNK_VULKAN_ENCODE=0 opts # back to libav VAAPI), and a failed open falls back to VAAPI so unsupported devices degrade gracefully. +# -p punktfunk-encode-worker: the capability-carrying PyroWave encode worker, shipped next to the +# host in %%{_bindir} and granted cap_sys_nice=ep via %%caps in %%files. It MUST be a separate file +# (the host can never carry a capability — KWin identification, see the note in %%files), and it +# must ship in the SAME package: host and worker version-check each other over their socket and +# fall back to the in-process encoder on any mismatch. Co-built in this one invocation on purpose — +# v1 accepts that the worker links the same FFmpeg the host does (same package, same sonames, no +# new break class), so cargo's feature unification here is harmless. %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-encode-worker \ + -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. pf-update still @@ -282,6 +290,10 @@ fi %if %{with host} # Binary install -Dm0755 target/release/punktfunk-host %{buildroot}%{_bindir}/punktfunk-host +# The PyroWave encode worker — a SEPARATE executable in the same bindir (the host resolves it as a +# sibling of /proc/self/exe). This is the ONLY binary in this package that carries a capability; +# see the %%caps note in %%files. +install -Dm0755 target/release/punktfunk-encode-worker %{buildroot}%{_bindir}/punktfunk-encode-worker # udev rule — /dev/uinput access for virtual gamepads (input group). install -Dm0644 scripts/60-punktfunk.rules %{buildroot}%{_udevrulesdir}/60-punktfunk.rules @@ -499,6 +511,27 @@ install -Dm0644 scripts/punktfunk-scripting.service %{buildroot}%{_userunitdir}/ # rpm applies file capabilities from package metadata, so a package built WITHOUT %caps() installs # the binary with none and an upgrade from 0.26.0-1 clears it — no scriptlet needed. %{_bindir}/punktfunk-host +# CAP_SYS_NICE on the ENCODE WORKER — the grant 0.26.0-1 aimed at the wrong binary, on a binary +# that can carry it. punktfunk-encode-worker is a separate executable (never a hardlink or a host +# subcommand: a shared inode would share the capability and re-create the breakage above). It is +# spawned per PyroWave session, speaks one socketpair to its parent and never touches Wayland, +# D-Bus or the network — so nothing ever resolves ITS /proc//exe and the KWin identification +# path stays clear. +# +# Declared with %%caps rather than a %%post setcap because that is the rpm-native form: rpm applies +# the capability at install, RESTORES it on upgrade (a replaced file is a new inode), and verifies +# it under `rpm -V`. A scriptlet does none of those. This also covers Bazzite via rpm-ostree +# layering, which honours file capabilities from package metadata. +# +# Why: PyroWave encodes on the GPU shader cores the game saturates, and an elevated +# VK_KHR_global_priority queue is the preemption lever. Every driver tested (NVIDIA and RADV) +# refuses EVERY class without CAP_SYS_NICE. Measured on .21 (RTX 5070 Ti): encode p99 6.4 -> 4.4 ms. +# Narrow — scheduling priority only, no filesystem/network/user-switching privilege, not setuid. +# Best-effort by construction: an uncapped worker still encodes, at default priority. +# +# Debugging the WORKER (not the host): a capability makes it AT_SECURE, so the loader ignores +# LD_LIBRARY_PATH/LD_PRELOAD for it and core dumps are suppressed by default. +%caps(cap_sys_nice=ep) %{_bindir}/punktfunk-encode-worker %{_bindir}/punktfunk-tray %{_udevrulesdir}/60-punktfunk.rules %dir %{_libexecdir}/punktfunk diff --git a/scripts/ci/assert-cap-matrix.sh b/scripts/ci/assert-cap-matrix.sh new file mode 100755 index 00000000..8b1194b5 --- /dev/null +++ b/scripts/ci/assert-cap-matrix.sh @@ -0,0 +1,357 @@ +#!/usr/bin/env bash +# Assert the file-capability matrix of a BUILT package, not of the source tree. +# +# usr/bin/punktfunk-host MUST carry no capability, ever. -> hard fail +# usr/bin/punktfunk-encode-worker MUST carry exactly cap_sys_nice=ep -> hard fail +# +# WHY THIS EXISTS. 0.26.0-1 shipped `cap_sys_nice=ep` on the host binary through five packaging +# channels at once. KWin identifies a Wayland client by resolving its /proc//exe and matching +# it against an installed .desktop's Exec=, and the kernel refuses that readlink to any reader +# whose effective set is not a superset of the target's PERMITTED set (cap_ptrace_access_check). +# KWin holds no capabilities, so a capability-carrying host is unidentifiable, the restricted +# globals are never advertised, and EVERY KDE desktop session dies — presenting as a missing or +# wrong .desktop file. A merged sysext cannot even be repaired on the box (read-only /usr). +# +# Every board in that release was green. The lesson recorded at the time was "verify the PACKAGE, +# never the board"; this script is that, mechanized. It reads what the artifact will actually do on +# a user's machine — the pacman scriptlet, the dpkg postinst, rpm's file-capability metadata, the +# xattrs inside the squashfs — and refuses the release if the matrix is wrong in either direction. +# +# Usage: +# scripts/ci/assert-cap-matrix.sh [ ...] +# scripts/ci/assert-cap-matrix.sh --self-test # red-team the assertions themselves +# +# Artifacts, dispatched by extension: +# *.pkg.tar.zst Arch — the payload listing + the .INSTALL scriptlet (pacman applies caps there, +# not from package metadata, so the scriptlet TEXT is the ground truth) +# *.deb Debian— the payload listing + DEBIAN/postinst (same reason) +# *.rpm RPM — rpm's own file-capability metadata (%caps), which is what rpm applies, +# restores on upgrade and verifies — and what rpm-ostree layers on Bazzite +# *.raw sysext— the squashfs xattrs, read back out of the image that will actually ship +# +# A skipped artifact (no host and no worker inside, e.g. a client-only package) is reported and +# ignored. Anything it cannot READ is a failure, never a pass: a blind check is worse than none, +# which is why the sysext path proves its own reader with a capability round-trip first. +set -euo pipefail + +HOST_REL='usr/bin/punktfunk-host' +WORKER_REL='usr/bin/punktfunk-encode-worker' +WANT_WORKER_CAPS='cap_sys_nice=ep' + +RC=0 +err() { printf '::error::%s\n' "$*" >&2; } +note() { printf '%s\n' "$*"; } + +# --- the matrix ------------------------------------------------------------------------------- +# Pure function of four already-extracted facts, so it can be (and is, below) unit-tested on any +# box with a bash — including one with no setcap, no rpm and no dpkg. +# +# $1 label human-readable artifact name, for the message +# $2 host_caps canonical capability string on the host binary, "" = none +# $3 worker_caps canonical capability string on the worker binary, "" = none +# $4 worker_present 1 if the artifact ships the worker at all +assert_matrix() { + local label="$1" host_caps="$2" worker_caps="$3" worker_present="$4" rc=0 + if [ -n "$host_caps" ]; then + err "$label: $HOST_REL carries '$host_caps' — it must carry NO capability, ever." + err "$label: a capability makes the host unidentifiable to KWin (it cannot readlink" + err "$label: /proc//exe of a capability-carrying process), so every KDE desktop session" + err "$label: dies with 'KWin does not expose zkde_screencast_unstable_v1 to this client'." + err "$label: The GPU-priority capability belongs on $WORKER_REL. This is the 0.26.0-1 incident." + rc=1 + fi + if [ "$worker_present" != 1 ]; then + err "$label: does not ship $WORKER_REL. Host and worker must move lockstep — they" + err "$label: version-check each other over their socket — and the GPU-priority lever is inert" + err "$label: without the worker." + rc=1 + elif [ "$worker_caps" != "$WANT_WORKER_CAPS" ]; then + err "$label: $WORKER_REL carries '${worker_caps:-}', expected exactly '$WANT_WORKER_CAPS'." + if [ -z "$worker_caps" ]; then + err "$label: without it every driver refuses every elevated VK_KHR_global_priority class and" + err "$label: PyroWave encodes at default GPU priority. Granting it needs CAP_SETFCAP at build" + err "$label: or install time — check the scriptlet/%caps/setcap for this channel." + fi + rc=1 + fi + if [ "$rc" = 0 ]; then + note "OK $label: host uncapped, worker $WANT_WORKER_CAPS" + fi + return "$rc" +} + +# Canonicalize a capability string. getcap has printed two forms over its life +# ("path cap_sys_nice=ep" since libcap ~2.36, "path = cap_sys_nice+ep" before) and rpm renders +# "(none)" for a file with no capability. Everything downstream compares canonical strings. +caps_norm() { + local s="${1:-}" + case "$s" in ''|'(none)'|'') printf ''; return 0 ;; esac + printf '%s' "$s" | sed -e 's/^= *//' -e 's/+/=/g' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' +} + +# --- scriptlet readers (Arch .INSTALL, dpkg postinst) ----------------------------------------- +# pacman and dpkg do NOT carry file capabilities in package metadata: the scriptlet applies them. +# So for those two channels the scriptlet text IS the shipped behaviour, and that is what gets +# read. Comments are stripped first — every one of these files carries a long comment block that +# quotes the very commands being searched for. +# +# Limitation, stated rather than hidden: this reads literal `setcap` invocations. A grant smuggled +# through a shell variable or an eval would not be seen. Nothing in this repo does that, and the +# reviewer-facing rule is simply "spell setcap out". +scriptlet_strip_comments() { sed -e 's/#.*$//'; } + +# Any capability GRANT naming the host -> echoed (and therefore fatal). `setcap -r ` is the +# removal we ship and carries no `cap_` token, so it is correctly invisible here. +scriptlet_host_grant() { + scriptlet_strip_comments \ + | grep -E 'setcap' \ + | grep -E 'punktfunk-host' \ + | grep -E 'cap_[a-z_]+[=+]' \ + | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \ + | head -1 || true +} + +# The worker grant, echoed as its canonical capability string when present. +scriptlet_worker_grant() { + scriptlet_strip_comments \ + | grep -E 'setcap' \ + | grep -E 'punktfunk-encode-worker' \ + | grep -oE 'cap_[a-z_]+[=+][a-z]+' \ + | head -1 || true +} + +# --- per-format extractors --------------------------------------------------------------------- + +# An artifact whose payload could not be listed must FAIL, never "skip": a reader that silently +# produces nothing would wave through the exact package this script exists to reject. +require_listing() { + local label="$1" list="$2" + if [ -z "$list" ]; then + err "$label: could not list the payload — refusing to report a PASS from an empty read." + return 1 + fi + return 0 +} + +check_arch_pkg() { + local pkg="$1" label; label="$(basename "$pkg")" + local list scriptlet worker_present=0 host_grant worker_grant + list="$(bsdtar -tf "$pkg" 2>/dev/null || tar -tf "$pkg" 2>/dev/null || true)" + require_listing "$label" "$list" || return 1 + case "$list" in *"$WORKER_REL"*) worker_present=1 ;; esac + case "$list" in *"$HOST_REL"*) ;; *) + if [ "$worker_present" = 0 ]; then note "-- $label: no host and no worker inside, skipping"; return 0; fi ;; + esac + scriptlet="$(bsdtar -xOf "$pkg" .INSTALL 2>/dev/null || true)" + if [ -z "$scriptlet" ]; then + err "$label: no .INSTALL scriptlet in the package — pacman applies capabilities ONLY from the" + err "$label: scriptlet, so a package without one cannot grant the worker anything." + return 1 + fi + host_grant="$(printf '%s\n' "$scriptlet" | scriptlet_host_grant)" + worker_grant="$(printf '%s\n' "$scriptlet" | scriptlet_worker_grant)" + assert_matrix "$label" "$(caps_norm "$host_grant")" "$(caps_norm "$worker_grant")" "$worker_present" +} + +check_deb() { + local deb="$1" label; label="$(basename "$deb")" + local list postinst worker_present=0 host_grant worker_grant + if command -v dpkg-deb >/dev/null 2>&1; then + list="$(dpkg-deb -c "$deb" 2>/dev/null || true)" + postinst="$(dpkg-deb --info "$deb" postinst 2>/dev/null || true)" + elif command -v bsdtar >/dev/null 2>&1; then + # dpkg-less fallback: a .deb is an `ar` archive of two tarballs, and libarchive reads both + # layers. (GNU `ar`/`ar p` is NOT used — Apple's ar rewrites the archive and loses members.) + list="$(bsdtar -xOf "$deb" 'data.tar*' 2>/dev/null | bsdtar -tf - 2>/dev/null || true)" + postinst="$(bsdtar -xOf "$deb" 'control.tar*' 2>/dev/null | bsdtar -xOf - './postinst' 'postinst' 2>/dev/null || true)" + else + err "$label: neither dpkg-deb nor bsdtar available — cannot read this package" + return 1 + fi + require_listing "$label" "$list" || return 1 + case "$list" in *"$WORKER_REL"*) worker_present=1 ;; esac + case "$list" in *"$HOST_REL"*) ;; *) + if [ "$worker_present" = 0 ]; then note "-- $label: no host and no worker inside, skipping"; return 0; fi ;; + esac + if [ -z "$postinst" ]; then + err "$label: no DEBIAN/postinst — dpkg applies capabilities only from the postinst, so this" + err "$label: package cannot grant the worker anything." + return 1 + fi + host_grant="$(printf '%s\n' "$postinst" | scriptlet_host_grant)" + worker_grant="$(printf '%s\n' "$postinst" | scriptlet_worker_grant)" + assert_matrix "$label" "$(caps_norm "$host_grant")" "$(caps_norm "$worker_grant")" "$worker_present" +} + +check_rpm() { + local rpm_file="$1" label; label="$(basename "$rpm_file")" + local caps_table host_caps worker_caps worker_present=0 + command -v rpm >/dev/null 2>&1 || { err "$label: no rpm(8) to read file capabilities with"; return 1; } + # rpm carries capabilities in its own header (%caps) and applies/restores/verifies them itself — + # this is the metadata, i.e. exactly what lands on the box (and what rpm-ostree layers). + caps_table="$(rpm -qp --qf '[%{FILENAMES} %{FILECAPS}\n]' "$rpm_file" 2>/dev/null || true)" + require_listing "$label" "$caps_table" || return 1 + case "$caps_table" in *"/$WORKER_REL"*|*"$WORKER_REL"*) worker_present=1 ;; esac + case "$caps_table" in + *"$HOST_REL"*) ;; + *) if [ "$worker_present" = 0 ]; then note "-- $label: no host and no worker inside, skipping"; return 0; fi ;; + esac + host_caps="$(printf '%s\n' "$caps_table" | awk -v p="/$HOST_REL" '$1 == p { $1=""; sub(/^ /,""); print; exit }')" + worker_caps="$(printf '%s\n' "$caps_table" | awk -v p="/$WORKER_REL" '$1 == p { $1=""; sub(/^ /,""); print; exit }')" + assert_matrix "$label" "$(caps_norm "$host_caps")" "$(caps_norm "$worker_caps")" "$worker_present" +} + +# Prove the reader is not blind BEFORE trusting an empty read from a squashfs. A check that cannot +# see a capability would pass the exact image it exists to reject, so: stage a file, cap it, squash +# it, unsquash it, read it back. If that round trip loses the capability (no CAP_SETFCAP in the +# container, a filesystem that cannot store security.capability, an unsquashfs without xattr +# support) this returns non-zero and the caller FAILS rather than silently approving. +squashfs_reader_is_honest() { + local probe img out got + probe="$(mktemp -d)"; img="$probe/probe.squashfs"; out="$probe/out" + mkdir -p "$probe/tree" + printf '#!/bin/true\n' > "$probe/tree/capped"; chmod 0755 "$probe/tree/capped" + printf '#!/bin/true\n' > "$probe/tree/plain"; chmod 0755 "$probe/tree/plain" + if ! setcap "$WANT_WORKER_CAPS" "$probe/tree/capped" 2>/dev/null; then + rm -rf "$probe"; return 1 + fi + mksquashfs "$probe/tree" "$img" -noappend -quiet >/dev/null 2>&1 || { rm -rf "$probe"; return 1; } + unsquashfs -no-progress -xattrs -d "$out" "$img" >/dev/null 2>&1 || { rm -rf "$probe"; return 1; } + got="$(caps_norm "$(getcap "$out/capped" 2>/dev/null | sed 's/^[^ ]* //')")" + # Positive control AND negative control: it must see the capability that is there, and must not + # invent one that is not. + [ "$got" = "$WANT_WORKER_CAPS" ] || { rm -rf "$probe"; return 1; } + [ -z "$(caps_norm "$(getcap "$out/plain" 2>/dev/null | sed 's/^[^ ]* //')")" ] || { rm -rf "$probe"; return 1; } + rm -rf "$probe"; return 0 +} + +check_sysext_raw() { + local raw="$1" label; label="$(basename "$raw")" + local tmp list host_caps worker_caps worker_present=0 + for t in unsquashfs mksquashfs getcap setcap; do + command -v "$t" >/dev/null 2>&1 || { err "$label: missing $t — cannot read the image's capabilities"; return 1; } + done + if ! squashfs_reader_is_honest; then + err "$label: this runner cannot round-trip a file capability through squashfs (no CAP_SETFCAP," + err "$label: or an unsquashfs/filesystem without xattr support). Refusing to report a PASS that" + err "$label: would be blind — a guard that cannot fail is not a guard. Run this leg as root on" + err "$label: a filesystem that stores security.capability." + return 1 + fi + list="$(unsquashfs -no-progress -l "$raw" 2>/dev/null || true)" + require_listing "$label" "$list" || return 1 + case "$list" in *"$WORKER_REL"*) worker_present=1 ;; esac + case "$list" in + *"$HOST_REL"*) ;; + *) if [ "$worker_present" = 0 ]; then note "-- $label: no host and no worker inside, skipping"; return 0; fi ;; + esac + tmp="$(mktemp -d)" + unsquashfs -no-progress -xattrs -d "$tmp/x" "$raw" "$HOST_REL" "$WORKER_REL" >/dev/null 2>&1 || true + host_caps=""; worker_caps="" + [ -f "$tmp/x/$HOST_REL" ] && host_caps="$(getcap "$tmp/x/$HOST_REL" 2>/dev/null | sed 's/^[^ ]* //')" + [ -f "$tmp/x/$WORKER_REL" ] && worker_caps="$(getcap "$tmp/x/$WORKER_REL" 2>/dev/null | sed 's/^[^ ]* //')" + rm -rf "$tmp" + assert_matrix "$label" "$(caps_norm "$host_caps")" "$(caps_norm "$worker_caps")" "$worker_present" +} + +# --- self-test --------------------------------------------------------------------------------- +# Red-teams the assertions themselves: every row states the verdict it MUST produce, and the row +# that matters most is the 0.26.0-1 one — a capped host has to come out RED. Pure bash, so it runs +# anywhere (macOS included) with no setcap, rpm or dpkg in sight. +self_test() { + local failures=0 + _expect() { # _expect