Files
punktfunk/packaging/arch/README.md
T
enricobuehler 4f8cce6751 feat(packaging): grant CAP_SYS_NICE to the encode worker on all six channels, and assert the host never gets it
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.
2026-08-09 12:50:47 +02:00

309 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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](../../clients/decky/) 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`](../../scripts/steamdeck/)** (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.
## Install from the binary repo (recommended)
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):
```sh
# 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.)
Step 2 **appends**, so running it twice leaves two `[punktfunk]` blocks and every later pacman
run opens with `error: could not register 'punktfunk' database (database already registered)`.
It is harmless — pacman ignores the duplicate and carries on — but to silence it, delete the
extra block from `/etc/pacman.conf`.
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.
### If pacman says `unable to satisfy dependency 'libavcodec.so=…'`
```
:: unable to satisfy dependency 'libavcodec.so=62-64' required by punktfunk-host
```
`punktfunk-host` links FFmpeg, so it depends on the exact libav sonames it was built against —
FFmpeg 8 provides `libavcodec.so=62`, FFmpeg 9 provides `libavcodec.so=63`. This message means the
package on offer was built against a *different* FFmpeg major than your box has. Because pacman
prepares the whole transaction at once, it stops your entire `pacman -Syu`, not just this package.
The bound is deliberate. Without it the upgrade succeeds and leaves a host binary that cannot
start at all — exit 127 before `main()`, in a systemd restart loop, with nothing in its own log
to explain it (`ldd /usr/bin/punktfunk-host | grep 'not found'` is the one-line diagnosis).
1. `sudo pacman -Syyu` — a forced db refresh, in case the matching build is already published.
Compare `pacman -Si punktfunk-host` against your `pacman -Q ffmpeg`.
2. Still refused? Then we published a build made against the wrong FFmpeg — please report it. The
repair arrives as a higher **pkgrel** of the same version (`0.25.0-2`), so a later `-Syu`
picks it up with nothing to undo.
3. To let the rest of the system upgrade in the meantime: `sudo pacman -Syu --ignore punktfunk-host`.
If pacman still refuses (your *installed* copy is the one carrying the bound), remove it with
`sudo pacman -Rdd punktfunk-host`, upgrade, and install it again once the rebuild lands. Either
way the host stays down until then — that is the soname break itself, not a second fault.
## Build from source — Arch Linux (mutable)
```sh
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 only**
`pkgname` 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:
```sh
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):
```sh
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`](../../scripts/steamdeck/) (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:
```sh
# 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: the image ships gamescope without `CAP_SYS_NICE`, so its frame pacing is marginally worse —
everything works. Capabilities inside the image: `punktfunk-host` carries **none**, on *either*
path, deliberately — one would make it unidentifiable to KWin and break desktop streaming;
`punktfunk-encode-worker` carries `cap_sys_nice=ep`, applied by `build-sysext.sh` because pacman
scriptlets never run for a sysext and a merged `/usr` is read-only. Both are asserted at build
time. See
[Running as a service](https://punktfunk.io/docs/running-as-a-service#gpu-scheduling-priority).)
## 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:
```sh
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](../../clients/decky/)** 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:
```sh
# 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):
```sh
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/`](../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).