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:
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.
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.
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.
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.
`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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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:Cause
systemd.user.*has no per-user form in NixOS — it installs units into every user's systemd manager.host.autoStartthen adds them todefault.targetfor every user, including root, whoseuser@0.servicesprings 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.usersdid not scope this. That option only grantedinput/punktfunkgroup 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:Fix
Render
ConditionUser=on all four user units (punktfunk-host,punktfunk-web,punktfunk-web-init,punktfunk-scripting) fromhost.users.⚠️ Entries are written
|user. The pipe makes each a triggering condition, which systemd ORs; plain repeatedConditionUser=lines are ANDed, so a two-user list would have matched nobody.punktfunk-web-init's pre-existingConditionPathExistsis non-triggering, so systemd requires the path and one user match — which is the intent.With
host.usersempty, fall back toConditionUser=!@system. That still keeps root out, while leaving the module header's documented manualsystemctl --user enable --now punktfunk-hostroute working for any normal login.Verification
Three layers, because eval alone would not have caught the systemd semantics:
packaging/nix/module-check.nixgains 3 assertions — the OR-ed scoping, web-init keeping its path condition alongside the new one, and the empty-usersfallback. These run innix.yml'sevalleg (nix flake check --no-buildinstantiates the check, and the assertions are pure Nix), andpackaging/nix/**is already in that workflow's trigger paths.ConditionResult=noand it stays inactive,systemctl --user list-units 'punktfunk*'as root is empty, nix-user holds 48010, andNRestarts=0across a cold boot.Rendered units, for the record:
Also
CHANGELOG.mdentry under v0.28.1.packaging/nix/README.md: the appliance example is exactly theautoStart+usersshape that triggers this, so it now says to setuserswheneverautoStartis on.Note
nixfmt-rfc-stylewants 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.