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/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/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index 6fe74c0f..adde4869 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -658,10 +658,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), 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/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/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 6d1a3456..a0d07f81 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 @@ -65,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"; @@ -84,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 @@ -99,12 +102,35 @@ 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). | -| `users` | `[ ]` | Users added to the `input` group (virtual gamepads). | +| `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` **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 +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,39 +151,67 @@ 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 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) @@ -180,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 @@ -278,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. 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 f7dfef8f..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 @@ -508,6 +575,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 +601,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 +634,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" + ]; }; }; })