bitspire/deploy/nixos/configuration.nix
Padreug e98fd67653 feat(deploy): set the timezone per machine, sintra to Europe/Paris
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.
2026-09-24 12:26:55 +02:00

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;
};
};
}