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).
713 lines
35 KiB
Nix
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 ];
|
|
};
|
|
}
|
|
);
|
|
}
|