{ config, pkgs, radiantwaveApp, upgradeFlakeRef, radiantwavePlymouth, ... }: let radiantApp = radiantwaveApp; # The published app arrives as a bare store-path string (from the channel # manifest, see flake.nix). Wrap it in a derivation built with this system's # pkgs so it can live in environment.systemPackages; the wrapper is a symlink # farm and the real closure is substituted from the channel's Attic cache. radiantAppPackage = pkgs.symlinkJoin { name = "radiantwave-published"; paths = [ radiantApp ]; }; # When the live-updater module is enabled, RadiantWave launches through the # mutable /var/lib/radiantwave/current # pointer so it can be swapped without a full nixos-rebuild. Otherwise it # launches straight from the immutable system closure as before. launchBin = if config.services.radiantwaveUpdater.enable or false then "/var/lib/radiantwave/current/bin/radiantwave" else "/run/current-system/sw/bin/radiantwave"; myKey = [ "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL4qWNZnYU5JaqR9c2qh6oFZktzVNBOx42PfDaGofCL6 cnclack@pm.me macbook" ]; # Headscale preauth key. Paste/replace here. # # This MUST be a *reusable* key with a long expiry (1 year is good), created # with: # headscale preauthkeys create --user --reusable --expiration 8760h # # A single-use key only ever enrolls one node, which would mean a fresh key # plus a rebuild for every kiosk that joins the fleet. A reusable key lets any # number of kiosks enroll unattended, and the timer below retries the join so # a node that misses its window at boot heals on its own. headscaleAuthKey = "hskey-auth-UfU_eS_sdOu6-CjrqrVdDJFRFsPnaTtK5sFuCiVlffJAS7uQ1wX1_65MNk205WGsF4clOiBD3ZOV-"; in { ############################################################ # System configuration ############################################################ # Included packages environment.systemPackages = [ pkgs.cage pkgs.rsync pkgs.dbus pkgs.mesa pkgs.git pkgs.tailscale pkgs.sqlite pkgs.wireplumber radiantAppPackage ]; # This populates the EFI partition and tells the NUC how to start NixOS. boot.loader.systemd-boot.enable = true; boot.loader.efi.canTouchEfiVariables = true; # Boot straight into the splash screen without waiting on the bootloader menu. boot.loader.timeout = 0; # Hardware-specific: NUC 14 needs these for the NVMe and Graphics boot.initrd.availableKernelModules = [ "nvme" "xhci_pci" "ahci" "usbhid" "usb_storage" "sd_mod" "i915" "drm" "drm_kms_helper" ]; # --- RadiantWave splash: Plymouth from boot until the app's first frame --- boot.plymouth.enable = true; boot.plymouth.theme = "radiantwave"; boot.plymouth.themePackages = [ (radiantwavePlymouth pkgs) ]; # Silence the kernel/initrd output behind the splash. The module adds # "splash" itself; we keep "quiet" plus show_status=false and loglevel 0. boot.consoleLogLevel = 0; boot.initrd.verbose = false; boot.kernelParams = [ "quiet" "splash" "systemd.show_status=false" ]; # Hold the splash until the app is ready to take over the display, instead of # quitting at multi-user.target (which would flash a black/console screen # during the login shell's network wait). The kiosk login shell runs # `sudo -n plymouth quit --wait` immediately before exec'ing cage. systemd.services.plymouth-quit.wantedBy = pkgs.lib.mkForce [ ]; systemd.services.plymouth-quit-wait.wantedBy = pkgs.lib.mkForce [ ]; # Allow the kiosk user to quit Plymouth (NOPASSWD, command-scoped). security.sudo.extraRules = [ { users = [ "kiosk" ]; commands = [ { command = "/run/current-system/sw/bin/plymouth"; options = [ "NOPASSWD" ]; } ]; } ]; # Let the kiosk app apply pending updates by restarting its own TTY # session (re-autologin -> fresh cage + RadiantWave). Scoped to exactly # one unit and verb; everything else keeps stock (denied) policy. security.polkit.enable = true; environment.etc."polkit-1/rules.d/10-kiosk-getty.rules".text = '' polkit.addRule(function(action, subject) { if (subject.user !== "kiosk") return null; if (action.id !== "org.freedesktop.systemd1.manage-units") return null; var unit = action.lookup("unit"); var verb = action.lookup("verb"); if ((unit === "getty@tty1.service" || unit === "getty@tty1") && verb === "restart") { return polkit.Result.YES; } }); ''; hardware.graphics.enable = true; time.timeZone = "America/Chicago"; networking.networkmanager.enable = true; ############################################################ # Tailscale (self-hosted Headscale control plane) ############################################################ # The node joins the RadiantWave Headscale server at # hs.radiantwavetech.com using the *hardware serial* as its Tailscale # node name (not networking.hostName), so a kiosk is always reachable at # even though every variant shares one hostName per branch. # # The preauth key is set in `headscaleAuthKey` in the let block at the # top of this file. NOTE: it ends up in the world-readable /nix/store, so # keep this file out of public repos while a live key is in it. services.tailscale = { enable = true; useRoutingFeatures = "client"; }; # Reach the kiosk's services (SSH, etc.) over the tailnet without extra # per-port rules. Membership is gated by the preauth key. networking.firewall.trustedInterfaces = [ "tailscale0" ]; systemd.services.radiantwave-tailscale-auth = { description = "Join the RadiantWave tailnet using the hardware serial as node name"; after = [ "tailscaled.service" "network-online.target" ]; wants = [ "tailscaled.service" "network-online.target" ]; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "oneshot"; # Deliberately no RemainAfterExit: the unit has to fall back to inactive # so the timer below can trigger it again. The script is idempotent, so # re-running it on an already-joined kiosk is a cheap no-op. }; path = [ pkgs.tailscale pkgs.coreutils pkgs.gnused pkgs.gnugrep ]; script = '' set -eu # Hardware serial. x86 exposes it via DMI; Raspberry Pi exposes it in # the device tree (null-terminated), mirrored under /proc/device-tree. serial="" for f in \ /sys/class/dmi/id/product_serial \ /sys/class/dmi/id/board_serial \ /sys/firmware/devicetree/base/serial-number \ /proc/device-tree/serial-number do if [ -r "$f" ]; then serial="$(tr -d '\000' < "$f" | tr -d '\n\r')" [ -n "$serial" ] && break fi done # Tailscale node names are RFC 1035 labels: lowercase, [a-z0-9-], <=63. serial="$(printf '%s' "$serial" \ | tr '[:upper:]' '[:lower:]' \ | tr -c 'a-z0-9-' '-' \ | sed -e 's/-\{1,\}/-/g' -e 's/^-//' -e 's/-$//' \ | cut -c1-63)" [ -n "$serial" ] || serial="radiantwave-unknown" # Already joined? Bail out early. This is what makes the periodic # re-invocation from the timer below cheap and safe to run forever. # The JSON is pretty-printed with a space, so allow optional whitespace. state="$(tailscale status --json 2>/dev/null \ | sed -n 's/.*"BackendState": *"\([A-Za-z]*\)".*/\1/p')" if [ "$state" = "Running" ]; then echo "radiantwave-tailscale-auth: already joined as '$serial'" exit 0 fi echo "radiantwave-tailscale-auth: joining as '$serial' (state: ''${state:-unknown})" for _ in $(seq 1 30); do if tailscale up \ --auth-key=${pkgs.lib.escapeShellArg headscaleAuthKey} \ --login-server=https://hs.radiantwavetech.com \ --hostname="$serial" \ --accept-dns=false then echo "radiantwave-tailscale-auth: connected" exit 0 fi sleep 2 done # Best-effort by design: do NOT exit non-zero here. # # This unit is part of the new generation that switch-to-configuration # starts, and *any* failing unit among those aborts the switch with # status 4/NOPERMISSION. While the preauth key was expired that is exactly # what happened: this unit failed, which failed `switch-to-configuration`, # which failed nixos-upgrade.service -- so the tailscale key silently # stopped *all* system upgrades on the fleet every 10 minutes. # # The retry timer below is what actually recovers the join, so a failed # attempt here must never take down the system switch with it. echo "radiantwave-tailscale-auth: could not join tailnet after retries;" >&2 echo "radiantwave-tailscale-auth: leaving the join to the retry timer" >&2 exit 0 ''; }; # The join only needs to happen once per node, but it fails for reasons # outside our control: the network may not be usable yet at boot, tailscaled # may be restarted later, or the preauth key may be rotated out from under # the fleet. A timer re-runs the (idempotent) join so a kiosk recovers on its # own instead of needing a manual `systemctl restart`. # # This replaces the old behavior where the one-shot unit failed once at boot # and stayed failed forever (Restart=no, NRestarts=0). systemd.timers.radiantwave-tailscale-auth = { description = "Periodically ensure the kiosk is joined to the tailnet"; wantedBy = [ "timers.target" ]; timerConfig = { # The join is attempted at boot via multi-user.target above; this is the # first retry in case that one lost the race with the network. OnBootSec = "5min"; # Measured from when the unit last went inactive, so a slow join doesn't # immediately retrigger. OnUnitInactiveSec = "10min"; Unit = "radiantwave-tailscale-auth.service"; }; }; system.autoUpgrade = { enable = true; flake = upgradeFlakeRef; dates = "*:00/10"; # --impure: the flake reads the channel's published manifest at eval time # (builtins.fetchurl) to pick the app store path for the next generation. flags = [ "--refresh" "--impure" "-L" ]; operation = "switch"; }; # Virtual machine declaration virtualisation.vmVariant = { boot.loader.systemd-boot.enable = pkgs.lib.mkForce false; boot.loader.efi.canTouchEfiVariables = pkgs.lib.mkForce false; virtualisation.forwardPorts = [ { from = "host"; host.port = 2222; guest.port = 22; } ]; virtualisation.qemu.options = [ "-device intel-hda" "-device hda-duplex" "-vga virtio" ]; virtualisation = { cores = 8; memorySize = 8192; }; services.logrotate.enable = false; }; virtualisation.diskSize = 10485760; # NixOS settings. nix.settings.experimental-features = [ "nix-command" "flakes" ]; nix.settings.accept-flake-config = true; # The app baked into each generation comes from the channel's published # manifest, fetched at eval time with `builtins.fetchurl` (see flake.nix). # Disable the tarball cache so a rebuild during autoUpgrade always reads the # current manifest instead of a copy up to an hour old. nix.settings.tarball-ttl = 0; nix.settings.extra-substituters = [ "https://attic.radiantwavetech.com/radiantwave" "https://attic.radiantwavetech.com/radiantwave-dev" ]; nix.settings.extra-trusted-public-keys = [ "radiantwave:QADrjpBWJssTZ3KmiZtcWEf4uAm66oOlHmtQI3jkrgA=" "radiantwave-dev:B8FhLu3MQ9JfeEIHIKNrKxnP+3akzzkqMbTZR4hRfj0=" ]; system.stateVersion = "24.05"; ############################################################ # User configuration ############################################################ users.users.root.openssh.authorizedKeys.keys = myKey; users.users.kiosk = { isNormalUser = true; uid = 1000; extraGroups = ["audio" "video" "input" "networkmanager" "wheel" "seat"]; openssh.authorizedKeys.keys = myKey; }; services.getty.autologinUser = "kiosk"; # --- The Kiosk Startup Script --- environment.loginShellInit = '' if [ "$(tty)" = "/dev/tty1" ]; then export GODEBUG=netdns=cgo export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt # Smart Network Wait for i in {1..10}; do if ${pkgs.iputils}/bin/ping -c 1 8.8.8.8 &> /dev/null; then break fi sleep 1 done # Graphics fixes export SDL_VIDEODRIVER=wayland export QT_QPA_PLATFORM=wayland export XDG_SESSION_TYPE=wayland export WLR_RENDERER=pixman export WLR_NO_HARDWARE_CURSORS=1 # Pin system audio to 100% and unmute before launching RadiantWave ${pkgs.systemd}/bin/systemctl --user start radiantwave-audio-init.service || true # Release the splash screen right before the compositor takes over the display sudo -n plymouth quit --wait || true exec dbus-run-session cage -- ${launchBin} -d -s > /tmp/kiosk.log 2>&1 fi ''; ############################################################ # Service configuration ############################################################ services.pipewire = { enable = true; alsa.enable = true; pulse.enable = true; }; # New sinks with no stored volume default to ~40% in WirePlumber 0.5+. # Force every newly-created sink to 100% so switching the default sink # never changes the volume. services.pipewire.wireplumber.extraConfig."99-kiosk-audio" = { "wireplumber.settings"."device.routes.default-sink-volume" = 1.0; }; # --- Pin all system audio sinks to 100% before RadiantWave launches --- # User unit (PipeWire runs per-user in the kiosk session), owned by this # system config. Ordered after PipeWire/WirePlumber; the retry loop covers # sink readiness at boot. Pins every connected sink (not just the default) # and unmutes them, so switching the default sink never changes the volume. # RadiantWave's loginShellInit waits on this before exec'ing cage. systemd.user.services.radiantwave-audio-init = { description = "Pin all system audio sinks to 100% and unmute"; after = [ "pipewire.service" "wireplumber.service" ]; wants = [ "pipewire.service" "wireplumber.service" ]; serviceConfig = { Type = "oneshot"; }; script = '' # Wait until WirePlumber exposes at least one sink. # `wpctl list` output is tab-separated: ID, name, media/object type, # then `*` if the node is the current default. `cut -f1` grabs the ID. sinks="" for i in {1..30}; do sinks=$(${pkgs.wireplumber}/bin/wpctl list audio sinks 2>/dev/null \ | ${pkgs.coreutils}/bin/cut -f1) if [ -n "$sinks" ]; then break fi sleep 1 done # Pin and unmute every connected sink for sink_id in $sinks; do ${pkgs.wireplumber}/bin/wpctl set-volume "$sink_id" 1.0 2>/dev/null || true ${pkgs.wireplumber}/bin/wpctl set-mute "$sink_id" 0 2>/dev/null || true done # Fallback in case the sink list came up empty ${pkgs.wireplumber}/bin/wpctl set-volume @DEFAULT_AUDIO_SINK@ 1.0 2>/dev/null || true ${pkgs.wireplumber}/bin/wpctl set-mute @DEFAULT_AUDIO_SINK@ 0 2>/dev/null || true # Persist 100% as WirePlumber's default for any brand-new sink so it # comes up at full volume even before this service runs on next boot. ${pkgs.wireplumber}/bin/wpctl settings device.routes.default-sink-volume 1.0 --save 2>/dev/null || true ''; }; services.seatd.enable = true; services.openssh.enable = true; services.openssh.settings.PermitRootLogin = "prohibit-password"; # Networking Network-Manager services.resolved.enable = false; systemd.services.NetworkManager-wait-online.enable = false; systemd.network.wait-online.enable = false; # --- Set ownership of Radiantwave files --- systemd.tmpfiles.rules = [ "d /home/kiosk/.local/radiantwave 0770 kiosk kiosk -" ]; # --- Backup service: runs before nixos-upgrade --- systemd.services.radiantwave-backup = { description = "Backup RadiantWave user data before upgrade"; before = [ "nixos-upgrade.service" ]; wantedBy = [ "nixos-upgrade.service" ]; serviceConfig = { Type = "oneshot"; User = "kiosk"; }; script = '' BACKUP_DIR="/home/kiosk/.local/radiantwave-backup" SOURCE_DIR="/home/kiosk/.local/radiantwave" mkdir -p "$BACKUP_DIR" ${pkgs.rsync}/bin/rsync -a \ --exclude='audio/' \ "$SOURCE_DIR/" "$BACKUP_DIR/" echo "Backup completed at $(date)" > "$BACKUP_DIR/.backup-timestamp" ''; }; }