Files
KioskConfig/configuration.nix
T
NeilClack f74d8cb708
Build Kiosk Image (Raspberry Pi) / rpi (push) Canceled after 9s
Build Kiosk Images (x86) / android (push) Successful in 3m56s
Build Kiosk Images (x86) / commercial (push) Successful in 3m32s
Build Kiosk Images (x86) / kiosk (push) Successful in 4m7s
Make the baked app a real store-path dependency via builtins.storePath
The previous commit wrapped the manifest's storePath string in a
symlinkJoin. symlinkJoin passes its paths as a file, so a bare store-path
string registered no dependency: Nix never substituted the closure and the
symlinkJoin failed with 'radiantwave-9b57e48: No such file or directory'.

builtins.storePath turns the published path into a real store-path
dependency that Nix substitutes from the channel's Attic cache, so the app
is baked and never compiled. Keep the raw string (publishedPath) for the
updater's plain-string baseline pointer.
2026-10-07 10:48:15 -05:00

409 lines
16 KiB
Nix

{ config, pkgs, radiantwaveApp, upgradeFlakeRef, radiantwavePlymouth, ... }:
let
# The published app is a store path with context (builtins.storePath in
# flake.nix), so Nix substitutes it from the channel's Attic cache and the
# system closure depends on it.
radiantApp = radiantwaveApp;
# 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 <user-id> --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
radiantApp
];
# 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
# <serial> 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"
'';
};
}