# punktfunk on NixOS / Nix

First-class Nix support via the repo's `flake.nix`: reproducible builds of the streaming **host**
and the native Linux **client**, a **NixOS module** that wires up everything the RPM/deb do
(systemd user service, udev rules, kernel modules, sysctl tuning, firewall, `input` group), and a
**dev shell** with the pinned toolchain and every system library.

> **Platform:** `x86_64-linux` only (the host encodes with desktop NVENC; matches the RPM's
> `ExclusiveArch: x86_64`). NixOS **24.11 or newer** for the `hardware.graphics` option.

---

## What the flake provides

| Output | Contents |
| --- | --- |
| `packages.x86_64-linux.punktfunk-host` | `punktfunk-host` + `punktfunk-tray` (built with `nvenc` + `vulkan-encode`, like CI) |
| `packages.x86_64-linux.punktfunk-client` | `punktfunk-client` (GTK4 shell) + `punktfunk-session` (Vulkan streamer, without the Skia OSD — see caveats) |
| `packages.x86_64-linux.punktfunk-web` | the management web console (bun-built Nitro SSR bundle; SPAKE2 pairing + host status) |
| `packages.x86_64-linux.punktfunk-scripting` | the plugin/script runner (bun-bundled Effect SDK; supervises host automation) |
| `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
NVIDIA (zero-copy dmabuf → CUDA → NVENC), AMD/Intel (raw Vulkan-Video HEVC / VAAPI), or software.

---

## Quick start (no NixOS required)

```sh
# Build
nix build git+https://git.unom.io/unom/punktfunk#punktfunk-host
nix build git+https://git.unom.io/unom/punktfunk#punktfunk-client

# Run
nix run git+https://git.unom.io/unom/punktfunk#punktfunk-host -- serve --gamestream
nix run git+https://git.unom.io/unom/punktfunk#punktfunk-client
```

GPU drivers are resolved at runtime from `/run/opengl-driver/lib`. On non-NixOS distros use
[nixGL](https://github.com/nix-community/nixGL) so that path is populated (`nixGL nix run …`); on
NixOS the module (below) sets `hardware.graphics.enable = true` for you.

> **Do this first, or the commands above compile the world:** without the binary cache, `nix build`
> here means the whole Rust workspace *and* a gamescope build from source — roughly an hour on a
> fast machine. See below.

---

## Binary cache (do this before your first build)

CI publishes every punktfunk package to **`https://nix.unom.io`** on each push to `main` that moves
the flake, so you get prebuilt binaries instead of an hour of `rustc`. It covers
`punktfunk-host`, `-client`, `-tray`, `-web`, `-scripting` and `-gamescope` — everything the flake
builds from source. Everything else in the closure is stock nixpkgs and comes from `cache.nixos.org`
as usual, so the cache is deliberately small and adding it costs you nothing on unrelated builds.

**NixOS** — in your system configuration:

```nix
nix.settings = {
  substituters = [ "https://nix.unom.io" ];
  trusted-public-keys = [ "punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=" ];
};
```

**Anywhere else** — in `/etc/nix/nix.conf` (or `~/.config/nix/nix.conf` if you are a trusted user):

```conf
extra-substituters = https://nix.unom.io
extra-trusted-public-keys = punktfunk-cache-1:yhOJmHxzg6tzXpxSFzlYn6Pc6r0jHprsWqt8MZC654o=
```

The current public key is served by the cache itself, so you can always check it against the source
of truth:

```sh
curl https://nix.unom.io/punktfunk-cache.pub
```

Verify the cache is being used — this should print the store paths without compiling anything:

```sh
nix build --dry-run git+https://git.unom.io/unom/punktfunk#punktfunk-host
```

### ⚠ `nixpkgs.follows` turns the cache off

Every store path is keyed by the exact inputs it was built from. Pointing punktfunk's nixpkgs at
yours changes those inputs, so **every** path misses and you compile the workspace anyway:

```nix
# Convenient, but it costs you the entire binary cache:
inputs.punktfunk.inputs.nixpkgs.follows = "nixpkgs";
```

That is a real trade, not a bug — `follows` buys you one shared nixpkgs in the closure instead of
two. Take it if closure size matters more to you than build time; leave it out to get binaries.

