From 4499313749b57d65d20c70e54c0e56340b2909de Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 14 Aug 2026 11:00:43 +0200 Subject: [PATCH] fix(nix): the module started a second host in root's systemd, stealing the ports from the real one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `systemd.user.*` has no per-user form in NixOS — it installs units into every user's manager. With `host.autoStart` adding them to `default.target`, that included root, whose `user@0.service` exists the moment anybody SSHes in as root. Root's host won the race for the fixed ports and the desktop user's copy crash-looped forever on `bind RTSP 48010: Address already in use`. Every other listener binds first and logs success, so the log reads like a clash with an unrelated program; a second copy of itself running as root is the last thing you look for. `host.users` did not help — it only granted input/punktfunk group membership and never scoped the units. Render `ConditionUser=` on all four user units from `host.users`. Entries are written `|user`: the pipe makes each a triggering condition, which systemd ORs, where plain repeated `ConditionUser=` lines are ANDed and would match nobody. With `host.users` empty, fall back to `!@system` — still keeps root out while leaving the manual `systemctl --user enable --now` route working for a login. module-check.nix gains three assertions covering both branches and web-init keeping its non-triggering ConditionPathExists alongside the new condition. They run in nix.yml's eval leg, and were confirmed to fail against the unfixed module (2 of 23) before being committed. Verified on the box that found this: root force-starting the host now yields ConditionResult=no. --- CHANGELOG.md | 32 +++++++++++++++++++++++ packaging/nix/README.md | 10 ++++++- packaging/nix/module-check.nix | 33 +++++++++++++++++++++++ packaging/nix/nixos-module.nix | 48 +++++++++++++++++++++++++++++++++- 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d71d7f8b..ebb142ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,38 @@ with the version table of the release you are moving to, then read **Breaking ch ## v0.28.1 — in development +### 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 diff --git a/packaging/nix/README.md b/packaging/nix/README.md index a0d07f81..48844f00 100644 --- a/packaging/nix/README.md +++ b/packaging/nix/README.md @@ -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 diff --git a/packaging/nix/module-check.nix b/packaging/nix/module-check.nix index 20b994f7..7e81dd2f 100644 --- a/packaging/nix/module-check.nix +++ b/packaging/nix/module-check.nix @@ -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//exe readlink it diff --git a/packaging/nix/nixos-module.nix b/packaging/nix/nixos-module.nix index 7139514b..d4ae9de9 100644 --- a/packaging/nix/nixos-module.nix +++ b/packaging/nix/nixos-module.nix @@ -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..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" ];