bitspire/deploy/nixos/bitspire-atm.nix
Padreug 42c0d3e9ca chore(deploy): seed a minimal .env — stop pre-seeding maskable vars (#70)
The bitspire-env activation seeds .env only when ABSENT (never refreshes on
redeploy), and env WINS over the pairing seed — so any value written at first
boot is frozen for the disk's life and silently masks the seed's source. That's
how a dead relay.aiolabs.dev and a provisioned VITE_OPERATOR_PUBKEYS made stale
installs "work" while a fresh machine broke.

Seed ONLY image-baked, non-maskable values (model, fiat, ELECTRON_FORCE_PROD,
DISPLAY, empty VITE_SPIRE_SEED placeholder). Relay + server pubkey come from the
seed; operator pubkey + fee config come from LNbits over the transport — so those
keys are no longer pre-seeded at all. VITE_RELAY_URL / VITE_LNBITS_SERVER_PUBKEY
are emitted only when the operator deliberately pins them via the Nix options (an
explicit override). Also drops the inert RELAY_URL/LNBITS_SERVER_PUBKEY lines from
/etc/bitspire/config.env (never loaded — EnvironmentFile is forced to .env).

Verified: built sintra-installed .env template is 5 lines, 0 maskable vars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 21:53:51 +00:00

286 lines
8.2 KiB
Nix

# bitSpire ATM Service Module
# Manages the ATM Electron application and related services
{
config,
lib,
pkgs,
pkgs-unstable,
...
}:
with lib;
let
cfg = config.services.bitspire;
in
{
options.services.bitspire = {
enable = mkEnableOption "bitSpire ATM service";
relayUrl = mkOption {
type = types.str;
default = "";
description = ''
Optional override for the Nostr relay the ATM uses. Empty by
default (aiolabs/bitspire#70): the relay comes from the pairing
SEED, not from provisioning — a fresh machine boots blank, scans a
spire-seed, and the seed's relay drives the connection. A non-empty
value here is seeded into `/var/lib/bitspire/.env` as
`VITE_RELAY_URL=…` and WINS over the seed (env-first precedence), so
only set it to pin a machine to a specific relay. The renderer's
resolution order is: `VITE_RELAY_URL` (this / .env) → the pairing
seed's relay → a dev-only `ws://localhost:7777` fallback.
'';
};
lnbitsServerPubkey = mkOption {
type = types.str;
default = "";
description = ''
Optional override for the LNbits nostr-transport server pubkey
(hex, 64 chars). Empty by default (aiolabs/bitspire#70): the
pubkey comes from the pairing SEED (the seed's `lnbits_npub`), so
a seed-paired machine needs nothing here. A non-empty value is
seeded into `.env` as `VITE_LNBITS_SERVER_PUBKEY=…` and WINS over
the seed (env-first precedence) — set it only to pin a machine to
a specific server. Mirrors `relayUrl`.
'';
};
appDir = mkOption {
type = types.path;
default = "/opt/bitspire";
description = "Directory containing the ATM application";
};
dataDir = mkOption {
type = types.path;
default = "/var/lib/bitspire";
description = "Directory for ATM data and configuration";
};
logLevel = mkOption {
type = types.enum [
"error"
"warn"
"info"
"debug"
];
default = "info";
description = "Logging level for the ATM application";
};
# Hardware configuration
billValidator = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable bill validator support";
};
device = mkOption {
type = types.str;
default = "/dev/ttyUSB0";
description = "Serial device for bill validator";
};
type = mkOption {
type = types.enum [
"id003"
"mei"
"ccnet"
];
default = "id003";
description = "Bill validator protocol type";
};
};
billDispenser = {
enable = mkOption {
type = types.bool;
default = false;
description = "Enable bill dispenser support (two-way machines)";
};
device = mkOption {
type = types.str;
default = "/dev/ttyUSB1";
description = "Serial device for bill dispenser";
};
type = mkOption {
type = types.enum [
"puloon"
"genmega"
];
default = "puloon";
description = "Bill dispenser type";
};
};
camera = {
enable = mkOption {
type = types.bool;
default = true;
description = "Enable camera for QR code scanning";
};
device = mkOption {
type = types.str;
default = "/dev/video0";
description = "Camera device";
};
};
};
config = mkIf cfg.enable {
# Create data directory
systemd.tmpfiles.rules = [
"d ${cfg.dataDir} 0750 bitspire bitspire -"
"d ${cfg.dataDir}/logs 0750 bitspire bitspire -"
# Operator branding override target (issue #47); empty by default
"d ${cfg.dataDir}/branding 0755 bitspire bitspire -"
];
# Descriptive-only ATM info at /etc/bitspire/config.env. NOTE: this is NOT
# the runtime environment — the systemd service's EnvironmentFile is
# mkForce'd to /var/lib/bitspire/.env, and the renderer reads only VITE_*
# vars. Relay + server pubkey are deliberately omitted here: they come from
# the pairing seed (aiolabs/bitspire#70), and duplicating them as non-VITE
# RELAY_URL/LNBITS_SERVER_PUBKEY only invited "looks authoritative" confusion.
environment.etc."bitspire/config.env".text = ''
# bitSpire ATM Configuration (descriptive; not the runtime env)
LOG_LEVEL=${cfg.logLevel}
DATA_DIR=${cfg.dataDir}
# Hardware
BILL_VALIDATOR_ENABLED=${boolToString cfg.billValidator.enable}
BILL_VALIDATOR_DEVICE=${cfg.billValidator.device}
BILL_VALIDATOR_TYPE=${cfg.billValidator.type}
BILL_DISPENSER_ENABLED=${boolToString cfg.billDispenser.enable}
BILL_DISPENSER_DEVICE=${cfg.billDispenser.device}
BILL_DISPENSER_TYPE=${cfg.billDispenser.type}
CAMERA_ENABLED=${boolToString cfg.camera.enable}
CAMERA_DEVICE=${cfg.camera.device}
# Display
DISPLAY=:0
ELECTRON_DISABLE_GPU=false
'';
# Main ATM service
systemd.services.bitspire = {
description = "bitSpire ATM Application";
wantedBy = [ "graphical.target" ];
after = [
"graphical.target"
"network-online.target"
];
wants = [ "network-online.target" ];
serviceConfig = {
Type = "simple";
User = "bitspire";
Group = "bitspire";
WorkingDirectory = cfg.appDir;
# Environment
EnvironmentFile = "/etc/bitspire/config.env";
# Start the Electron app
ExecStart = "${pkgs-unstable.electron}/bin/electron ${cfg.appDir}";
# Restart policy
Restart = "always";
RestartSec = 5;
# Resource limits
MemoryMax = "1G";
CPUQuota = "80%";
# Security hardening
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [
cfg.dataDir
"/tmp"
];
PrivateTmp = true;
# Allow device access for hardware
DeviceAllow = [
"/dev/ttyUSB* rw"
"/dev/ttyACM* rw"
"/dev/ttyS* rw"
"/dev/video* rw"
];
};
# Pre-start script to verify hardware
preStart = ''
echo "bitSpire starting..."
echo "Relay: ${cfg.relayUrl}"
# Check bill validator if enabled
if [ "${boolToString cfg.billValidator.enable}" = "true" ]; then
if [ ! -c "${cfg.billValidator.device}" ]; then
echo "Warning: Bill validator device ${cfg.billValidator.device} not found"
fi
fi
# Check camera if enabled
if [ "${boolToString cfg.camera.enable}" = "true" ]; then
if [ ! -c "${cfg.camera.device}" ]; then
echo "Warning: Camera device ${cfg.camera.device} not found"
fi
fi
'';
};
# Openbox autostart for kiosk mode
environment.etc."xdg/openbox/autostart".text = ''
# Disable screen saver and power management
xset s off
xset -dpms
xset s noblank
# Hide cursor after inactivity
unclutter -idle 3 &
# Start ATM (handled by systemd, but ensure display is ready)
sleep 2
'';
# udev rules for ATM hardware
services.udev.extraRules = ''
# ID-003 Bill Validator (JCM)
SUBSYSTEM=="tty", ATTRS{idVendor}=="0451", ATTRS{idProduct}=="3410", MODE="0666", SYMLINK+="bill-validator"
# MEI Bill Validator
SUBSYSTEM=="tty", ATTRS{idVendor}=="0b00", MODE="0666", SYMLINK+="bill-validator"
# CCNET Bill Validator (CashCode)
SUBSYSTEM=="tty", ATTRS{idVendor}=="0x1b5a", MODE="0666", SYMLINK+="bill-validator"
# Puloon Bill Dispenser
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", MODE="0666", SYMLINK+="bill-dispenser"
# Generic USB-Serial adapters
SUBSYSTEM=="tty", ATTRS{idVendor}=="067b", MODE="0666"
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", MODE="0666"
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", MODE="0666"
# Camera access
SUBSYSTEM=="video4linux", MODE="0666"
'';
# Additional packages for hardware support
environment.systemPackages = with pkgs; [
unclutter # Hide cursor
];
};
}