Files
punktfunk/packaging/arch
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 on Arch Linux / SteamOS

Packaging for punktfunk on Arch and Arch-derived immutable distros. The PKGBUILD is a split package producing punktfunk-host (the gaming-rig host) and punktfunk-client (the native GTK4/libadwaita Linux client) — mirrors the rpm subpackages (packaging/rpm/punktfunk.spec) and the deb build scripts. On a Steam Deck used as a client you want punktfunk-client (it's what the Decky plugin launches); on a gaming rig, punktfunk-host.

Steam Deck as a HOST: don't use this PKGBUILD — SteamOS's read-only root makes makepkg/sysext awkward, and a prebuilt binary breaks on OS library bumps. Use the on-device build script instead: scripts/steamdeck/install.sh (it builds in a Debian-trixie distrobox ABI-matched to SteamOS and uses VAAPI on the Deck's AMD GPU). The Deck host path is the one exception to "host encode is NVENC-only" below.

A third member, punktfunk-web (the browser management console — pairing + status), is opt-in: build it by setting PF_WITH_WEB=1, which requires bun at build time (bun-bin from the AUR if it isn't in your repos). bun is also the runtime — the console serves HTTPS (HTTP/1.1 over TLS) via Bun.serve, so the package vendors the bun binary (no nodejs dependency). A default makepkg builds only host+client with no JS tooling — mirroring the RPM spec's %bcond_with web.

Host encode: NVENC on NVIDIA, VAAPI on AMD/Intel (PUNKTFUNK_ENCODER=auto picks one). The host now has a VAAPI encoder + zero-copy dmabuf path alongside NVENC/CUDA, so punktfunk-host works on Arch + NVIDIA and AMD/Intel (incl. the Steam Deck — see the on-device path above). The client decodes via VAAPI on AMD/Intel with a software fallback.

CI (.gitea/workflows/arch.yml) builds this PKGBUILD in an archlinux:base-devel container on every push and publishes the packages to the Gitea Arch package registry — a plain pacman repo, so an Arch box installs and updates punktfunk with pacman -Syu like everything else. Two repos mirror the deb/rpm channels: punktfunk (release tags) and punktfunk-canary (rolling main-branch builds, versioned X.Y.Z-0.<run#> so a later release always outranks them). Enable exactly one.

The registry signs the repo database and every package, so first import its key into pacman's keyring (a one-time step — after this, packages install signature-verified):

# 1. Trust the registry signing key.
curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
  | sudo pacman-key --add -
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69

# 2. Add the repo (pick ONE channel — punktfunk for releases, punktfunk-canary for main builds).
#    printf, not a heredoc, so this works in fish too (CachyOS's default shell has no `<<EOF`).
printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' \
  | sudo tee -a /etc/pacman.conf >/dev/null

# 3. Sync + install.
sudo pacman -Sy punktfunk-host        # gaming rig
sudo pacman -Sy punktfunk-client      # the native GTK4 Linux client
sudo pacman -Sy punktfunk-web         # optional browser management console

(No SigLevel line needed — pacman's default Required DatabaseOptional verifies the signed packages against the key you just trusted. Arch is rolling, so the packages are built against current Arch sonames — keep the box itself updated too.)

Then the same first-run steps as a source build (printed by the install scriptlet): input group, host.env, systemctl --user enable --now punktfunk-host — see the next section.

Build from source — Arch Linux (mutable)

cd packaging/arch
# Build the working tree (CI / dev) — no git fetch:
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
# …or build the tagged release the AUR way:
makepkg -si
# …add the web console too (needs bun / bun-bin):
PF_WITH_WEB=1 PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver

aarch64 (Arch Linux ARM) — the client

The PKGBUILD declares arch=('x86_64' 'aarch64'). On aarch64 it builds the client onlypkgname drops punktfunk-host, so makepkg never enters the host's build or package path, and build() skips the host/tray cargo invocations and their NVENC/Vulkan-encode features. The host stays x86-only because its encode stack (NVENC/QSV/AMF) is.

Nothing else changes — run the same command on an Arch Linux ARM box:

cd packaging/arch
PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
# -> punktfunk-client-<ver>-<rel>-aarch64.pkg.tar.zst   (no punktfunk-host package)

There is no cross-compile path here: makepkg builds for CARCH, so this wants a real aarch64 Arch machine (or an emulated Arch Linux ARM container, which is slow). Unlike the deb, it has not been verified end to end yet — there is no official arm64 Arch container to test in. Then the standard first-run (printed by the install scriptlet):

sudo usermod -aG input "$USER"          # virtual gamepads; re-login after
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env   # gamescope backend
systemctl --user enable --now punktfunk-host
# Web console (if you installed the punktfunk-web package): enable it + read the login password.
systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'   # open https://<host-ip>:47992

NVENC/EGL come from the NVIDIA driver: sudo pacman -S --needed nvidia-utils. Arch's stock ffmpeg already has NVENC built in — no RPM-Fusion-style swap needed (unlike Fedora).

Runtime dependency map (Fedora/Debian → Arch)

Need Arch package
FFmpeg + NVENC ffmpeg (NVENC built in)
PipeWire + session mgr pipewire wireplumber
PulseAudio-API audio for games pipewire-pulse (host optdepend — real pulseaudio also works; never a hard dep, it CONFLICTS with pulseaudio)
Opus / input injection opus libei
GL/EGL + gbm + xkb + wayland libglvnd mesa libxkbcommon wayland
NVIDIA driver (NVENC/EGL/CUDA) nvidia-utils (optdepend — never a hard dep)
Compositor backends gamescope (≥3.16.22) / kwin / mutter / sway (optdepends)

Immutable Arch (SteamOS 3) — the systemd-sysext mechanism

SteamOS has a read-only /usr on A/B partitions, and every OS update reimages the rootfs — so steamos-readonly disable + pacman is fragile for anything that must survive updates. The SteamOS-blessed overlay mechanism is a systemd-sysext: an image merged read-only over /usr at boot, living in the writable /var/lib/extensions/.

For a SteamOS HOST this is NOT the supported path — that is scripts/steamdeck/install.sh (the on-device distrobox build, which also builds the HDR gamescope). A host sysext carries a prebuilt binary that breaks on the next SteamOS soname bump, and /var — where sysexts live — is per-A/B-partition-set. The mechanism below is what the Deck client image uses (next section), and an option for operators on other immutable Arch derivatives who accept the prebuilt trade-off.

Build the package, then wrap its /usr payload into a sysext image:

# 1. build the pacman packages (needs an Arch environment / container)
cd packaging/arch && PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
( cd ../gamescope && makepkg -f -d --holdver )   # optional: the HDR gamescope companion
# 2. turn it into a sysext .raw (extracts the packages' /usr into an image + extension-release);
#    --gamescope folds the HDR build into a HOST image (verified by its +pfhdr banner)
bash build-sysext.sh --gamescope ../gamescope/punktfunk-gamescope-*.pkg.tar.zst punktfunk-host-*.pkg.tar.zst
# 3. on the box:
sudo cp punktfunk-host.raw /var/lib/extensions/
sudo systemctl enable --now systemd-sysext      # merges it
systemctl --user enable --now punktfunk-host     # the user unit is now under /usr/lib

The udev rule, sysctl, and systemd user unit all live under /usr/lib, so the merged sysext exposes them. systemd-sysext refresh re-merges after a reboot. (One HDR nuance of the sysext path: file capabilities don't survive it, so gamescope runs without CAP_SYS_NICE — everything works, frame pacing is marginally worse than the pacman install, whose .install sets the cap.)

Steam Deck — the client (what the Decky plugin launches)

To stream to a Deck, you install punktfunk-client there — same sysext mechanism, but wrapping the client package instead. The split makepkg produces both .pkg.tar.zst files; on the Deck use the client one:

cd packaging/arch && PF_SRCDIR="$(git rev-parse --show-toplevel)" makepkg -f --holdver
bash build-sysext.sh punktfunk-client-*.pkg.tar.zst        # → punktfunk-client.raw
# on the Deck:
sudo cp punktfunk-client.raw /var/lib/extensions/
sudo systemctl enable --now systemd-sysext
sudo pacman -S --needed libva-mesa-driver                  # VAAPI hw decode on the Deck's AMD APU

Now punktfunk-client is on PATH, so the Decky plugin finds and launches it (punktfunk-client --connect host:port) — gamescope composites its video like a game. The client needs no /dev/uinput or compositor-spawning rights (it captures input and decodes), so it's a much lighter sysext than the host.

Firewall

Stock Arch ships no firewall — every port is open by default, so there is nothing to do. Spins that enable one do not get their ports opened for you: an Arch package never touches the admin's running firewall. CachyOS is the common case — it ships ufw enabled by default (not firewalld), so out of the box the host is unreachable until you allow it. Some other spins (e.g. EndeavourOS) enable firewalld instead.

The punktfunk-host package ships openers for both — a ufw application profile (/etc/ufw/applications.d/punktfunk) and firewalld service definitions (/usr/lib/firewalld/services/) — so enabling is one command whichever you run:

# ufw (CachyOS, and Ubuntu once you enable ufw) — reads the profile at once, no reload needed:
sudo ufw allow punktfunk-native        # the native-only host (the default)
sudo ufw allow punktfunk-gamestream    # …or add this for the Moonlight/GameStream host

# firewalld (EndeavourOS and other Fedora-like spins):
sudo firewall-cmd --reload                                        # pick up the installed def
sudo firewall-cmd --permanent --add-service=punktfunk-native
#                              --add-service=punktfunk-gamestream  # …for the Moonlight host
sudo firewall-cmd --reload

punktfunk-gamestream opens the fixed Moonlight ports + mDNS; punktfunk-native opens the QUIC control port (UDP 9777) + mDNS + the mgmt/library API (TCP 47990, HTTPS + mTLS). Enable both if the host runs serve --gamestream (which serves both planes). The data plane is an ephemeral UDP port the client opens with a hole-punch, so there is no fixed data port in either service — the host streams back out through the path the client opened, which any firewall that allows outbound UDP (the default) passes. The mgmt REST API (TCP 47990, HTTPS + mTLS) binds all interfaces by default so paired clients can browse the game library — the punktfunk-native profile opens it. Off-loopback it serves only read-only status/library to a paired client cert and keeps the admin surface loopback-only (--mgmt-bind 127.0.0.1:47990 to opt out).

If you installed the web console (punktfunk-web) and want it reachable from another device, open its port with the matching one-liner — sudo ufw allow punktfunk-web or sudo firewall-cmd --permanent --add-service=punktfunk-web && sudo firewall-cmd --reload — which opens TCP 47992 (HTTPS, login-gated). The mgmt API (47990) is opened for paired clients by the punktfunk-native profile (game-library browsing over mTLS); off-loopback it serves only read-only status/library and keeps admin loopback-only.

Prefer explicit rules (or a firewall the shipped profiles don't cover)? Open the ports directly. The native punktfunk/1 plane:

  • QUIC control plane: UDP 9777 (serve --native-port N to change).
  • Data plane: a separate UDP port. By default it's random — the host binds 0.0.0.0:0 and tells the client which port it got. Video flows host → client, but the client sends the first packet (a hole-punch), so the host learns the client's real source and streams back — this traverses NAT / inter-VLAN with no forwarded port. You normally don't open it: if a deny-inbound firewall drops the punch, the host waits ~2.5 s and falls back to the client-reported address, and a stateful firewall then admits the return (it just adds ~2.5 s to session start). To skip that delay, pin it with serve --data-port <PORT> (or PUNKTFUNK_DATA_PORT): the host binds that fixed port and streams direct (no punch-wait) — open exactly that one port. A fixed port serves one session at a time (concurrent ones fall back to random + hole-punch), and direct mode needs the client's reported address to be reachable (flat LAN / a non-remapping port-forward).

And the GameStream / Moonlight ports (fixed) — only needed if you run the host with serve --gamestream (opt-in, trusted LAN only); bare serve is native-only and doesn't open these:

Port Proto Purpose
47984 TCP HTTPS nvhttp (paired, mutual-TLS)
47989 TCP HTTP nvhttp (/serverinfo, /pair PIN flow)
48010 TCP RTSP handshake
4799848010 UDP Video RTP (+ FEC), ENet control (47999), audio (48000)
5353 UDP mDNS auto-discovery

The mgmt API (TCP 47990, HTTPS + mTLS) binds all interfaces by default so paired clients can browse the game library — the punktfunk-native profile opens it. Off-loopback it serves only read-only status/library to a paired client cert; the admin surface stays loopback-only. Pass --mgmt-bind 127.0.0.1:47990 to keep it loopback-only (then leave 47990 closed).

With ufw (explicit ports, instead of the shipped punktfunk-native/punktfunk-gamestream profile):

sudo ufw allow 9777/udp                                 # punktfunk/1 control plane
sudo ufw allow 47990/tcp                                # mgmt/library API (HTTPS + mTLS; LAN = read-only, paired)
sudo ufw allow 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp
sudo ufw allow 47998,47999,48000/udp                    # GameStream video/control/audio
sudo ufw allow 5353/udp                                 # mDNS discovery
# The punktfunk/1 data plane uses a random UDP port; leave it closed on a LAN — the host hole-punches
# and falls back (~2.5s at session start if firewalled). To skip that, pin it: `serve --data-port
# 9778` and `ufw allow 9778/udp`.

With raw nftables (add to your inet filter input chain):

udp dport 9777 accept                  # punktfunk/1 control plane
tcp dport 47990 accept                 # mgmt/library API (HTTPS + mTLS; LAN = read-only, paired)
tcp dport { 47984, 47989, 48010 } accept
udp dport { 47998-48000, 5353 } accept # GameStream video/control/audio + mDNS
# The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s
# fallback). Pin it with `serve --data-port <PORT>` to open exactly one instead.

Files

  • PKGBUILD — split package: punktfunk-host + punktfunk-client (builds the working tree via PF_SRCDIR, or a git tag for AUR).
  • punktfunk-host.install / punktfunk-client.install — pacman scriptlets (udev reload + sysctl + first-run hint, incl. the ufw/firewalld enable command for whichever is present), mirror the RPM %post / deb postinst.
  • The firewall openers are shared across all Linux packaging and live in ../linux/: the ufw application profile (punktfunk.ufw/etc/ufw/applications.d/punktfunk) and the firewalld service definitions (punktfunk-native.xml / punktfunk-gamestream.xml / punktfunk-web.xml/usr/lib/firewalld/services/). None auto-enabled; see Firewall above.
  • build-sysext.sh — wraps either built .pkg.tar.zst into a systemd-sysext .raw for SteamOS (derives the name from the package, so it works for host or client).