Files
punktfunk/packaging/nix/module-check.nix
T
enricobuehler 23d0452157 feat(host): GameStream opt-in on every route; the native plane is deny(unsafe_code)-enforced
The user direction after WP0: ENet exists only for Moonlight, so the native
plane must be provably safe and the compat planes a deliberate choice.

Opt-in, everywhere. Windows already was (unchecked installer task). The three
opt-out surfaces are flipped: the shipped systemd user unit (deb/RPM/Arch/
sysext) no longer bakes --gamestream into ExecStart — a new
PUNKTFUNK_GAMESTREAM=1 host.env knob (pf-host-config, OR-ed with the CLI
flag) is the packaged opt-in; the NixOS module default goes true→false, with
a module-check assertion that unset = native-only; the Deck installer takes
--gamestream to opt in (--no-gamestream kept as explicit-off). Docs
(quickstart, running-as-a-service, moonlight, ubuntu/fedora/arch firewall
sections, gnome/sway, how-it-works) rewritten to the opt-in shape; the
CHANGELOG carries the upgrade note.

Enforced-safe. punktfunk-core is #![deny(unsafe_code)] crate-wide — every
module that parses network bytes is safe Rust as a compile error, not a
census result. Carve-outs are exactly two documented classes, neither of
which interprets attacker bytes: the client surface (abi, client) and the
transport syscall-batching shims (udp/{apple,linux,windows}, qos_windows).
In punktfunk-host, the modules a secure-default host exposes — native
(cfg-not-test: its tests exercise the client C ABI on purpose),
native_pairing, mgmt, mgmt_token, discovery, wol — are #[forbid(unsafe_code)].

Gates: Linux amd64 container clippy --all-targets -D warnings clean over
core+host-config+host; core 204 tests green under the deny; mgmt 46/46,
control 6/6. .133 Windows clippy (shipped features, clean-first,
sentinel-checked) clean — covers the qos_windows/udp-windows carve-outs.
macOS + iOS cargo check green (the apple.rs carve-out compiles for real).
2026-08-11 20:21:16 +02:00

275 lines
12 KiB
Nix

# 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" ];
# Explicit: gamestream defaults to false now (opt-in on every route) — this fixture
# is the one that proves opting IN still reaches the argv and the firewall.
gamestream = true;
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/<pid>/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;
}
{
# The DEFAULT is the secure native-only host — gamestream unset must behave like
# gamestream=false (opt-in on every package route; the appliance fixture leaves it unset).
name = "gamestream default (unset) is native-only";
ok =
!(has appliance "punktfunk-host" "--gamestream")
&& !(lib.elem 47984 appliance.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"
''