From 002702bcec1cd2fb88b862a3ee80d72b94638c2c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 18:38:14 +0200 Subject: [PATCH 1/4] =?UTF-8?q?fix(pf-vdisplay):=20NixOS=20sessions=20were?= =?UTF-8?q?=20undetectable=20=E2=80=94=20comm=20is=20the=20WRAPPER's=20nam?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session probe decided "is a desktop live?" by reading /proc//comm for every process of our uid and exact-matching it against "kwin_wayland" / "gamescope" / "gnome-shell" / "Hyprland". comm is the kernel's name for the executed FILE, truncated to 15 bytes — not argv[0]. nixpkgs wraps essentially every graphical binary: wrapProgram moves the real ELF aside to `.-wrapped` and installs a wrapper under the original name, which then `exec -a "$0"`s the hidden file. So on NixOS the kernel reports `.kwin_wayland-w` (15 bytes of `.kwin_wayland-wrapped`) while ps/pgrep -a show a perfectly ordinary `kwin_wayland`, because they read argv. Measured against a live kernel: `.kwin_wayland-w`, `.kwin_wayland_w` (KWin's own kwin_wayland_wrapper), `.gamescope-wrap`, all 15 bytes. Nothing downstream could recover from that one string comparison: - detect_active_session returned ActiveKind::None on a *running* KDE desktop; - wayland_display is only resolved for a detected kind, so the connect log reported wayland="-" even though WAYLAND_DISPLAY was correct; - pick_compositor's Auto arm returns the DETECTED backend, so a live, fully working KWin sitting in available() was never chosen — every connect died "no usable compositor"; - and PUNKTFUNK_COMPOSITOR could not rescue it: pinned_at_a_dead_session consults the same probe, turning the miss into a hard error instead. No environment variable reached the comparison — the XDG_CURRENT_DESKTOP fallback in detect() is only on the pinned path. Capture itself was never at fault: a decoy process merely NAMED kwin_wayland satisfied the probe and the stream came up against the real KWin. Resolve the name through /proc//exe (the full, untruncated file name) and strip the nixpkgs decoration. Both the leading `.` and the trailing `-wrapped` are required before anything is stripped, so KWin's own real `kwin_wayland_wrapper` binary keeps its name rather than collapsing into `kwin_wayland` and handing the probe the parent's PID. The comm fast path is kept for every ordinary distro — one read, no readlink, and no name that matched before can stop matching. Also applied to foreign_gamescope_running, which had the same defect: nixpkgs wraps gamescope too, so the attach-vs-spawn ladder saw no foreign session. Tests are fixture-driven rather than spawn-driven on purpose: a stand-in has to be a real ELF that tolerates being renamed, and /bin/sleep is not one — modern coreutils is a multi-call binary that dispatches on the executable's own name, so a copy called `.kwin_wayland-wrapped` exits instantly and /proc//exe is gone before it can be read. That failure looks exactly like this resolver being broken; it cost one debugging round here and the same trap is already recorded in punktfunk-host's /proc matcher. --- .../src/vdisplay/linux/gamescope.rs | 6 +- crates/pf-vdisplay/src/vdisplay/proc.rs | 258 ++++++++++++++++++ crates/pf-vdisplay/src/vdisplay/session.rs | 11 +- 3 files changed, 269 insertions(+), 6 deletions(-) diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 6dcba18f..b7a06251 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -645,10 +645,12 @@ pub fn foreign_gamescope_running() -> bool { if md.uid() != uid { continue; } - let Ok(comm) = std::fs::read_to_string(e.path().join("comm")) else { + // Resolved, not a raw `comm` read: nixpkgs wraps gamescope too, so on NixOS the kernel + // reports `.gamescope-wrap` and this probe saw no foreign session at all. + let Some(comm) = crate::proc::match_name(&e.path()) else { continue; }; - if !matches!(comm.trim(), "gamescope" | "gamescope-wl") { + if !matches!(comm.as_str(), "gamescope" | "gamescope-wl") { continue; } if !descends_from(pid, our_pid) { diff --git a/crates/pf-vdisplay/src/vdisplay/proc.rs b/crates/pf-vdisplay/src/vdisplay/proc.rs index 0c38b91b..a2971875 100644 --- a/crates/pf-vdisplay/src/vdisplay/proc.rs +++ b/crates/pf-vdisplay/src/vdisplay/proc.rs @@ -111,6 +111,74 @@ pub(crate) fn current_uid() -> u32 { unsafe { libc::getuid() } } +/// The longest `/proc//comm` the kernel will report: `TASK_COMM_LEN` is 16 *including* the +/// NUL, so a name of exactly this many bytes may be a truncation of a longer one. +#[cfg(target_os = "linux")] +const COMM_MAX: usize = 15; + +/// The executable name to identify a process by, with nixpkgs wrapper decoration undone. +/// +/// `comm` is the kernel's name for the **executed file**, truncated to [`COMM_MAX`] bytes — it is +/// not `argv[0]` and not the command line. nixpkgs wraps essentially every graphical binary: +/// `wrapProgram` moves the real ELF aside to `.-wrapped` and installs a shell wrapper under +/// the original name, and that wrapper `exec -a "$0"`s the hidden file. So `ps`/`pgrep -a` show a +/// perfectly ordinary `kwin_wayland` (they read argv) while the kernel reports `.kwin_wayland-w` +/// — 15 bytes of `.kwin_wayland-wrapped`, which can never equal `kwin_wayland`. +/// +/// That is not a KDE-only detail. On NixOS `kwin_wayland`, `gamescope`, `gnome-shell` and +/// `Hyprland` are all wrapped, so an exact `comm` comparison made [`super::session`]'s probe +/// answer [`crate::ActiveKind::None`] on a visibly running desktop — and because the probe is the +/// *only* input to that decision, no environment variable could reach it: `WAYLAND_DISPLAY` was +/// correct, capture worked the moment detection was satisfied, and a `PUNKTFUNK_COMPOSITOR` pin +/// turned the miss into a hard error via `pinned_at_a_dead_session`. (sway survives by accident — +/// nixpkgs' wrapper execs a real binary that is itself still called `sway`.) +/// +/// The `comm` fast path is kept for every ordinary distro: one read, no readlink. Only a name that +/// *could* be decorated or truncated — it starts with `.`, or it is exactly [`COMM_MAX`] bytes — +/// is re-resolved through `/proc//exe`, which carries the full, untruncated file name. +/// +/// `pid_path` is a `/proc/` directory. `None` when the process vanished mid-scan. +#[cfg(target_os = "linux")] +pub(crate) fn match_name(pid_path: &std::path::Path) -> Option { + let comm = std::fs::read_to_string(pid_path.join("comm")).ok()?; + let comm = comm.trim(); + // An undecorated name short enough to be complete is already the answer. + if !comm.starts_with('.') && comm.len() < COMM_MAX { + return Some(comm.to_string()); + } + // Reading our OWN uid's `/proc//exe` needs no privilege (every caller filters on uid + // first), but it is still absent for a kernel thread and for a process exiting under us — + // in which case the truncated `comm` is the best that exists. + match std::fs::read_link(pid_path.join("exe")) + .ok() + .as_deref() + .and_then(|p| p.file_name()) + .and_then(|n| n.to_str()) + { + Some(full) => Some(undecorate(full).to_string()), + None => Some(comm.to_string()), + } +} + +/// Strip nixpkgs `wrapProgram` decoration: `.-wrapped`, plus the `_` suffixes make-wrapper +/// appends when that hidden name is already taken (a doubly-wrapped app — Qt *and* GApps). +/// +/// **Both** halves are required, and that is the load-bearing part rather than pedantry: KWin +/// ships its own real binary called `kwin_wayland_wrapper` (the session's parent process), so a +/// rule that merely stripped a `wrapper`-ish suffix would rewrite it into `kwin_wayland` and hand +/// the session probe the wrong PID. Demanding the leading `.` as well keeps it — and any genuine +/// `foo-wrapped` — under its real name. +#[cfg(target_os = "linux")] +fn undecorate(name: &str) -> &str { + let Some(rest) = name.strip_prefix('.') else { + return name; + }; + match rest.trim_end_matches('_').strip_suffix("-wrapped") { + Some(real) if !real.is_empty() => real, + _ => name, + } +} + /// Ending the *tree* the helper started, not just the process we spawned. /// /// [`std::process::Child::kill`] is one `TerminateProcess` / one `SIGKILL`: it ends exactly the @@ -280,6 +348,196 @@ mod tests { } } +/// The `comm`-vs-real-name resolution ([`match_name`]). Linux-only, because the trap it exists for +/// is a Linux kernel detail (`comm` names the executed FILE, truncated to 15 bytes) crossed with a +/// nixpkgs packaging convention. +/// +/// Driven against **fixture** `/proc/` directories rather than spawned processes, for the same +/// reason the `/proc` matcher in `punktfunk-host` learned the hard way: a stand-in has to be a real +/// ELF that tolerates being *renamed*, and `/bin/sleep` is not one. Modern coreutils (uutils on +/// Ubuntu 25.10+, busybox elsewhere) is a MULTI-CALL binary — copied to `.kwin_wayland-wrapped` it +/// prints "unknown program" and exits before `/proc` can be read, and restoring `argv[0]` does not +/// save it. That reads exactly like this resolver being broken. The truncation the fixtures encode +/// is not guessed: the strings below were measured from a live kernel (`.kwin_wayland-w`, +/// `.kwin_wayland_w`, `.gamescope-wrap` — all 15 bytes) against binaries installed and exec'd the +/// way nixpkgs does it. +#[cfg(all(test, target_os = "linux"))] +mod name_tests { + use super::*; + use std::path::{Path, PathBuf}; + + /// A fake `/proc/` directory: a `comm` file and, optionally, the `exe` symlink. Removed on + /// drop. + struct FakePid { + dir: PathBuf, + } + + impl FakePid { + /// `comm` is written exactly as the kernel would report it — i.e. already truncated. + fn new(tag: &str, comm: &str, exe: Option<&str>) -> FakePid { + let dir = std::env::temp_dir().join(format!("pf-vd-name-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("fixture dir"); + std::fs::write(dir.join("comm"), format!("{comm}\n")).expect("comm"); + if let Some(exe) = exe { + // The target need not exist: `read_link` reports the link's contents, and a real + // `/proc//exe` routinely points at a path that has since been replaced. + std::os::unix::fs::symlink( + format!("/nix/store/eeee-kwin-6.5.0/bin/{exe}"), + dir.join("exe"), + ) + .expect("exe symlink"); + } + FakePid { dir } + } + fn path(&self) -> &Path { + &self.dir + } + } + + impl Drop for FakePid { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } + } + + /// The decoration table. The `kwin_wayland_wrapper` rows are the ones that earn their keep: it + /// is a REAL KWin binary (the session's parent process), so the rule must leave it under its own + /// name in both its plain and its wrapped form rather than collapsing either into + /// `kwin_wayland` and handing the session probe the wrong PID. + #[test] + fn undecorate_strips_only_a_real_nixpkgs_wrapper() { + for (raw, want) in [ + (".kwin_wayland-wrapped", "kwin_wayland"), + (".gamescope-wrapped", "gamescope"), + (".gnome-shell-wrapped", "gnome-shell"), + (".Hyprland-wrapped", "Hyprland"), + // make-wrapper appends `_`s when the hidden name is already taken (a Qt + GApps + // double-wrap), so the underscores come off before the suffix does. + (".kwin_wayland-wrapped_", "kwin_wayland"), + (".kwin_wayland-wrapped__", "kwin_wayland"), + // Not decoration — every one of these keeps its exact name. + ("kwin_wayland", "kwin_wayland"), + ("kwin_wayland_wrapper", "kwin_wayland_wrapper"), + (".kwin_wayland_wrapper-wrapped", "kwin_wayland_wrapper"), + ("foo-wrapped", "foo-wrapped"), + (".hidden", ".hidden"), + (".-wrapped", ".-wrapped"), + ] { + assert_eq!(undecorate(raw), want, "undecorate({raw:?})"); + } + } + + /// The whole bug. Every compositor the session probe matches on is wrapped by nixpkgs, so the + /// kernel reports a truncated, decorated `comm` that can never equal the name being compared — + /// which is why `detect_active_session` answered `ActiveKind::None` on a *running* KDE desktop + /// and every connect died "no usable compositor". + #[test] + fn a_nixpkgs_wrapped_compositor_resolves_to_its_real_name() { + for (tag, comm, exe, want) in [ + ( + "kwin", + ".kwin_wayland-w", + ".kwin_wayland-wrapped", + "kwin_wayland", + ), + ( + "gamescope", + ".gamescope-wrap", + ".gamescope-wrapped", + "gamescope", + ), + ( + "gnome", + ".gnome-shell-wr", + ".gnome-shell-wrapped", + "gnome-shell", + ), + ("hypr", ".Hyprland-wrapp", ".Hyprland-wrapped", "Hyprland"), + ] { + let p = FakePid::new(tag, comm, Some(exe)); + assert_eq!( + match_name(p.path()).as_deref(), + Some(want), + "a nixpkgs-wrapped {want} must resolve to the name the session probe matches" + ); + } + } + + /// KWin's own `kwin_wayland_wrapper` is a real binary that runs *alongside* `kwin_wayland`, and + /// its wrapped `comm` (`.kwin_wayland_w`) differs from the compositor's by a single byte. It + /// must NOT resolve to `kwin_wayland`: the probe would then match the parent process and carry + /// its PID as the compositor identity, which drives restart detection. + #[test] + fn kwins_own_wrapper_binary_does_not_masquerade_as_the_compositor() { + let p = FakePid::new( + "kwrap", + ".kwin_wayland_w", + Some(".kwin_wayland_wrapper-wrapped"), + ); + assert_eq!( + match_name(p.path()).as_deref(), + Some("kwin_wayland_wrapper") + ); + } + + /// The other half of the 15-byte limit, with no nix involved: a long name is truncated too, and + /// has to be recovered from `exe` rather than matched short. + #[test] + fn a_long_name_is_recovered_untruncated() { + let p = FakePid::new( + "long", + "a-very-long-com", + Some("a-very-long-compositor-name"), + ); + assert_eq!( + match_name(p.path()).as_deref(), + Some("a-very-long-compositor-name") + ); + } + + /// The fast path answers without consulting `exe` at all — which is what keeps this probe at one + /// read per process on every ordinary distro, and what lets it answer for a process whose `exe` + /// is unreadable in the first place. + #[test] + fn an_ordinary_short_name_never_needs_the_exe_link() { + let p = FakePid::new("plain", "kwin_wayland", None); + assert_eq!(match_name(p.path()).as_deref(), Some("kwin_wayland")); + } + + /// A decorated-or-truncated name whose `exe` cannot be read (a kernel thread, or a process + /// exiting under the scan) degrades to the truncated `comm` instead of failing the whole entry. + #[test] + fn an_unreadable_exe_falls_back_to_comm() { + let p = FakePid::new("noexe", ".kwin_wayland-w", None); + assert_eq!(match_name(p.path()).as_deref(), Some(".kwin_wayland-w")); + } + + /// A pid directory that does not exist yields `None`, not a bogus name — the scans `continue`. + #[test] + fn a_vanished_process_yields_none() { + assert_eq!(match_name(Path::new("/proc/0")), None); + } + + /// The one thing a fixture cannot establish: that reading `/proc//exe` is actually + /// *permitted* for a process of our own uid, which the whole resolver depends on. Checked + /// against the only such process guaranteed to be running — this one. + #[test] + fn our_own_exe_link_is_readable() { + let me = Path::new("/proc/self"); + let exe = std::fs::read_link(me.join("exe")) + .expect("/proc/self/exe must be readable for our own uid"); + let name = exe.file_name().and_then(|n| n.to_str()).expect("exe name"); + let got = match_name(me).expect("our own name"); + // Whichever rung answered, it must agree with the real binary: the fast path returns the + // (short, undecorated) comm, which is a prefix of it; the exe path returns it outright. + assert!( + name.starts_with(got.as_str()) || got == name, + "resolved {got:?} disagrees with our real binary {name:?}" + ); + } +} + /// The same two cases through `cmd /c`, so the budget logic is covered on the platform whose /// process model differs most (job objects, no `SIGKILL`). `ping -n` is the standard Windows /// no-extra-tooling sleep. diff --git a/crates/pf-vdisplay/src/vdisplay/session.rs b/crates/pf-vdisplay/src/vdisplay/session.rs index d95e6cc9..e99b6f88 100644 --- a/crates/pf-vdisplay/src/vdisplay/session.rs +++ b/crates/pf-vdisplay/src/vdisplay/session.rs @@ -311,8 +311,11 @@ pub fn detect_active_session() -> ActiveSession { let dbus = default_bus(&env, &xdg_runtime_dir); // Process probe: the running graphical compositor of THIS uid decides the kind. Priority lets - // a real desktop (kwin/gnome/sway) win over a leftover gamescope child. comm names mirror the - // `pkill -x` discipline (exact, ≤15 chars so untruncated). + // a real desktop (kwin/gnome/sway) win over a leftover gamescope child. Names are matched + // exactly, `pkill -x` style — but resolved through [`crate::proc::match_name`], NOT a raw + // `comm` read: on NixOS every one of these binaries is a nixpkgs wrapper whose real ELF is + // `.-wrapped`, so a raw `comm` says `.kwin_wayland-w` and this whole probe answered + // `None` on a running KDE desktop. let mut kind = ActiveKind::None; let mut best = 0u8; // The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the @@ -332,10 +335,10 @@ pub fn detect_active_session() -> ActiveSession { if md.uid() != uid { continue; } - let Ok(comm) = std::fs::read_to_string(pid_path.join("comm")) else { + let Some(comm) = crate::proc::match_name(&pid_path) else { continue; }; - let (k, prio) = match comm.trim() { + let (k, prio) = match comm.as_str() { "gamescope" | "gamescope-wl" => (ActiveKind::Gaming, 1), "kwin_wayland" => (ActiveKind::DesktopKde, 4), "gnome-shell" => (ActiveKind::DesktopGnome, 4), From 159bbdbfc246246244fd32af49114bdb6df2698c Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 19:27:14 +0200 Subject: [PATCH 2/4] fix(nix): port three NixOS-module divergences from the shipped systemd units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sweep of the Nix packaging against the units the deb/rpm actually install found three decisions that were made, documented and deliberate everywhere else, and simply not carried into packaging/nix/nixos-module.nix. punktfunk-web — StartLimitIntervalSec=0. The unit's EnvironmentFile for the mgmt token is mandatory ON PURPOSE, so the console genuinely fails until the host's first `serve` writes it. systemd's default rate limit (5 starts / 10 s) against RestartSec=2 then gives up permanently after ~10 s — which on an appliance is exactly the window before the host is ready, so a console enabled before the host's first run stayed dead until someone restarted it by hand. scripts/punktfunk-web.service has carried the override since that defect was found; the Nix module omitted it while its own comment went on promising "Restart retries until the host has created it". punktfunk-web — Restart=always, not on-failure. A console that exits 0 has still stopped serving, and on-failure leaves it down. Matches the shipped unit and web-run.cmd on Windows, both of which relaunch bun on ANY exit. An explicit `systemctl --user stop` is unaffected. punktfunk-scripting — the sandbox was missing entirely. The shipped unit confines the runner with NoNewPrivileges, ProtectSystem= strict, ReadWritePaths=%h /tmp and an AF_UNIX/AF_INET/AF_INET6 address-family restriction, plus PrivateTmp=no (a field report: a private /tmp hides /tmp/vhclient and /tmp/.X11-unix, so a plugin launches its vendor binary and then cannot reach the daemon behind it). The NixOS unit had none of it — so the one unit here that executes arbitrary operator TypeScript by design ran strictly LESS confined on NixOS than on every other channel. Verified by evaluating the module against the pinned nixpkgs and rendering the units: assertions clean, cap_sys_nice=ep on the encode-worker wrapper, firewall 47984/47989/47990/47992/47993/48010, and each unit carrying exactly the directives above. That evaluation is NOT something CI does — measured: `nix flake check` passes a nixosModule containing a nonexistent option, a nonexistent pkgs attribute and a nonexistent lib function, printing "checking NixOS module ... all checks passed!" while never evaluating it against nixpkgs. nix.yml's header claims that leg covers the module. It does not; tracked separately. --- packaging/nix/nixos-module.nix | 47 +++++++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/packaging/nix/nixos-module.nix b/packaging/nix/nixos-module.nix index f7dfef8f..e66b72cf 100644 --- a/packaging/nix/nixos-module.nix +++ b/packaging/nix/nixos-module.nix @@ -508,6 +508,15 @@ in ]; wants = [ "punktfunk-web-init.service" ]; wantedBy = optional cfg.web.autoStart "default.target"; + # Retry INDEFINITELY while the host is still writing the mgmt token + identity cert. The + # EnvironmentFile below is mandatory on purpose, so the unit genuinely fails until those + # exist — and systemd's default rate limit (5 starts / 10 s) against `RestartSec = 2` gives + # up permanently after ~10 s, which on an appliance is exactly the window before the host's + # first `serve` completes. A console enabled before the host's first run then stayed dead + # until someone restarted it by hand. The shipped unit (scripts/punktfunk-web.service) has + # carried this since that defect was found; it was missed in the port, while the comment + # below went on promising the behaviour it removes. + unitConfig.StartLimitIntervalSec = 0; environment = { PUNKTFUNK_MGMT_URL = "https://127.0.0.1:47990"; PORT = "47992"; @@ -525,7 +534,11 @@ in "-%h/.config/punktfunk/web-password" ]; ExecStart = "${cfg.web.package}/bin/punktfunk-web-server"; - Restart = "on-failure"; + # `always`, not `on-failure`: a console that exits 0 has still stopped serving, and + # `on-failure` would leave it down. An explicit `systemctl --user stop` is still honoured + # (Restart= never fights that). Matches scripts/punktfunk-web.service and the Windows + # web-run.cmd, both of which relaunch bun on ANY exit. + Restart = "always"; RestartSec = 2; }; }; @@ -554,6 +567,38 @@ in KillMode = "mixed"; KillSignal = "SIGTERM"; TimeoutStopSec = 30; + + # Sandbox — the same confinement scripts/punktfunk-scripting.service gives the deb/rpm + # installs. The runner `import()`s the operator's own `.ts` files, so this is the one unit + # here that executes arbitrary code by design; without these it ran strictly LESS confined + # on NixOS than on every other channel. Read-only outside $HOME, no setuid re-escalation, + # and only the address families automation actually uses (loopback mgmt API, LAN/IPv6 + # webhooks, unix sockets). + NoNewPrivileges = true; + # PrivateTmp deliberately OFF (field report 2026-08-03, the VirtualHere plugin). A + # plugin's whole job is integrating with things already running on this box, and on Linux + # those talk over /tmp: VirtualHere's client IPC is the FIFO pair /tmp/vhclient + + # /tmp/vhclient_response, X11 is /tmp/.X11-unix. A private /tmp hides all of it — the + # plugin launches the vendor binary fine and then cannot reach the daemon behind it, + # which presents as an error no amount of config fixes. + PrivateTmp = false; + ProtectSystem = "strict"; + # ReadWritePaths puts back the write bit ProtectSystem=strict takes away: plugin state and + # ~/.config/punktfunk under $HOME, plus the /tmp above. A plugin that must write OUTSIDE + # $HOME (a game library on another mount) gets it with + # systemctl --user edit punktfunk-scripting → [Service] ReadWritePaths=/mnt/games + # ⚠ ProtectSystem is a MOUNT-NAMESPACE option, and for a *user* unit that needs + # unprivileged user namespaces. On a kernel/config that restricts those it fails the unit + # rather than degrading — drop it via the same drop-in if this box is one of them. + ReadWritePaths = [ + "%h" + "/tmp" + ]; + RestrictAddressFamilies = [ + "AF_UNIX" + "AF_INET" + "AF_INET6" + ]; }; }; }) From f8cde0adafb9ab0a66bdc6af465fcfdaf286ab19 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 19:57:10 +0200 Subject: [PATCH 3/4] feat(nix): actually check the NixOS module in CI, and close the sweep's open issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE CI GAP. `nix flake check` does not check `nixosModules`. It forces the value and asserts it is a lambda taking an open attribute set — nothing more; nix's own source carries `// FIXME: if we have a 'nixpkgs' input, use it to check the module.` Measured: a flake whose module sets a nonexistent OPTION, references a nonexistent `pkgs` attribute AND calls a nonexistent `lib` function passes clean, printing `checking NixOS module 'nixosModules.default'... all checks passed!`. nix.yml's header claimed that leg covered the module; it never did, for the module's whole life — on a flake whose history is Nix regressions reaching main invisibly. Closed with `checks..nixos-module` (packaging/nix/module-check.nix): it evaluates the module against real nixpkgs in four scenarios (desktop, appliance, native-only, client-only) and asserts on the rendered systemd units. The assertions are PURE NIX so instantiating the check runs them — which means the eval-only `--no-build` leg CI already runs is sufficient, and no Rust is built. Stub fake-derivation packages keep it independent of punktfunk-host/-client and the from-source gamescope; crane and bun2nix are provably not needed (they are `throw`s in the wiring test and it still instantiates). 17 checks, including regression guards for every divergence the sweep found and for the KWin identification trap (host ExecStart must stay on the plain store path, never a capability wrapper, while the encode worker points AT the wrapper). Mutation-tested: 8 mutants, each re-introducing one real defect, all 8 rejected, baseline green. The suite already earned it once — its first run failed a correct module because systemd renders `After=` as one space-separated line, so those assertions now read the evaluated lists instead of the text. Also closed from the sweep: * services.punktfunk.host.desktopSession (new, default false) — binds the host to graphical-session.target, the declarative form of the punktfunk-host-desktop-session.conf drop-in. Without it a Plasma/GNOME restart leaves the host holding a Wayland socket and portal D-Bus connection that died with the old compositor: it still listens, still answers, and every session it then serves fails at capture. Off by default because an appliance may never reach that target and would be left permanently stopped. * scripting.autoStart now defaults ON, matching the deb postinst and RPM %post, which both `systemctl --global enable` the runner. It was opt-in here on the reasoning that the runner is inert until you add automation — which stopped being true when the game-library scanners became plugins. A NixOS host came up with an empty library and no obvious reason why. The module and README carried the superseded rationale verbatim; both updated. * A warning when the host is enabled and xdg.portal is not. A warning rather than `xdg.portal.enable = mkDefault true`, because enabling the portal service with no `extraPortals` backend is its own broken state and only the operator knows which backend their compositor needs. * punktfunk-gamescope gets a `build-gamescope` dispatch input. It is on the critical path of every host build (`gamescopeHdr` defaults true) yet nothing compiled it; it tracks nixpkgs' gamescope, so a flake.lock bump — not a change of ours — is what breaks it, and the first to find out would be an operator whose system rebuild fails. All .nix files reformatted with the flake's own declared formatter (nixfmt-rfc-style from the PINNED nixpkgs, not a channel's). --- .gitea/workflows/nix.yml | 42 +++++- flake.nix | 12 ++ packaging/nix/README.md | 43 +++++- packaging/nix/module-check.nix | 263 +++++++++++++++++++++++++++++++++ packaging/nix/nixos-module.nix | 95 ++++++++++-- 5 files changed, 432 insertions(+), 23 deletions(-) create mode 100644 packaging/nix/module-check.nix diff --git a/.gitea/workflows/nix.yml b/.gitea/workflows/nix.yml index 5693d755..d8a99526 100644 --- a/.gitea/workflows/nix.yml +++ b/.gitea/workflows/nix.yml @@ -7,10 +7,24 @@ # Two tiers, because a full `nix flake check` builds the whole Rust workspace with crane and would # run for an hour on every push: # -# * eval — `nix flake check --no-build`: instantiates every package, app, check, devShell and -# the NixOS module without building them. Catches the failures that actually happen to -# this flake — a renamed file, a callPackage argument that no longer exists, a syntax -# error, a package attribute dropped from packages.nix. +# * eval — `nix flake check --no-build`: instantiates every package, app, check and devShell +# without building them. Catches the failures that actually happen to this flake — a +# renamed file, a callPackage argument that no longer exists, a syntax error, a package +# attribute dropped from packages.nix. +# +# ⚠ It does NOT, on its own, check the NixOS module. `nix flake check` handles +# `nixosModules` by forcing the value and asserting it is a lambda taking an open +# attribute set — nothing more (nix's own source: `// FIXME: if we have a 'nixpkgs' +# input, use it to check the module.`). MEASURED: a module setting a nonexistent +# OPTION, referencing a nonexistent `pkgs` attribute AND calling a nonexistent `lib` +# function passes clean, printing `checking NixOS module ... all checks passed!`. This +# header used to claim the module was covered here; it was not, for the module's whole +# life. It is covered NOW because `checks..nixos-module` +# (packaging/nix/module-check.nix) evaluates it against real nixpkgs and asserts on the +# rendered systemd units — and because those assertions are pure Nix, INSTANTIATING +# that check runs them, so `--no-build` is enough. Keep them pure: a shell script in +# the derivation body would only run under a full `nix flake check`, which builds the +# hour-long Rust packages. # * bun — actually BUILDS punktfunk-web + punktfunk-scripting. These are the two derivations # whose inputs churn constantly (every dependency bump moves a lockfile) and they cost # minutes, not hours, because neither compiles Rust. This is the end-to-end proof that @@ -22,6 +36,12 @@ # They are the expensive ones and their inputs are already gated by the `rust` job in ci.yml; build # them by hand on a Nix box, or with the `build-rust` dispatch input below. # +# ⚠ punktfunk-gamescope deserves the dispatch run more than it looks: `host.gamescopeHdr` DEFAULTS +# TRUE, so it is on the critical path of every `services.punktfunk.host.enable = true` build, while +# being the one package nothing here compiles. It patches whatever gamescope the pinned nixpkgs +# carries, so a nixpkgs bump — not a change of ours — is what breaks it, and the first person to +# find out would be an operator whose system rebuild fails. Run the dispatch after a flake.lock bump. +# # ⚠ pull_request is deliberately present. flatpak.yml shipped with push-only triggers and manifest # breakage reached main invisibly for weeks — do not "simplify" this workflow by dropping it. # ⚠ The two path lists are duplicated on purpose: a YAML anchor would be tidier, but Gitea's @@ -66,6 +86,10 @@ on: description: "Also build punktfunk-host + punktfunk-client (slow: full Rust workspace)" type: boolean default: false + build-gamescope: + description: "Also build punktfunk-gamescope (patched gamescope from source; run after a flake.lock bump)" + type: boolean + default: false jobs: flake: @@ -165,3 +189,13 @@ jobs: if: ${{ github.event.inputs.build-rust == 'true' }} run: | "$NIX" build --print-build-logs .#punktfunk-host .#punktfunk-client + + # The patched compositor. Separate from build-rust because its failure mode is different: it + # tracks nixpkgs' gamescope, not our Rust, so it wants a run after a flake.lock bump rather + # than after a code change. `gamescope.nix` fails loudly (an eval-time `throw` if nixpkgs no + # longer exposes a patchable derivation, a `+pfhdr` grep in installCheckPhase) — but only if + # something actually builds it. + - name: Build the patched gamescope (dispatch opt-in) + if: ${{ github.event.inputs.build-gamescope == 'true' }} + run: | + "$NIX" build --print-build-logs .#punktfunk-gamescope diff --git a/flake.nix b/flake.nix index c6862b3c..faa9e701 100644 --- a/flake.nix +++ b/flake.nix @@ -126,6 +126,7 @@ system: let pf = packagesFor system; + pkgs = pkgsFor system; in { inherit (pf) @@ -134,6 +135,17 @@ punktfunk-web punktfunk-scripting ; + + # The NixOS module, actually evaluated. `nix flake check` does NOT do this for + # `nixosModules` — it only forces the value and asserts it is a lambda taking an open + # attribute set, so a module with a nonexistent option, a nonexistent `pkgs` attribute + # and a nonexistent `lib` function passes clean (measured). Routing the module through a + # `checks` entry instead means the eval-only CI leg has to instantiate it, and every + # assertion in module-check.nix is pure Nix so instantiation is enough to run them. + nixos-module = pkgs.callPackage ./packaging/nix/module-check.nix { + inherit nixpkgs system; + module = import ./packaging/nix/nixos-module.nix; + }; } ); diff --git a/packaging/nix/README.md b/packaging/nix/README.md index 6d1a3456..786f90af 100644 --- a/packaging/nix/README.md +++ b/packaging/nix/README.md @@ -21,6 +21,7 @@ and the native Linux **client**, a **NixOS module** that wires up everything the | `packages.x86_64-linux.default` | = `punktfunk-host` | | `nixosModules.default` | `services.punktfunk.host` / `.client` / `.web` / `.scripting` | | `devShells.x86_64-linux.default` | pinned Rust (from `rust-toolchain.toml`) + all build deps | +| `checks.x86_64-linux.nixos-module` | evaluates the NixOS module against real nixpkgs and asserts on the rendered systemd units | | `apps` / `checks` / `formatter` | `nix run`, `nix flake check`, `nix fmt` | One binary per GPU vendor: NVENC/CUDA entry points are `dlopen`'d at runtime, so the host runs on @@ -99,12 +100,33 @@ systemctl --user enable --now punktfunk-host | `enable` | `false` | Install the host + wire udev/sysctl/kernel-modules/firewall and the user service. | | `gamestream` | `true` | `serve --gamestream` (Moonlight-compatible). `false` = native-only, more secure. | | `autoStart` | `false` | Add the user service to `default.target` (appliance mode — pair with lingering). | +| `desktopSession` | `false` | Bind the host to `graphical-session.target` — **turn this on for a machine somebody logs into** (see below). | | `users` | `[ ]` | Users added to the `input` group (virtual gamepads). | | `settings` | `{ }` | `host.env` key/values (see `${package}/share/punktfunk-host/host.env.example`). | | `environmentFile` | `null` | Extra `EnvironmentFile` for secrets (e.g. `PUNKTFUNK_MGMT_TOKEN`); loaded optionally. | | `openFirewall` | `false` | Open the inbound ports (see below). | | `package` | flake's | Override the package. | +**`desktopSession` — set it on a desktop, leave it off on an appliance.** On a machine somebody logs +into, a compositor restart (a crash, a logout/login, "restart the shell") otherwise leaves the host +running while it holds a Wayland socket and a portal D-Bus connection that both died with the old +compositor. It cannot recover either in-process, and the failure is *silent*: the host still +listens, still answers, and every session it then serves fails at capture. `desktopSession = true` +adds `PartOf=`/`WantedBy=graphical-session.target` (in addition to `default.target`), so the host +restarts with the session. Leave it `false` for an appliance — a pinned `PUNKTFUNK_COMPOSITOR`, a +headless KWin or a gamescope box — which may never reach that target and would be left permanently +stopped. sway/Hyprland and anything else not under systemd session management never reach it +either; there, start the host from the compositor's config after `systemctl --user +import-environment`. + +**Portals.** The host reaches the desktop through `xdg-desktop-portal` on several backends (Mutter's +ScreenCast/RemoteDesktop, and the libei input path), so a hand-assembled machine wants +`xdg.portal.enable = true` plus the backend for its compositor +(`xdg-desktop-portal-kde` / `-gnome` / `-hyprland` / `-wlr`). The KDE and GNOME desktop-manager +modules already do this. The module emits a warning if the host is enabled and portals are not — +the KWin backend's own virtual output uses the privileged `zkde_screencast` protocol and needs no +portal, so KDE-only setups are unaffected in practice. + `services.punktfunk.client`: `enable`, `openFirewall` (UDP 5353), `package`. `services.punktfunk.web` (the management console — **on by default whenever the host is enabled**, @@ -125,20 +147,31 @@ with `journalctl --user -u punktfunk-web-init` (or `~/.config/punktfunk/web-pass `https://:47992` and trust the self-signed host cert once. Enable it (with the host) via `systemctl --user enable --now punktfunk-web`. -`services.punktfunk.scripting` (the plugin/script runner — installed with the host, but **opt-in to -run**): +`services.punktfunk.scripting` (the plugin/script runner — installed **and started** with the host, +matching the deb/rpm, which `systemctl --global enable` it): | Option | Default | Meaning | | --- | --- | --- | | `enable` | `host.enable` | Install the runner + define its `systemd --user` unit `punktfunk-scripting`. | -| `autoStart` | `false` | Add the unit to `default.target`. Off even on an auto-start host — running operator scripts/plugins is a deliberate opt-in. | +| `autoStart` | `scripting.enable` | Add the unit to `default.target`. **On by default** — the game-library scanners are plugins, so a host without the runner has an empty library. | | `package` | flake's | Override the package. | The runner discovers loose scripts under `~/.config/punktfunk/scripts` and installed `punktfunk-plugin-*` packages under `~/.config/punktfunk/plugins`, and supervises each as an Effect fiber (SIGTERM shuts the tree down structurally so plugin finalizers run). A plugin auto-wires to -the host's mgmt token + identity cert. It's inert until you add automation, so the unit ships -un-started; turn it on with `systemctl --user enable --now punktfunk-scripting`. +the host's mgmt token + identity cert. + +It used to ship un-started here, on the reasoning that the runner is inert until you add +automation. That stopped being true when the library scanners became plugins — a host with the +runner off comes up with an empty library and no obvious reason why — so it now runs by default, +as it already did on every other channel. Opt out with `scripting.autoStart = false`, or per user +`systemctl --user mask punktfunk-scripting` (`mask`, not `disable`). + +The runner is sandboxed exactly as the deb/rpm unit is (`NoNewPrivileges`, `ProtectSystem=strict`, +`ReadWritePaths=%h /tmp`, `RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6`) — with `PrivateTmp` +deliberately **off**, because plugins integrate with things that talk over `/tmp`. `ProtectSystem` +on a *user* unit needs unprivileged user namespaces; drop it with +`systemctl --user edit punktfunk-scripting` on a kernel that restricts them. ### What the host module configures for you diff --git a/packaging/nix/module-check.nix b/packaging/nix/module-check.nix new file mode 100644 index 00000000..c085e1ec --- /dev/null +++ b/packaging/nix/module-check.nix @@ -0,0 +1,263 @@ +# Does the NixOS module actually evaluate, and does it still render the units we decided on? +# +# WHY THIS FILE EXISTS. `nix flake check` does NOT check `nixosModules`. It forces the value and +# asserts it is a lambda taking an open attribute set — nothing more; nix's own source carries +# `// FIXME: if we have a 'nixpkgs' input, use it to check the module.` MEASURED: a flake whose +# `nixosModules.default` sets a nonexistent OPTION, references a nonexistent `pkgs` attribute AND +# calls a nonexistent `lib` function passes clean, printing the thoroughly reassuring +# +# checking NixOS module 'nixosModules.default'... all checks passed! +# +# So every option name, type, `pkgs.*` reference and systemd directive in nixos-module.nix was +# unverified by CI while reading as covered — on a flake whose own history is Nix regressions +# reaching main invisibly (`nix build .#punktfunk-web` was broken for 553 commits). +# +# HOW IT CLOSES THAT. Exposed as a flake `checks` output, so `nix flake check --no-build` — the leg +# CI already runs — must INSTANTIATE it, and instantiating forces the `assert` below. Every +# assertion is therefore pure Nix, evaluated at instantiation: a shell script inside the derivation +# would only run under a full `nix flake check`, which builds the hour-long Rust packages and is +# exactly what CI cannot afford. Keep it that way — if you add a check, add it to `results`, not to +# a `runCommand` body. +# +# STUB PACKAGES, on purpose. The real derivations would drag punktfunk-host, punktfunk-client and +# (via `gamescopeHdr`) a from-source gamescope into this check's closure, making the cheap leg +# expensive and coupling a module regression to a Rust build. What is under test here is the MODULE. +# ⚠ The stubs must be fake DERIVATIONS, not store-path strings: `types.package` accepts anything +# `isDerivation` as-is, but runs a store-path string through `builtins.storePath`, which demands the +# path actually exist and fails eval with "no substituter can build it". +{ + lib, + runCommand, + # The nixpkgs SOURCE. Interpolated rather than `nixpkgs + "/..."` because this is called with the + # flake INPUT, which is an attribute set (coerced through its `outPath`) and not a path — the + # difference only shows up as "expected a set but found a string" from whichever side is wrong. + nixpkgs, + system, + module, +}: +let + fakeDrv = name: { + type = "derivation"; + inherit name; + outPath = "/pf-stub/${name}"; + outputs = [ "out" ]; + }; + + stubSelf = { + packages.${system} = lib.genAttrs [ + "punktfunk-host" + "punktfunk-client" + "punktfunk-web" + "punktfunk-scripting" + "punktfunk-gamescope" + ] fakeDrv; + }; + + # A machine just complete enough for eval-config, plus the scenario under test. + evalWith = + scenario: + (import "${nixpkgs}/nixos/lib/eval-config.nix" { + inherit system; + modules = [ + (module stubSelf) + { + boot.loader.grub.enable = false; + fileSystems."/" = { + device = "/dev/sda1"; + fsType = "ext4"; + }; + system.stateVersion = "24.11"; + nixpkgs.hostPlatform = system; + # `host.users` adds group membership to an existing user; declare one so NixOS's own + # "isNormalUser or isSystemUser" assertion is not what this check trips over. + users.users.alice.isNormalUser = true; + } + scenario + ]; + }).config; + + # Every scenario keeps `gamescopeHdr = false`: it is the one option whose default would pull a + # real (stub, here) gamescope onto the unit PATH, and nothing below is about that. + desktop = evalWith { + services.punktfunk.host = { + enable = true; + users = [ "alice" ]; + gamescopeHdr = false; + desktopSession = true; + }; + }; + + appliance = evalWith { + services.punktfunk.host = { + enable = true; + autoStart = true; + openFirewall = true; + gamescopeHdr = false; + }; + }; + + nativeOnly = evalWith { + services.punktfunk.host = { + enable = true; + openFirewall = true; + gamestream = false; + gamescopeHdr = false; + }; + }; + + clientOnly = evalWith { services.punktfunk.client.enable = true; }; + + unit = cfg: name: cfg.systemd.user.units."${name}.service".text; + has = + cfg: name: infix: + lib.hasInfix infix (unit cfg name); + # A module's own failed assertions, as messages. + failedAssertions = cfg: map (a: a.message) (lib.filter (a: !a.assertion) cfg.assertions); + + results = [ + # --- the module evaluates at all, in every shape an operator can ask for ------------------- + { + name = "desktop scenario has no failing assertions"; + ok = failedAssertions desktop == [ ]; + } + { + name = "appliance scenario has no failing assertions"; + ok = failedAssertions appliance == [ ]; + } + { + name = "client-only scenario has no failing assertions"; + ok = failedAssertions clientOnly == [ ]; + } + + # --- 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 + # identifies the client by — which cost every KDE box its desktop streaming in 0.26.0-1. + { + name = "host ExecStart is the store binary, never a capability wrapper"; + ok = + has desktop "punktfunk-host" "ExecStart=/pf-stub/punktfunk-host/bin/punktfunk-host serve" + && !(has desktop "punktfunk-host" "ExecStart=/run/wrappers"); + } + # ...while the ENCODE WORKER, which nothing ever has to identify, is pointed at the wrapper. + { + name = "host points PUNKTFUNK_ENCODE_WORKER at the capability wrapper"; + ok = + has desktop "punktfunk-host" + "PUNKTFUNK_ENCODE_WORKER=/run/wrappers/bin/punktfunk-encode-worker"; + } + { + name = "the encode-worker wrapper carries exactly cap_sys_nice=ep"; + ok = desktop.security.wrappers.punktfunk-encode-worker.capabilities == "cap_sys_nice=ep"; + } + + # --- the desktop-login route (scripts/punktfunk-host-desktop-session.conf) ----------------- + # Asserted on the evaluated LISTS, not the rendered text: systemd renders `After=` as one + # space-separated line, so `hasInfix "After=graphical-session.target"` silently depends on + # ordering — it failed against a correct module the first time this check ran. + { + name = "desktopSession binds the host to graphical-session.target"; + ok = + let + u = desktop.systemd.user.services.punktfunk-host; + in + lib.elem "graphical-session.target" u.after + && lib.elem "graphical-session.target" u.partOf + # IN ADDITION to default.target, never instead of it. + && lib.elem "graphical-session.target" u.wantedBy; + } + { + name = "desktopSession is NOT applied to the appliance route (it would stay stopped there)"; + ok = + let + u = appliance.systemd.user.services.punktfunk-host; + in + !(lib.elem "graphical-session.target" u.partOf) && lib.elem "default.target" u.wantedBy; + } + + # --- GameStream opt-out reaches both the argv and the firewall ----------------------------- + { + name = "gamestream=true passes --gamestream"; + ok = has desktop "punktfunk-host" "serve --gamestream"; + } + { + name = "gamestream=false drops --gamestream and its firewall ports"; + ok = + !(has nativeOnly "punktfunk-host" "--gamestream") + && !(lib.elem 47984 nativeOnly.networking.firewall.allowedTCPPorts) + && lib.elem 47990 nativeOnly.networking.firewall.allowedTCPPorts; + } + { + name = "openFirewall opens the console AND its plugin origin"; + ok = + lib.elem 47992 appliance.networking.firewall.allowedTCPPorts + && lib.elem 47993 appliance.networking.firewall.allowedTCPPorts; + } + + # --- the three divergences from the shipped units, as regression guards -------------------- + # Each of these was ONCE wrong here while right in scripts/*.service. Assert the decision, so a + # future edit cannot quietly drift back. + { + # Without this, systemd's default 5-starts-per-10s against RestartSec=2 gives up permanently + # after ~10 s — the exact window before the host's first `serve` writes the mgmt token. + name = "web console retries indefinitely while the host writes its mgmt token"; + ok = has appliance "punktfunk-web" "StartLimitIntervalSec=0"; + } + { + # A console that exits 0 has still stopped serving. + name = "web console restarts on ANY exit, not just failure"; + ok = + has appliance "punktfunk-web" "Restart=always" + && !(has appliance "punktfunk-web" "Restart=on-failure"); + } + { + # The one unit here that runs arbitrary operator TypeScript by design. + name = "the plugin runner is sandboxed like the deb/rpm unit"; + ok = + has appliance "punktfunk-scripting" "NoNewPrivileges=true" + && has appliance "punktfunk-scripting" "ProtectSystem=strict" + && has appliance "punktfunk-scripting" "ReadWritePaths=%h" + && has appliance "punktfunk-scripting" "ReadWritePaths=/tmp" + && has appliance "punktfunk-scripting" "RestrictAddressFamilies=AF_UNIX"; + } + { + # PrivateTmp is OFF on purpose (the VirtualHere field report: a private /tmp hides + # /tmp/vhclient and /tmp/.X11-unix, so a plugin cannot reach the daemon it integrates with). + name = "the plugin runner keeps the real /tmp"; + ok = has appliance "punktfunk-scripting" "PrivateTmp=false"; + } + { + # Since the library scanners became plugins, a runner that is off means an EMPTY LIBRARY and + # no obvious reason why — which is why deb+rpm `systemctl --global enable` it. + name = "the plugin runner is started by default, like every other channel"; + ok = has appliance "punktfunk-scripting" "WantedBy=default.target"; + } + + # --- the client half must not drag the host's system wiring in ----------------------------- + { + name = "a client-only machine defines no host/web/scripting units"; + ok = + !(clientOnly.systemd.user.services ? punktfunk-host) + && !(clientOnly.systemd.user.services ? punktfunk-web) + && !(clientOnly.systemd.user.services ? punktfunk-scripting); + } + ]; + + failures = map (r: r.name) (lib.filter (r: !r.ok) results); +in +# The `assert` is what makes `--no-build` sufficient: instantiating this derivation forces it. +assert + failures == [ ] + || throw '' + The punktfunk NixOS module no longer renders what packaging/nix/module-check.nix requires. + Failing checks (${toString (lib.length failures)} of ${toString (lib.length results)}): + - ${lib.concatStringsSep "\n - " failures} + ''; +runCommand "punktfunk-nixos-module-check" + { + # Recorded in the output so a green run says what it actually covered. + passed = toString (lib.length results); + } + '' + echo "punktfunk NixOS module: $passed checks passed at eval time" > "$out" + '' diff --git a/packaging/nix/nixos-module.nix b/packaging/nix/nixos-module.nix index e66b72cf..c1f8fd5c 100644 --- a/packaging/nix/nixos-module.nix +++ b/packaging/nix/nixos-module.nix @@ -3,8 +3,8 @@ # the systemd *user* service, the uinput/uhid/vhci udev rules, the vhci-hcd autoload, the 32 MB # UDP socket-buffer sysctls, the firewall openers, the `input`- and `punktfunk`-group membership # for virtual gamepads, the management web console (`services.punktfunk.web`, on by default with -# the host — the RPM/deb Recommends), and the opt-in plugin/script runner -# (`services.punktfunk.scripting`). +# the host — the RPM/deb Recommends), and the plugin/script runner +# (`services.punktfunk.scripting`, likewise on by default — the game-library scanners are plugins). # # Usage (flake): # { inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk"; @@ -107,6 +107,34 @@ in ''; }; + desktopSession = mkOption { + type = types.bool; + default = false; + description = '' + Bind the host to the DESKTOP LOGIN session's lifetime + (`PartOf=`/`WantedBy=graphical-session.target`), so a Plasma/GNOME restart restarts the + host with it. + + Turn this on for a machine somebody logs into. Without it, when the compositor restarts + (a crash, a logout/login, "restart the shell") the host keeps running while holding a + Wayland socket and a portal D-Bus connection that both died with the old compositor. It + cannot recover either in-process, and the failure is silent: the host still listens, + still answers, and every session it then serves fails at capture. A host that idles for + days between sessions is exactly the shape that gets discovered at the worst moment. + + Leave it OFF for an appliance — a pinned `PUNKTFUNK_COMPOSITOR`, a headless KWin or a + gamescope box. Those start their own compositor and may never reach + `graphical-session.target` at all, and this would leave the host permanently stopped. + + No effect under sway/Hyprland or any session not managed by systemd (they never reach + that target either): there, start the host from the compositor's own config, after + `systemctl --user import-environment`, so it dies and comes back with the session. + + This is the declarative equivalent of the `scripts/punktfunk-host-desktop-session.conf` + drop-in the deb/rpm document for the same route. + ''; + }; + users = mkOption { type = types.listOf types.str; default = [ ]; @@ -257,9 +285,9 @@ in }; # The plugin/script runner — host automation on bun. Ships with the host (the RPM/deb Recommends - # it), but running it is OPT-IN: the `systemd --user` unit is defined yet NOT added to - # `default.target`, because the runner is inert until you add scripts/plugins. Turn it on with - # `systemctl --user enable --now punktfunk-scripting`. + # it) and, like them, runs by default: the game-library scanners are plugins, so a host with the + # runner off has an empty library. Opt out with `scripting.autoStart = false` or, per user, + # `systemctl --user mask punktfunk-scripting`. scripting = { enable = mkOption { type = types.bool; @@ -267,9 +295,10 @@ in defaultText = literalExpression "config.services.punktfunk.host.enable"; description = '' Install the plugin/script runner and define its `systemd --user` unit - (`punktfunk-scripting`). Enabled by default whenever the host is — but the unit is not - auto-started (see `autoStart`), since the runner does nothing until you add scripts to - `~/.config/punktfunk/scripts` or install `punktfunk-plugin-*` packages under + (`punktfunk-scripting`). Enabled by default whenever the host is, and started by default + too (see `autoStart`) — the game-library scanners are plugins, so a host without the + runner has an empty library. It also runs whatever you put in + `~/.config/punktfunk/scripts` or install as `punktfunk-plugin-*` under `~/.config/punktfunk/plugins`. A plugin auto-wires to the host's mgmt token + identity cert. ''; }; @@ -283,11 +312,21 @@ in autoStart = mkOption { type = types.bool; - default = false; + default = cfg.scripting.enable; + defaultText = literalExpression "config.services.punktfunk.scripting.enable"; description = '' Start the runner automatically in every user's graphical session (adds it to the user - `default.target`). Off by default even when the host auto-starts — running arbitrary - operator scripts/plugins is a deliberate opt-in; enable it once you have automation to run. + `default.target`). + + ON by default, matching every other channel: the deb postinst and the RPM `%post` both + run `systemctl --global enable punktfunk-scripting.service`, and the sysext image bakes + in the `default.target.wants` symlink. It used to be opt-in here, on the reasoning that + the runner does nothing until you add scripts or plugins — that stopped being true when + the game-library scanners became plugins. A host whose runner is off now comes up with an + empty library and no obvious reason why (design/library-scanner-plugins.md D9). + + It remains opt-OUT: set this to `false`, or per user + `systemctl --user mask punktfunk-scripting`. ''; }; }; @@ -305,6 +344,23 @@ in # The GPU driver libs the binaries dlopen at runtime (libcuda / libnvidia-encode / libEGL / # the Vulkan ICD) live under /run/opengl-driver/lib — provided by hardware.graphics. hardware.graphics.enable = mkDefault true; + + # A WARNING, not `xdg.portal.enable = mkDefault true`: enabling the portal service without an + # `extraPortals` backend is its own broken state, and only the operator knows which backend + # their compositor needs. The desktop-manager modules (plasma6, gnome) already wire theirs, so + # this fires exactly where it should — a headless/appliance or sway/Hyprland box assembled by + # hand. It matters because the host reaches the desktop through portals on several backends: + # Mutter's virtual output is ashpd ScreenCast/RemoteDesktop, and the libei input path's own + # error message is "is xdg-desktop-portal-kde/gnome running and XDG_CURRENT_DESKTOP set?". + warnings = optional (cfg.host.enable && !config.xdg.portal.enable) '' + services.punktfunk.host is enabled but xdg.portal.enable is false. The host drives the + compositor through xdg-desktop-portal on several backends (Mutter's ScreenCast/RemoteDesktop + and the libei input path), so capture or input will fail there with a portal error. Set + xdg.portal.enable = true and add the backend for your compositor, e.g. + xdg.portal.extraPortals = [ pkgs.xdg-desktop-portal-kde ] # or -gnome / -hyprland / -wlr + (the KDE and GNOME desktop-manager modules already do this for you). The KWin backend's own + virtual output uses the privileged zkde_screencast protocol and needs no portal. + ''; # 32 MB UDP socket buffers — without this the kernel clamps the host's SO_SNDBUF / client's # SO_RCVBUF and high-bitrate frames overflow (measured: 4 MB cap = 31.6 % loss at 2 Gbps). boot.kernel.sysctl = { @@ -409,9 +465,17 @@ in description = "punktfunk GameStream + punktfunk/1 streaming host"; documentation = [ "https://git.unom.io/unom/punktfunk" ]; # Soft ordering: the host listens immediately and only touches the compositor per session. - after = [ "pipewire.service" ]; + after = [ "pipewire.service" ] ++ optional cfg.host.desktopSession "graphical-session.target"; wants = [ "pipewire.service" ]; - wantedBy = optional cfg.host.autoStart "default.target"; + # `graphical-session.target` is IN ADDITION to `default.target`, never instead of it: the + # host still comes up at login before the graphical session is ready — it listens without + # touching the compositor and only opens one per client connect, so an early start costs + # nothing. `partOf` is the half that matters, taking the host down with the session so the + # next one gets a fresh compositor connection (see `desktopSession`). + partOf = optional cfg.host.desktopSession "graphical-session.target"; + wantedBy = + optional cfg.host.autoStart "default.target" + ++ optional cfg.host.desktopSession "graphical-session.target"; # The host may exec external helpers (pw-dump, sh, and — for the gamescope/kwin backends — # the compositor). Extend this in your config for a headless gamescope/KWin appliance. path = [ @@ -477,7 +541,10 @@ in # policy keeps a plugin from acting as the logged-in operator. Leaving it closed does not # degrade gracefully — every plugin interface is simply an empty panel from any other device. # Keep in step with packaging/linux/punktfunk-web.xml and punktfunk.ufw. - allowedTCPPorts = [ 47992 47993 ]; + allowedTCPPorts = [ + 47992 + 47993 + ]; }; # First-run setup: generate the console login password once, in the user's config dir, and From 1befa8a2c4a5d75acf667939e788d70fec3acbd8 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 20:24:21 +0200 Subject: [PATCH 4/4] docs(nix): bring the Nix docs in line with the module, and fix a stale claim they shared MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There are three places Nix is documented — the public docs-site, packaging/nix/ README.md, and packaging/README.md — plus the changelog. All had drifted. STALE CLAIM, and not only for Nix. install.md said the plugin runner's "user unit ships **disabled** — enable it once you have" something to run. That is true only of Arch and source installs: the deb postinst and RPM %post both `systemctl --global enable punktfunk-scripting.service`, and the Bazzite sysext bakes in a default.target.wants symlink (build-sysext.sh:113). bazzite.md carried the same claim about its own image. Both corrected, per channel, with the reason the default flipped — the library scanners are plugins, so a host without the runner can come up with an empty library — and the `mask`-not-`disable` opt-out the sysext's own comment documents. docs-site: * install.md NixOS — `desktopSession` in the example and explained, the runner no longer needs enabling, and the host/console line says what autoStart does. * running-as-a-service.md — "Restart the host with your desktop" documented the drop-in for packaged installs only; NixOS gets its one-liner beside it. * bazzite.md — the runner is started for you, not "isn't started". packaging/nix/README.md: * option tables gain `desktopSession`, `gamescopeHdr`, `gamescopePackage`, and the `punktfunk` group next to `input` (both are required — the udev rule chgrp's the vhci nodes and fails outright if the group was never created). * "what the module configures" gains the security.wrappers entry, and a note on why the capability sits on the encode worker and never on the host: a wrapper raises it into the ambient set, which lands it in the permitted set and fails KWin's /proc//exe readlink identically to a file capability. * the appliance snippet no longer tells you to put pkgs.gamescope on PATH — gamescopeHdr does that with the patched build, and desktopSession is called out as the thing to leave off there. * a caveat recording that `nix flake check` does not check the module, and the two rules for editing module-check.nix (assertions stay pure Nix; assert list-valued unit fields on the lists, not the rendered text). packaging/README.md: the flake ships five packages, not "host + client". CHANGELOG.md v0.27.0: a NixOS section covering the comm/session-detection fix, the module changes including the scripting default flip as an explicit behaviour change, and the flake-check gap — plus the documentation bullets above. --- CHANGELOG.md | 68 +++++++++++++++++++ docs-site/content/docs/bazzite.md | 6 +- docs-site/content/docs/install.md | 31 +++++++-- .../content/docs/running-as-a-service.md | 6 ++ packaging/README.md | 4 +- packaging/nix/README.md | 68 ++++++++++++++++--- 6 files changed, 164 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ee3801..cca76c58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,64 @@ code. `PYROWAVE_QUEUE_PRIORITY` keeps its 0.26.0 grammar and is now forwarded ** handshake rather than read from the worker's environment, which is sanitized at spawn; one env var still means one thing on both platforms. +### NixOS — session detection, module defaults, and a CI gate that was never running + +🛑 **The host could not detect any graphical session on NixOS, at all.** The live-session probe +matched `/proc//comm` exactly against `kwin_wayland` / `gamescope` / `gnome-shell` / +`Hyprland`. `comm` is the kernel's name for the **executed file**, truncated to 15 bytes — not +`argv[0]` — and nixpkgs wraps essentially every graphical binary: `wrapProgram` moves the real ELF +aside to `.-wrapped` and installs a wrapper that `exec -a "$0"`s it. So the kernel reports +`.kwin_wayland-w` while `ps` and `pgrep -a` show a perfectly ordinary `kwin_wayland`, because they +read argv. Every probe answered `ActiveKind::None` on a running desktop, and nothing downstream +could recover: `wayland` logged as `-`, a correct `WAYLAND_DISPLAY` changed nothing, `Auto` returned +the *detected* backend so a live KWin already in `available()` was never chosen, and a +`PUNKTFUNK_COMPOSITOR` pin turned the miss into a hard error through `pinned_at_a_dead_session`. +sway and river survived by accident — nixpkgs' wrapper execs a binary still called `sway`. + +Names are now resolved through `/proc//exe`, whose file name is untruncated, with the nixpkgs +decoration stripped. Stripping requires **both** the leading `.` and a trailing `-wrapped`, so +KWin's own real `kwin_wayland_wrapper` binary keeps its name instead of collapsing into +`kwin_wayland` and handing the probe the parent's PID. The `comm` fast path is unchanged for every +ordinary distro — one read, no readlink — and no name that matched before can stop matching. Also +applied to the foreign-gamescope probe, which had the same defect. + +**Module changes** (`services.punktfunk`): + +- **`host.desktopSession`** *(new, default `false`)* — binds the host to `graphical-session.target`, + the declarative form of the `punktfunk-host-desktop-session.conf` drop-in. Without it a + Plasma/GNOME restart leaves the host holding a Wayland socket and portal D-Bus connection that + died with the old compositor: it still listens, still answers, and every session after that fails + at capture. Off by default because an appliance may never reach that target and would be left + permanently stopped. +- ⚠ **`scripting.autoStart` now defaults ON** *(behaviour change)*, matching the deb `postinst` and + RPM `%post`, which both `systemctl --global enable` the runner, and the sysext's baked-in + `default.target.wants` symlink. It was opt-in here on the reasoning that the runner is inert until + you add automation — untrue since the game-library scanners became plugins, so a NixOS host came + up with an empty library and no obvious cause. Opt out with `scripting.autoStart = false` or + `systemctl --user mask punktfunk-scripting`. +- **Three divergences from the shipped units, ported.** `punktfunk-web` gains + `StartLimitIntervalSec=0` (without it, 5 starts / 10 s against `RestartSec=2` gives up permanently + after ~10 s — exactly the window before the host's first `serve` writes the mgmt token, so a + console enabled before the host's first run stayed dead) and `Restart=always` rather than + `on-failure`. `punktfunk-scripting` gains the sandbox the deb/rpm unit has all along + (`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=%h /tmp`, restricted address families, + `PrivateTmp=no`) — it is the one unit that runs arbitrary operator TypeScript by design, and it + had been running strictly less confined on NixOS than anywhere else. +- A **warning** when the host is enabled and `xdg.portal.enable` is not. + +🛑 **`nix flake check` does not check `nixosModules`** — worth knowing for anyone maintaining a +flake. It forces the value and asserts it is a lambda taking an open attribute set, and stops; +nix's source still carries `// FIXME: if we have a 'nixpkgs' input, use it to check the module.` +Measured: a module with a nonexistent option, a nonexistent `pkgs` attribute **and** a nonexistent +`lib` function passes, printing `checking NixOS module ... all checks passed!`. `nix.yml`'s header +claimed that leg covered the module; it never had. `checks..nixos-module` +(`packaging/nix/module-check.nix`) now evaluates it against real nixpkgs across four scenarios and +asserts on the rendered units, including a guard that the host's `ExecStart` stays on the plain +store path while the encode worker points at the wrapper. Its assertions are pure Nix, so +instantiation runs them and the existing `--no-build` leg is enough. `punktfunk-gamescope` gains a +`build-gamescope` dispatch input — it is on the critical path of every host build yet nothing +compiled it, and it tracks nixpkgs' gamescope, so a `flake.lock` bump is what breaks it. + ### Host and client environment variables - **`PUNKTFUNK_ENCODE_WORKER`** *(new, host, Linux)* — where to find the encode worker. Resolution @@ -87,6 +145,16 @@ still means one thing on both platforms. - The 0.26.0 user-facing notes describe a privilege that is deliberately not granted. That is the record of what 0.26.0 shipped and is **not** rewritten; the new phrasing — granted to the worker, never to the host — lives in `docs/releases/v0.27.0.md`. +- `install.md` **NixOS** documents `desktopSession`, and its `punktfunk-scripting` bullet no longer + claims the runner "ships disabled": that was true only of Arch and source installs — apt, dnf, the + Bazzite sysext and now the NixOS module all start it, because the library scanners are plugins. + `bazzite.md` carried the same stale claim and is corrected. **Running as a service → Restart the + host with your desktop** gains the NixOS one-liner beside the drop-in. +- `packaging/nix/README.md`: `desktopSession`, `gamescopeHdr`/`gamescopePackage` and the + `punktfunk` group added to the option tables; the "what the module configures" list gains the + `security.wrappers` entry, with the KWin-identification reasoning for why the capability is on the + worker and not the host; and a caveat recording that `nix flake check` does not check the module, + plus the two rules for editing `module-check.nix`. --- diff --git a/docs-site/content/docs/bazzite.md b/docs-site/content/docs/bazzite.md index 1d6497fe..e31ea247 100644 --- a/docs-site/content/docs/bazzite.md +++ b/docs-site/content/docs/bazzite.md @@ -44,8 +44,10 @@ manifest is OpenPGP-signed by packages@unom.io (key `AF245C506F4E4763`, the same RPMs), and `punktfunk-sysext` checks that signature against a key baked into the script before it trusts a single checksum — so it needs `gpg` on the box, and it refuses a feed it can't verify. -The plugin runner rides along in the image but isn't started: run -`systemctl --user enable --now punktfunk-scripting` when you want [plugins](/docs/plugins). +The plugin runner rides along in the image and is **started for you** — the image bakes in its +`default.target.wants` symlink, because the game-library scanners ship as +[plugins](/docs/plugins). To turn it off: `systemctl --user mask punktfunk-scripting` (`mask`, not +`disable` — a plain disable cannot remove a symlink that lives in `/usr`). From then on: diff --git a/docs-site/content/docs/install.md b/docs-site/content/docs/install.md index d63548fe..8dcb467e 100644 --- a/docs-site/content/docs/install.md +++ b/docs-site/content/docs/install.md @@ -101,6 +101,7 @@ services.punktfunk.host = { enable = true; users = [ "alice" ]; # added to the `input` group, for virtual gamepads openFirewall = true; + desktopSession = true; # on a machine you log into — see below settings = { RUST_LOG = "info"; }; # these become host.env }; ``` @@ -108,13 +109,27 @@ services.punktfunk.host = { The module does declaratively what the deb/RPM scriptlets do — the systemd user service, udev rules, kernel modules, sysctl tuning, the firewall ports and `input` group membership — and brings in the web console alongside the host. Because `settings` writes the environment file for you, skip the -`host.env` step in [After installing](#after-installing). The user services are defined but not -started, so from your graphical session enable the host and the console: +`host.env` step in [After installing](#after-installing). + +**Set `desktopSession = true` on any machine somebody logs into.** It ties the host to +`graphical-session.target`, so restarting Plasma or GNOME restarts the host with it. Without it the +host keeps running against a compositor that no longer exists — still listening, still answering, +and failing at capture on every session after that. Leave it off for the headless appliance route +(a pinned compositor or a gamescope box), which may never reach that target. Same reasoning, and +the same caveats for Sway and Hyprland, as [Restart the host with your +desktop](/docs/running-as-a-service#restart-the-host-with-your-desktop). + +The host and console user services are defined but not started (set `autoStart = true` for an +appliance), so from your graphical session enable them: ```sh systemctl --user enable --now punktfunk-host punktfunk-web ``` +The plugin runner needs no such step — like the deb and RPM, the module starts it for you, because +the game-library scanners ship as plugins. Opt out with +`services.punktfunk.scripting.autoStart = false;`. + The full option reference (client, console and scripting options, GPU driver notes, headless appliance setup) is in [packaging/nix](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/nix/README.md). To @@ -138,14 +153,20 @@ update, run `nix flake update punktfunk` in your flake directory, then `sudo nix For Gaming Mode, add the [Decky plugin](/docs/steam-deck) on top of it. Full client instructions for every device: [Install a Client](/docs/install-client). -- **`punktfunk-scripting`** — the plugin/script runner. Install it if you want - [plugins](/docs/plugins) or [automation](/docs/automation). It's inert until you add something to - run, so its user unit ships **disabled** — enable it once you have: +- **`punktfunk-scripting`** — the plugin/script runner, behind [plugins](/docs/plugins) and + [automation](/docs/automation). The game-library scanners ship as plugins, so a host without the + runner can come up with an empty library — which is why **apt, dnf, the Bazzite sysext and the + NixOS module all start it for you**. On **Arch** and source installs it is not started, so enable + it yourself: ```sh systemctl --user enable --now punktfunk-scripting ``` + To opt out where it *is* on: `systemctl --user mask punktfunk-scripting` (`mask`, not `disable` — + a plain disable cannot remove a symlink that lives in `/etc` or `/usr`), or on NixOS + `services.punktfunk.scripting.autoStart = false;`. + ## After installing These three steps are for the **Linux packages**. On Windows the installer does the equivalent for diff --git a/docs-site/content/docs/running-as-a-service.md b/docs-site/content/docs/running-as-a-service.md index 7773c21f..cd52daa5 100644 --- a/docs-site/content/docs/running-as-a-service.md +++ b/docs-site/content/docs/running-as-a-service.md @@ -108,6 +108,12 @@ the host running against a compositor that no longer exists. It keeps listening every session after that fails at capture, which is a confusing way to find out. The drop-in makes a compositor restart a host restart. +On **NixOS** don't copy anything — the module has the option: + +```nix +services.punktfunk.host.desktopSession = true; +``` + Skip it on the headless/appliance route below (which has its own session unit), and on **Sway or Hyprland**, which don't hand their session to systemd: they never reach `graphical-session.target`, so the drop-in is harmless there but does nothing. To make the host come and go with the session on diff --git a/packaging/README.md b/packaging/README.md index 8055b958..ddb3c7de 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -26,8 +26,8 @@ The other packaging targets have their own READMEs: [`debian/`](debian/README.md [`arch/`](arch/README.md) (pacman binary repo + PKGBUILD + SteamOS sysext), [`flatpak/`](flatpak/README.md) (the client), [`windows/`](windows/README.md) (host installer + drivers), plus `kde/` and `linux/` helpers. **NixOS / Nix** users get a flake (`flake.nix` at the -repo root) with reproducible host + client packages and a `services.punktfunk` NixOS module — -see [`nix/README.md`](nix/README.md). +repo root) with reproducible `punktfunk-host`, `-client`, `-web`, `-scripting` and `-gamescope` +packages plus a `services.punktfunk` NixOS module — see [`nix/README.md`](nix/README.md). ## What's needed beyond base Fedora diff --git a/packaging/nix/README.md b/packaging/nix/README.md index 786f90af..a0d07f81 100644 --- a/packaging/nix/README.md +++ b/packaging/nix/README.md @@ -66,6 +66,7 @@ Add the flake and enable the host and/or client: enable = true; users = [ "alice" ]; # → added to the `input` group for virtual gamepads openFirewall = true; # native + GameStream ports + desktopSession = true; # a machine you log into — restart the host with the desktop settings = { PUNKTFUNK_VIDEO_SOURCE = "virtual"; RUST_LOG = "info"; @@ -85,10 +86,11 @@ Add the flake and enable the host and/or client: } ``` -Then, in your graphical session: +Then, in your graphical session (the console follows with `punktfunk-web`; the plugin runner is +already started for you — see `scripting.autoStart` below): ```sh -systemctl --user enable --now punktfunk-host +systemctl --user enable --now punktfunk-host punktfunk-web ``` ### Options @@ -101,10 +103,12 @@ systemctl --user enable --now punktfunk-host | `gamestream` | `true` | `serve --gamestream` (Moonlight-compatible). `false` = native-only, more secure. | | `autoStart` | `false` | Add the user service to `default.target` (appliance mode — pair with lingering). | | `desktopSession` | `false` | Bind the host to `graphical-session.target` — **turn this on for a machine somebody logs into** (see below). | -| `users` | `[ ]` | Users added to the `input` group (virtual gamepads). | +| `users` | `[ ]` | Users added to the `input` **and `punktfunk`** groups (virtual gamepads; the second covers the usbip/vhci nodes the virtual Steam Deck pad attaches through — it can emulate arbitrary USB hardware, so list only users you'd trust with that). | | `settings` | `{ }` | `host.env` key/values (see `${package}/share/punktfunk-host/host.env.example`). | | `environmentFile` | `null` | Extra `EnvironmentFile` for secrets (e.g. `PUNKTFUNK_MGMT_TOKEN`); loaded optionally. | | `openFirewall` | `false` | Open the inbound ports (see below). | +| `gamescopeHdr` | `true` | Put `punktfunk-gamescope` (gamescope + our `pipewire-hdr` patches) on the service PATH, so a 10-bit client can stream true HDR10 off a gamescope output. Costs a gamescope build from source — set `false` to skip it and stay SDR on that backend. | +| `gamescopePackage` | flake's | The patched gamescope used when `gamescopeHdr = true`. | | `package` | flake's | Override the package. | **`desktopSession` — set it on a desktop, leave it off on an appliance.** On a machine somebody logs @@ -178,19 +182,36 @@ on a *user* unit needs unprivileged user namespaces; drop it with Everything the RPM's `%install` + `%post` do, declaratively: - **systemd `--user` service** `punktfunk-host` → `serve [--gamestream]`, `EnvironmentFile` from - `settings` (+ optional secret file), `Restart=on-failure`. + `settings` (+ optional secret file), `Restart=on-failure`, and — with `desktopSession` — + `PartOf=graphical-session.target`. - **udev rules** (`60-punktfunk.rules`): `/dev/uinput` + `/dev/uhid` group access and the vhci sysfs perms for the virtual Steam Deck. - **kernel modules**: `uinput`, `uhid`, `vhci-hcd` (usbip transport so Steam Input adopts the virtual Deck). - **sysctl**: `net.core.{r,w}mem_max = 32 MB` (high-bitrate UDP headroom; `mkDefault`). -- **`input` group** membership for `users`. +- **`input` and `punktfunk` groups**, declared and joined for `users`. Both are required: the udev + rule `chgrp punktfunk`s the vhci nodes and fails outright if nothing ever created that group. +- **A `security.wrappers` entry for `punktfunk-encode-worker`** carrying `cap_sys_nice=ep`, with + `PUNKTFUNK_ENCODE_WORKER` pointed at it. A file capability cannot live on a read-only store path, + so a wrapper is the only mechanism NixOS has. The capability is deliberately **not** on the host + itself — see the caveat below. - **`hardware.graphics.enable = true`** (`mkDefault`) so `/run/opengl-driver/lib` has the driver libs the binaries `dlopen`. - **firewall** (when `openFirewall`): native UDP 9777/5353 + TCP 47990; with `gamestream` also TCP - 47984/47989/48010 + UDP 47998/47999/48000. The media data plane is an ephemeral, hole-punched - UDP port — nothing fixed to open. + 47984/47989/48010 + UDP 47998/47999/48000; with the console, TCP 47992 + 47993. The media data + plane is an ephemeral, hole-punched UDP port — nothing fixed to open. - **tray autostart** entry (`--autostart`; self-gates to users who actually run a host). +- **A warning** if `xdg.portal.enable` is off (see the portal note above). + +> **Why the capability is on the worker and not the host.** KWin only advertises its restricted +> protocols (`zkde_screencast_unstable_v1` for the virtual output, `org_kde_kwin_fake_input` for +> input) to a client it can *identify*, by resolving that client's `/proc//exe` and matching an +> installed `.desktop`'s `Exec=`. The kernel refuses that readlink to any reader whose effective set +> is not a superset of the target's permitted set, and KWin holds no capabilities. A NixOS wrapper +> does not dodge this — it raises the capability into the ambient set before exec'ing, which lands +> it in the permitted set and fails the readlink identically. Giving the host `cap_sys_nice` broke +> desktop streaming on every KDE box in 0.26.0-1. The encode worker is a separate binary that +> nothing ever has to identify, so the grant is safe there. ### GPU drivers (out of scope of the module — set these yourself) @@ -213,8 +234,15 @@ services.punktfunk.host = { settings = { PUNKTFUNK_COMPOSITOR = "gamescope"; }; # appliance-only; omit to auto-detect }; users.users.streamer.linger = true; -# For the gamescope/KWin backends extend the service PATH, e.g.: -# systemd.user.services.punktfunk-host.path = [ pkgs.gamescope ]; +``` + +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 +backend needs no PATH surgery; extend it only for a helper the module doesn't know about: + +```nix +# systemd.user.services.punktfunk-host.path = [ pkgs.some-helper ]; ``` The `${package}/share/punktfunk-host/headless/` helpers (KDE/Sway session scripts, example @@ -311,10 +339,30 @@ The shell exports an to consume a prebuilt Skia offline (a fixed-output derivation of the rust-skia tarball) or a vendored from-source Skia build — a tracked follow-up. +- **⚠ `nix flake check` does NOT check the NixOS module — that is why `module-check.nix` exists.** + For `nixosModules`, nix forces the value and asserts it is a lambda taking an open attribute set, + and stops there (its source still carries `// FIXME: if we have a 'nixpkgs' input, use it to check + the module.`). Measured: a module setting a nonexistent *option*, referencing a nonexistent + `pkgs` attribute **and** calling a nonexistent `lib` function passes clean, printing + `checking NixOS module 'nixosModules.default'... all checks passed!`. So the reassuring line means + nothing. `checks..nixos-module` (`packaging/nix/module-check.nix`) closes it: it evaluates + the module against real nixpkgs in four scenarios and asserts on the rendered systemd units. + Two rules if you edit it — **keep every assertion pure Nix** (instantiating the derivation is what + runs them, which is what lets the cheap `--no-build` CI leg cover it; a shell script in the + `runCommand` body would only run under a full `nix flake check`, i.e. an hour of Rust), and + **assert list-valued unit fields on the evaluated lists**, not the rendered text — systemd renders + `After=` as one space-separated line, so an `hasInfix` on it silently depends on ordering. + ## Verified -Both packages build, install, and run on real Nix hardware (NixOS-equivalent: CachyOS + Nix, +The packages build, install, and run on real Nix hardware (NixOS-equivalent: CachyOS + Nix, RTX 5070 Ti, driver 610). `punktfunk-host --version` and `punktfunk-session` run; the driver RUNPATH (`/run/opengl-driver/lib`) and the GTK GApps wrapper (GSettings schemas + pixbuf loaders) are present. Fixes discovered during that bring-up: `CMAKE_POLICY_VERSION_MINIMUM=3.5` (CMake ≥ 4), system `libopus` (audiopus_sys), and the session Skia note above. + +In CI (`.gitea/workflows/nix.yml`): `nix flake check --no-build` evaluates every output *including* +the module check above, and `punktfunk-web` + `punktfunk-scripting` are built for real. The Rust +packages and `punktfunk-gamescope` are `workflow_dispatch` opt-ins (`build-rust`, +`build-gamescope`) — run the latter after a `flake.lock` bump, since it patches whatever gamescope +the pinned nixpkgs carries.