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
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" ];
|
||||
|
||||
Reference in New Issue
Block a user