### Why not `cachix`?

Nothing against it — punktfunk simply self-hosts every other channel (flatpak, deb, rpm, Arch,
docker, winget), and a Nix cache is static files behind a web server, so it rides the same unom-1
box and the same deploy key as the rest. Nothing about the cache is punktfunk-specific: it speaks
plain HTTP binary-cache protocol, so any nix client works with it.

---

## NixOS module

Add the flake and enable the host and/or client:

```nix
{
  inputs.punktfunk.url = "git+https://git.unom.io/unom/punktfunk";
  # (optional) share your nixpkgs: inputs.punktfunk.inputs.nixpkgs.follows = "nixpkgs";
  #   ⚠ this DISABLES the binary cache — different inputs, different store paths, so every
  #     package is rebuilt from source (~1h). See "Binary cache" above.

  outputs = { self, nixpkgs, punktfunk, ... }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      system = "x86_64-linux";
      modules = [
        punktfunk.nixosModules.default
        ({ ... }: {
          services.punktfunk.host = {
            enable = true;
            users = [ "alice" ];        # → `input` group for gamepads, AND scopes the units to alice
            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";
              # PUNKTFUNK_444 = true;   # booleans render as 1/0
            };
          };

          # …and/or the client on the same or another box:
          services.punktfunk.client = {
            enable = true;
            openFirewall = true;        # UDP 5353 for mDNS discovery
          };
        })
      ];
    };
  };
}
```

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 punktfunk-web
```

### Options

`services.punktfunk.host`:

| Option | Default | Meaning |
| --- | --- | --- |
| `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). |
| `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**,
mirroring the RPM's `Recommends: punktfunk-web`):

| Option | Default | Meaning |
| --- | --- | --- |
| `enable` | `host.enable` | Run the console as a `systemd --user` service on **TCP 47992 (HTTPS)**. Set `false` for a console-less host. |
| `openFirewall` | `host.openFirewall` | Open TCP 47992 so other devices on the LAN can reach it. |
| `autoStart` | `host.autoStart` | Add the console user service to `default.target` (appliance mode). |
| `package` | flake's | Override the package. |

The console is **auto-wired** to the host on the same box: it reads the host's per-user
`~/.config/punktfunk/{mgmt-token,cert.pem,key.pem}` (written by `serve`), serves HTTPS with the
host's own identity cert, and proxies the loopback mgmt API with the bearer token injected
server-side (never sent to the browser). A login password is generated on first start — read it
with `journalctl --user -u punktfunk-web-init` (or `~/.config/punktfunk/web-password`). Then open
`https://<host-ip>: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 **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` | `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 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`, 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` 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; 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/<pid>/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)

- **NVIDIA:** `hardware.nvidia` + `hardware.graphics.enable = true`. NVENC/CUDA come from the
  driver at runtime (nothing pinned in the closure).
- **AMD/Intel:** `hardware.graphics.enable = true` with `extraPackages = [ vaapiVdpau … ]` /
  `intel-media-driver` for VAAPI encode; the host's raw Vulkan-Video HEVC path needs only Mesa.

### Headless / appliance

Set `autoStart = true`, enable lingering, and — for a **dedicated single-session appliance** —
pin a backend in `settings` (pinning `PUNKTFUNK_COMPOSITOR` disables live-session auto-detection,
so leave it out on any box that switches between a desktop and Game Mode):

```nix
services.punktfunk.host = {
  enable = true;
  autoStart = true;
  users = [ "streamer" ];
  settings = { PUNKTFUNK_COMPOSITOR = "gamescope"; };  # appliance-only; omit to auto-detect
};
users.users.streamer.linger = true;
```

**Set `users` whenever `autoStart` is on.** `systemd.user.*` installs into *every* user's systemd
manager — root's included, and root gets one the moment anybody logs in as root. Without `users` to
scope them, `autoStart` therefore starts a *second* host in root's manager, which wins the race for
the fixed ports and leaves the real one restarting forever on `bind RTSP 48010: Address already in
use` (every other listener having bound fine, so it reads like a clash with an unrelated program).
The module renders `ConditionUser=` from this list to prevent that; with the list empty it falls
back to refusing system users, which keeps root out but cannot tell two logins apart.

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
`host.env` files, the OpenAPI doc) are installed for reference.

