The NixOS module started a second host in root's systemd, which stole the ports from the real one #218

Merged
enricobuehler merged 2 commits from worktree-nixos-module-user-scoping into main 2026-08-14 09:17:56 +00:00
Owner

Found on the first real deployment of packaging/nix/nixos-module.nix (NixOS 26.05, punktfunk 0.28.0-nix, ABI v19). The host crash-looped forever on one line:

ERROR punktfunk_host: start RTSP server: bind RTSP 48010: Address already in use (os error 98)

Cause

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: 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 scope this. That option only granted input/punktfunk group membership; it never touched the units.

The tell, for anyone who hits this on an older build — the port holder's cgroup is user-0.slice:

ss -tlnp 'sport = :48010'
cat /proc/<pid>/cgroup   # /user.slice/user-0.slice/user@0.service/app.slice/punktfunk-host.service

Fix

Render ConditionUser= on all four user units (punktfunk-host, punktfunk-web, punktfunk-web-init, punktfunk-scripting) from host.users.

⚠️ Entries are written |user. The pipe makes each a triggering condition, which systemd ORs; plain repeated ConditionUser= lines are ANDed, so a two-user list would have matched nobody. punktfunk-web-init's pre-existing ConditionPathExists is non-triggering, so systemd requires the path and one user match — which is the intent.

With host.users empty, fall back to ConditionUser=!@system. That still keeps root out, while leaving the module header's documented manual systemctl --user enable --now punktfunk-host route working for any normal login.

Verification

Three layers, because eval alone would not have caught the systemd semantics:

  1. packaging/nix/module-check.nix gains 3 assertions — the OR-ed scoping, web-init keeping its path condition alongside the new one, and the empty-users fallback. These run in nix.yml's eval leg (nix flake check --no-build instantiates the check, and the assertions are pure Nix), and packaging/nix/** is already in that workflow's trigger paths.
  2. The assertions were confirmed to fail against the unfixed module — 2 of 23, with readable messages — before being committed. They have teeth; they are not decorative.
  3. Run on the box that found the bug. With the hand-written workaround removed and this module in place: root force-starting the host returns ConditionResult=no and it stays inactive, systemctl --user list-units 'punktfunk*' as root is empty, nix-user holds 48010, and NRestarts=0 across a cold boot.

Rendered units, for the record:

punktfunk-host.service:ConditionUser=|nix-user
punktfunk-scripting.service:ConditionUser=|nix-user
punktfunk-web-init.service:ConditionPathExists=!%h/.config/punktfunk/web-password
punktfunk-web-init.service:ConditionUser=|nix-user
punktfunk-web.service:ConditionUser=|nix-user

Also

  • CHANGELOG.md entry under v0.28.1.
  • packaging/nix/README.md: the appliance example is exactly the autoStart + users shape that triggers this, so it now says to set users whenever autoStart is on.

Note nixfmt-rfc-style wants to reformat one pre-existing line (PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR) that this branch does not touch. No CI leg gates Nix formatting, so it is left alone to keep the diff to the actual fix; the additions here are already nixfmt-clean.

Found on the first real deployment of `packaging/nix/nixos-module.nix` (NixOS 26.05, punktfunk 0.28.0-nix, ABI v19). The host crash-looped forever on one line: ``` ERROR punktfunk_host: start RTSP server: bind RTSP 48010: Address already in use (os error 98) ``` ## Cause `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: 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** scope this. That option only granted `input`/`punktfunk` group membership; it never touched the units. The tell, for anyone who hits this on an older build — the port holder's cgroup is `user-0.slice`: ```sh ss -tlnp 'sport = :48010' cat /proc/<pid>/cgroup # /user.slice/user-0.slice/user@0.service/app.slice/punktfunk-host.service ``` ## Fix Render `ConditionUser=` on all four user units (`punktfunk-host`, `punktfunk-web`, `punktfunk-web-init`, `punktfunk-scripting`) from `host.users`. ⚠️ Entries are written **`|user`**. The pipe makes each a *triggering* condition, which systemd ORs; plain repeated `ConditionUser=` lines are **ANDed**, so a two-user list would have matched nobody. `punktfunk-web-init`'s pre-existing `ConditionPathExists` is non-triggering, so systemd requires the path **and** one user match — which is the intent. With `host.users` empty, fall back to `ConditionUser=!@system`. That still keeps root out, while leaving the module header's documented manual `systemctl --user enable --now punktfunk-host` route working for any normal login. ## Verification Three layers, because eval alone would not have caught the systemd semantics: 1. **`packaging/nix/module-check.nix` gains 3 assertions** — the OR-ed scoping, web-init keeping its path condition alongside the new one, and the empty-`users` fallback. These run in `nix.yml`'s `eval` leg (`nix flake check --no-build` instantiates the check, and the assertions are pure Nix), and `packaging/nix/**` is already in that workflow's trigger paths. 2. **The assertions were confirmed to fail against the unfixed module** — 2 of 23, with readable messages — before being committed. They have teeth; they are not decorative. 3. **Run on the box that found the bug.** With the hand-written workaround removed and this module in place: root force-starting the host returns `ConditionResult=no` and it stays inactive, `systemctl --user list-units 'punktfunk*'` as root is empty, nix-user holds 48010, and `NRestarts=0` across a cold boot. Rendered units, for the record: ``` punktfunk-host.service:ConditionUser=|nix-user punktfunk-scripting.service:ConditionUser=|nix-user punktfunk-web-init.service:ConditionPathExists=!%h/.config/punktfunk/web-password punktfunk-web-init.service:ConditionUser=|nix-user punktfunk-web.service:ConditionUser=|nix-user ``` ## Also - `CHANGELOG.md` entry under v0.28.1. - `packaging/nix/README.md`: the appliance example is exactly the `autoStart` + `users` shape that triggers this, so it now says to set `users` whenever `autoStart` is on. Note `nixfmt-rfc-style` wants to reformat one **pre-existing** line (`PUNKTFUNK_GAMESCOPE_WSI_LAYER_DIR`) that this branch does not touch. No CI leg gates Nix formatting, so it is left alone to keep the diff to the actual fix; the additions here are already nixfmt-clean.
enricobuehler added 1 commit 2026-08-14 09:14:59 +00:00
fix(nix): the module started a second host in root's systemd, stealing the ports from the real one
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m17s
ci / bun-nix (pull_request) Successful in 1m33s
nix / flake (pull_request) Failing after 1m23s
ci / rust (pull_request) Canceled after 1m54s
ci / rust-arm64 (pull_request) Canceled after 2m3s
4499313749
`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.
enricobuehler added 1 commit 2026-08-14 09:16:59 +00:00
Merge remote-tracking branch 'origin/main' into worktree-nixos-module-user-scoping
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 1m1s
ci / rust-arm64 (pull_request) Successful in 3m45s
ci / docs-site (pull_request) Successful in 3m49s
ci / rust (pull_request) Successful in 18m12s
nix / flake (pull_request) Successful in 15m35s
e8bc10bf0c
# Conflicts:
#	CHANGELOG.md
enricobuehler merged commit f8361f3e6f into main 2026-08-14 09:17:56 +00:00
enricobuehler deleted branch worktree-nixos-module-user-scoping 2026-08-14 09:17:59 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#218