bitspire/flake.nix
Padreug 6524cad7a8 chore(flake): drop the lamassu-live-* alias outputs; retire the lamassu-next comment
The aliases were added for the brand transition with a note to drop them
once nothing referenced them. Nothing does. The autoUpgrade comment still
said the legacy aiolabs/lamassu-next repo fed batm3 and douro; every live
machine pulls from this repo now (CLAUDE.md → Branch model).
2026-10-09 21:58:55 +02:00

713 lines
35 KiB
Nix

{
description = "bitSpire - Nostr-Native Lightning ATM";
inputs = {
# Stable NixOS for the ATM OS base
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
# Unstable for Electron, Node.js, pnpm (latest versions)
nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
flake-utils.url = "github:numtide/flake-utils";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs-unstable";
};
devenv = {
url = "github:cachix/devenv";
inputs.nixpkgs.follows = "nixpkgs-unstable";
};
# Determinate Nix — ensures douro uses same nix version as dev machines
# so cachix binary cache hits match (nix 2.33 hashes == nix 2.33 hashes).
# The bare `?3` semver pin (≥3.0.0) was resolving to 3.16.3, which
# ships a regressed nix-functional-tests:local-overlay-store /
# stale-file-handle that FAILs when the determinate-nix derivation
# has to be built from source (no binary cache hit) — this blocked
# disk-image-sintra builds locally. `?3.15` (semver ≥3.15.0) skips
# past the regression; flake.lock currently resolves it to 3.20.0,
# which builds and ships nix 2.34.6.
determinate.url = "https://flakehub.com/f/DeterminateSystems/determinate/3.15";
# ATM TUI — operator management tool
atm-tui = {
url = "git+ssh://forgejo@git.atitlan.io/aiolabs/atm-tui.git";
inputs.nixpkgs.follows = "nixpkgs-unstable";
};
};
outputs = { self, nixpkgs, nixpkgs-unstable, flake-utils, rust-overlay, devenv, determinate, atm-tui }:
let
system = "x86_64-linux";
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
pkgs-unstable = import nixpkgs-unstable {
inherit system;
config.allowUnfree = true;
overlays = [ (import rust-overlay) ];
};
# Kiosk launcher. The GPU-related Electron flags sit in a shell variable
# rather than being baked into ExecStart, so they can be changed on a
# running machine by editing /var/lib/bitspire/.env and restarting the
# unit. No rebuild, no reboot, and a bad value is one edit away from
# being undone — which matters on a box whose screen nobody can see.
#
# THE DEFAULT IS NOW HARDWARE ACCELERATION.
#
# From the first ISO commit (19d43c2) until today the kiosk launched with
# --disable-gpu AND --disable-software-rasterizer, which turns off GPU
# compositing and the SwiftShader fallback together and leaves Chromium
# rasterising every pixel on the CPU. Nothing in git ever justified the
# pair: no comment, no issue, no commit message. Meanwhile the
# descriptive config at /etc/bitspire/config.env claimed
# ELECTRON_DISABLE_GPU=false, contradicting the actual command line.
#
# Tested on sintra 2026-09-24. With the flags removed the GPU process is
# stable (zero crashes, zero service restarts) and genuinely on hardware
# — /proc/<gpu-pid>/maps shows libgallium, libGLX_mesa and dri_gbm, with
# no swrast and no SwiftShader — rendering through crocus on Braswell.
# Confirmed by eye on the panel.
#
# DOURO IS EXEMPT. It keeps the old flags. Bay Trail carries three
# separate display workarounds already — a 5.15 kernel pin for an i915
# eDP regression, i915.enable_psr=0, and vt.handoff=7 to preserve the
# BIOS display init — so it is the most plausible machine for the
# original flags to have been a real fix rather than scaffolding. It is
# also down pending a reflash, so it cannot be tested. Drop this
# exemption once douro is back and accelerates cleanly.
#
# To override per machine, in /var/lib/bitspire/.env:
# BITSPIRE_ELECTRON_GPU_FLAGS= acceleration
# BITSPIRE_ELECTRON_GPU_FLAGS=--disable-gpu no GPU
# BITSPIRE_ELECTRON_GPU_FLAGS=--use-gl=egl force EGL
# (line absent) model default
#
# Note `-` and not `:-`: an explicitly EMPTY value means "no GPU flags at
# all", and must not fall back to the default. Unquoted on purpose so the
# value word-splits into argv.
mkKioskLauncher = machineModel: atm-app: pkgs.writeShellScript "bitspire-kiosk" ''
default_gpu_flags="${
if machineModel == "douro" then "--disable-gpu --disable-software-rasterizer" else ""
}"
exec ${pkgs-unstable.electron}/bin/electron \
--no-sandbox --disable-gpu-sandbox --enable-logging \
''${BITSPIRE_ELECTRON_GPU_FLAGS-$default_gpu_flags} \
${atm-app}
'';
# Pure ATM app builder (no --impure needed)
mkAtmApp = import ./nix/mkAtmApp.nix {
inherit pkgs pkgs-unstable;
src = self;
};
# Fiat code per machine model
fiatCodeForModel = {
douro = "GTQ";
tejo = "GTQ";
sintra = "EUR";
batm3 = "USD";
};
# Nightly auto-upgrade window per machine model, as a systemd calendar
# spec. An upgrade restarts the app, and the Fujitsu dispenser runs an
# audible init routine when it does, so this wants to land in the middle
# of the machine's own night rather than its business hours.
#
# The timezone suffix (systemd 252+) is what makes that work WITHOUT
# setting the system clock: the timer follows the named zone and its DST,
# while time.timeZone stays a fleet-wide default nobody has to maintain
# per host. Verified on sintra with systemd-analyze — `04:00
# Europe/Paris` resolves to 02:00 UTC in summer, `04:00
# America/Guatemala` to 10:00 UTC.
#
# Do NOT check a spec like this under `nix-shell -p systemd`. The sandbox
# cannot resolve named zones and silently computes EVERY one of them as
# UTC, while still echoing the zone back in its "Normalized form" line.
# It looks accepted and is wrong. Test on a real system.
#
# Keyed on model like fiatCodeForModel above, and inheriting the same
# limitation: model is a hardware model, and it only doubles as host
# identity while there is one machine of each. A second sintra in another
# country needs this keyed on host instead, along with the fiat code and
# the app build that bakes it in.
#
# Unlisted models get 04:00 in whatever time.timeZone says, unchanged.
upgradeWindowForModel = {
sintra = "04:00 Europe/Paris";
};
# Which models have a contactless (CCID) card reader fitted, for Bolt
# Card taps. Same keying caveat as the two tables above.
#
# This can't live in a hardware file: hardware/upboard.nix is shared by
# sintra (HID Global OMNIKEY 5022) and tejo (nothing fitted), so before
# this table the tejo inherited pcscd it had no use for while the douro,
# with its own hardware file, got none and wedged on boot — nfc-pcsc
# busy-spins Electron's main thread when pcscd is absent (see
# apps/machine/electron/nfc-service.ts). Flip a model to true when a
# reader is actually fitted; douro and tejo are planned.
nfcReaderForModel = {
batm3 = true; # Feitian KP382
sintra = true; # HID Global OMNIKEY 5022
};
# WireGuard address on the 10.0.0.0/24 management tunnel to the VPS
# (peer + listenPort live in configuration.nix; only the address is
# per-machine). Same keying caveat as the three tables above.
#
# This cannot live in a hardware file for the UP Board models, and the
# reason it now lives here for ALL of them is tejo: hardware/upboard.nix
# is shared by tejo and sintra, so an address set there would be claimed
# by both machines on the same /24. tejo had no address at all as a
# result — `wg0.ips = [ ]` brings the interface up with no IP and the
# tunnel is dead, which is a silent way to lose remote access to a
# machine that has no other route in. Keeping douro's and batm3's
# addresses here too means there is one list to read when allocating the
# next one, rather than three files plus the VPS peer config.
#
# An unlisted model gets no address and no tunnel. That is deliberate for
# sintra, which is reachable on the LAN (192.168.0.252) and has never had
# a tunnel address.
#
# NOTE: the address is only half of it. The VPS maps peer PUBLIC KEY to
# pragma: allowlist secret
# tunnel IP, so a machine also needs its private key at
# /var/lib/wireguard/wg0.key — carried over from the machine's previous
# install, or newly generated with its pubkey added to the VPS peer list.
# The key is operator-provisioned and deliberately not in the image.
wireguardIpForModel = {
tejo = "10.0.0.3/24";
douro = "10.0.0.4/24";
batm3 = "10.0.0.5/24";
};
lib = nixpkgs.lib;
# Helper to create a live USB NixOS config for a specific machine model
mkLiveConfig = machineModel:
let
atm-app = mkAtmApp {
model = machineModel;
fiatCode = fiatCodeForModel.${machineModel} or "USD";
};
in
nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = {
inherit pkgs-unstable nixpkgs machineModel atm-app;
kioskLauncher = mkKioskLauncher machineModel atm-app;
};
modules = [
./deploy/nixos/live.nix
determinate.nixosModules.default
{
environment.systemPackages = [
atm-tui.packages.${system}.default
(pkgs.writeShellScriptBin "fund-atm" ''
exec ${pkgs-unstable.nodejs}/bin/node ${atm-app}/dist-electron/fund-atm.bundle.cjs "$@"
'')
];
environment.variables.ATM_DB_PATH = "/var/lib/bitspire/state.db";
}
];
};
# Helper to create a disk-installed NixOS config for a specific machine model.
# Unlike live configs (squashfs + tmpfs), installed configs use ext4 root
# and support `nixos-rebuild switch` for in-place updates.
mkInstalledConfig = machineModel: hardwareModule:
let
atm-app = mkAtmApp {
model = machineModel;
fiatCode = fiatCodeForModel.${machineModel} or "USD";
};
fiatCode = fiatCodeForModel.${machineModel} or "USD";
in
nixpkgs.lib.nixosSystem {
inherit system;
specialArgs = {
inherit pkgs-unstable atm-app;
};
modules = [
hardwareModule
./deploy/nixos/configuration.nix
./deploy/nixos/bitspire-atm.nix
determinate.nixosModules.default
({ config, lib, pkgs, ... }: {
services.bitspire = {
enable = true;
appDir = "${atm-app}";
nfc.enable = nfcReaderForModel.${machineModel} or false;
};
# Management-tunnel address; see wireguardIpForModel.
networking.wireguard.interfaces.wg0.ips =
lib.optional (wireguardIpForModel ? ${machineModel})
wireguardIpForModel.${machineModel};
# Operator TUI and CLI tools
environment.systemPackages = [
atm-tui.packages.${system}.default
(pkgs.writeShellScriptBin "fund-atm" ''
exec ${pkgs-unstable.nodejs}/bin/node ${atm-app}/dist-electron/fund-atm.bundle.cjs "$@"
'')
];
environment.variables.ATM_DB_PATH = "/var/lib/bitspire/state.db";
# Electron sandbox needs unprivileged user namespaces
boot.kernel.sysctl."kernel.unprivileged_userns_clone" = 1;
# Passwordless sudo for remote nixos-rebuild switch
security.sudo.wheelNeedsPassword = false;
# Allow bitspire user to use nix commands + pull from aiolabs binary cache.
# max-jobs = 1: prefer substitution from the cache, but allow ONE
# local build slot for tiny activation-time stitch derivations
# (boot.json, system-units, X-Restart-Triggers, etc.) that are
# inherently per-machine and can never be pre-cached. Heavy
# nixpkgs compiles (rustc, kernel, electron) are still
# effectively cache-only — they're upstream-cached, so a cache
# miss on them stays vanishingly rare in practice.
# max-jobs = 0 was tried first and silently bricked nightly
# auto-upgrades for 6+ days on an uncacheable trivial derivation
# (systemd-boot's boot.json).
nix.settings = {
max-jobs = 1;
# Hard ceiling on any local build's wall-clock time. Activation-
# time stitch derivations (boot.json, system-units, etc.) finish
# in well under a second; anything that doesn't return in 60s
# is by definition a heavy compile that has no business running
# on ATM hardware (kernel, electron, rustc). Kill it fast so
# the upgrade fails loudly instead of silently wedging the box
# for an hour. Time-bounds the max-jobs=1 escape hatch.
timeout = 60;
trusted-users = [ "root" "bitspire" ];
substituters = [ "https://cache.nixos.org" "https://aiolabs.cachix.org" ];
trusted-public-keys = [
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
"aiolabs.cachix.org-1:PrAjsGU9PE77tFKP2+iO+mgR88c4xv3utM9JmpTblUQ="
];
};
# Auto-upgrade: pulls latest flake and runs nixos-rebuild switch.
# NOTE: bitSpire machines pull from the `aiolabs/bitspire` repo —
# the post-migration home of this code. This branch (dev) pins the
# upgrade source to ?ref=dev so any ATM flashed from `dev` stays on
# `dev`. Without the explicit ?ref=dev, nix would resolve the repo's
# default branch and could silently change a dev-deployed Sintra at
# 04:00. (Every live machine now pulls from this repo; the
# pre-rename `aiolabs/lamassu-next` repo no longer feeds anything.)
# To update manually: sudo nixos-rebuild switch --flake git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#<model>-installed
system.autoUpgrade = {
enable = true;
flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed";
flags = [ "--refresh" ];
# Daily at 4am in the machine's own zone; see
# upgradeWindowForModel above.
dates = upgradeWindowForModel.${machineModel} or "04:00";
allowReboot = false;
};
# Minimal env template (aiolabs/bitspire#70 remnant hygiene).
# Seed ONLY image-baked, non-maskable values. Everything else the
# ATM needs comes from the pairing SEED (relay, lnbits_npub, bunker)
# or from LNbits over the transport (operator pubkey, fee config) —
# so we must NOT pre-seed those keys. A present-but-empty
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY / VITE_OPERATOR_PUBKEYS
# is a masking hazard: env WINS over the seed, and this activation
# only writes when .env is ABSENT, so any value written at first
# boot is frozen for the life of the disk. Leaving the keys out
# entirely lets the seed/transport be the sole source.
#
# VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY are emitted ONLY when
# the operator deliberately pins them via the Nix options (non-empty
# default ""), which is an explicit override that wins over the seed.
system.activationScripts.bitspire-env = ''
mkdir -p /var/lib/bitspire
if [ ! -f /var/lib/bitspire/.env ]; then
cp ${pkgs.writeText "bitspire-env-default" (''
VITE_BITSPIRE_MACHINE_MODEL=${machineModel}
VITE_BITSPIRE_FIAT_CODE=${fiatCode}
VITE_SPIRE_SEED=
ELECTRON_FORCE_PROD=1
DISPLAY=:0
# Uncomment to change Electron's GPU flags without a
# rebuild, then `systemctl restart bitspire`. An empty
# value means full GPU acceleration; the line being absent
# means the shipped default (GPU and software rasterizer
# both off). Commented rather than set, because a present
# -but-empty value here would silently enable the GPU on
# every machine that regenerates its .env.
# BITSPIRE_ELECTRON_GPU_FLAGS=
'' + pkgs.lib.optionalString (config.services.bitspire.relayUrl != "") ''
VITE_RELAY_URL=${config.services.bitspire.relayUrl}
'' + pkgs.lib.optionalString (config.services.bitspire.lnbitsServerPubkey != "") ''
VITE_LNBITS_SERVER_PUBKEY=${config.services.bitspire.lnbitsServerPubkey}
'')} /var/lib/bitspire/.env
chmod 600 /var/lib/bitspire/.env
chown bitspire:bitspire /var/lib/bitspire/.env
fi
'';
# Override systemd service for Electron runtime
systemd.services.bitspire = {
serviceConfig = {
EnvironmentFile = lib.mkForce "/var/lib/bitspire/.env";
Environment = "LD_LIBRARY_PATH=${pkgs.stdenv.cc.cc.lib}/lib";
ExecStart = lib.mkForce "${mkKioskLauncher machineModel atm-app}";
MemoryMax = lib.mkForce "1G";
NoNewPrivileges = lib.mkForce false;
ProtectSystem = lib.mkForce false;
ProtectHome = lib.mkForce false;
PrivateTmp = lib.mkForce false;
DevicePolicy = lib.mkForce "auto";
DeviceAllow = lib.mkForce [ "char-* rw" ];
};
};
# Reset eDP display output after X starts
systemd.services.display-reset = {
description = "Reset eDP display output";
after = [ "display-manager.service" ];
requires = [ "display-manager.service" ];
wantedBy = [ "graphical.target" ];
before = [ "bitspire.service" ];
serviceConfig = {
Type = "oneshot";
User = "bitspire";
Environment = "DISPLAY=:0";
ExecStart = "${pkgs.bash}/bin/bash -c '${pkgs.xorg.xrandr}/bin/xrandr --output eDP-1 --off; sleep 1; ${pkgs.xorg.xrandr}/bin/xrandr --output eDP-1 --auto'";
};
};
# Swap file — ATMs have ~2GB RAM; prevents hard-freeze under memory pressure
swapDevices = [{ device = "/var/swapfile"; size = 1024; }];
# Clean /tmp on boot to prevent stale build artifacts filling disk
boot.tmp.cleanOnBoot = true;
# SSH with password for initial provisioning
services.openssh.settings.PasswordAuthentication = lib.mkForce true;
})
];
};
# Module that turns an <model>-installed config into the one a dd'd USB
# stick actually runs. Shared by every *-usb variant so the USB-boot
# hazards are solved once:
# - distinct fs labels (nixos-usb / ESP-USB) so stage-1 can't latch an
# internal drive that already holds a generic nixos/ESP-labelled install;
# - nofail /boot: the firmware already loaded the bootloader before Linux;
# without nofail a slow/late ESP-USB enumeration (BOT is slower than UAS)
# blows past systemd's 90s device-timeout into emergency mode with root
# locked — a dead end. nofail + short timeout lets the already-mounted
# root carry the boot; /boot mounts if/when it shows;
# - NO growPartition/autoResize: sfdisk rewriting the partition table on
# first boot is the single most bus-stressing write, and flaky USB
# bridges drop off the bus mid-rewrite (sfdisk wedges in D-state and
# ESP-USB vanishes with the device). Persistent state is a few MB and
# the image ships ~2GB free. The internal-disk images keep it;
# - autoUpgrade off: no scheduled nix-store churn or bootloader writes on
# the stick. Updates go in-place via `nix copy` + switch-to-configuration
# against the named <model>-usb config (preserves pairing + /var/lib).
usbBootModule = { lib, ... }: {
fileSystems."/".device = lib.mkForce "/dev/disk/by-label/nixos-usb";
fileSystems."/boot".device = lib.mkForce "/dev/disk/by-label/ESP-USB";
fileSystems."/boot".options = [ "nofail" "x-systemd.device-timeout=10s" ];
system.autoUpgrade.enable = lib.mkForce false;
};
# Bus hardening that makes a USB stick a reliable boot medium: keep the
# flash drive off the flaky UAS driver (many bridges advertise UAS then
# drop off the bus under sustained write load — "device offline error,
# dev sdb"), and stop USB autosuspend cutting power mid-I/O. douro.nix
# and batm3.nix carry this in their hardware files because those machines
# boot from USB exclusively; hardware/upboard.nix is SHARED with sintra's
# eMMC install, so for the UP Board models it is scoped to the -usb
# config here rather than changing a production machine's cmdline.
usbBusHardening = {
boot.blacklistedKernelModules = [ "uas" ];
boot.kernelParams = [ "usbcore.autosuspend=-1" ];
};
# Aaeon UP Board firmware (tejo, sintra) USB-boots in Legacy/BIOS mode —
# it boots the live ISO via its isolinux (BIOS) El Torito image, not the
# UEFI ESP. systemd-boot is UEFI-only, so a dd'd systemd-boot image is not
# recognised as bootable at all. Switch the UP Board USB configs to GRUB
# with BOTH BIOS (MBR + bios_grub partition, via mkUsbDiskImage's
# partitionTableType = "hybrid") and UEFI (removable
# /EFI/BOOT/BOOTX64.EFI) — mirroring the live ISO's dual boot — so the
# stick boots on Legacy and UEFI alike. Scoped to the USB configs; the
# eMMC installs keep systemd-boot.
#
# devices = [ "nodev" ] here, NOT the image's disk. This config is also
# what in-place updates (`nix copy` + switch-to-configuration) run against
# on a LIVE stick, where the build VM's /dev/vda does not exist and a BIOS
# grub-install against it would fail the switch. "nodev" regenerates
# grub.cfg and skips the MBR write, which is the correct behaviour for an
# update: GRUB's embedded core.img reads grub.cfg off the partition, so
# the MBR stage never needs rewriting per generation. mkUsbDiskImage's
# grubBiosDevice overrides this for the image build, where the BIOS stage
# genuinely has to be written.
usbGrubHybridModule = { lib, ... }: {
boot.loader.systemd-boot.enable = lib.mkForce false;
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
boot.loader.grub = {
enable = lib.mkForce true;
efiSupport = true;
efiInstallAsRemovable = true;
# Plain definition, NOT mkForce: grubBiosDevice overrides it with
# mkForce, and two mkForce list definitions would merge (both
# priority 50) into [ "/dev/vda" "nodev" ] instead of replacing.
# Nothing else in the module stack defines grub.devices.
devices = [ "nodev" ];
};
};
# dd-able USB image of a <model>-usb config. make-disk-image gives the
# ext4 root the nixos-usb label directly (-L) but hardcodes the ESP FAT
# label to "ESP", so the volume is relabelled to ESP-USB afterwards —
# volume label only; bootloader files are untouched and UEFI loads
# /EFI/BOOT/BOOTX64.EFI regardless.
#
# partitionTableType: "efi" (GPT + ESP, systemd-boot) for batm3 and douro,
# whose firmware UEFI-USB-boots fine via that removable fallback;
# "hybrid" (GPT + bios_grub + ESP) for the UP Board models, paired with
# usbGrubHybridModule. In BOTH layouts the ESP is partition 1 — the hybrid
# table creates the ESP first and the bios_grub partition second — so the
# parted/mlabel relabel below is layout-independent.
#
# grubBiosDevice: the build VM's disk, for hybrid images only. GRUB must
# write its BIOS stage to that disk's MBR at image-build time, while the
# config itself says "nodev" so in-place updates on a live stick work;
# see usbGrubHybridModule.
mkUsbDiskImage =
{ machineModel
, usbConfig
, partitionTableType ? "efi"
, grubBiosDevice ? null
}:
let
imageConfig =
if grubBiosDevice == null then
usbConfig
else
usbConfig.extendModules {
modules = [
({ lib, ... }: {
boot.loader.grub.devices = lib.mkForce [ grubBiosDevice ];
})
];
};
baseImage = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
inherit pkgs lib partitionTableType;
config = imageConfig.config;
format = "raw";
diskSize = "auto";
label = "nixos-usb"; # ext4 root label (make-disk-image -L)
};
in
pkgs.runCommand "nixos-disk-image-${machineModel}-usb"
{ nativeBuildInputs = [ pkgs.parted pkgs.mtools ]; }
''
mkdir -p $out
cp --sparse=always ${baseImage}/nixos.img $out/nixos.img
chmod +w $out/nixos.img
espStart=$(parted -sm "$out/nixos.img" unit B print | awk -F: '$1==1 {gsub("B","",$2); print $2}')
echo "ESP partition starts at byte $espStart — relabelling to ESP-USB"
export MTOOLS_SKIP_CHECK=1
mlabel -i "$out/nixos.img@@$espStart" ::ESP-USB
printf 'verify ESP label: '; mlabel -i "$out/nixos.img@@$espStart" -s :: || true
'';
in
{
# ── NixOS Configurations (top-level, not per-system) ──────────
nixosConfigurations = {
# Ergonomic names: `nixos-rebuild switch --flake .#douro`
douro = mkLiveConfig "douro";
tejo = mkLiveConfig "tejo";
sintra = mkLiveConfig "sintra";
batm3 = mkLiveConfig "batm3";
# Branded aliases for the live configs above. The lamassu-live-* names
# were dropped 2026-10-09; nothing referenced them.
bitSpire-live-douro = mkLiveConfig "douro";
bitSpire-live-tejo = mkLiveConfig "tejo";
bitSpire-live-sintra = mkLiveConfig "sintra";
bitSpire-live = mkLiveConfig "douro";
# Installed-to-disk configs (proper GPT + systemd-boot, supports nixos-rebuild)
douro-installed = mkInstalledConfig "douro" ./deploy/nixos/hardware/douro.nix;
tejo-installed = mkInstalledConfig "tejo" ./deploy/nixos/hardware/upboard.nix;
# Sintra shares Aaeon UP Board hardware with tejo (same validator
# at ttyJ5, dispenser at ttyJ7 layout) — reuse the same hw module.
sintra-installed = mkInstalledConfig "sintra" ./deploy/nixos/hardware/upboard.nix;
batm3-installed = mkInstalledConfig "batm3" ./deploy/nixos/hardware/batm3.nix;
# USB-bootable variants of <model>-installed (see usbBootModule for
# what changes). These are the configs a flashed stick actually runs.
# Exposed as named configs (not just inline in the disk-image targets)
# so their system closures can be built here and deployed in-place with
# `nix copy` + `switch-to-configuration` — updating the app on a running
# stick WITHOUT reflashing (preserves pairing + /var/lib state).
# disk-image-<model>-usb builds its filesystem image from the same config.
batm3-usb = self.nixosConfigurations.batm3-installed.extendModules {
modules = [ usbBootModule ];
};
# douro: the production unit's internal drive is not NixOS, so the
# label disambiguation is moot today, but the nofail /boot and the
# uas/autosuspend hardening in douro.nix are what make a stick a
# reliable boot medium on the Bay Trail box. Same in-place update flow.
douro-usb = self.nixosConfigurations.douro-installed.extendModules {
modules = [ usbBootModule ];
};
# UP Board models get the same run-from-USB shape plus two things the
# Bay Trail / OptiPlex boxes don't need: GRUB on a hybrid table, because
# the Aaeon firmware USB-boots in Legacy/BIOS mode (usbGrubHybridModule),
# and the uas/autosuspend hardening from here instead of
# hardware/upboard.nix, which sintra's eMMC install also reads.
#
# tejo: the unit still runs its factory Debian (ubilinux4) on internal
# storage, so the stick has to BE the system, same as douro. Its
# internal install is not NixOS and carries no nixos/ESP labels, which
# makes the label disambiguation moot there today — but the hardened
# /boot and the bus settings are what make a stick a reliable boot
# medium, so it takes the identical module set as sintra.
tejo-usb = self.nixosConfigurations.tejo-installed.extendModules {
modules = [ usbBootModule usbBusHardening usbGrubHybridModule ];
};
sintra-usb = self.nixosConfigurations.sintra-installed.extendModules {
modules = [ usbBootModule usbBusHardening usbGrubHybridModule ];
};
};
# ── Standalone NixOS module ───────────────────────────────────
nixosModules.default = import ./deploy/nixos/bitspire-atm.nix;
nixosModules.bitspire = import ./deploy/nixos/bitspire-atm.nix;
# ── Packages (x86_64-linux only for ATM hardware) ─────────────
packages.${system} = {
# Pure ATM app derivations
atm-app-douro = mkAtmApp { model = "douro"; fiatCode = "GTQ"; };
atm-app-tejo = mkAtmApp { model = "tejo"; fiatCode = "GTQ"; };
atm-app-sintra = mkAtmApp { model = "sintra"; fiatCode = "EUR"; };
atm-app-batm3 = mkAtmApp { model = "batm3"; fiatCode = "USD"; };
# ISO images
iso-douro = self.nixosConfigurations.douro.config.system.build.isoImage;
iso-tejo = self.nixosConfigurations.tejo.config.system.build.isoImage;
iso-sintra = self.nixosConfigurations.sintra.config.system.build.isoImage;
iso-batm3 = self.nixosConfigurations.batm3.config.system.build.isoImage;
# Raw disk images (dd-able to mSATA/eMMC, proper GPT + ESP)
disk-image-douro = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
inherit pkgs lib;
config = self.nixosConfigurations.douro-installed.config;
format = "raw";
partitionTableType = "efi";
diskSize = "auto";
};
disk-image-sintra = import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
inherit pkgs lib;
config = self.nixosConfigurations.sintra-installed.config;
format = "raw";
partitionTableType = "efi";
diskSize = "auto";
};
# BATM3 (OptiPlex 9030 AIO board-swap) installed image, dd-able to
# its SATA drive. Unlike the douro/sintra images, this one grows
# itself: growPartition expands the root partition to fill whatever
# drive it lands on (16GB today) at first boot and autoResize
# stretches the ext4 to match — no manual parted/resize2fs step
# after flashing, and all the drive's headroom is available to the
# nix store from day one (cf. #55). Image-only override: once
# grown, subsequent nixos-rebuilds against plain batm3-installed
# are unaffected.
disk-image-batm3 =
let
cfg = self.nixosConfigurations.batm3-installed.extendModules {
modules = [
{
boot.growPartition = true;
fileSystems."/".autoResize = true;
}
];
};
in
import (nixpkgs + "/nixos/lib/make-disk-image.nix") {
inherit pkgs lib;
config = cfg.config;
format = "raw";
partitionTableType = "efi";
diskSize = "auto";
};
# USB-bootable images (see usbBootModule / mkUsbDiskImage in the let
# block). Flash with dd or balenaEtcher, boot the stick, done — no
# installer step. The plain disk-image-<model> reuses the generic
# nixos/ESP labels, so a stick carrying it, booted on a machine whose
# internal drive ALREADY holds a nixos/ESP-labelled install, makes
# stage-1's by-label/nixos resolve to the internal drive instead of the
# stick — the stage-2 init path baked into the USB's boot entry isn't on
# that root, so stage 1 aborts. These variants can't hit that.
disk-image-batm3-usb = mkUsbDiskImage {
machineModel = "batm3";
usbConfig = self.nixosConfigurations.batm3-usb;
};
disk-image-douro-usb = mkUsbDiskImage {
machineModel = "douro";
usbConfig = self.nixosConfigurations.douro-usb;
};
# UP Board models: hybrid table + GRUB, so one stick boots on the Aaeon
# firmware's Legacy/BIOS USB path as well as UEFI. Distinct labels also
# matter more here than on douro — a sintra's eMMC already holds a
# nixos/ESP-labelled install, and stage-1 would otherwise race the two
# roots and likely mount the eMMC.
disk-image-tejo-usb = mkUsbDiskImage {
machineModel = "tejo";
usbConfig = self.nixosConfigurations.tejo-usb;
partitionTableType = "hybrid";
grubBiosDevice = "/dev/vda";
};
disk-image-sintra-usb = mkUsbDiskImage {
machineModel = "sintra";
usbConfig = self.nixosConfigurations.sintra-usb;
partitionTableType = "hybrid";
grubBiosDevice = "/dev/vda";
};
# Backwards compat
iso = self.nixosConfigurations.douro.config.system.build.isoImage;
};
}
//
# ── Dev shells (per-system via flake-utils) ───────────────────
flake-utils.lib.eachDefaultSystem (sys:
let
dev-pkgs = import nixpkgs-unstable {
system = sys;
overlays = [ (import rust-overlay) ];
};
in
{
devShells.default = devenv.lib.mkShell {
pkgs = dev-pkgs;
modules = [ ./devenv.nix ];
};
}
);
}