Files
punktfunk/packaging/nix/nixos-module.nix
T
enricobuehler e7a8cf2bba
ci / web (push) Successful in 58s
apple / swift (push) Successful in 1m24s
ci / docs-site (push) Successful in 59s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 17s
decky / build-publish (push) Successful in 25s
windows-host / package (push) Successful in 19m46s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 17s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 17s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
android / android (push) Successful in 16m12s
arch / build-publish (push) Successful in 12m14s
ci / bench (push) Successful in 5m27s
apple / screenshots (push) Successful in 6m32s
ci / rust (push) Successful in 19m26s
deb / build-publish (push) Successful in 12m58s
docker / deploy-docs (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 24m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 24m51s
feat(packaging): nix web console + plugin/script runner everywhere; fix tray zbus/tokio crash
Ship the management web console and the plugin/script runner across the packaging
matrix, and fix a Linux tray startup crash.

- nix: add punktfunk-web (Nitro SSR on bun) and punktfunk-scripting (Effect runner),
  with services.punktfunk.{web,scripting} modules. Both bun node_modules come from
  fixed-output derivations pinned to real deps hashes; patchShebangs the web
  node_modules so orval/paraglide/vite run in the sandbox. Validated on x86_64-linux
  (nix build .#punktfunk-{web,scripting,tray,host} all green).

- punktfunk-scripting now ships for deb/rpm/arch/nix/windows, mirroring the web-console
  mechanics (vendored bun on deb/rpm/arch, pkgs.bun on nix; one self-contained
  `bun build --target=bun` bundle). Opt-in: host Recommends it, but the systemd --user
  unit ships un-started (Windows registers its scheduled task disabled).

- fix(tray): build punktfunk-tray in its OWN cargo invocation on nix/rpm/arch. Cargo
  unifies features across one `cargo build`, so co-building the tray with the host pulls
  the host's ashpd->zbus/tokio onto the tray's shared zbus and it panics at startup
  ("there is no reactor running, must be called from the context of a Tokio 1.x
  runtime"). Isolated, the tray's zbus stays on async-io. The .deb already did this;
  runtime-verified on nix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 21:16:34 +02:00

396 lines
18 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`-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`).
#
# 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 = true;
description = ''
Advertise the GameStream/Moonlight-compatible planes (`serve --gamestream`) so a stock
Moonlight client can pair. Set to `false` for a native-only, more secure host (no
plain-HTTP pairing / legacy GCM path) and drop the GameStream firewall ports.
'';
};
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.
'';
};
users = mkOption {
type = types.listOf types.str;
default = [ ];
example = [ "alice" ];
description = ''
Users to add to the `input` group — required for the virtual gamepads the host creates
(`/dev/uinput`, `/dev/uhid`, and the usbip/vhci virtual Steam Deck). 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).
'';
};
};
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.";
};
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), 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`.
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 — 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
`~/.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 = false;
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.
'';
};
};
};
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;
# 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).
users.groups.input = { };
users.users = genAttrs cfg.host.users (_: { extraGroups = [ "input" ]; });
# 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;
};
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" ];
wants = [ "pipewire.service" ];
wantedBy = optional cfg.host.autoStart "default.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 ];
serviceConfig = {
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}");
};
};
})
# --- 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 {
allowedTCPPorts = [ 47992 ]; # console HTTPS (packaging/linux/punktfunk-web.xml)
};
# 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";
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";
Restart = "on-failure";
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;
};
};
})
];
}