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).
275 lines
12 KiB
Nix
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"
|
|
''
|