diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb03444..a99d5991 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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" ];