60-punktfunk.rules chgrp's the usbip vhci attach/detach nodes to a dedicated
`punktfunk` group (security-review 2026-08-05 M-4: writing `attach` materialises
an arbitrary emulated USB device, so it must not ride on `input`). Four of the
six install paths shipped that rule in 0.25.0 without ever creating the group.
chgrp then failed, the nodes stayed root:root 0644, and the virtual Steam Deck
pad silently never attached — while `usermod -aG punktfunk` failed outright with
"group 'punktfunk' does not exist".
Affected and fixed:
* arch — post_upgrade() called only _ensure_update_group, so every box that
reached 0.25.0 by `pacman -Syu` missed it; post_install was correct.
* nix — no users.groups.punktfunk at all, though host.users' own description
already promised the usbip/vhci pad. Declares it now and adds
host.users to both groups.
* bazzite sysext — a group is host state and cannot ride an image, and the
deb/rpm scriptlets that would create it never run there.
* steamdeck install.sh/update.sh — handled `input` only. Both now create the
group and join it: running that script IS the statement "make my
Deck a host with native pad passthrough".
deb and rpm were correct throughout (one postinst/%post for install + upgrade).
Also on the Deck path: web.env secret hygiene. install.sh's `chmod 600` sat
inside the create-only branch despite a comment calling it "the idempotent belt
for a pre-existing file", and update.sh never touched the config dir at all — so
an install set up once and only updated since kept web.env world-readable
(0644) with the console password and session secret in it. Both scripts now
harden ~/.config/punktfunk to 0700 and web.env to 0600 on every run, and say so
loudly, because a chmod does not un-leak an already-readable secret: the
password still needs rotating.
Both group blocks are `if ensure_group ...` rather than `ensure_group || true`:
a failed groupadd must not fall through to a usermod against a nonexistent
group, which under `set -e` aborted install.sh after the long build and
update.sh before the service restart (verified: exit 6, no restart).
Docs: the group is now documented where people actually look — the per-distro
guides, install.md, steamos-host.md, a new troubleshooting entry for "pad
arrives as an Xbox 360 controller", and the uninstall pages. The 0.25.0 notes
gain the "group does not exist" caveat and turn the password bullet from
"consider rotating" into a real instruction, and CHANGELOG records the known
issue against the breaking change that introduced it.
Verified: bash -n on all four scripts; the arch scriptlet's post_upgrade driven
in a container (creates the group, idempotent on re-run); the ensure_group
helper and both membership branches, including a control that reproduces the
original bug (chgrp to a missing group leaves the node root:root 0644); the
find -perm /0077 probe across 0644/0640/0604/0600/0400 on GNU findutils;
`nix flake check --no-build` (the exact CI gate) and a NixOS eval showing
alice.extraGroups == ["input","punktfunk"]; docs-site build + typecheck.
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.