Every machine inherited America/Guatemala from the shared base config, which is right for douro and tejo and wrong for sintra in France. It read six hours behind, so its journal timestamps had to be converted by hand against anything on the relay. The clock is not cosmetic here. system.autoUpgrade's `dates = "04:00"` is local time, so the zone decides when the nightly rebuild restarts the app and the Fujitsu dispenser runs its audible init routine. On sintra that was firing at 10:17 in the morning, in the room, rather than at 4am. timeZoneForModel mirrors fiatCodeForModel and lists only the exceptions. The base config keeps America/Guatemala as the fleet default, now under mkDefault so a per-model value wins without mkForce. Verified by eval: sintra resolves to Europe/Paris, douro, tejo and batm3 are unchanged. batm3 is deliberately left alone. It is USD and in the field, and I do not know where.
275 lines
9.3 KiB
Nix
275 lines
9.3 KiB
Nix
# bitSpire ATM NixOS Configuration
|
|
# Base system configuration for ATM kiosk
|
|
|
|
{ config, lib, pkgs, pkgs-unstable, ... }:
|
|
|
|
{
|
|
# System basics
|
|
system.stateVersion = "24.05";
|
|
|
|
# ── Image slimming (bitspire#70 sizing) ──────────────────────────────
|
|
# This is a single-purpose Electron kiosk; strip the desktop/multimedia
|
|
# baggage NixOS pulls in by default so the disk image stays lean.
|
|
# - speechd: text-to-speech (speech-dispatcher → espeak-ng → mbrola, ~1GB).
|
|
# An ATM does not talk.
|
|
# - documentation: man/info/NixOS manual — no one reads them on a kiosk.
|
|
services.speechd.enable = lib.mkForce false;
|
|
documentation.enable = false;
|
|
documentation.nixos.enable = false;
|
|
|
|
# Networking
|
|
networking = {
|
|
hostName = "bitspire";
|
|
|
|
# Use NetworkManager for easy WiFi configuration
|
|
networkmanager.enable = true;
|
|
|
|
# Firewall - minimal exposure
|
|
firewall = {
|
|
enable = true;
|
|
allowedTCPPorts = [ ]; # ATM initiates all connections
|
|
allowedUDPPorts = [ 51820 ]; # WireGuard
|
|
};
|
|
|
|
# WireGuard VPN tunnel to VPS for remote SSH access
|
|
# IP address set per-machine in hardware/*.nix via networking.wireguard.interfaces.wg0.ips
|
|
wireguard.interfaces.wg0 = {
|
|
listenPort = 51820;
|
|
privateKeyFile = "/var/lib/wireguard/wg0.key";
|
|
|
|
peers = [{
|
|
publicKey = "R6uB4o5ELEKEHCvK+llRYbzdkZGDHegVmS0f08aRtWM=";
|
|
endpoint = "170.75.161.21:51820";
|
|
allowedIPs = [ "10.0.0.0/24" ];
|
|
persistentKeepalive = 25;
|
|
}];
|
|
};
|
|
};
|
|
|
|
# Fleet default. Override per machine with timeZoneForModel in flake.nix —
|
|
# mkDefault is what lets that override win without mkForce.
|
|
time.timeZone = lib.mkDefault "America/Guatemala";
|
|
|
|
# Locale
|
|
i18n.defaultLocale = "en_US.UTF-8";
|
|
|
|
# Users
|
|
users.groups.bitspire = { };
|
|
users.users.bitspire = {
|
|
isNormalUser = true;
|
|
group = "bitspire";
|
|
description = "bitSpire ATM";
|
|
home = "/home/bitspire";
|
|
extraGroups = [
|
|
"wheel" # For admin access
|
|
"video" # GPU access
|
|
"audio" # Sound
|
|
"dialout" # Serial ports
|
|
"plugdev" # USB devices
|
|
"networkmanager" # Network config
|
|
];
|
|
# No password - kiosk mode
|
|
initialPassword = "bitspire"; # pragma: allowlist secret
|
|
};
|
|
|
|
# Kiosk display configuration
|
|
services.xserver = {
|
|
enable = true;
|
|
|
|
# No desktop environment - just the ATM app
|
|
desktopManager.xterm.enable = false;
|
|
|
|
# Basic window manager for Electron
|
|
windowManager.openbox.enable = true;
|
|
|
|
# Disable screen blanking
|
|
serverFlagsSection = ''
|
|
Option "BlankTime" "0"
|
|
Option "StandbyTime" "0"
|
|
Option "SuspendTime" "0"
|
|
Option "OffTime" "0"
|
|
'';
|
|
|
|
# Intel driver
|
|
videoDrivers = [ "modesetting" ];
|
|
};
|
|
|
|
# Display manager - auto-login (top-level since NixOS 24.11+)
|
|
services.displayManager.autoLogin = {
|
|
enable = true;
|
|
user = "bitspire";
|
|
};
|
|
|
|
# Audio (for transaction sounds)
|
|
security.rtkit.enable = true;
|
|
services.pipewire = {
|
|
enable = true;
|
|
alsa.enable = true;
|
|
pulse.enable = true;
|
|
};
|
|
|
|
# System packages
|
|
environment.systemPackages = with pkgs; [
|
|
# System utilities
|
|
htop
|
|
vim
|
|
git
|
|
curl
|
|
wget
|
|
|
|
# Hardware debugging
|
|
usbutils
|
|
pciutils
|
|
lsof
|
|
|
|
# Serial port tools
|
|
minicom
|
|
screen
|
|
|
|
# For the Electron app
|
|
pkgs-unstable.electron
|
|
|
|
# Node.js for the application
|
|
pkgs-unstable.nodejs_22
|
|
|
|
# Camera support. v4l-utils' default build drags in the whole Qt6 stack
|
|
# for its qv4l2 GUI (~0.5GB) — we only ever use the v4l2-ctl CLI, so drop
|
|
# the GUI.
|
|
(v4l-utils.override { withGUI = false; })
|
|
fswebcam
|
|
|
|
# ATM operations
|
|
sqlite
|
|
(writeShellScriptBin "atm-transactions" (builtins.readFile ./atm-transactions.sh))
|
|
];
|
|
|
|
# Enable SSH for remote administration
|
|
services.openssh = {
|
|
enable = true;
|
|
settings = {
|
|
PasswordAuthentication = false;
|
|
PermitRootLogin = "prohibit-password";
|
|
};
|
|
};
|
|
|
|
# Root SSH key access
|
|
users.users.root.openssh.authorizedKeys.keys = [
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIES8I43AgXppATvtBqnUycakMMs68T2J52cTwNt6qPak padreug@gizmo"
|
|
];
|
|
|
|
# Auto-updates (optional - disabled by default for stability)
|
|
# system.autoUpgrade.enable = false;
|
|
|
|
# Trust the Forgejo host key up front. system.autoUpgrade fetches the flake
|
|
# over ssh AS ROOT, and a machine whose root has never connected by hand has
|
|
# no known_hosts entry, so every nightly run dies at
|
|
# "Host key verification failed" before it reaches authentication. batm3 did
|
|
# exactly that, silently, from its 2026-08-06 install until 09-22 (#98): it
|
|
# sat on its install generation for six weeks while reporting a failed unit
|
|
# nobody was watching. sintra only ever worked because a human had ssh'd as
|
|
# root once and accepted the key. Declaring it means a freshly flashed ATM
|
|
# can update from first boot with no manual step.
|
|
programs.ssh.knownHosts."git.atitlan.io".publicKey =
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMlo3f05o4+bk0+8x2VG91o9GubshOb46HmBPvND9pJx";
|
|
|
|
# pragma: allowlist secret
|
|
# Ensure WireGuard private key directory exists with correct permissions
|
|
system.activationScripts.wireguard-key = ''
|
|
mkdir -p /var/lib/wireguard
|
|
chmod 700 /var/lib/wireguard
|
|
if [ -f /var/lib/wireguard/wg0.key ]; then
|
|
chmod 600 /var/lib/wireguard/wg0.key
|
|
fi
|
|
'';
|
|
|
|
# The tunnel is operator-provisioned: wg0.key is written per machine after
|
|
# flashing, and until it is, `wg set … private-key` exits 1 with
|
|
# "fopen: No such file or directory". One failed unit makes
|
|
# switch-to-configuration exit 4, which marks the entire nightly
|
|
# system.autoUpgrade run as failed — so an ATM that simply never had its
|
|
# tunnel provisioned reports a broken updater for the life of the machine
|
|
# (sintra, #98). Skip the unit when there is no key instead of failing
|
|
# activation over an interface that was never set up; a provisioned machine
|
|
# is unaffected. Guarded on wg0 still being declared so the live image,
|
|
# which mkForce's the interfaces away, doesn't get a unit with no ExecStart.
|
|
systemd.services = lib.mkIf (config.networking.wireguard.interfaces ? wg0) (
|
|
let
|
|
iface = config.networking.wireguard.interfaces.wg0;
|
|
guard = { unitConfig.ConditionPathExists = "/var/lib/wireguard/wg0.key"; };
|
|
# The module emits one unit per peer alongside the interface unit, and a
|
|
# skipped interface is NOT a failed dependency, so the peer units still
|
|
# run and die on "Unable to modify interface: No such device" — same
|
|
# exit 4, different unit. Guard them too. Names come from the module's
|
|
# own `peers.*.name` option (whose default is the escaped public key)
|
|
# rather than re-deriving the escaping here; the `-refresh` suffix
|
|
# follows nixpkgs' peerUnitServiceName, where a peer's null refresh
|
|
# interval falls back to the interface's.
|
|
refreshes = peer:
|
|
(if peer.dynamicEndpointRefreshSeconds != null then
|
|
peer.dynamicEndpointRefreshSeconds
|
|
else
|
|
iface.dynamicEndpointRefreshSeconds) != 0;
|
|
peerUnit = peer:
|
|
"wireguard-wg0-peer-${peer.name}" + lib.optionalString (refreshes peer) "-refresh";
|
|
in
|
|
{ wireguard-wg0 = guard; }
|
|
// lib.listToAttrs (map (peer: lib.nameValuePair (peerUnit peer) guard) iface.peers)
|
|
);
|
|
|
|
# In-place rename migration: lamassu user → bitspire user.
|
|
# Runs after `users` activation so the bitspire user exists with its UID.
|
|
# Idempotent: re-running on an already-migrated system is a chown no-op.
|
|
# Leaves /home/lamassu in place as evidence — operator can `rm -rf` after
|
|
# confirming bitspire works.
|
|
system.activationScripts.bitspire-user-migration = {
|
|
deps = [ "users" ];
|
|
text = ''
|
|
# SSH key migration: copy authorized_keys to /home/bitspire if missing,
|
|
# so the dev box can still SSH in as bitspire after the rename.
|
|
if [ -f /home/lamassu/.ssh/authorized_keys ] \
|
|
&& [ ! -f /home/bitspire/.ssh/authorized_keys ]; then
|
|
mkdir -p /home/bitspire/.ssh
|
|
cp /home/lamassu/.ssh/authorized_keys /home/bitspire/.ssh/authorized_keys
|
|
chown -R bitspire:bitspire /home/bitspire/.ssh
|
|
chmod 700 /home/bitspire/.ssh
|
|
chmod 600 /home/bitspire/.ssh/authorized_keys
|
|
fi
|
|
|
|
# Data dir ownership: state.db / .env / branding/ may still be owned by
|
|
# the now-removed lamassu UID. Reset every boot — cheap no-op once done.
|
|
if [ -d /var/lib/bitspire ]; then
|
|
chown -R bitspire:bitspire /var/lib/bitspire
|
|
fi
|
|
'';
|
|
};
|
|
|
|
# Journal configuration
|
|
services.journald = {
|
|
extraConfig = ''
|
|
SystemMaxUse=100M
|
|
MaxRetentionSec=1week
|
|
'';
|
|
};
|
|
|
|
# Nix settings
|
|
nix = {
|
|
settings = {
|
|
experimental-features = [ "nix-command" "flakes" ];
|
|
auto-optimise-store = true;
|
|
};
|
|
|
|
# Garbage collection — daily at 03:30, 30 min before the 04:00 auto-
|
|
# upgrade so each upgrade attempt gets the freshest headroom. 15GB
|
|
# eMMC + ~7GB closure means cross-release upgrades are always tight;
|
|
# weekly was leaving up to a week of generations stacked when the
|
|
# upgrade ran (caught 2026-05-26 on the 24.05 → 24.11 attempt).
|
|
# persistent so a Sintra that was powered off at 03:30 still runs the
|
|
# GC on next boot rather than skipping until next week.
|
|
gc = {
|
|
automatic = true;
|
|
dates = "03:30";
|
|
options = "--delete-older-than 7d";
|
|
persistent = true;
|
|
};
|
|
};
|
|
}
|