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.
198 lines
11 KiB
Plaintext
198 lines
11 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
|
|
}
|
|
|
|
# NO capability on the host binary — and an active removal of the one 0.26.0-1 granted.
|
|
#
|
|
# 0.26.0-1 ran `setcap cap_sys_nice=ep` here, to let the encoder open an elevated global-priority
|
|
# Vulkan queue (PyroWave shares the GPU's shader cores with the game; measured 2026-08-08 on an
|
|
# RTX 5070 Ti, the encode dispatch goes ~2 ms -> 15-18 ms at 95 % game load without it). That grant
|
|
# BROKE DESKTOP STREAMING ON EVERY KDE BOX, and it cannot be made to work — the two are mutually
|
|
# exclusive at the kernel level:
|
|
#
|
|
# KWin hands out its restricted Wayland protocols (zkde_screencast_unstable_v1, which mints our
|
|
# virtual output, and org_kde_kwin_fake_input, which injects input) only to a client it can
|
|
# IDENTIFY, by resolving that client's /proc/<pid>/exe and matching it against an installed
|
|
# .desktop's Exec= (ours is io.unom.Punktfunk.Host.desktop). 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 the moment this binary carries a
|
|
# capability it becomes unidentifiable: KWin's executablePath() is empty, nothing matches, the
|
|
# globals are never advertised, and every session dies with
|
|
# "KWin does not expose zkde_screencast_unstable_v1 to this client" after 8 retries — while
|
|
# looking exactly like a missing or wrong .desktop file.
|
|
#
|
|
# Verified on CachyOS (kernel 7.1.6), same-uid reader, cap_sys_nice=ep on the target:
|
|
# no capability .............................. readlink /proc/<pid>/exe OK
|
|
# capability ................................. EPERM
|
|
# capability + prctl(PR_SET_DUMPABLE, 1) ..... EPERM <- dumpable is NOT the gate
|
|
# capability dropped + PR_SET_DUMPABLE(1) .... OK <- only a capability-free process works
|
|
#
|
|
# The third row also rules out the obvious "move it to the systemd unit": AmbientCapabilities= puts
|
|
# CAP_SYS_NICE in exactly the same permitted set and fails identically. Nothing short of not having
|
|
# the capability restores identification, so the host does not get one. The encoder already walks
|
|
# REALTIME -> HIGH -> default when the class is refused (pf-zerocopy vulkan.rs), so this costs
|
|
# pacing under a GPU-bound game and nothing else — 0.25.0's behaviour exactly.
|
|
#
|
|
# The removal below heals boxes that ran 0.26.0-1's scriptlet. A pacman upgrade writes a new inode
|
|
# and file capabilities do not survive that, so this is belt-and-braces for reinstall/downgrade
|
|
# paths — cheap, and the failure it prevents is an 8-retry session death with a misleading message.
|
|
_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).
|
|
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
|
|
# Also on UPGRADE, not just post_install: 'punktfunk' was introduced in 0.25.0, so every box that
|
|
# reached it by `pacman -Syu` from 0.24.x ran only this function and never got the group at all —
|
|
# leaving 60-punktfunk.rules to chgrp to a nonexistent group, the vhci attach/detach nodes
|
|
# root-only, and the virtual Steam Deck pad silently unable to attach. groupadd is idempotent, so
|
|
# this is a no-op on boxes that installed fresh.
|
|
_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
|
|
}
|
|
|
|
# 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
|
|
}
|