Follows the security audit (#5/#9): the GameStream-compat plane carries inherent on-path weaknesses that can't be fixed on the wire without breaking stock Moonlight — its pairing runs over plain HTTP (#9, MITM-able during the pairing window) and its legacy control encryption can reuse GCM nonces (#5, a passive eavesdropper can recover/forge input). The native punktfunk/1 plane (SPAKE2 PIN pairing + per-direction AEAD nonces) has neither. So flip the default to secure-by-default: - `serve` → native punktfunk/1 plane + management API ONLY (no GameStream surface). - `serve --gamestream` → ALSO the GameStream/Moonlight-compat planes (nvhttp pairing, RTSP, ENet control, _nvstream mDNS). Opt-in, logged with a trusted-LAN caveat. `--moonlight` is an alias. - The native plane is now ALWAYS on in `serve` (`--native` is a kept-for-compat no-op); the unified GameStream+native host is `serve --gamestream`. `gamestream::serve` gates the GameStream spawns (nvhttp/rtsp/control/mdns) on the flag; the native plane + mgmt + native-pairing handle always run. To avoid silently regressing validated Moonlight deployments, the explicit deployment configs PRESERVE Moonlight via `--gamestream` (each documents dropping it for a secure native-only host): the Linux systemd unit, the Steam Deck installer, and the Windows service default (DEFAULT_HOST_CMD). The bare `serve` default (new/manual use) is secure. Docs swept to match (host-cli, moonlight, quickstart, install, packaging READMEs, CLAUDE.md, README, …): Moonlight setup now instructs `--gamestream`; native/console refs use bare `serve`. OpenAPI regenerated (a stale "run `serve --native`" string). fmt + clippy clean; 94 host tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
22 KiB
Setting up punktfunk on Bazzite
A step-by-step setup guide for running the punktfunk low-latency streaming host on
Bazzite (the immutable, Fedora-Atomic gaming distro). Everything below is grounded in this
repo's packaging and ops files; where something is not yet published or not in the repo, it's
flagged explicitly. For the higher-level packaging rationale ("why not Flatpak", the build), see
../README.md.
What you get on Bazzite: it already ships the three things punktfunk normally has to fight for — gamescope, PipeWire/WirePlumber, and (on the
-nvidiaimages) the NVIDIA driver with NVENC/EGL. The only genuinely new runtime bits punktfunk adds areffmpeg-libs(with NVENC, from RPM Fusion nonfree),opus, andlibei. Source:packaging/README.md,packaging/rpm/punktfunk.spec.
⚠️ Read this first — the COPR is operator-run, not yet published. Both install paths below pull the punktfunk RPM from a COPR project named
enricobuehler/punktfunk. That COPR is a configuration the maintainer has to create and build (seepackaging/copr/README.md— it documents how to set it up, not a live repo URL you can assume exists). Ifrpm-ostree install punktfunk404s, the COPR hasn't been published yet, and your only path is to build the RPM yourself (see the appendix). The guide flags every command that depends on the COPR being live.
1. Choose an install path
There are two supported paths on Bazzite, driven by different files in packaging/:
| Path | Driven by | What it does | Best for |
|---|---|---|---|
| A — rpm-ostree layering | packaging/copr/README.md + packaging/rpm/punktfunk.spec |
Layers the punktfunk RPM onto your existing Bazzite deployment with rpm-ostree install |
One host, quick iteration |
| B — bootc / OCI image | packaging/bootc/Containerfile |
Bakes punktfunk into a FROM bazzite-nvidia image once; you bootc switch any number of hosts onto it |
Fleets, reproducible appliances, no per-host drift |
Trade-off: Path A is a per-host package layer — simple, but each host accumulates its own
layered-package state. Path B builds one image (RPM Fusion + the Gitea RPM repo + the host and
web console + udev rule pre-installed) that you push to a registry and rebase hosts onto
atomically — no per-host rpm-ostree install drift, at the cost of running a podman build/push
pipeline. Both require the same first-run setup (sections 3–6); note Path B installs from the
Gitea RPM registry (which carries punktfunk-web), whereas Path A's COPR builds host+client
only — for the web console on Path A, layer from the Gitea registry instead (../rpm/README.md).
Path A — rpm-ostree layering from the COPR
Run on the Bazzite host. (Commands verbatim from packaging/README.md.)
# 1. RPM Fusion (free + nonfree) — provides the NVENC-capable ffmpeg-libs.
# Usually already enabled on Bazzite; harmless to re-run.
rpm-ostree install \
https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm \
https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm
# 2. Enable the punktfunk COPR repo ⚠️ requires the COPR to be published (see callout above)
sudo wget -O /etc/yum.repos.d/_copr_punktfunk.repo \
https://copr.fedorainfracloud.org/coprs/enricobuehler/punktfunk/repo/fedora-$(rpm -E %fedora)/
# 3. Layer punktfunk and reboot to activate the new deployment.
rpm-ostree install punktfunk
systemctl reboot
The reboot is mandatory —
rpm-ostree installstages a new deployment that only takes effect on the next boot. This is normal atomic-distro behavior, not a punktfunk quirk.
Path B — bootc image (FROM bazzite-nvidia)
The image is built off-host (on any machine with podman) from
packaging/bootc/Containerfile, which bases on ghcr.io/ublue-os/bazzite-nvidia:stable
(override with --build-arg BASE_IMAGE=…), enables RPM Fusion free + nonfree, adds the Gitea RPM
repo (--build-arg PUNKTFUNK_RPM_GROUP=…, default bazzite), and installs the host and the web
console (punktfunk punktfunk-web). It uses the Gitea registry rather than the COPR specifically
because the registry carries punktfunk-web (COPR's mock chroot can't build it — no bun).
# Build + push (run from the repo root, on your builder machine):
podman build -t ghcr.io/<you>/bazzite-punktfunk -f packaging/bootc/Containerfile .
podman push ghcr.io/<you>/bazzite-punktfunk
# On each target Bazzite host:
sudo bootc switch ghcr.io/<you>/bazzite-punktfunk && systemctl reboot
⚠️ The image installs from the Gitea RPM registry (group
bazzite), so Path B depends on that registry being populated — CI (.gitea/workflows/rpm.yml) publishespunktfunk+punktfunk-webon every push tomain. Packages are unsigned with GPG-signed metadata (repo_gpgcheck=1), matchingpackaging/rpm/README.md.
2. Prerequisites — what Bazzite gives you vs. what you must still do
Already satisfied on Bazzite (-nvidia images):
- NVIDIA driver:
libnvidia-encode(NVENC) +libEGL_nvidiafor the zero-copy path. gamescope— the default compositor backend punktfunk uses on Bazzite.- PipeWire + WirePlumber — the capture/audio graph.
You must still do (covered below):
- Reboot after layering / rebasing (section 1).
- Join the
inputgroup and ensure the udev rule is installed (section 3) — required for virtual gamepads / DualSense. - Place
host.envand enable the systemd user service (sections 4–5). - Open firewall ports (section 6).
RPM Fusion's ffmpeg-libs is a weak dependency (Recommends: in the spec) — the package
installs without it, but NVENC encoding will fail at runtime if it's missing. The RPM Fusion
step in section 1 covers this.
3. udev rule + the input group
punktfunk creates virtual X-Box-360 gamepads via /dev/uinput and virtual DualSense pads
via /dev/uhid (kernel hid-playstation driver — LEDs, adaptive triggers, touchpad, gyro). The
udev rule grants the input group access to both nodes.
The RPM already installs the rule to /usr/lib/udev/rules.d/60-punktfunk.rules and its %post
reloads udev. So on a packaged install (Path A or B) you only need to join the input group:
ujust add-user-to-input-group # then LOG OUT and back in (or reboot)
⚠️ On Bazzite use
ujust add-user-to-input-group, NOTsudo usermod -aG input $USER. Bazzite is an atomic (rpm-ostree) OS where/etc/groupis managed declaratively — a plainusermodeither doesn't stick or gets reverted on the next update. Theujustrecipe edits the group the immutable-OS-correct way (and reloads udev). (ujustships with Bazzite;ujust --listshows all recipes.)
🔁 The group change does not apply to your current login session — you must re-login (or reboot). Until then, gamepad creation fails with a permission error on
/dev/uinput. This is the single most common "why don't my gamepads work" gotcha.
If you installed from a tarball/source instead of the RPM (so the rule isn't in place), install it
manually — the exact commands from the rule file's header (scripts/60-punktfunk.rules):
sudo cp scripts/60-punktfunk.rules /etc/udev/rules.d/
ujust add-user-to-input-group # NOT `usermod` on Bazzite (see the note above); then re-login
sudo udevadm control --reload-rules && sudo udevadm trigger
The rule contents, for reference:
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", GROUP="input", MODE="0660", TAG+="uaccess"
KERNEL=="uhid", SUBSYSTEM=="misc", OPTIONS+="static_node=uhid", GROUP="input", MODE="0660", TAG+="uaccess"
4. Configure host.env
The systemd user unit reads its environment from ~/.config/punktfunk/host.env
(EnvironmentFile=%h/.config/punktfunk/host.env in scripts/punktfunk-host.service). The RPM
ships a Bazzite-tuned template at /usr/share/punktfunk/host.env.bazzite. Copy it into place:
mkdir -p ~/.config/punktfunk
cp /usr/share/punktfunk/host.env.bazzite ~/.config/punktfunk/host.env
# then edit ~/.config/punktfunk/host.env
The Bazzite template (packaging/bazzite/host.env) contains:
XDG_RUNTIME_DIR=/run/user/1000
DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
# gamescope backend: spawned per session, no compositor login required.
PUNKTFUNK_COMPOSITOR=gamescope
PUNKTFUNK_VIDEO_SOURCE=virtual
PUNKTFUNK_GAMESCOPE_APP=steam -gamepadui
# gamescope hosts its own EIS input socket — input lands in the nested session.
PUNKTFUNK_INPUT_BACKEND=gamescope
# GPU zero-copy capture (dmabuf -> CUDA -> NVENC). Auto-falls back to CPU if unavailable.
PUNKTFUNK_ZEROCOPY=1
#RUST_LOG=info
What each knob means and why these are the Bazzite defaults:
| Knob | Value | Meaning |
|---|---|---|
XDG_RUNTIME_DIR / DBUS_SESSION_BUS_ADDRESS |
…/user/1000 |
Session bus / runtime dir. 1000 assumes your user is UID 1000 — change both if id -u says otherwise. |
PUNKTFUNK_COMPOSITOR |
gamescope |
The Bazzite default. The host spawns a headless gamescope per session at the client's exact resolution/refresh and captures its PipeWire node — so you need no graphical desktop login to stream. Bazzite ships gamescope, so this "just works." |
PUNKTFUNK_VIDEO_SOURCE |
virtual |
Create a per-client virtual output at the client's exact WxH@Hz (the flagship "native resolution, no scaling" mode), vs. portal which captures an existing monitor. |
PUNKTFUNK_GAMESCOPE_APP |
steam -gamepadui |
The command launched inside the nested gamescope — here, a SteamOS-style couch UI. Set it to whatever you want the session to run. |
PUNKTFUNK_INPUT_BACKEND |
gamescope |
Inject mouse/keyboard/gamepad into the nested gamescope via its own EIS socket. |
PUNKTFUNK_ZEROCOPY |
1 |
GPU zero-copy capture (dmabuf → CUDA → NVENC). Falls back to CPU automatically if unavailable. |
RUST_LOG |
(commented) | Uncomment RUST_LOG=info for verbose logs while debugging. |
Optional — a real DualSense for clients holding one: add PUNKTFUNK_GAMEPAD=dualsense to present
games a virtual Sony DualSense (lightbar, adaptive triggers, touchpad, motion) instead of the
default X-Box-360 pad. The feedback flows back to a real DualSense on the client.
Alternative — drive the full Plasma/GNOME desktop instead of a nested gamescope (per the
template's footer comment): switch to PUNKTFUNK_COMPOSITOR=kwin and
PUNKTFUNK_INPUT_BACKEND=libei, and run the host inside a KDE session with WAYLAND_DISPLAY /
XDG_CURRENT_DESKTOP set. The full knob list (FEC %, per-stage timing, etc.) is in
scripts/host.env.example / /usr/share/punktfunk/host.env.example.
The gamescope default is what makes Bazzite the easy path: it's a headless, per-session compositor — no desktop login, no display manager, no
--drmscanout. You don't need any of the headless-KDE bring-up scripts (scripts/headless/run-headless-kde.sh) on Bazzite unless you deliberately switch to the KWin backend.
5. Enable and start the service
punktfunk runs as a systemd --user service (not root) — it needs your graphical/user
session's PipeWire and D-Bus. The unit (scripts/punktfunk-host.service) is installed by the RPM
into the user unit directory.
systemctl --user daemon-reload
systemctl --user enable --now punktfunk-host
# Management web console (pairing + status), if you installed punktfunk-web (it ships in the Gitea
# RPM registry / bootc image — COPR can't build it; see ../rpm/README.md). Read the login password:
systemctl --user enable --now punktfunk-web
journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p' # then open http://<host-ip>:3000
Check health and logs:
systemctl --user status punktfunk-host
journalctl --user -u punktfunk-host -f
What
serveactually starts. The bundled unit'sExecStartrunspunktfunk-host serve --gamestream, so out of the box you get the unified host: the nativepunktfunk/1(QUIC) plane — always on inserve— plus the GameStream/Moonlight-compat planes (mDNS discovery, pairing, RTSP, the fixed GameStream ports) and the management REST API on 47990. The--gamestreamflag is what adds the Moonlight surface; GameStream pairs over plain HTTP and its legacy encryption is weaker than the native plane's (security-review #5/#9), so it's opt-in and trusted-LAN only. For a secure native-only host, drop--gamestreamfrom the unit'sExecStart(bareserve) — native clients still work; only stock Moonlight stops. (Source:crates/punktfunk-host/src/main.rs—serveruns the native plane + mgmt;--gamestreamaddsgamestream::serve.)
Unit caveat:
scripts/punktfunk-host.servicedeclares onlyAfter=pipewire.serviceand (in the upstream/dev layout) assumes the binary at%h/punktfunk/target/release/punktfunk-host. The RPM-installed binary lives at/usr/bin/punktfunk-host. Ifsystemctl --user cat punktfunk-hostshowsExecStartpointing at a missing path in your home dir, drop an override (systemctl --user edit punktfunk-host) settingExecStart=/usr/bin/punktfunk-host serve --gamestream(or bareservefor a secure native-only host).
6. Firewall
⚠️ There is no firewall script or firewall doc in the repo. The ports below are derived directly from the code constants (
crates/punktfunk-host/src/gamestream/mod.rs,mgmt.rs) and the GameStream-host port-map (docs/gamestream-host-plan.md). Treat thefirewall-cmdlines as recommended-but-verified, not a checked-in script.
GameStream / Moonlight ports (fixed; Moonlight derives them from the HTTP base). These only apply
when the host runs serve --gamestream (the bundled unit's default); on a bare-serve native-only
host you don't open them:
| Port | Proto | Purpose |
|---|---|---|
| 47984 | TCP | HTTPS nvhttp (paired, mutual-TLS) |
| 47989 | TCP | HTTP nvhttp (/serverinfo, /pair PIN flow) |
| 48010 | TCP | RTSP handshake |
| 47998 | UDP | Video RTP (+ FEC) |
| 47999 | UDP | ENet control stream + remote input |
| 48000 | UDP | Audio (Opus) |
| 5353 | UDP | mDNS — so Moonlight auto-discovers the host (_nvstream._tcp.local.) |
Management REST API: TCP 47990 — but serve binds it to 127.0.0.1 (loopback) by
default, so you do not open it in the firewall unless you deliberately move it off loopback
with --mgmt-bind IP:PORT (which also requires --mgmt-token). Leave it closed for a normal setup.
Open the GameStream ports with firewalld (Bazzite uses firewalld):
sudo firewall-cmd --permanent --add-port=47984/tcp \
--add-port=47989/tcp \
--add-port=48010/tcp
sudo firewall-cmd --permanent --add-port=47998/udp \
--add-port=47999/udp \
--add-port=48000/udp \
--add-port=5353/udp
sudo firewall-cmd --reload
If you also run the native punktfunk/1 host (punktfunk-host punktfunk1-host, not started by the
default unit):
- QUIC control plane: UDP 9777 (default
--port; change with--port N). - Data plane: an ephemeral UDP port —
punktfunk1-hostbinds0.0.0.0:0and tells the client which port it got, so there is no fixed data port to open. For a restrictive firewall you'd need to allow the ephemeral UDP range; the repo does not pin one.
# Only if you run `punktfunk1-host`:
sudo firewall-cmd --permanent --add-port=9777/udp && sudo firewall-cmd --reload
6.5 Desktop (KDE) mode — stream the desktop at the client's resolution (optional)
The host auto-detects the live session per connect: in Steam Gaming Mode it attaches to the
running gamescope (no setup); switch the box to the KDE Desktop and it drives a KWin virtual
output at the connecting client's exact resolution (no TV-stretch, churn-free). The Desktop path
needs one one-shot setup the first time, because a normal KDE login withholds two things the
headless host needs — the privileged zkde_screencast virtual-output protocol, and an
auto-approved RemoteDesktop input grant:
bash /usr/share/punktfunk/bazzite/kde-desktop-setup.sh
# then log out + back into the KDE Desktop session once (or reboot) so KWin restarts with the flag
That writes ~/.config/environment.d/10-punktfunk-kwin.conf
(KWIN_WAYLAND_NO_PERMISSION_CHECKS=1) and seeds the kde-authorized RemoteDesktop grant into
~/.local/share/flatpak/db/. Gaming Mode is unaffected. To connect from Desktop Mode, switch to it
(Steam → Power → Switch to Desktop), then connect the client; switching mid-stream requires a
reconnect (the host resolves the backend per connect).
7. Verify it's working
1. Watch the startup log:
journalctl --user -u punktfunk-host -f
A healthy serve startup logs the punktfunk-host (punktfunk_core ABI v…) banner, then mDNS advertising, and an RTSP listening line on port 48010. No NVENC/EGL errors on the first connection.
2. Pair a stock Moonlight client (recommended first test):
- Open Moonlight on your phone/PC on the same LAN — the host should appear automatically (mDNS).
- Select it; Moonlight shows a 4-digit PIN. The host completes the GameStream pairing handshake (it persists across restarts).
- Launch the app — you should get video at your client's native resolution/refresh, with the nested
steam -gamepadui(or whateverPUNKTFUNK_GAMESCOPE_APPyou set) running inside gamescope.
3. (Optional) native punktfunk/1 client — only if you're running the separate punktfunk1-host. The
repo's reference client is punktfunk-probe, e.g. punktfunk-probe --mode 1280x720x120 --out /tmp/a.h265 (add --pin HEX for PIN pairing). This is a headless/decode-to-file reference, not a
desktop viewer.
8. Troubleshooting (grounded in the repo's real gotchas)
-
Gamepads don't appear / permission denied on
/dev/uinputor/dev/uhid. Either you haven't joined theinputgroup, or you haven't re-logged-in since. On Bazzite join it withujust add-user-to-input-group(a plainsudo usermod -aG inputdoesn't stick on an atomic OS — see section 3), then log out and back in (or reboot): group membership only takes effect on a new session. The host log makes this unambiguous — it printsvirtual gamepad created/virtual DualSense createdon success, or… creation failed — controller input disabledwhen the device node isn't writable. (scripts/60-punktfunk.rules,packaging/README.md.) -
No video / NVENC fails to encode. RPM Fusion's
ffmpeg-libs(with NVENC) is missing — it's a weak dependency, so the package installed without it. Re-run the RPM Fusion step in section 1. (packaging/rpm/punktfunk.spec:Recommends: ffmpeg-libs.) -
gamescope session won't come up / capture deadlocks. punktfunk needs gamescope ≥ 3.16.22 — older versions (e.g. the broken 3.16.20 some bases shipped) deadlock on PipeWire ≥ 1.6, and a wedged capture link can head-block the whole PipeWire daemon system-wide. Check with
gamescope --version. Bazzite tracks recent gamescope, but verify if you hit hangs. (Project notes.) -
NVENC/EGL silently stops working after a system update. punktfunk's reference box uses the NVIDIA open kernel module, and a kernel update can silently drop it. On Bazzite the NVIDIA stack is image-managed (
bazzite-nvidia), so this is far less likely — but if NVENC dies right after anrpm-ostree/bootcupdate, confirm the NVIDIA driver still loads (nvidia-smi) before blaming punktfunk. -
PUNKTFUNK_ZEROCOPY=1but it falls back to CPU. The zero-copy path needs working EGL/CUDA from the NVIDIA driver. The code falls back to CPU automatically; check the log for the fallback line and verify the-nvidiaimage / driver is healthy. -
Wrong UID in
host.env.XDG_RUNTIME_DIR=/run/user/1000and the bus path assume UID 1000. Runid -u; if it's different, fix both lines or the host can't reach your session's PipeWire/D-Bus. -
Service
ExecStartpoints at a missing path in$HOME. The dev unit references%h/punktfunk/target/release/.... The RPM binary is/usr/bin/punktfunk-host. OverrideExecStart=/usr/bin/punktfunk-host serve --gamestream(or bareservefor native-only) if needed (section 5). -
Moonlight can't see the host. Ensure UDP 5353 (mDNS) and the GameStream ports are open (section 6) and client + host are on the same L2 LAN segment.
Appendix — if the COPR isn't published yet
The COPR (enricobuehler/punktfunk) is operator-run and may not be live. If rpm-ostree install punktfunk can't find the package, build the RPM yourself on a Fedora machine/toolbox (not
Debian/Ubuntu — the host links system FFmpeg/PipeWire and won't build there), per
packaging/README.md:
git archive --format=tar.gz --prefix=punktfunk-0.0.1/ \
-o ~/rpmbuild/SOURCES/punktfunk-0.0.1.tar.gz HEAD
rpmbuild -ba packaging/rpm/punktfunk.spec # needs the spec's BuildRequires + RPM Fusion
To publish the COPR for others (so rpm-ostree install punktfunk / the bootc image work), follow
packaging/copr/README.md — create the project, point build-from-SCM at the repo with spec path
packaging/rpm/punktfunk.spec, add RPM Fusion nonfree as an external repo, and select chroots
matching your Bazzite Fedora base (rpm -E %fedora).
Accuracy flags
- The COPR is operator-run / not assumed published — both install paths depend on it.
- There is no firewall script/doc in the repo — the ports above are derived from the code.
- The bundled systemd unit runs
serve --gamestream— the nativepunktfunk/1QUIC plane (always on) plus the GameStream/Moonlight planes. Drop--gamestreamfor a secure native-only host;punktfunk1-hostis a separate standalone native host, unmanaged by the unit. - The mgmt port (47990) is loopback-only by default — don't open it.