Merge pull request 'The NixOS module started a second host in root's systemd, which stole the ports from the real one' (#218) from worktree-nixos-module-user-scoping into main
ci / web (push) Successful in 1m10s
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
ci / bun-nix (push) Successful in 15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
docker / builders-arm64cross (push) Successful in 9s
ci / rust (push) Canceled after 6m29s
ci / docs-site (push) Canceled after 5m54s
docker / deploy-docs (push) Canceled after 3m33s
nix / flake (push) Canceled after 4m8s

Reviewed-on: #218
This commit was merged in pull request #218.
This commit is contained in:
2026-08-14 09:17:54 +00:00
4 changed files with 121 additions and 2 deletions
+32
View File
@@ -66,6 +66,38 @@ Punktfunk only takes it for the duration of a stream. If you *want* apps to reco
while idle, select "Punktfunk Microphone" manually; the host no longer re-asserts it (idle
re-assertion used to stomp a manual choice within one mic-pump reopen).
### The NixOS module started a second host in root's systemd, which stole the ports from the real one
Found on the first real deployment of `packaging/nix/nixos-module.nix` (NixOS 26.05, punktfunk
0.28.0-nix). The host crash-looped forever on one line:
```
ERROR punktfunk_host: start RTSP server: bind RTSP 48010: Address already in use (os error 98)
```
`systemd.user.*` has no per-user form in NixOS: it installs units into **every** user's systemd
manager. `host.autoStart` then adds them to `default.target` — for every user, including **root**,
whose `user@0.service` springs into existence the moment anybody so much as SSHes in as root. Root's
copy of the host won the race for the fixed ports, and the desktop user's copy could never bind.
The failure is nastier than it sounds because every *other* listener binds first and logs success —
the version banner, mDNS on 47989, the GameStream warning all print normally — so the log reads like
a conflict with some unrelated program. A second copy of *itself*, running as root, is the last
thing anyone looks for. `host.users` did not help: that option only granted `input`/`punktfunk`
group membership and never scoped the units.
Fixed by rendering `ConditionUser=` on all four user units (`punktfunk-host`, `punktfunk-web`,
`punktfunk-web-init`, `punktfunk-scripting`) from `host.users`. Each entry is written `|user` — the
pipe makes it a *triggering* condition, which systemd ORs; plain repeated `ConditionUser=` lines are
ANDed and would have matched nobody. With `host.users` empty the units fall back to
`ConditionUser=!@system`, which still keeps root out while leaving a normal login free to run the
host by hand, as the module header documents.
`packaging/nix/module-check.nix` gained three assertions covering both branches and the fact that
`punktfunk-web-init` keeps its pre-existing (non-triggering) `ConditionPathExists` alongside the new
condition. They run in the `eval` leg of `nix.yml`, and were verified to fail against the unfixed
module before being committed.
### The Steam plugin synced nothing on Windows: its art is in Program Files, the art roots were not
Field report — the plugin installed, the grid stayed empty, and the only clue was one host warn per
+9 -1
View File
@@ -64,7 +64,7 @@ Add the flake and enable the host and/or client:
({ ... }: {
services.punktfunk.host = {
enable = true;
users = [ "alice" ]; # → added to the `input` group for virtual gamepads
users = [ "alice" ]; # → `input` group for gamepads, AND scopes the units to alice
openFirewall = true; # native + GameStream ports
desktopSession = true; # a machine you log into — restart the host with the desktop
settings = {
@@ -236,6 +236,14 @@ services.punktfunk.host = {
users.users.streamer.linger = true;
```
**Set `users` whenever `autoStart` is on.** `systemd.user.*` installs into *every* user's systemd
manager — root's included, and root gets one the moment anybody logs in as root. Without `users` to
scope them, `autoStart` therefore starts a *second* host in root's manager, which wins the race for
the fixed ports and leaves the real one restarting forever on `bind RTSP 48010: Address already in
use` (every other listener having bound fine, so it reads like a clash with an unrelated program).
The module renders `ConditionUser=` from this list to prevent that; with the list empty it falls
back to refusing system users, which keeps root out but cannot tell two logins apart.
Leave `desktopSession` off here — an appliance starts its own compositor and may never reach
`graphical-session.target`, which would leave the host permanently stopped. `gamescopeHdr` (on by
default) already puts the patched `punktfunk-gamescope` on the service PATH, so the gamescope
+33
View File
@@ -143,6 +143,39 @@ let
ok = failedAssertions clientOnly == [ ];
}
# --- user scoping: the second-copy-steals-the-ports trap -----------------------------------
# `systemd.user.*` installs into EVERY user's manager, root's included (user@0.service exists
# as soon as anyone logs in as root), and `autoStart` puts these in default.target. Root's host
# then wins the fixed ports and the desktop user's restarts forever on
# `bind RTSP 48010: Address already in use` — every other listener in its log having bound
# fine, so it reads like an unrelated program. MEASURED on a real box before this was fixed.
{
# `|` = TRIGGERING condition, which systemd ORs. Plain repeated ConditionUser= lines are
# ANDed and would match nobody — the whole reason the prefix is there.
name = "host.users scopes every user unit to those users, OR-ed";
ok =
let
scoped = name: has desktop name "ConditionUser=|alice";
in
scoped "punktfunk-host" && scoped "punktfunk-web" && scoped "punktfunk-scripting";
}
{
# web-init already carried a ConditionPathExists. That one is NON-triggering, so systemd
# requires it AND at least one triggering user condition — adding ours must not drop it.
name = "web-init keeps its path condition alongside the user scope";
ok =
has desktop "punktfunk-web-init" "ConditionUser=|alice"
&& has desktop "punktfunk-web-init" "ConditionPathExists=!%h/.config/punktfunk/web-password";
}
{
# With no host.users to name, still keep SYSTEM users (root) out, while leaving the module
# header's manual `systemctl --user enable --now punktfunk-host` working for a normal login.
name = "with no host.users, the units still refuse system users (root)";
ok =
has appliance "punktfunk-host" "ConditionUser=!@system"
&& !(has appliance "punktfunk-host" "ConditionUser=|");
}
# --- the KWin identification trap (packaging/arch/punktfunk-host.install) -------------------
# The host MUST exec the plain store path. A capability wrapper here would put CAP_SYS_NICE in
# the process's permitted set, and the kernel then refuses KWin the /proc/<pid>/exe readlink it
+47 -1
View File
@@ -55,6 +55,27 @@ let
hostSettingsFile = pkgs.writeText "punktfunk-host.env" (renderEnv cfg.host.settings);
# WHICH users' `systemd --user` instances may run these units — and why they need saying at all.
#
# `systemd.user.*` installs into EVERY user's manager; there is no per-user form of it in NixOS.
# Combined with `autoStart` putting the units in `default.target`, that includes **root**, whose
# `user@0.service` springs into existence the moment anybody so much as SSHes in as root. Root's
# copy of the host then wins the race for the fixed ports and the desktop user's copy fails
# forever on `bind RTSP 48010: Address already in use` — with every other listener in its log
# having bound fine, so it reads like a clash with some unrelated program rather than a second
# copy of itself. MEASURED 2026-08-14 on a fresh NixOS 26.05 box.
#
# `host.users` is already documented as "the host runs as these users' systemd --user service",
# so it is the right scope. When it is empty we cannot name the intended user, so fall back to
# excluding system users — which is precisely what keeps root out — and leave the module header's
# manual `systemctl --user enable --now punktfunk-host` route working for any normal login.
#
# ⚠ The `|` prefix is load-bearing: it makes each entry a TRIGGERING condition, and systemd ORs
# those. Plain repeated `ConditionUser=` lines are ANDed, so a two-user list would match NOBODY.
# Non-triggering conditions on the same unit (punktfunk-web-init's ConditionPathExists) still
# have to hold, which is the behaviour we want.
userScope = if cfg.host.users == [ ] then [ "!@system" ] else map (u: "|${u}") cfg.host.users;
# Native punktfunk/1 ports (control plane + discovery + mgmt API). The media data plane is an
# ephemeral per-session UDP port the host hole-punches, so nothing fixed to open (see
# packaging/linux/punktfunk.ufw).
@@ -106,6 +127,10 @@ in
Start the host automatically in every user's graphical session (adds it to the user
`default.target`). For a login-less appliance, also enable lingering for the host user
(`users.users.<name>.linger = true`) so the user service comes up at boot.
"Every user" is bounded by `host.users` via `ConditionUser=` without that bound this
option also starts a host in ROOT's user manager the moment anybody logs in as root, and
that copy takes the ports from the real one. Set `host.users` on a multi-user box.
'';
};
@@ -147,6 +172,15 @@ in
usbip/vhci nodes the virtual Steam Deck pad attaches through. The second is separate on
purpose it can emulate arbitrary USB hardware, so only list users you would trust with
that. The host runs as these users' `systemd --user` service.
This list ALSO scopes the units themselves: they carry a `ConditionUser=` for these
users, so no other user's `systemd --user` instance can start them. That matters because
`systemd.user.*` installs into every user's manager including root's, which exists as
soon as anyone logs in as root and a second host silently wins the race for the fixed
ports, leaving the real one restarting forever on "Address already in use".
Left empty, the units are merely refused to SYSTEM users (`ConditionUser=!@system`), so
any normal login can still run the host by hand and root still cannot.
'';
};
@@ -466,6 +500,9 @@ in
systemd.user.services.punktfunk-host = {
description = "punktfunk GameStream + punktfunk/1 streaming host";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
# Keep root (and every other system user) from starting a second host that steals the
# fixed ports from the desktop user's — see `userScope`.
unitConfig.ConditionUser = userScope;
# Soft ordering: the host listens immediately and only touches the compositor per session.
after = [ "pipewire.service" ] ++ optional cfg.host.desktopSession "graphical-session.target";
wants = [ "pipewire.service" ];
@@ -573,7 +610,12 @@ in
systemd.user.services.punktfunk-web-init = {
description = "punktfunk web console first-run setup (login password)";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
unitConfig.ConditionPathExists = "!%h/.config/punktfunk/web-password";
# ⚠ ConditionUser here is TRIGGERING (`|`) and ConditionPathExists is not, so systemd
# requires the path condition AND at least one user condition — which is the intent.
unitConfig = {
ConditionPathExists = "!%h/.config/punktfunk/web-password";
ConditionUser = userScope;
};
path = [ pkgs.coreutils ];
serviceConfig = {
Type = "oneshot";
@@ -589,6 +631,8 @@ in
systemd.user.services.punktfunk-web = {
description = "punktfunk management web console";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
# Same scoping as the host: root's instance would take 47992 from the real one.
unitConfig.ConditionUser = userScope;
after = [
"punktfunk-web-init.service"
"punktfunk-host.service"
@@ -640,6 +684,8 @@ in
systemd.user.services.punktfunk-scripting = {
description = "punktfunk plugin/script runner";
documentation = [ "https://git.unom.io/unom/punktfunk" ];
# Same scoping as the host: a root-side runner would talk to the wrong session's mgmt API.
unitConfig.ConditionUser = userScope;
# Plugins talk to the host's loopback mgmt API; order after it (soft — the runner backs off
# and retries per unit, so this is ordering only, not a hard requirement).
after = [ "punktfunk-host.service" ];