---

## Development

```sh
nix develop        # pinned toolchain (rust-toolchain.toml) + all system libs
cargo build --release -p punktfunk-host -p punktfunk-client-linux -p punktfunk-client-session
# The tray gets its OWN invocation — co-building it with the host unifies the host's
# ashpd -> zbus/tokio onto the tray's zbus (which runs ksni's async-io executor, no tokio runtime),
# and the resulting binary panics at launch: "there is no reactor running, must be called from the
# context of a Tokio 1.x runtime". Same split the .deb / RPM / Arch packaging does.
cargo build --release -p punktfunk-tray
```

The shell exports an
`LD_LIBRARY_PATH` that includes `/run/opengl-driver/lib` so `cargo run` finds the GPU driver.
`nix fmt` formats the `.nix` files.

---

## Notes & caveats

- **Build tool:** [crane](https://github.com/ipetkov/crane). The lockfile carries
  `windows 0.62.2` from both crates.io *and* a pinned `microsoft/windows-rs` git rev (the Windows
  client), which `rustPlatform.importCargoLock` can't vendor (colliding `name-version`); crane
  vendors per-source and fetches the git rev via `builtins.fetchGit` (no output hash to maintain).
  Those crates are `cfg(windows)`-gated — vendored, never compiled on Linux.
- **First build compiles from scratch** (no split dep cache — pyrowave-sys builds a CMake tree in
  its build.rs that a crane "dummy" source would drop) and has no public binary cache, so expect a
  long initial build. `nix develop` gives incremental rebuilds.
- **The status tray is built in its own derivation, on purpose.** `punktfunk-tray` uses `ksni`'s
  `async-io` zbus executor with no tokio runtime (by design — see `crates/punktfunk-tray/Cargo.toml`).
  Cargo unifies features across everything in one `cargo build`, so co-building the tray with the
  host would pull the host's `ashpd → zbus/tokio` onto the tray's shared `zbus`, and the tray then
  panics at startup (`there is no reactor running, must be called from the context of a Tokio 1.x
  runtime`). Building it as a separate `-p punktfunk-tray` invocation keeps its `zbus` on async-io;
  the host package copies the resulting binary into its `$out`. (The rpm/arch builds split it the same
  way. The **.deb did not**, despite its sibling comments claiming otherwise: `deb.yml` co-built
  `-p punktfunk-host -p punktfunk-tray`, and `build-deb.sh`'s own standalone build was skipped because
  the poisoned artifact already existed — so this shipped as a real crash-at-launch on Debian/Ubuntu,
  not a latent one. Fixed 2026-07-27: the workflow no longer co-builds it and `build-deb.sh` now
  rebuilds it unconditionally.)
- **The bun packages (`punktfunk-web`, `punktfunk-scripting`) use [bun2nix](https://github.com/nix-community/bun2nix).**
  Their `node_modules` is fetched **one `fetchurl` per package**, straight from the integrity hashes
  already in the lockfile, via a generated-and-committed `bun.nix` (`web/bun.nix`, `sdk/bun.nix`).
  There is **no aggregate deps hash to bump** — the previous design put `bun install` in a
  fixed-output derivation whose single `outputHash` silently went stale on every lockfile change and
  broke the build. `bun2nix` is a devDependency of both packages and regenerates `bun.nix` on every
  `bun install` (web's `postinstall`; the SDK's `prepare`, since sdk/ is the *published*
  `@punktfunk/host` package and a `postinstall` would then fire on consumers' installs).
  The `@unom` scope needs no special handling: `web/bun.lock` records those tarballs' full
  `https://git.unom.io/api/packages/unom/npm/…` URLs and the registry is read-public (the same
  anonymous pull CI's rpm/deb builds do).

  > ⚠⚠ **That devDependency hook is a convenience, NOT the guarantee — `bun.nix` still drifts.**
  > It fires only on a local `bun install` that runs lifecycle scripts. It does *not* fire under
  > `bun install --ignore-scripts`, which is what every bun install in CI uses; and it cannot fire
  > on a **merge or rebase**, where git carries someone else's `bun.lock` change past a `bun.nix`
  > generated before it and reports no conflict. That is how `web/bun.nix` shipped on main holding
  > `brace-expansion@5.0.7` while `web/bun.lock` said `5.0.8` — for **553 commits** (2026-07-27 →
  > 2026-08-05), with `nix build .#punktfunk-web` broken the whole time, until an unrelated
  > advisory bump happened to rerun a real `bun install` and closed it by accident.
  >
  > The enforcement point is **`scripts/ci/check-bun-nix.sh`** (the `bun-nix` job in `ci.yml`,
  > unfiltered so it sees the innocuous-looking commits drift arrives through). It regenerates each
  > `bun.nix` from its committed `bun.lock` and diffs. Fix any report with:
  >
  >     scripts/ci/check-bun-nix.sh --fix
  >
  > Never regenerate with a bare `bunx bun2nix`: **`bun.nix` has no schema stability across bun2nix
  > versions**, and an unpinned `bunx` uses whatever is newest. The flake input
  > (`github:nix-community/bun2nix?ref=2.1.2`) and the npm devDependency in `web/package.json` +
  > `sdk/package.json` must name the *same exact version* — the script checks that too, and always
  > generates with the pinned one. Move all three together, then rerun it with `--fix`.

  Everything past the deps fetch is offline (the console's codegen + vite build; the runner's
  `bun build --target=bun` bundle). Both launchers exec `pkgs.bun` from the store — unlike the
  deb/rpm, which vendor a bun binary because apt/dnf have none.
- **Commit `flake.lock`:** it pins the input revisions (nixpkgs / crane / rust-overlay / bun2nix).
  It is generated on first eval and checked in.
- **Session Skia OSD is off under Nix.** `punktfunk-session`'s default `ui` feature draws its
  on-screen stats/console overlay with `skia-safe`, whose build *downloads* a prebuilt Skia from
  the rust-skia releases — which Nix's network-less build sandbox forbids, and a from-source Skia
  build pulls the whole gn/ninja/python toolchain plus network-fetched third-party. The feature is
  explicitly droppable ("same streaming, stats on stdout only"), so the Nix build compiles the
  session with `--no-default-features --features pyrowave`. **Everything streams**; only the
  session binary's *optional* on-glass stats overlay is absent, and the **GTK shell
  (`punktfunk-client`) is skia-free and fully featured.** Re-adding it means teaching skia-bindings
  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.<system>.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

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`), three tiers: `nix flake check --no-build` evaluates every output
*including* the module check above; `punktfunk-web` + `punktfunk-scripting` are built for real on
every PR; and on a push to `main` the Rust packages and `punktfunk-gamescope` are built and
published to the binary cache. A `flake.lock` bump that breaks the gamescope patches therefore goes
red on main rather than in an operator's rebuild. The `build-rust` / `build-gamescope`
`workflow_dispatch` inputs remain, for checking those on a branch before merging.

---

## Cache infrastructure (maintainers)

`https://nix.unom.io` is a `caddy:2-alpine` container on unom-1 serving a static directory —
`packaging/nix/server/` — exactly like the flatpak repo (3230) and the winget source (3240). A Nix
binary cache *is* just `nix-cache-info` + `<hash>.narinfo` + `nar/<hash>.nar.xz` behind a web
server; there is no cache daemon to run.

**Why not Gitea, and why not `storage.unom.io`:**

- Gitea has 23 package registry types and none is Nix. It is not a missing label — the protocol
  needs fixed anonymous paths at a URL *root* (`/nix-cache-info`, `/<hash>.narinfo`,
  `/nar/…`), and `/api/packages/{owner}/generic/{name}/{version}/{file}` cannot express them.
- The RustFS S3 at `storage.unom.io` *would* work mechanically (nix speaks `s3://…?endpoint=`, and
  the sccache credentials already exist), but it is a local box on the home uplink with no CDN, so
  every user download competes with CI. It also answers **403** for a missing key unless the bucket
  policy grants anonymous `ListBucket` — and nix treats anything other than **404** as a hard error
  rather than a cache miss, which would break users' builds for packages the cache never held.

**One-time setup — in this order.** The publish step ends by fetching `nix.unom.io` to prove the
cache really answers (and answers **404**, not 403, for a path it does not hold), so stand the
service up *before* you set the secret that switches publishing on. The secret is the last step for
exactly that reason: until it exists the publish no-ops with a warning and `main` stays green,
the same way flatpak.yml's repo deploy does.

1. **Ingress — both halves live in `unom/infra`, and they must move together.** Neither the DNS
   record nor the vhost is a click: `terraform/cloudflare/records.tf` owns the zone and
   `caddy/Caddyfile` owns the vhosts, and that file says so itself — *"a name here with no vhost
   404s, a vhost with no name here never cuts over."*

   - `terraform/cloudflare/records.tf` — add `"nix"` to `local.hostnames`. It inherits
     `proxied = false`, which this service specifically needs: a proxy that masked the origin's
     404s would fail users' builds for every package the cache does not hold.
   - `caddy/Caddyfile` — next to the `docs.punktfunk.unom.io` block:

     ```caddyfile
     nix.unom.io {
         import security_headers
         reverse_proxy localhost:3250
     }
     ```

   Apply with the **`dns-cutover.yml`** workflow (`target=hcloud`, `action=plan` first — expect a
   single added `cloudflare_record.a["nix"]`, stop if it shows anything else) and `deploy-all` for
   the Caddyfile.

   ⚠ **Neither by hand.** A record added in the Cloudflare dashboard is out-of-band and risks the
   duplicate-record round-robin `records.tf` documents; `~/caddy/Caddyfile` on unom-1 looks like the
   config but is a copy `deploy-all.sh` rsyncs from the repo, with no `.git` to warn you — a vhost
   added only on the box survives until the next deploy and no longer (this bit the winget source on
   2026-07-26; see `packaging/winget/server/README.md`).

   Until both land the hostname fails the TLS handshake, because Caddy has no certificate for a name
   it does not serve. Expected on first setup — and also exactly how a later clobber presents.
   Diagnose by SNI, not by port 80 (Caddy 308s every Host to https, including names it has never
   heard of, so a redirect proves nothing):

   ```sh
   openssl s_client -connect nix.unom.io:443 -servername nix.unom.io </dev/null 2>&1 \
     | grep -E '^subject=|alert'
   ```
2. Dispatch `deploy-services.yml` (or `unom/infra`'s `deploy-all`) to bring the container up. It
   serves an empty cache — every path 404s, which is exactly what a healthy empty cache does.
3. **Signing key — done.** `NIX_CACHE_SIGNING_KEY` is installed as a repo Actions secret, and its
   public half is pinned in the "Binary cache" section above and in
   `docs-site/content/docs/install.md`. Regenerate only deliberately: a new key invalidates every
   signature already published, and every user pinning the old one starts failing. If you ever must:

   ```sh
   nix key generate-secret --key-name punktfunk-cache-1          # on a Nix box
   docker run --rm nixos/nix nix --extra-experimental-features nix-command \
     key generate-secret --key-name punktfunk-cache-1            # or anywhere with docker
   ```
4. Push to `main` touching the flake. The publish step also writes the public key to
   `https://nix.unom.io/punktfunk-cache.pub`, so users can always check the docs against the cache.

`scripts/setup-nix-cache.sh` walks through it interactively, and each stage detects work already
done — so it is safe to run now that the key exists.

**Operational notes:**

- Only punktfunk's own store paths are published (`nix path-info -r … | grep -- '-punktfunk'`).
  Everything else in a closure is stock nixpkgs, already on `cache.nixos.org` behind a real CDN;
  mirroring it would cost disk and home-to-cloud bandwidth to serve a worse copy. The publish step
  asserts every built output is matched by that filter, so a future `pname` change fails the build
  instead of silently dropping a package from the cache.
- `rsync` runs **without** `--delete` (a client mid-download is never pulled out from under), and
  NARs are uploaded *before* narinfos — a narinfo whose NAR has not landed is a hard download
  failure for whoever fetches it in that window, while an unreferenced NAR is merely invisible.
- Growth is bounded by `packaging/nix/server/prune.sh` (evicts narinfos untouched for 180 days,
  then sweeps NARs nothing references). The flatpak repo next door reached 3.84 GB publishing the
  same way with no sweep, on a box that has run out of disk before — hence the sweep from the first
  publish. Run its self-check with `sh packaging/nix/server/prune.sh --self-test`.
- A user on a pinned rev older than the eviction window falls back to building from source, which
  is the pre-cache status quo.
