Files
punktfunk/packaging/arch/punktfunk-host.install
T
enricobuehler 767e67caf4 feat(packaging): grant the host CAP_SYS_NICE, without which the GPU-priority lever does nothing
Wave-2 PW1, second half. The companion commit wires `PYROWAVE_QUEUE_PRIORITY` into the Linux
PyroWave device; this is what makes it work on a packaged host.

Measured on .21 (RTX 5070 Ti, NVIDIA 610.43.02), same binary in both arms:

  as packaged (no capability)     every class refused, REALTIME *and* HIGH -> default priority
  same binary, cap_sys_nice+ep    granted REALTIME on the FIRST attempt, no downgrade

RADV behaves the same way. So this is not the RADV-specific "expect one downgrade to HIGH" the
plan predicted — without the capability there is no elevated priority at all, on any vendor, and
the knob is decoration.

Worth being precise about what is being granted, because it is a network-facing daemon.
CAP_SYS_NICE permits raising scheduling priority (nice, ioprio, affinity, RT class) and nothing
else: no filesystem access, no network privilege, no user switching, and it is NOT setuid. The
repo already ships exactly this capability on its gamescope binary for the same reason. Two side
effects that will otherwise confuse someone debugging: a capability-carrying binary is AT_SECURE,
so the loader ignores LD_LIBRARY_PATH/LD_PRELOAD for it (note this box was propped up by exactly
such a shim during the ffmpeg-9 soname break — that workaround would now be silently ignored), and
core dumps are suppressed by default.

Per packaging path, because none of them are the same:

- Arch: a `_grant_sched_capability` in the scriptlet, called from post_install AND post_upgrade —
  a replaced binary is a new inode, so the capability does not survive an upgrade by itself.
- Debian: the same setcap in the postinst `configure` branch.
- RPM: `%caps(cap_sys_nice=ep)` on the binary in `%files`, which is the rpm-native form — rpm then
  applies it on install, restores it on upgrade, and verifies it. A `%post setcap` does none of
  those.
- NixOS: `security.wrappers`, because a store path is read-only and shared and cannot be setcap'd.
  The unit's ExecStart moves to `config.security.wrapperDir` — without that the wrapper exists and
  the service still runs the uncapped store path, which is the whole failure this fixes.
- Steam Deck: setcap in the installer's sudo block. That box needs it most (one small Van Gogh GPU
  shared between the game and the encode). The binary lives under $HOME, so unlike the /etc
  drop-ins it survives a SteamOS A/B update on its own and needs no atomic-keep entry — but it
  does need re-applying after each rebuild, which re-running the installer does.
