feat(packaging): grant CAP_SYS_NICE to the encode worker on all six channels, and assert the host never gets it

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.
This commit is contained in:
2026-08-09 12:50:47 +02:00
parent 4a4118e3ce
commit 4f8cce6751
16 changed files with 878 additions and 42 deletions
+15
View File
@@ -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.
+18 -1
View File
@@ -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.
+32 -1
View File
@@ -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.
+13 -1
View File
@@ -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
+5 -2
View File
@@ -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)
+83 -9
View File
@@ -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/<pid>/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/<pid>/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
+34
View File
@@ -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/<pid>/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
+86 -16
View File
@@ -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/<pid>/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/<pid>/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
+18
View File
@@ -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/<pid>/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
+54 -3
View File
@@ -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" <<EOF
@@ -218,7 +245,7 @@ EOF
SHDEPS_RAW="$(
cd "$SHLIB_TMP"
if [ "$BUNDLE_FFMPEG" = "1" ]; then export LD_LIBRARY_PATH="$FFMPEG_PREFIX/lib"; fi
dpkg-shlibdeps -O --ignore-missing-info "$ROOTDIR/$BIN" $BUNDLED_LIBS 2>"$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/<pid>/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
+43 -3
View File
@@ -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/<pid>/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/<pid>/exe then resolves to the
# very path packages.nix substituted into io.unom.Punktfunk.Host.desktop's Exec=, which is
+15 -2
View File
@@ -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 // {
+34 -1
View File
@@ -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/<pid>/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
+357
View File
@@ -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/<pid>/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 <artifact> [<artifact> ...]
# 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/<pid>/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:-<none>}', 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)'|'<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 <host>` 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 <want:pass|fail> <label> <host> <worker> <present>
local want="$1"; shift
local got=pass
assert_matrix "$@" >/dev/null 2>&1 || got=fail
if [ "$got" = "$want" ]; then
printf ' ok %-42s -> %s\n' "$1" "$got"
else
printf ' FAIL %-42s -> %s (wanted %s)\n' "$1" "$got" "$want"; failures=$((failures + 1))
fi
}
note "matrix:"
_expect pass "clean package" "" "$WANT_WORKER_CAPS" 1
_expect fail "HOST CAPPED (the 0.26.0-1 regression)" "cap_sys_nice=ep" "$WANT_WORKER_CAPS" 1
_expect fail "host capped with something else" "cap_net_admin=ep" "$WANT_WORKER_CAPS" 1
_expect fail "host capped, worker fine but missing" "cap_sys_nice=ep" "" 0
_expect fail "worker absent" "" "" 0
_expect fail "worker present but uncapped" "" "" 1
_expect fail "worker over-granted" "" "cap_sys_admin=ep" 1
_expect fail "worker granted the wrong flags" "" "cap_sys_nice=eip" 1
note "canonicalisation:"
_norm() {
local got; got="$(caps_norm "$1")"
if [ "$got" = "$2" ]; then printf ' ok %-42s -> %s\n' "${1:-<empty>}" "${got:-<empty>}"
else printf ' FAIL %-42s -> %s (wanted %s)\n' "${1:-<empty>}" "${got:-<empty>}" "$2"; failures=$((failures + 1)); fi
}
_norm 'cap_sys_nice=ep' 'cap_sys_nice=ep' # libcap >= ~2.36
_norm '= cap_sys_nice+ep' 'cap_sys_nice=ep' # older libcap
_norm '(none)' '' # rpm, no %caps
_norm '' ''
note "scriptlet reader (the Arch .INSTALL / dpkg postinst channels):"
local _sl
_slcase() { # _slcase <label> <text> <want_host_grant:yes|no> <want_worker_caps>
local label="$1" text="$2" want_host="$3" want_worker="$4" gh gw ok=1
gh="$(printf '%s\n' "$text" | scriptlet_host_grant)"
gw="$(caps_norm "$(printf '%s\n' "$text" | scriptlet_worker_grant)")"
case "$want_host" in
yes) [ -n "$gh" ] || ok=0 ;;
no) [ -z "$gh" ] || ok=0 ;;
esac
[ "$gw" = "$want_worker" ] || ok=0
if [ "$ok" = 1 ]; then printf ' ok %-42s\n' "$label"
else printf ' FAIL %-42s host_grant=%s worker=%s\n' "$label" "${gh:-<none>}" "${gw:-<none>}"; failures=$((failures + 1)); fi
}
_sl='_grant() { setcap '"'"'cap_sys_nice=ep'"'"' usr/bin/punktfunk-encode-worker; }
_revoke() { setcap -r usr/bin/punktfunk-host 2>/dev/null || true; }'
_slcase "what this repo ships" "$_sl" no 'cap_sys_nice=ep'
_sl='# setcap '"'"'cap_sys_nice=ep'"'"' usr/bin/punktfunk-host <- only a comment
setcap '"'"'cap_sys_nice=ep'"'"' usr/bin/punktfunk-encode-worker'
_slcase "a commented-out host grant is not a grant" "$_sl" no 'cap_sys_nice=ep'
_sl='setcap '"'"'cap_sys_nice=ep'"'"' usr/bin/punktfunk-host
setcap '"'"'cap_sys_nice=ep'"'"' usr/bin/punktfunk-encode-worker'
_slcase "0.26.0-1 scriptlet (must be caught)" "$_sl" yes 'cap_sys_nice=ep'
_sl='setcap -r usr/bin/punktfunk-host 2>/dev/null || true'
_slcase "removal only, no worker grant" "$_sl" no ''
if [ "$failures" != 0 ]; then err "self-test: $failures case(s) wrong"; return 1; fi
note "self-test: all cases behaved as specified"
return 0
}
# --- main ---------------------------------------------------------------------------------------
main() {
[ $# -gt 0 ] || { echo "usage: $0 <artifact> [...] | --self-test" >&2; return 2; }
if [ "$1" = "--self-test" ]; then self_test; return $?; fi
local artifact
for artifact in "$@"; do
[ -e "$artifact" ] || { err "no such artifact: $artifact"; RC=1; continue; }
case "$artifact" in
*.pkg.tar.zst|*.pkg.tar.xz) check_arch_pkg "$artifact" || RC=1 ;;
*.deb) check_deb "$artifact" || RC=1 ;;
*.rpm) check_rpm "$artifact" || RC=1 ;;
*.raw) check_sysext_raw "$artifact" || RC=1 ;;
*) err "don't know how to read capabilities out of $artifact"; RC=1 ;;
esac
done
if [ "$RC" != 0 ]; then
err "capability matrix WRONG — refusing to publish. The host must never carry a capability"
err "(0.26.0-1 killed every KDE desktop session that way); the GPU-priority grant belongs on"
err "$WORKER_REL and nowhere else."
fi
return "$RC"
}
# Sourceable: `source scripts/ci/assert-cap-matrix.sh` defines the readers and `assert_matrix`
# without running anything, so they can be driven from a test harness on a box that has none of
# the packaging tools (this is how the scriptlet reader is exercised against the real
# packaging/arch/punktfunk-host.install and the real dpkg postinst).
if [ "${BASH_SOURCE[0]}" = "$0" ]; then
main "$@"
exit $?
fi
+37 -1
View File
@@ -52,6 +52,10 @@ for arg in "$@"; do
done
TARGET_DIR="$SRC/target-steamos"
BIN="$TARGET_DIR/release/punktfunk-host"
# The PyroWave encode worker — a separate executable next to the host, and the ONLY binary this
# installer setcaps. Never a hardlink or a mode of $BIN: a shared inode shares the file capability
# and would void the KWin .desktop grant written below.
WORKER="$TARGET_DIR/release/punktfunk-encode-worker"
CONFIG="$HOME/.config/punktfunk"
UNITS="$HOME/.config/systemd/user"
XRD="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
@@ -141,13 +145,25 @@ log "Building punktfunk-host (release) — first build is slow (~10-15 min)"
# vulkan-encode matches the packaged builds (deb/arch): the raw Vulkan Video HEVC/AV1 backend
# (real RFI loss recovery). Pure-Rust ash — no extra system dep. A featureless hand build would
# silently fall back to libav VAAPI.
#
# punktfunk-encode-worker is built alongside: the capability-carrying PyroWave encode worker, a
# SEPARATE binary that lands next to the host in $TARGET_DIR/release (which is how the host finds
# it — sibling of /proc/self/exe). The sudo block further down setcaps that one and only that one;
# the host must stay capability-free or KWin cannot identify it and Desktop mode dies.
distrobox enter "$BOX" -- bash -lc "
set -e
export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'
cd '$SRC' && cargo build -r -p punktfunk-host --features punktfunk-host/vulkan-encode
cd '$SRC' && cargo build -r -p punktfunk-host -p punktfunk-encode-worker --features punktfunk-host/vulkan-encode
"
[ -x "$BIN" ] || die "build did not produce $BIN"
ok "host binary: $BIN"
# Not fatal if it is missing — an absent worker just means the in-process encoder at default GPU
# priority, which is what every 0.26.x Deck already runs.
if [ -x "$WORKER" ]; then
ok "encode worker: $WORKER"
else
warn "no punktfunk-encode-worker at $WORKER — PyroWave will encode at default GPU priority"
fi
if [ "$WITH_WEB" = 1 ]; then
log "Building the management web console (bun)"
@@ -362,6 +378,26 @@ if [ "$SUDO_OK" = 1 ]; then
if [ -x "$BIN" ]; then
sudo setcap -r "$BIN" 2>/dev/null || true
fi
# CAP_SYS_NICE on the ENCODE WORKER — the same grant, on the binary that can hold it, and the
# Deck is the box that wants it most: one small Van Gogh GPU shared between the game and
# PyroWave's compute-shader encode. punktfunk-encode-worker is a separate executable spawned
# per session that speaks one socketpair to the host and never touches Wayland, D-Bus or the
# network — nothing resolves ITS /proc/<pid>/exe, so the .desktop grant written above stays
# valid. Every driver tested (RADV included) refuses EVERY elevated global-priority class
# without this capability, so without it the lever is decoration.
#
# Like $BIN it lives under $HOME, so it survives a SteamOS A/B update on its own — but a
# REBUILD is a new inode and file capabilities do not follow, so it must be re-applied after
# every rebuild. Re-running this installer does that, and so does update.sh.
#
# Best-effort: a failure just means the encode runs at default priority, as it does today.
if [ -x "$WORKER" ]; then
if sudo setcap 'cap_sys_nice=ep' "$WORKER" 2>/dev/null; then
ok "granted CAP_SYS_NICE to the encode worker (PyroWave can outrank a GPU-bound game)"
else
warn "could not grant CAP_SYS_NICE to $WORKER — PyroWave stays at default GPU priority"
fi
fi
# SteamOS A/B updates rebuild /etc and DROP everything not on Valve's keep list — verified
# live: an OS update stripped the udev rule + vhci autoload + UDP sysctl (gamepads silently
# degrade to Xbox 360, buffers back to 208 KB). The sanctioned fix is a preserve drop-in in
+34 -2
View File
@@ -23,6 +23,11 @@ ensure_group() {
SRC="${PUNKTFUNK_SRC:-$HOME/punktfunk}"
BOX="${PUNKTFUNK_BOX:-pf2}"
TARGET_DIR="$SRC/target-steamos"
BIN="$TARGET_DIR/release/punktfunk-host"
# The PyroWave encode worker — a separate executable next to the host, and the only one this
# script setcaps (see the capability block in the sudo section). Never a hardlink or a mode of
# $BIN: a shared inode shares the file capability and voids the KWin .desktop grant.
WORKER="$TARGET_DIR/release/punktfunk-encode-worker"
[ -d "$SRC/crates/punktfunk-host" ] || die "no punktfunk source at $SRC (set PUNKTFUNK_SRC)"
WEB=0; [ -f "$HOME/.config/systemd/user/punktfunk-web.service" ] && WEB=1
@@ -52,8 +57,10 @@ if [ "${1:-}" = "--pull" ]; then
fi
log "Rebuilding host (release)"
# vulkan-encode matches the packaged builds (deb/arch) — see install.sh.
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'; cd '$SRC' && cargo build -r -p punktfunk-host --features punktfunk-host/vulkan-encode"
# vulkan-encode matches the packaged builds (deb/arch) — see install.sh. punktfunk-encode-worker
# rides along: host and worker version-check each other over their socket and fall back to the
# in-process encoder on any mismatch, so an update must never move one without the other.
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'; cd '$SRC' && cargo build -r -p punktfunk-host -p punktfunk-encode-worker --features punktfunk-host/vulkan-encode"
ok "host rebuilt"
if [ "$WEB" = 1 ]; then
log "Rebuilding web console"
@@ -198,6 +205,31 @@ if [ "$SUDO_OK" = 1 ]; then
warn "could not create the 'punktfunk' group — the native Steam Deck pad will not attach."
warn "By hand: sudo groupadd --system punktfunk; sudo usermod -aG punktfunk $USER"
fi
# Capabilities, re-applied because this script just REBUILT both binaries and a rebuilt file is
# a new inode — file capabilities do not follow it.
#
# host -> `setcap -r`. It must carry NO capability, ever: KWin identifies a client by
# resolving /proc/<pid>/exe against a .desktop Exec= (the one refreshed above), and
# the kernel refuses that readlink for a capability-carrying process, so every
# Desktop-mode session dies with "KWin does not expose zkde_screencast_unstable_v1
# to this client". This also heals a Deck that ran 0.26.0-1's installer and has
# only ever updated since — install.sh's removal is not reachable from this path.
# worker -> `cap_sys_nice=ep`. Separate binary, spawned per PyroWave session, no Wayland/
# D-Bus/network, so nothing resolves its /proc/<pid>/exe. Without the capability
# every driver refuses every elevated global-priority class and the lever is inert.
#
# Both best-effort: `setcap -r` exits non-zero on a file that has no capability, and a failed
# grant just means the encode runs at default priority.
if [ -x "$BIN" ]; then
sudo setcap -r "$BIN" 2>/dev/null || true
fi
if [ -x "$WORKER" ]; then
if sudo setcap 'cap_sys_nice=ep' "$WORKER" 2>/dev/null; then
ok "re-granted CAP_SYS_NICE to the encode worker (rebuild = new inode)"
else
warn "could not grant CAP_SYS_NICE to $WORKER — PyroWave stays at default GPU priority"
fi
fi
# Register the tuning on Valve's atomic-update preserve list (see install.sh §4): without
# this, every SteamOS A/B update strips the three files above again (verified live —
# gamepads silently degrade to Xbox 360, UDP buffers back to 208 KB).