pcscd was enabled in hardware/batm3.nix and hardware/upboard.nix, which cannot express "is a reader fitted": upboard.nix is shared by sintra (HID Global OMNIKEY 5022) and tejo (nothing fitted), so tejo inherited pcscd it has no use for, while the douro — with its own hardware file — got none and wedged on every boot. Make it a machine capability instead. services.bitspire.nfc.enable owns pcscd, the two polkit rules and the wedge-recovery unit, and hands the app a BITSPIRE_NFC_ENABLED flag so it doesn't initialise nfc-pcsc at all on a machine with no reader. Per-model truth lives in nfcReaderForModel in flake.nix next to fiatCodeForModel and upgradeWindowForModel, since a shared hardware file can't answer the question. batm3 and sintra are true; douro and tejo flip to true when readers are fitted. The flag goes through the unit's Environment rather than /var/lib/bitspire/.env, because .env is only written when absent — a machine provisioned months ago would never pick up a new value.
377 lines
13 KiB
Nix
377 lines
13 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";
|
|
};
|
|
};
|
|
|
|
# Contactless (CCID) card reader for Bolt Card taps — ADR-003.
|
|
#
|
|
# Opt-in, and deliberately defaulted off: only some machines have a reader
|
|
# fitted, and on a machine without one the app must not so much as
|
|
# initialise nfc-pcsc, because pcsclite busy-spins Electron's main thread
|
|
# when pcscd is absent (the full story is in nfc-service.ts). Per-model
|
|
# truth lives in `nfcReaderForModel` in flake.nix, since sintra and tejo
|
|
# share hardware/upboard.nix but only one of them has a reader.
|
|
nfc = {
|
|
enable = mkOption {
|
|
type = types.bool;
|
|
default = false;
|
|
description = ''
|
|
Enable the Bolt Card reader. Starts pcscd, authorises the `bitspire`
|
|
user to talk to it and to the card via polkit, installs the
|
|
wedge-recovery unit, and tells the app to initialise NFC at all.
|
|
Leave false on machines with no reader fitted; cash-out over QR is
|
|
unaffected either way.
|
|
'';
|
|
};
|
|
};
|
|
};
|
|
|
|
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
|
|
'';
|
|
|
|
# ── Bolt Card reader (services.bitspire.nfc.enable) ─────────────────
|
|
# Lifted out of hardware/batm3.nix and hardware/upboard.nix so that "is a
|
|
# reader fitted" is one per-machine flag rather than a block copied into
|
|
# each hardware file — upboard.nix is shared by sintra (OMNIKEY 5022) and
|
|
# tejo (no reader), so a hardware file cannot answer the question.
|
|
|
|
# pcscd binds the CCID driver to the reader; the app talks to pcscd's
|
|
# socket via nfc-pcsc rather than the USB device directly. Reader-agnostic
|
|
# (Feitian KP382 on batm3, HID Global OMNIKEY 5022 on sintra).
|
|
services.pcscd.enable = mkIf cfg.nfc.enable true;
|
|
|
|
# pcscd gates client access via polkit; without a rule the sandboxed
|
|
# `bitspire` service user is "Rejected unauthorized PC/SC client".
|
|
# Authorise it to talk to the daemon and the card, and to trigger the
|
|
# wedge-recovery unit below.
|
|
security.polkit.extraConfig = mkIf cfg.nfc.enable ''
|
|
polkit.addRule(function(action, subject) {
|
|
if ((action.id == "org.debian.pcsc-lite.access_pcsc" ||
|
|
action.id == "org.debian.pcsc-lite.access_card") &&
|
|
subject.user == "bitspire") {
|
|
return polkit.Result.YES;
|
|
}
|
|
});
|
|
polkit.addRule(function(action, subject) {
|
|
if (action.id == "org.freedesktop.systemd1.manage-units" &&
|
|
action.lookup("unit") == "nfc-reader-reset.service" &&
|
|
subject.user == "bitspire") {
|
|
return polkit.Result.YES;
|
|
}
|
|
});
|
|
'';
|
|
|
|
# NFC reader wedge-recovery. A CCID reader (the Feitian R502-CL especially)
|
|
# can wedge: it keeps detecting a card but every APDU returns "card absent
|
|
# or mute", and ONLY a USB power-cycle clears it — restarting pcscd or the
|
|
# app does not. This oneshot re-binds the reader's USB device (a software
|
|
# replug); pcscd + nfc-pcsc then re-detect it on hotplug with no app
|
|
# restart (verified on-device). The app (unprivileged `bitspire`) starts it
|
|
# via the polkit rule above when it sees repeated read failures. Matches
|
|
# the USB CCID interface class (0x0B), so a future reader swap needs no
|
|
# config change.
|
|
systemd.services.nfc-reader-reset = mkIf cfg.nfc.enable {
|
|
description = "Power-cycle a wedged CCID NFC reader (USB re-bind)";
|
|
serviceConfig = {
|
|
Type = "oneshot";
|
|
ExecStart = pkgs.writeShellScript "reset-nfc-reader" ''
|
|
set -u
|
|
found=0
|
|
for iface in /sys/bus/usb/devices/*:*/bInterfaceClass; do
|
|
[ -f "$iface" ] || continue
|
|
[ "$(${pkgs.coreutils}/bin/cat "$iface" 2>/dev/null)" = "0b" ] || continue
|
|
ifname=$(${pkgs.coreutils}/bin/basename "$(${pkgs.coreutils}/bin/dirname "$iface")")
|
|
dev=''${ifname%%:*}
|
|
echo "reset-nfc-reader: power-cycling CCID reader USB device $dev" >&2
|
|
echo -n "$dev" > /sys/bus/usb/drivers/usb/unbind 2>/dev/null || true
|
|
${pkgs.coreutils}/bin/sleep 2
|
|
echo -n "$dev" > /sys/bus/usb/drivers/usb/bind 2>/dev/null || true
|
|
found=1
|
|
done
|
|
[ "$found" = 1 ] || { echo "reset-nfc-reader: no CCID reader found" >&2; exit 1; }
|
|
'';
|
|
};
|
|
};
|
|
|
|
# Main ATM service
|
|
systemd.services.bitspire = {
|
|
description = "bitSpire ATM Application";
|
|
wantedBy = [ "graphical.target" ];
|
|
after = [
|
|
"graphical.target"
|
|
"network-online.target"
|
|
];
|
|
wants = [ "network-online.target" ];
|
|
|
|
# Read by electron/main.ts. Lives in the unit rather than the .env
|
|
# EnvironmentFile because .env is only written when absent, so a machine
|
|
# provisioned months ago would never pick a new value up.
|
|
environment.BITSPIRE_NFC_ENABLED = boolToString cfg.nfc.enable;
|
|
|
|
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
|
|
];
|
|
};
|
|
}
|