- Bazzite sysext: at IMAGE BUILD time, before mksquashfs. It cannot be done in the merge hook (a
  merged sysext's /usr is read-only squashfs) and it cannot ride in from the RPM either — 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), so a setcap on the staging tree is what lands in the image. Needs root/CAP_SETFCAP;
  a plain-user CI build warns and ships without it rather than failing a release over a
  performance lever.

Every one of them is best-effort and cannot fail an install: a box without libcap, or a filesystem
that cannot store capabilities, simply runs at default priority exactly as it does today.

Documented in the same PR — the configuration row now says the packages grant it, and
running-as-a-service gets a section explaining what it is, how to check it (`getcap`), and how to
remove it (`setcap -r`, or just `PYROWAVE_QUEUE_PRIORITY=off`), including the two debugging side
effects.

Verified: the Arch scriptlet grants the capability from a fake package root exactly as pacman
would invoke it, and the resulting binary reaches REALTIME end to end on the RTX 5070 Ti; the RPM
spec's %caps line parses under rpmspec in a Fedora 41 container; the NixOS module parses under
nix-instantiate; all five edited shell scripts pass `bash -n`. No Rust file changed in this
commit, so the CI-parity Rust gates from the companion commit still stand.
2026-08-08 15:24:54 +02:00

143 lines
7.3 KiB
Plaintext

# pacman install scriptlet — mirrors the RPM %post / deb postinst.
_ensure_update_group() {
# The (empty) opt-in group for web-console-triggered updates — nobody is auto-added.
getent group punktfunk-update >/dev/null 2>&1 || groupadd --system punktfunk-update 2>/dev/null || true
}
_ensure_punktfunk_group() {
# Owns the usbip vhci attach/detach nodes (60-punktfunk.rules). Separate from 'input' on
# purpose: writing 'attach' materialises an arbitrary emulated USB device, which is a root-only
# kernel primitive and must not ride on the group users are told to join for gamepads
# (security-review 2026-08-05 M-4).
getent group punktfunk >/dev/null 2>&1 || groupadd --system punktfunk 2>/dev/null || true
}
# CAP_SYS_NICE on the host binary — the GPU-scheduling grant.
#
# WHY: PyroWave encodes on the GPU's shader cores, so a GPU-bound game starves it (measured: the
# encode dispatch goes from ~2 ms to 15-18 ms at 95 % game load). The fix is an elevated
# global-priority Vulkan queue, which the driver gates on CAP_SYS_NICE — measured 2026-08-08 on an
# RTX 5070 Ti: WITHOUT the capability every priority class is refused, WITH it the encoder is
# granted REALTIME on the first attempt. RADV is the same. Without this line the knob exists and
# does nothing. Same capability, same mechanism, as our gamescope package sets on its own binary.
#
# NARROW: CAP_SYS_NICE only permits raising scheduling priority (nice/ioprio/affinity/RT class). It
# grants no filesystem, network or user-switching privilege, and it is NOT setuid.
#
# TWO CONSEQUENCES worth knowing before you debug something odd on this host:
# * a file capability makes the process AT_SECURE, so the dynamic loader IGNORES LD_LIBRARY_PATH
# and LD_PRELOAD for it. A library-path shim that used to work will silently stop.
# * core dumps are suppressed for capability-carrying binaries by default (fs.suid_dumpable).
#
# Never fails the install: a box without libcap, or a filesystem that cannot store capabilities
# (some overlay/NFS setups), just runs at default priority exactly as before.
_grant_sched_capability() {
setcap 'cap_sys_nice=ep' usr/bin/punktfunk-host 2>/dev/null || true
}
post_install() {
_ensure_update_group
_ensure_punktfunk_group
_grant_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).
sysctl -p /usr/lib/sysctl.d/99-punktfunk-net.conf >/dev/null 2>&1 || true
cat <<'MSG'
punktfunk-host installed.
1. Add yourself to the 'input' group for virtual gamepads:
sudo usermod -aG input "$USER" # then re-login
Only if you want the virtual Steam Deck pad (usbip), ALSO join 'punktfunk':
sudo usermod -aG punktfunk "$USER"
That group can emulate arbitrary USB devices — join it only on a machine you trust.
2. Pick a backend config (gamescope is the no-desktop default on SteamOS/Deck):
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
3. Enable the host:
systemctl --user enable --now punktfunk-host
NOTE: encode is NVENC-only. Install 'nvidia-utils' on an NVIDIA host. An AMD Steam Deck is NOT
yet supported — it needs a VAAPI (hevc_vaapi) encoder backend (see packaging/arch/README.md).
MSG
# Firewall: stock Arch ships none (ports already open); CachyOS ships ufw; some spins (EndeavourOS)
# enable firewalld. We install a ufw app profile AND firewalld service definitions but never touch
# the running firewall — just point the way for whichever is active.
if command -v ufw >/dev/null 2>&1; then
cat <<'MSG'
4. ufw is installed — open the streaming ports once (native-only host shown; add
'punktfunk-gamestream' as well for Moonlight compat):
sudo ufw allow punktfunk-native
MSG
fi
if command -v firewall-cmd >/dev/null 2>&1; then
cat <<'MSG'
4. firewalld is active — open the streaming ports once (native-only host shown; add
'punktfunk-gamestream' as well for Moonlight compat):
sudo firewall-cmd --reload # load the new service def
sudo firewall-cmd --permanent --add-service=punktfunk-native
sudo firewall-cmd --reload
MSG
fi
# Conflicting Moonlight-compatible host (Sunshine/Apollo/...): reuse the host's own detector so
# the warning lives in one place. Exit 1 = found; never fail the install on it.
if command -v punktfunk-host >/dev/null 2>&1; then
if ! conflict="$(punktfunk-host detect-conflicts 2>/dev/null)"; then
printf '\n%s\n' "$conflict"
fi
fi
}
post_upgrade() {
_ensure_update_group
# A replaced binary is a NEW inode — file capabilities do not survive the upgrade, so re-grant.
_grant_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
}
# An already-open firewall does NOT pick up a port we added to a profile.
#
# ufw expands an app profile into concrete rules when you run `ufw allow`, and stores THOSE. Editing
# /etc/ufw/applications.d later — which is all a package upgrade does — changes nothing about the
# rules already installed. firewalld is friendlier (its permanent config names the service, so a
# reload re-reads the XML) but still needs that reload. Either way the operator has an old rule and
# no reason to suspect it.
#
# That is not hypothetical: 47993 (plugin UIs, a separate origin from the console) arrived exactly
# this way, and on an upgraded ufw box every plugin interface silently became an empty panel in the
# console. So on upgrade, look at what is actually open and say so — still without touching the
# running firewall, which stays the operator's call.
_warn_stale_firewall_ports() {
# `ufw status verbose` prints each rule with its EXPANDED ports — "47992/tcp (punktfunk-web)"
# before the refresh, "47992,47993/tcp (punktfunk-web)" after — so one listing answers both "is
# the profile allowed at all" and "does that rule know the new port". (Plain `ufw status` prints
# the profile NAME instead, which cannot tell the two apart.)
if command -v ufw >/dev/null 2>&1 &&
ufw status verbose 2>/dev/null | grep -q 'punktfunk-web' &&
! ufw status verbose 2>/dev/null | grep -q '47993'; then
cat <<'MSG'
punktfunk: your ufw rule for 'punktfunk-web' predates TCP 47993, the separate origin plugin UIs
are served from. Until it is refreshed, plugin interfaces will not load in the web console:
sudo ufw app update punktfunk-web && sudo ufw reload
MSG
fi
# `--info-service` asks the DAEMON, which answers from the definition it loaded at its last
# (re)start — precisely the stale copy we are warning about. The file on disk already says 47993.
if command -v firewall-cmd >/dev/null 2>&1 &&
firewall-cmd --state >/dev/null 2>&1 &&
firewall-cmd --query-service=punktfunk-web >/dev/null 2>&1 &&
! firewall-cmd --info-service=punktfunk-web 2>/dev/null | grep -q '47993'; then
cat <<'MSG'
punktfunk: the punktfunk-web firewalld service now also covers TCP 47993, the separate origin
plugin UIs are served from. Reload so the running firewall picks it up, or plugin interfaces will
not load in the web console:
sudo firewall-cmd --reload
MSG
fi
}