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.
punktfunk host on a Steam Deck
Run a punktfunk host on a Steam Deck — stream its Game Mode (or KDE desktop) to other devices. (Streaming to a Deck is the client; use the Flatpak + Decky plugin instead.)
User-facing guide: docs-site → "SteamOS (Host)" (docs-site/content/docs/steamos-host.md).
This README is the deep reference for what the scripts do and how to operate them by hand.
Why build on-device (not a package or prebuilt binary)
SteamOS 3 is an immutable, read-only Arch base:
- No
pacman -Sfor system libs;/usris read-only and reset on A/B updates. - A prebuilt binary is fragile — it links the system FFmpeg/glibc, and a SteamOS update can bump those sonames out from under it (the same class of breakage as the NVIDIA-driver-after-update issue).
- The host needs unsandboxed
/dev/uinput+/dev/uhid, PipeWire, the compositor, and VAAPI — so Flatpak (the normal Deck app channel) doesn't fit. Flatpak/Decky are for the client.
So the host is built natively inside a Debian-trixie distrobox (pf2), chosen because its
FFmpeg/glibc ABI matches SteamOS's — the resulting binary runs natively on SteamOS (the container
is only the build environment; punktfunk-host is launched directly, not via distrobox enter). A
rebuild always matches the running OS. Encode is VAAPI on the Deck's AMD GPU (NVENC on NVIDIA),
auto-selected by PUNKTFUNK_ENCODER=auto.
The honest trade-off: on-device building costs a slow first install (~10–15 min, ~1 GB of image +
toolchain) and adds moving parts (apt mirrors, rustup, bun) — the price of an install that can
always chase the OS. Both failure modes of that chase are automated away now:
punktfunk-rebuild-check rebuilds when an OS update breaks the binary's links, and the
atomic-update keep list preserves the /etc tuning. The eventual lighter-weight alternative is a
CI-prebuilt bundle with the volatile libraries (FFmpeg et al.) vendored under an $ORIGIN rpath —
OS-update-proof without a toolchain on the device — worth it once SteamOS host volume justifies
per-release artifact signing/hosting; the from-source path would stay as the dev/fallback route.
The web console is the one part that stays in the container at runtime: it's a Nitro bun
build (bun both builds and runs it — the bun-preset output uses Bun.serve with TLS,
serving HTTPS (HTTP/1.1 over TLS) with the host's identity cert), so its service does
distrobox enter pf2 -- … bun .output/server/index.mjs. bun is provisioned in the container.
Scripts
| Script | What it does |
|---|---|
install.sh |
Idempotent installer: ensure the pf2 distrobox + toolchain → build host + web + plugin runner → write config → build the HDR gamescope below → tune sysctl + udev + vhci-hcd + input group and register it on SteamOS's atomic-update keep list (sudo) → install + start punktfunk-host / punktfunk-web systemd user services with linger, plus the rebuild check below. |
update.sh |
Rebuild everything from the current source and restart the services (config + pairings persist). --pull does git pull first. Also retrofits anything a newer install.sh writes (runner, HDR gamescope, keep-list registration, rebuild check) onto older installs. |
build-gamescope.sh |
Build gamescope + the pipewire-hdr patches (packaging/gamescope) in the same distrobox and install it as ~/.local/bin/punktfunk-gamescope, wiring PUNKTFUNK_GAMESCOPE_BIN into host.env — what lets Game Mode stream 10-bit BT.2020 PQ (HDR) instead of 8-bit SDR. Best-effort: a failure warns and the host streams SDR. Content-stamped — a no-op unless packaging/gamescope/ changed or the binary broke. |
rebuild-check.sh |
The post-OS-update self-heal (run by punktfunk-rebuild-check.service before the host at session start): ldd-probes the host binary and the HDR gamescope — milliseconds when healthy, a full update.sh rebuild only when a SteamOS update actually broke library links. |
git clone https://git.unom.io/unom/punktfunk ~/punktfunk
bash ~/punktfunk/scripts/steamdeck/install.sh # PIN pairing required (secure default)
bash ~/punktfunk/scripts/steamdeck/install.sh --open # trusted LAN: accept unpaired clients
bash ~/punktfunk/scripts/steamdeck/install.sh --no-web # host only, no web console
bash ~/punktfunk/scripts/steamdeck/install.sh --no-gamestream # native punktfunk/1 only, no Moonlight surface
bash ~/punktfunk/scripts/steamdeck/update.sh # after pulling new source
Note: unlike a bare serve (native-only by default), the Deck install enables --gamestream by
default so stock Moonlight clients work out of the box; --no-gamestream turns that surface off.
Env overrides: PUNKTFUNK_SRC (source dir, default ~/punktfunk), PUNKTFUNK_BOX (container name,
default pf2), PUNKTFUNK_MGMT_PORT (47990), PUNKTFUNK_WEB_PORT (47992).
What gets installed
- Binary:
~/punktfunk/target-steamos/release/punktfunk-host(built inpf2, run natively). - Config:
~/.config/punktfunk/host.env(encoder/compositor) andweb.env(generated web login password + session secret). Trust material (cert.pem,mgmt-token,punktfunk1-paired.json) lives here too and persists across updates. - Services:
~/.config/systemd/user/punktfunk-host.service(runsserve --gamestream --mgmt-bind 0.0.0.0:47990,+ --openif chosen —--gamestreamadds the Moonlight-compat planes so the Deck's Game Mode also streams to stock Moonlight; the nativepunktfunk/1plane is always on),punktfunk-web.service,punktfunk-rebuild-check.service(post-OS-update self-heal, enabled), andpunktfunk-scripting.service(plugin runner, opt-in — enable it once you use plugins/scripts). Linger is enabled so they run without a login session. - Plugin runner: the deb's payload laid out user-scoped (read-only
/usrcan't take the package): wrapper~/.local/bin/punktfunk-scripting, pinnedbunin~/.local/lib/punktfunk-scripting/, bundle in~/.local/share/punktfunk-scripting/. - HDR gamescope:
~/.local/bin/punktfunk-gamescope(gamescope + thepipewire-hdrpatches, built inpf2, run natively — it does not replace the system gamescope; only the sessions the host spawns use it).host.envgainsPUNKTFUNK_GAMESCOPE_BIN=pointing at it — a linebuild-gamescope.shmaintains and removes again if the binary ever stops working, because a stale absolute override would break session spawning, not just HDR. HDR is attempted by default when present;PUNKTFUNK_GAMESCOPE_HDR=0inhost.envforces SDR. Verify withpunktfunk-host hdr-probe. - System tuning (sudo):
/etc/sysctl.d/99-punktfunk-net.conf(32 MB UDP buffers — the #1 high-bitrate lever),/etc/udev/rules.d/60-punktfunk.rules(uinput/uhidaccess),/etc/modules-load.d/punktfunk.conf(vhci-hcdfor the native Deck pad),$USERin theinputgroup and inpunktfunk— the latter created here if missing, because the udev rulechgrps the vhciattach/detachnodes to it and a rule that names a nonexistent group fails silently, leaving the native Deck pad unable to attach (the deb/rpm/arch scriptletsgroupaddit; nothing on this path did until now). It is separate frominputon purpose: writingattachmaterialises an arbitrary emulated USB device (security-review 2026-08-05 M-4). Drop it withsudo gpasswd -d "$USER" punktfunkif you would rather stream without that pad. Plus/etc/atomic-update.conf.d/punktfunk.conf, which registers the three files on SteamOS's atomic-update keep list so A/B OS updates carry them over (verified: without it an update silently strips them — pads degrade to Xbox 360, buffers drop to 208 KB).
Operating
systemctl --user status punktfunk-host punktfunk-web
journalctl --user -u punktfunk-host -f # watch sessions / pairing PIN
systemctl --user restart punktfunk-host # after editing host.env
Pair from the web console (Devices → arm pairing) or directly from a client with the host's PIN. The
host advertises over mDNS as _punktfunk._udp, so clients discover it automatically.
Gotchas
- distrobox required. If missing:
curl -sfL https://raw.githubusercontent.com/89luca89/distrobox/main/install | sh -s -- --prefix ~/.local(then ensure~/.local/binis on PATH). - First build is slow (~10–15 min + ~1 GB toolchain/image). Incremental afterwards.
- No passwordless sudo → the installer skips the sysctl/udev/group steps with a warning; high
bitrates will drop packets until you apply
99-punktfunk-net.confand joininput(andpunktfunk, for the native Deck pad) yourself. The script prints the exact commands. - Installed before 0.25.0?
web.envwas written at the ambient umask, i.e. world-readable, so the console password and session secret leaked to every local account.install.sh/update.shnow tighten~/.config/punktfunkto0700andweb.envto0600on every run and say so — but rotatePUNKTFUNK_UI_PASSWORDafterwards, because a chmod does not un-leak a read secret. - Game Mode auto-suspend drops the host off the network on idle — disable it (Settings → Power) for a headless host.
- WiFi tx ceiling ≈ 250 Mbps goodput (a Deck hardware/driver packet-rate limit, band-independent); fine for 1080p/1440p60. A wired dock lifts it.
- After a SteamOS update nothing should be needed: the
/etctuning survives via the atomic-update keep list, andpunktfunk-rebuild-checkrebuilds the binary automatically if the new base actually broke its library links (first session start after the update takes the build's few minutes in that case). A manualupdate.shremains harmless.