Files
punktfunk/scripts/steamdeck
enricobuehler 8103958169 fix(security): the plugin lane stops being a way in
Acts on the 2026-08-05 host security review. 36 of its 38 findings; the two
exceptions are recorded below and in the review doc.

The review's headline is that `plugin_may_access` was the one authorization
gate in the system that was allow-by-default — a hand-maintained denylist of
route prefixes, where every sibling gate is deny-by-default. Its own doc
comment names the two capabilities it exists to withhold, and both were
reachable one route over, because ~1450 commits of new routes were added and
the list was never one of the things anyone remembered to update.

So the gate is now an allowlist, and a test walks the live route table and
fails the build for any route that has not been deliberately classified for
both non-admin lanes. That test is the actual fix: it is what stops the next
route from arriving pre-authorized.

Route reachability and field authority turned out to be different questions.
A provider plugin has to be able to reconcile its own library entries — that
is what a scanner plugin IS — but `prep` and a `command` launch inside that
payload are handed to `/bin/sh -c` as the host user, and every execution site
documents them as operator-typed. Requests now carry the lane that authorized
them, and those two fields are refused to everyone but the operator's own
token.

The art proxy read any absolute path off disk in the host process, which on
Windows is LocalSystem, from a path the plugin lane could write and then read
back — so it yielded `mgmt-token`, which is full admin. It now serves only
real images (extension AND magic bytes, so a renamed secret fails), only from
inside an allowed root, only after canonicalization, and never over UNC; and
a path it would refuse to serve can no longer be persisted in the first place.

On Windows, the config-dir hardening was skipped exactly when it was needed —
it ran only in the branch that CREATES host.env, so the case it was written
for (a local user pre-created the directory and planted one) was the one case
it never ran in. It is now unconditional and first, an existing host.env is
re-owned, and the inheritable OWNER RIGHTS ACE that kept an attacker's files
theirs after the directory was re-owned is gone. The identity and token
readers were hardening the directory only on the path that GENERATED a new
secret, so a planted cert/key or token was adopted verbatim and permanently;
they harden before the first read now.

`ensure_admin_only_source` is implemented. The 2026-07-05 audit recorded it as
FIXED and it was in no commit in this repository's history — the local EoP it
described was live, and it is the payload half of the config-dir chain above.

Also: the three input planes are bounded and lossy like the mic plane on the
same loop already was; Android's library client no longer accepts any
publicly-trusted certificate for the pinned host; the usbip vhci nodes get
their own group instead of riding on `input`, which every packaging scriptlet
tells users to join; a registry URL can no longer inject a TOML table into
bunfig.toml; the pairing cooldown is charged before the arming state is read,
so armed/disarmed is no longer a free oracle; and the whole Low tier, of which
the two worth naming are a clipboard MIME NUL that panicked the host on one
control message, and an unauthenticated global logout that let any LAN peer
sign the operator out on a loop.

NOT fixed, deliberately:

  H-3 (plugin UIs framed allow-same-origin). Dropping allow-same-origin does
  not work: the document's origin goes opaque, its subresource requests are
  then cross-site, the SameSite=Lax session cookie is not sent, and every
  plugin asset 302s to /login. The "open in new tab" link is the same
  escalation with no iframe at all, so the sandbox attribute is not where this
  gets fixed either. It needs a second listener — a distinct origin that is
  still the same site — which changes the console's deploy model and wants
  on-glass validation. The mechanism and the dead end are written down at the
  iframe.

  H-6 registry authentication, whose other half lives in unom/infra. The
  in-repo halves are done: workflow_dispatch inputs no longer interpolate into
  run: blocks (one of them in the step holding UPDATE_MANIFEST_KEY), and the
  syft installer is pinned to its tag instead of main. Digest pinning is left
  until the registry is authenticated, because a tag — content-keyed or not —
  can simply be overwritten while anonymous pushes are accepted.

M-5 is half done: the oracle is closed, but binding the arming window needs
the console to learn the fingerprint first, which is a knock-then-bind flow
rather than an edit.

Verified: cargo fmt --all --check clean; cargo check --all-targets green on
Linux and on Windows (confirmed non-vacuous — a planted type error in
windows/install.rs fails the build); scripts/xcheck.sh windows check green;
cargo test -p punktfunk-host --bins 416 passed, the single failure being
gamestream::stream::tests::sender_delivers_batches, the known qemu-environmental
UDP-loopback flake that fails identically on clean main in the same container;
cargo test -p pf-clipboard 13 passed; web console typechecks.
2026-08-05 17:12:12 +02:00
..

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 -S for system libs; /usr is 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 (~1015 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 in pf2, run natively).
  • Config: ~/.config/punktfunk/host.env (encoder/compositor) and web.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 (runs serve --gamestream --mgmt-bind 0.0.0.0:47990, + --open if chosen — --gamestream adds the Moonlight-compat planes so the Deck's Game Mode also streams to stock Moonlight; the native punktfunk/1 plane is always on), punktfunk-web.service, punktfunk-rebuild-check.service (post-OS-update self-heal, enabled), and punktfunk-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 /usr can't take the package): wrapper ~/.local/bin/punktfunk-scripting, pinned bun in ~/.local/lib/punktfunk-scripting/, bundle in ~/.local/share/punktfunk-scripting/.
  • HDR gamescope: ~/.local/bin/punktfunk-gamescope (gamescope + the pipewire-hdr patches, built in pf2, run natively — it does not replace the system gamescope; only the sessions the host spawns use it). host.env gains PUNKTFUNK_GAMESCOPE_BIN= pointing at it — a line build-gamescope.sh maintains 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=0 in host.env forces SDR. Verify with punktfunk-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/uhid access), /etc/modules-load.d/punktfunk.conf (vhci-hcd for the native Deck pad), $USER in the input group — and /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/bin is on PATH).
  • First build is slow (~1015 min + ~1 GB toolchain/image). Incremental afterwards.
  • No passwordless sudo → the installer skips the sysctl/udev/input steps with a warning; high bitrates will drop packets until you apply 99-punktfunk-net.conf and join input yourself.
  • 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 /etc tuning survives via the atomic-update keep list, and punktfunk-rebuild-check rebuilds 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 manual update.sh remains harmless.