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).
676 lines
34 KiB
Nix
676 lines
34 KiB
Nix
# NixOS integration for punktfunk — the declarative equivalent of everything the RPM/deb do in
|
|
# their %install + %post (packaging/rpm/punktfunk.spec, packaging/debian/build-deb.sh):
|
|
# 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 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";
|
|
# outputs = { punktfunk, nixpkgs, ... }: {
|
|
# nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
|
|
# modules = [ punktfunk.nixosModules.default
|
|
# { services.punktfunk.host.enable = true;
|
|
# services.punktfunk.host.users = [ "alice" ]; } ];
|
|
# };
|
|
# };
|
|
# }
|
|
#
|
|
# The host is fundamentally a per-user, in-graphical-session service (it drives the live
|
|
# compositor, PipeWire and the desktop portals), so it ships as a `systemd.user` unit. Enable it
|
|
# for a session with `systemctl --user enable --now punktfunk-host` (or set `autoStart = true` for a
|
|
# headless appliance with `users.users.<u>.linger = true`).
|
|
self:
|
|
{
|
|
config,
|
|
lib,
|
|
pkgs,
|
|
...
|
|
}:
|
|
let
|
|
inherit (lib)
|
|
mkEnableOption
|
|
mkOption
|
|
mkIf
|
|
mkMerge
|
|
mkDefault
|
|
types
|
|
optional
|
|
optionals
|
|
optionalString
|
|
literalExpression
|
|
concatStringsSep
|
|
mapAttrsToList
|
|
genAttrs
|
|
;
|
|
|
|
cfg = config.services.punktfunk;
|
|
system = pkgs.stdenv.hostPlatform.system;
|
|
|
|
# host.env rendering: booleans → 1/0 (what PUNKTFUNK_* knobs expect), everything else verbatim.
|
|
renderVal = v: if lib.isBool v then (if v then "1" else "0") else toString v;
|
|
renderEnv =
|
|
attrs: concatStringsSep "\n" (mapAttrsToList (k: v: "${k}=${renderVal v}") attrs) + "\n";
|
|
|
|
hostSettingsFile = pkgs.writeText "punktfunk-host.env" (renderEnv cfg.host.settings);
|
|
|
|
# Native punktfunk/1 ports (control plane + discovery + mgmt API). The media data plane is an
|
|
# ephemeral per-session UDP port the host hole-punches, so nothing fixed to open (see
|
|
# packaging/linux/punktfunk.ufw).
|
|
nativeTCP = [ 47990 ]; # mgmt/library REST API (HTTPS + mTLS)
|
|
nativeUDP = [
|
|
9777
|
|
5353
|
|
]; # QUIC control plane + mDNS
|
|
# GameStream/Moonlight-compat fixed ports (opt-in with `host.gamestream`).
|
|
gamestreamTCP = [
|
|
47984
|
|
47989
|
|
48010
|
|
];
|
|
gamestreamUDP = [
|
|
47998
|
|
47999
|
|
48000
|
|
];
|
|
in
|
|
{
|
|
options.services.punktfunk = {
|
|
host = {
|
|
enable = mkEnableOption "the punktfunk streaming host (systemd --user service + system wiring)";
|
|
|
|
package = mkOption {
|
|
type = types.package;
|
|
default = self.packages.${system}.punktfunk-host;
|
|
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-host";
|
|
description = "The punktfunk-host package (bundles punktfunk-host + punktfunk-tray).";
|
|
};
|
|
|
|
gamestream = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Advertise the GameStream/Moonlight-compatible planes (`serve --gamestream`) so a stock
|
|
Moonlight client can pair, and open their firewall ports. OPT-IN (default `false`,
|
|
matching every other package route): the compat planes carry plain-HTTP pairing and the
|
|
legacy GCM-nonce path (security-review #5/#9), so the default is the secure native-only
|
|
host — Punktfunk clients only. Enable only on a trusted LAN.
|
|
'';
|
|
};
|
|
|
|
autoStart = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Start the host automatically in every user's graphical session (adds it to the user
|
|
`default.target`). For a login-less appliance, also enable lingering for the host user
|
|
(`users.users.<name>.linger = true`) so the user service comes up at boot.
|
|
'';
|
|
};
|
|
|
|
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 = [ ];
|
|
example = [ "alice" ];
|
|
description = ''
|
|
Users to add to the `input` and `punktfunk` groups — required for the virtual gamepads
|
|
the host creates: `input` covers `/dev/uinput` and `/dev/uhid`, `punktfunk` covers the
|
|
usbip/vhci nodes the virtual Steam Deck pad attaches through. The second is separate on
|
|
purpose — it can emulate arbitrary USB hardware, so only list users you would trust with
|
|
that. The host runs as these users' `systemd --user` service.
|
|
'';
|
|
};
|
|
|
|
settings = mkOption {
|
|
type = types.attrsOf (
|
|
types.oneOf [
|
|
types.str
|
|
types.int
|
|
types.bool
|
|
]
|
|
);
|
|
default = { };
|
|
example = literalExpression ''
|
|
{
|
|
PUNKTFUNK_VIDEO_SOURCE = "virtual";
|
|
PUNKTFUNK_COMPOSITOR = "kwin";
|
|
PUNKTFUNK_444 = true;
|
|
RUST_LOG = "info";
|
|
}
|
|
'';
|
|
description = ''
|
|
`host.env` key/value pairs passed to the service via `EnvironmentFile`. See
|
|
`''${package}/share/punktfunk-host/host.env.example` for the full surface. Booleans render
|
|
as `1`/`0`. Leave empty to rely on the host's per-connect auto-detection of the
|
|
compositor + input backend. Do NOT put secrets here (world-readable in the store) — use
|
|
`environmentFile` instead.
|
|
'';
|
|
};
|
|
|
|
environmentFile = mkOption {
|
|
type = types.nullOr types.path;
|
|
default = null;
|
|
example = "/run/secrets/punktfunk-host.env";
|
|
description = ''
|
|
Extra `EnvironmentFile` layered AFTER `settings` (its values win). For secrets such as
|
|
`PUNKTFUNK_MGMT_TOKEN`. Loaded optionally (a missing file does not fail the unit).
|
|
'';
|
|
};
|
|
|
|
openFirewall = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Open the host's inbound ports. Native punktfunk/1 always: UDP 9777 (QUIC) + 5353 (mDNS),
|
|
TCP 47990 (mgmt API). With `gamestream = true` also TCP 47984/47989/48010 and UDP
|
|
47998/47999/48000. The ephemeral media UDP port is hole-punched, so a default-deny
|
|
firewall still streams (it just adds ~2.5 s at session start).
|
|
'';
|
|
};
|
|
|
|
gamescopeHdr = mkOption {
|
|
type = types.bool;
|
|
default = true;
|
|
description = ''
|
|
Put `punktfunk-gamescope` — gamescope carrying punktfunk's `pipewire-hdr` patches — on
|
|
the host service's PATH, so a 10-bit-capable client can stream true HDR10 (BT.2020 PQ)
|
|
off a gamescope virtual output. HDR is attempted by default once the binary is present
|
|
(`PUNKTFUNK_GAMESCOPE_HDR=0` in `settings`/`environmentFile` forces SDR).
|
|
|
|
It does NOT replace the system's `gamescope`: the binary has its own name and the host
|
|
prefers it only for the sessions it spawns itself. Costs a gamescope build from source
|
|
— set `false` to skip that build; the host then stays SDR on the gamescope backend.
|
|
'';
|
|
};
|
|
|
|
gamescopePackage = mkOption {
|
|
type = types.package;
|
|
default = self.packages.${system}.punktfunk-gamescope;
|
|
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-gamescope";
|
|
description = ''
|
|
The patched gamescope used when `gamescopeHdr = true`. Override to build it from a
|
|
different nixpkgs (the patches apply to whatever gamescope that nixpkgs pins).
|
|
'';
|
|
};
|
|
};
|
|
|
|
client = {
|
|
enable = mkEnableOption "the native punktfunk Linux client";
|
|
|
|
package = mkOption {
|
|
type = types.package;
|
|
default = self.packages.${system}.punktfunk-client;
|
|
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-client";
|
|
description = "The punktfunk-client package (bundles punktfunk-client + punktfunk-session).";
|
|
};
|
|
|
|
openFirewall = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = "Open UDP 5353 (mDNS) so the client can auto-discover hosts on the LAN.";
|
|
};
|
|
};
|
|
|
|
# The management web console (SPAKE2 PIN pairing + host status) — the browser UI every client
|
|
# needs. Ships by DEFAULT alongside the host (mirrors the RPM's `Recommends: punktfunk-web` and
|
|
# the .deb the host package pulls in), auto-wired to the host's mgmt token + identity cert.
|
|
web = {
|
|
enable = mkOption {
|
|
type = types.bool;
|
|
default = cfg.host.enable;
|
|
defaultText = literalExpression "config.services.punktfunk.host.enable";
|
|
description = ''
|
|
Run the management web console as a `systemd --user` service on TCP 47992 (HTTPS). Enabled
|
|
by default whenever the host is enabled — set to `false` for a console-less host. It
|
|
auto-wires to `~/.config/punktfunk/{mgmt-token,cert.pem,key.pem}` (written by the host's
|
|
`serve`) and generates a login password on first start.
|
|
'';
|
|
};
|
|
|
|
package = mkOption {
|
|
type = types.package;
|
|
default = self.packages.${system}.punktfunk-web;
|
|
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-web";
|
|
description = "The punktfunk-web package (the bun-built Nitro SSR console bundle).";
|
|
};
|
|
|
|
openFirewall = mkOption {
|
|
type = types.bool;
|
|
default = cfg.host.openFirewall;
|
|
defaultText = literalExpression "config.services.punktfunk.host.openFirewall";
|
|
description = ''
|
|
Open TCP 47992 so the console is reachable from other devices on the LAN, and TCP 47993,
|
|
the separate origin its plugin UIs are served from (without it, plugin interfaces do not
|
|
load in the console).
|
|
'';
|
|
};
|
|
|
|
autoStart = mkOption {
|
|
type = types.bool;
|
|
default = cfg.host.autoStart;
|
|
defaultText = literalExpression "config.services.punktfunk.host.autoStart";
|
|
description = ''
|
|
Start the console automatically in every user's graphical session (adds it to the user
|
|
`default.target`). Follows the host's `autoStart` by default — for a login-less appliance,
|
|
enable lingering for the user as well.
|
|
'';
|
|
};
|
|
};
|
|
|
|
# The plugin/script runner — host automation on bun. Ships with the host (the RPM/deb Recommends
|
|
# 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;
|
|
default = cfg.host.enable;
|
|
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, 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.
|
|
'';
|
|
};
|
|
|
|
package = mkOption {
|
|
type = types.package;
|
|
default = self.packages.${system}.punktfunk-scripting;
|
|
defaultText = literalExpression "punktfunk.packages.\${system}.punktfunk-scripting";
|
|
description = "The punktfunk-scripting package (the bun-bundled Effect SDK runner).";
|
|
};
|
|
|
|
autoStart = mkOption {
|
|
type = types.bool;
|
|
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`).
|
|
|
|
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`.
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
|
|
config = mkMerge [
|
|
# --- shared: whenever either half is enabled -----------------------------------------------
|
|
(mkIf (cfg.host.enable || cfg.client.enable) {
|
|
assertions = [
|
|
{
|
|
assertion = system == "x86_64-linux";
|
|
message = "services.punktfunk is x86_64-linux only (desktop NVENC host; no aarch64 build).";
|
|
}
|
|
];
|
|
# 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 = {
|
|
"net.core.wmem_max" = mkDefault 33554432;
|
|
"net.core.rmem_max" = mkDefault 33554432;
|
|
};
|
|
})
|
|
|
|
# --- host ----------------------------------------------------------------------------------
|
|
(mkIf cfg.host.enable {
|
|
environment.systemPackages = [ cfg.host.package ];
|
|
# 60-punktfunk.rules: /dev/uinput + /dev/uhid group access + the vhci sysfs perms.
|
|
services.udev.packages = [ cfg.host.package ];
|
|
# The vhci attach/detach rule shells out to chgrp/chmod (coreutils) — put them on udev's PATH.
|
|
services.udev.path = [ pkgs.coreutils ];
|
|
# uinput/uhid: the virtual X360 + DualSense nodes. vhci-hcd: the usbip transport that makes
|
|
# the virtual Steam Deck a real USB device (Steam Input only adopts USB pads).
|
|
boot.kernelModules = [
|
|
"uinput"
|
|
"uhid"
|
|
"vhci-hcd"
|
|
];
|
|
|
|
# `input` group membership for the virtual-gamepad nodes (mirrors the RPM's usermod hint).
|
|
#
|
|
# `punktfunk` is the SECOND group 60-punktfunk.rules needs: it owns the usbip vhci
|
|
# attach/detach nodes, and is deliberately not `input` because writing `attach` materialises
|
|
# an arbitrary emulated USB device — a root-only kernel primitive that must not ride on the
|
|
# group every gamepad guide tells you to join (security-review 2026-08-05 M-4). Declaring the
|
|
# group is not optional: the rule shells out to `chgrp punktfunk`, which fails outright if
|
|
# nothing ever created it, leaving the nodes root-only and the virtual Steam Deck pad unable
|
|
# to attach. Membership follows `host.users`, which is already the explicit "these users run
|
|
# the host" list this option's description scopes to the usbip/vhci pad.
|
|
users.groups.input = { };
|
|
users.groups.punktfunk = { };
|
|
users.users = genAttrs cfg.host.users (_: {
|
|
extraGroups = [
|
|
"input"
|
|
"punktfunk"
|
|
];
|
|
});
|
|
|
|
# Status-tray autostart entry (self-gating: `--autostart` exits unless this user runs a host).
|
|
environment.etc."xdg/autostart/io.unom.Punktfunk.Tray.desktop".source =
|
|
"${cfg.host.package}/etc/xdg/autostart/io.unom.Punktfunk.Tray.desktop";
|
|
|
|
networking.firewall = mkIf cfg.host.openFirewall {
|
|
allowedTCPPorts = nativeTCP ++ optionals cfg.host.gamestream gamestreamTCP;
|
|
allowedUDPPorts = nativeUDP ++ optionals cfg.host.gamestream gamestreamUDP;
|
|
};
|
|
|
|
# NO CAP_SYS_NICE wrapper for the HOST — deliberately, and note there is deliberately one for
|
|
# the WORKER just below; the difference is the whole point. 0.26.0-1 gave the host a
|
|
# `security.wrappers.punktfunk-host` carrying `cap_sys_nice=ep` for the GPU-priority lever,
|
|
# and that broke desktop streaming on every KDE box.
|
|
#
|
|
# KWin advertises its restricted Wayland protocols (zkde_screencast_unstable_v1 for the
|
|
# virtual output, org_kde_kwin_fake_input for input) only to a client it can IDENTIFY, by
|
|
# resolving that client's /proc/<pid>/exe and matching it against an installed .desktop's
|
|
# Exec= (packages.nix substitutes ours to the store path). The kernel refuses that readlink to
|
|
# any reader whose effective set is not a superset of the target's PERMITTED set
|
|
# (cap_ptrace_access_check), and KWin holds no capabilities.
|
|
#
|
|
# A NixOS wrapper does not dodge this. It raises the capability into its AMBIENT set before
|
|
# exec'ing the store binary, precisely so the capability survives — which lands CAP_SYS_NICE
|
|
# in the exec'd process's permitted set and fails the readlink identically. Measured: an
|
|
# ambient-only grant (dumpable=1, CapPrm set) is refused exactly like a file capability. See
|
|
# packaging/arch/punktfunk-host.install for the full matrix.
|
|
#
|
|
# The capability lives on the ENCODE WORKER instead — a different binary, and one nothing
|
|
# ever has to identify.
|
|
#
|
|
# punktfunk-encode-worker is spawned per PyroWave session, speaks one socketpair to its
|
|
# parent, and never connects to Wayland, D-Bus or the network. Nothing resolves ITS
|
|
# /proc/<pid>/exe, so the ambient grant a NixOS wrapper performs — the very thing that makes
|
|
# a wrapper useless for the host — is exactly right here. (It must also stay a SEPARATE file:
|
|
# a hardlink or a host subcommand would share the inode, hence the capability, and re-create
|
|
# the breakage above on every file-capability channel.)
|
|
#
|
|
# A file capability cannot live on a store path (read-only, and shared by every generation),
|
|
# so `security.wrappers` is the only mechanism NixOS has — which is why the unit below points
|
|
# PUNKTFUNK_ENCODE_WORKER at `config.security.wrapperDir` rather than the store path. The
|
|
# host's own ExecStart stays on the store path and must never move.
|
|
#
|
|
# Best-effort by construction: if the wrapper is absent or the operator overrides the env,
|
|
# the host falls back to its in-process encoder at default GPU priority — one warn, never a
|
|
# dead session. What the capability buys: PyroWave encodes on the GPU shader cores a game
|
|
# saturates, and every driver tested (NVIDIA and RADV) refuses EVERY elevated
|
|
# VK_KHR_global_priority class without CAP_SYS_NICE. Measured on an RTX 5070 Ti under load:
|
|
# encode p99 6.4 -> 4.4 ms.
|
|
#
|
|
# Narrow: CAP_SYS_NICE permits raising scheduling priority only — no filesystem, network or
|
|
# user-switching privilege, and the wrapper is capability-based, NOT setuid.
|
|
security.wrappers.punktfunk-encode-worker = {
|
|
source = "${cfg.host.package}/bin/punktfunk-encode-worker";
|
|
capabilities = "cap_sys_nice=ep";
|
|
owner = "root";
|
|
group = "root";
|
|
};
|
|
|
|
systemd.user.services.punktfunk-host = {
|
|
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" ] ++ optional cfg.host.desktopSession "graphical-session.target";
|
|
wants = [ "pipewire.service" ];
|
|
# `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 = [
|
|
pkgs.bash
|
|
pkgs.coreutils
|
|
pkgs.pipewire
|
|
]
|
|
# The HDR-capable gamescope, if enabled. On PATH rather than pinned through
|
|
# PUNKTFUNK_GAMESCOPE_BIN so an operator's own override of that env still wins.
|
|
++ optional cfg.host.gamescopeHdr cfg.host.gamescopePackage;
|
|
# Point the host at the WRAPPED encode worker (see `security.wrappers` above). The host's
|
|
# own resolution order is PUNKTFUNK_ENCODE_WORKER -> alongside /proc/self/exe -> PATH, and
|
|
# on NixOS the sibling of the store binary is the UNCAPPED store copy — it would run, and
|
|
# be refused every priority class, silently. This env is the whole reason the override
|
|
# exists. `config.security.wrapperDir` rather than a hard-coded /run/wrappers/bin so an
|
|
# operator who has moved it is still correct.
|
|
#
|
|
# NixOS renders `Environment=` before `EnvironmentFile=`, so `settings`/`environmentFile`
|
|
# can still override this (or set it to `off` to force the in-process encoder) — the same
|
|
# "an operator's own override still wins" posture as PUNKTFUNK_GAMESCOPE_BIN above.
|
|
environment.PUNKTFUNK_ENCODE_WORKER = "${config.security.wrapperDir}/punktfunk-encode-worker";
|
|
serviceConfig = {
|
|
# The store path DIRECTLY — not a capability wrapper. /proc/<pid>/exe then resolves to the
|
|
# very path packages.nix substituted into io.unom.Punktfunk.Host.desktop's Exec=, which is
|
|
# what lets KWin identify the host and grant it the screencast/fake-input protocols (see
|
|
# the note above the firewall block).
|
|
ExecStart =
|
|
"${cfg.host.package}/bin/punktfunk-host serve" + optionalString cfg.host.gamestream " --gamestream";
|
|
Restart = "on-failure";
|
|
RestartSec = 2;
|
|
EnvironmentFile =
|
|
(optional (cfg.host.settings != { }) "${hostSettingsFile}")
|
|
++ (optional (cfg.host.environmentFile != null) "-${toString cfg.host.environmentFile}");
|
|
};
|
|
# No PUNKTFUNK_GAMESCOPE_HDR here: the host defaults it on, and the capability probe on
|
|
# the resolved binary keeps a build-less box SDR. `gamescopeHdr` only controls whether
|
|
# the patched binary is on PATH; `settings`/`environmentFile` can still set =0 to force SDR.
|
|
};
|
|
})
|
|
|
|
# --- client --------------------------------------------------------------------------------
|
|
(mkIf cfg.client.enable {
|
|
environment.systemPackages = [ cfg.client.package ];
|
|
# 70-punktfunk-client.rules: hidraw access for the seated user's DualSense (SDL HIDAPI). The
|
|
# rule is uaccess-tagged, so the active-seat user gets it with no group membership.
|
|
services.udev.packages = [ cfg.client.package ];
|
|
|
|
networking.firewall = mkIf cfg.client.openFirewall {
|
|
allowedUDPPorts = [ 5353 ];
|
|
};
|
|
})
|
|
|
|
# --- web console ---------------------------------------------------------------------------
|
|
# The declarative equivalent of the punktfunk-web .deb / RPM subpackage: the two systemd --user
|
|
# units (the console + its first-run password generator) plus the firewall opener, all auto-wired
|
|
# to the host's per-user mgmt token + identity cert (no env editing on a packaged install).
|
|
(mkIf cfg.web.enable {
|
|
environment.systemPackages = [ cfg.web.package ];
|
|
|
|
networking.firewall = mkIf cfg.web.openFirewall {
|
|
# 47992 = the console itself. 47993 = the SEPARATE ORIGIN its plugin UIs are served from
|
|
# (console port + 1): same host and certificate, different port, so the browser's same-origin
|
|
# 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
|
|
];
|
|
};
|
|
|
|
# First-run setup: generate the console login password once, in the user's config dir, and
|
|
# surface it to the --user journal. Self-gates via ConditionPathExists (mirrors
|
|
# scripts/punktfunk-web-init.service).
|
|
systemd.user.services.punktfunk-web-init = {
|
|
description = "punktfunk web console first-run setup (login password)";
|
|
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
|
unitConfig.ConditionPathExists = "!%h/.config/punktfunk/web-password";
|
|
path = [ pkgs.coreutils ];
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
RemainAfterExit = true;
|
|
ExecStart = "${cfg.web.package}/share/punktfunk-web/web-init.sh";
|
|
};
|
|
};
|
|
|
|
# The console itself: Nitro SSR on bun, HTTPS on 47992 with the host's identity cert, proxying
|
|
# the host's loopback mgmt API with the bearer token injected server-side. mgmt-token is
|
|
# REQUIRED (the host's `serve` writes it) — if absent the unit fails and Restart retries until
|
|
# the host has created it; web-password is optional ('-'). Mirrors scripts/punktfunk-web.service.
|
|
systemd.user.services.punktfunk-web = {
|
|
description = "punktfunk management web console";
|
|
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
|
after = [
|
|
"punktfunk-web-init.service"
|
|
"punktfunk-host.service"
|
|
];
|
|
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";
|
|
HOST = "0.0.0.0";
|
|
# Serve HTTPS with the host's own identity cert (the anchor native clients already pin) and
|
|
# mark the session cookie Secure. The host's `serve` writes these PEMs.
|
|
PUNKTFUNK_UI_TLS_CERT = "%h/.config/punktfunk/cert.pem";
|
|
PUNKTFUNK_UI_TLS_KEY = "%h/.config/punktfunk/key.pem";
|
|
PUNKTFUNK_UI_SECURE = "1";
|
|
};
|
|
serviceConfig = {
|
|
Type = "simple";
|
|
EnvironmentFile = [
|
|
"%h/.config/punktfunk/mgmt-token"
|
|
"-%h/.config/punktfunk/web-password"
|
|
];
|
|
ExecStart = "${cfg.web.package}/bin/punktfunk-web-server";
|
|
# `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;
|
|
};
|
|
};
|
|
})
|
|
|
|
# --- plugin/script runner ------------------------------------------------------------------
|
|
# Installs the runner + defines its opt-in `systemd --user` unit (mirrors the deb/rpm
|
|
# punktfunk-scripting subpackage). NOT auto-started unless `scripting.autoStart` is set.
|
|
(mkIf cfg.scripting.enable {
|
|
environment.systemPackages = [ cfg.scripting.package ];
|
|
|
|
systemd.user.services.punktfunk-scripting = {
|
|
description = "punktfunk plugin/script runner";
|
|
documentation = [ "https://git.unom.io/unom/punktfunk" ];
|
|
# Plugins talk to the host's loopback mgmt API; order after it (soft — the runner backs off
|
|
# and retries per unit, so this is ordering only, not a hard requirement).
|
|
after = [ "punktfunk-host.service" ];
|
|
wantedBy = optional cfg.scripting.autoStart "default.target";
|
|
serviceConfig = {
|
|
Type = "simple";
|
|
ExecStart = "${cfg.scripting.package}/bin/punktfunk-scripting";
|
|
Restart = "on-failure";
|
|
RestartSec = 2;
|
|
# Deliver SIGTERM to the runner (it orchestrates the structural shutdown of its unit
|
|
# fibers) and give it room to run their finalizers before the cgroup is reaped.
|
|
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"
|
|
];
|
|
};
|
|
};
|
|
})
|
|
];
|
|
}
|