bitspire/deploy/nixos
Padreug 2572a14c61 fix(rpi4): enable pcscd — without it the kiosk hangs before drawing
This is the white screen. Not graphics, not the bundle, not pairing.

The app constructs @pokusew/pcsclite at startup. That calls
SCardEstablishContext(), which calls SCardCheckDaemonAvailability(), which
— finding no pcscd — BUSY-LOOPS in fstatat64 at ~92% CPU rather than
returning an error. It runs on Electron's main thread before the
BrowserWindow is created, so no window is ever made and the panel stays
white.

It is a hang, not a crash, which is why it presented so badly. Nothing
throws. Nothing is logged after "[StateStore] Initialized database". The
process looks healthy: systemd reports the service active, Electron is
running, and there is even a gpu-process. But a setInterval registered
before startup never fires once in 32 seconds, the main thread sits in
state R, and the remote debugger reports zero page targets.

V8's own tooling cannot see it either, because the thread never yields to
the inspector: Debugger.pause returns nothing and Profiler.stop times out.
A native backtrace was the only thing that worked:

  #0 fstatat64                     libc
  #1 SCardCheckDaemonAvailability  libpcsclite
  #2 SCardEstablishContext         libpcsclite
  #3 PCSCLite::PCSCLite()          pcsclite.node
  #4 PCSCLite::New(...)

Ruled out along the way, each by direct test on the machine: graphics (it
fails identically with the GPU fully disabled), Electron on aarch64 (a
minimal app renders fine), the Vue bundle (loading the real index.html
from a minimal main process mounts the app and reaches "[ATM] State
machine initialized"), the preload script, the CSP, kiosk and fullscreen
window options, better-sqlite3, /dev/shm, memory, page size, X
authorisation, and isDev.

upboard.nix and batm3.nix both enable pcscd for their real readers, which
is why no x86 machine has ever hit this. This module did not, and that was
the entire difference. pcscd with no reader attached simply idles, so
enabling it costs nothing.

Worth noting for the wider fleet: any future board that omits pcscd
inherits this, and it presents as a blank screen with a healthy-looking
service. The robust fix is for the app to not block its main thread on a
card-reader handshake at all — the NFC path is already documented as
best-effort — but that is an app change and this unblocks the hardware.
2026-09-25 22:09:36 +02:00
..
hardware fix(rpi4): enable pcscd — without it the kiosk hangs before drawing 2026-09-25 22:09:36 +02:00
udev refactor(deploy): rename system user lamassu → bitspire 2026-06-01 19:11:32 +02:00
atm-transactions.sh fix: rename remaining /var/lib/lamassu-atm references → /var/lib/bitspire (2d follow-up) 2026-06-01 19:08:03 +02:00
bitspire-atm.nix chore(deploy): seed a minimal .env — stop pre-seeding maskable vars (#70) 2026-07-02 21:53:51 +00:00
build-iso.sh feat(deploy): add batm3 ISO build target 2026-03-23 03:20:46 -04:00
configuration.nix perf(deploy): slim the kiosk closure (disable TTS, Qt, docs) 2026-07-02 21:31:36 +02:00
factory-reset-atm.sh feat(deploy): add factory-reset-atm.sh for a truly-fresh machine (#70) 2026-07-02 21:53:51 +00:00
flash-douro-usb.sh chore(nix): bump nixpkgs 24.05 → 24.11 2026-06-01 19:12:11 +02:00
live.nix chore(deploy): seed a minimal .env — stop pre-seeding maskable vars (#70) 2026-07-02 21:53:51 +00:00
provision-atm.sh fix(deploy): provision-atm.sh writes relay/pubkey only on explicit override (#70) 2026-07-02 21:53:51 +00:00
provision-branding.sh feat(machine): operator branding — optional logo-dark.png variant 2026-06-01 19:12:11 +02:00
README.md feat(deploy): add aarch64 Raspberry Pi 4 target 2026-09-24 21:44:25 +02:00

bitSpire NixOS Deployment

NixOS module + tooling for deploying bitSpire to ATM hardware (Sintra, tejo, douro, batm3). The deploy pipeline produces a dd-able raw disk image; you flash it onto the ATM's internal storage and provision the runtime .env with LNbits credentials via SSH.

The production cutover lives on main; this README documents dev reality. Production ATMs (batm3, douro) still run from main with the Lightning.Pub backend until cutover.

File layout

deploy/nixos/
├── bitspire-atm.nix        # NixOS module: services.bitspire option tree + systemd unit + udev rules
├── configuration.nix       # Base system (NixOS 24.11, locale, kernel, packages, bitspire user)
├── live.nix                # Live USB variant (squashfs + tmpfs root) — used by mkLiveConfig
├── hardware/
│   ├── douro.nix           # Dell OptiPlex 9030 AIO (stock Douro motherboard; SATA SSD, eGalax touch)
│   ├── batm3.nix           # GeneralBytes BATM3 chassis with a Dell OptiPlex 9030 AIO grafted in (custom mod; WireGuard wired in)
│   ├── upboard.nix         # Aaeon UP Board (Sintra + tejo; eMMC root via sdhci-acpi + mmc_block)
│   ├── raspberry-pi-5.nix  # Raspberry Pi 5 (aarch64) — DIY build; board glue on top of nixos-hardware
│   └── raspberry-pi-4.nix  # Raspberry Pi 4 (aarch64) — same, previous-generation board
├── udev/
│   └── 99-bitspire-hardware.rules   # additional udev rules (loaded via configuration.nix)
├── provision-atm.sh        # Push LNbits credentials to a deployed ATM via SSH
├── atm-transactions.sh     # Operator query tool — reads /var/lib/bitspire/state.db
├── flash-douro-usb.sh      # Helper for flashing a douro live USB
└── build-iso.sh            # Convenience wrapper for `nix build .#iso-<model>`

Build pipeline

Each ATM model has two flake outputs:

Output (flake.nix) Type Purpose
nixosConfigurations.<model> live squashfs live USB, tmpfs root, no persistence — for first-boot testing
nixosConfigurations.<model>-installed installed full GPT + systemd-boot install, ext4 root, supports nixos-rebuild switch
packages.x86_64-linux.iso-<model> ISO ISO image of the live variant
packages.x86_64-linux.disk-image-<model> raw image dd-able full disk image of the installed variant

Models: douro, tejo, sintra, batm3.

The Raspberry Pi boards (rpi5, rpi4) are aarch64 and follow a different pipeline — SD-card image instead of GPT disk image, U-Boot instead of systemd-boot, and they need an aarch64 builder. See docs/raspberry-pi-setup.md.

# Build a Sintra disk image
nix build .#disk-image-sintra
# → result/nixos.img  (~8.5 GB sparse, 6 GB actual, GPT-partitioned)

# Build a live ISO for tejo
nix build .#iso-tejo

Deploying to Sintra (full walkthrough)

The Sintra ships from the factory with whatever its previous OS was — Android, vendor Linux, or wiped. You need an Alpine live USB to act as your installer.

0. (Re-flash only) Preserve state from the existing Sintra

If you're re-flashing a Sintra that's already in service, dd will wipe /var/lib/bitspire/. Back up the parts you care about first, especially the ATM's nostr private key. Without it, LNbits will treat the reflashed ATM as a brand-new unit and spawn a fresh wallet — the old wallet's balance becomes inaccessible.

mkdir -p ~/sintra-backup-$(date +%Y%m%d)
scp bitspire@<sintra-ip>:/var/lib/bitspire/.env       ~/sintra-backup-$(date +%Y%m%d)/
scp bitspire@<sintra-ip>:/var/lib/bitspire/state.db   ~/sintra-backup-$(date +%Y%m%d)/

The .env is the load-bearing one — it contains VITE_SPIRE_SEED (the NIP-46 bunker pairing seed; or the dev-only VITE_ATM_PRIVATE_KEY fallback) plus the LNbits / relay URLs. Note the persisted bunker binding (the ATM's transport key) lives in state.db once paired — so on a bunker-backed unit, keep state.db too or you'll need to re-pair. state.db also holds transaction history. Reuse these in step 7 instead of regenerating.

Also before powering off the Sintra: make sure any unpushed commits on dev have been pushed AND ./deploy/push-cache.sh sintra has run. Otherwise the next 04:00 auto-upgrade on the freshly-flashed unit will fail to substitute the new closure (or silently downgrade to whatever origin/dev HEAD points at).

1. Prep a flashing USB stick

On your dev box:

nix build .#disk-image-sintra
sudo dd if=result/nixos.img of=/dev/sdX bs=4M status=progress conv=fsync && sync

/dev/sdX is the USB stick you'll carry to the Sintra. Verify the target is the stick, not your workstation's main disk — lsblk -f /dev/sdX should show Flash Drive or similar.

2. Boot Sintra into Alpine live

You need a separate USB stick with a minimal Alpine ISO. Boot it on the Sintra, get to a shell, bring up networking:

ip link set eth0 up
udhcpc -i eth0
ver=$(cat /etc/alpine-release | cut -d. -f1-2)
cat > /etc/apk/repositories <<EOF
http://dl-cdn.alpinelinux.org/alpine/v${ver}/main
http://dl-cdn.alpinelinux.org/alpine/v${ver}/community
EOF
apk update
apk add util-linux coreutils gptfdisk parted e2fsprogs e2fsprogs-extra dosfstools openssh

3. Identify the internal eMMC

lsblk -f
# look for: mmcblk0  with `removable=0`, ~14-32 GB

The bitSpire-flashed USB will show up as another disk (sdb or similar) — note both device paths.

4. dd the bitSpire image to the eMMC

# Limit count to ~11.2 GB (the actual image size + small margin) to avoid
# spending 30 minutes copying empty USB tail. Bump this count if the
# image grows further on future nixpkgs bumps — check `ls -lh result/`.
dd if=/dev/sdb of=/dev/mmcblk0 bs=4M count=2800 status=progress conv=fsync && sync

5. Repair the GPT secondary header

Because the source disk (60 GB USB) is larger than the target (14 GB eMMC), the backup GPT header that was placed at the end of the USB is now off the end of the eMMC. Fix with parted (which also lets us resize root in one go):

parted /dev/mmcblk0 resizepart 2 100%
# When prompted "Fix/Ignore?" answer Fix.
# When prompted "Partition number?" answer 2.
# When prompted "End?" answer 100%.

e2fsck -f /dev/mmcblk0p2
resize2fs /dev/mmcblk0p2

If e2fsck complains No such file or directory ... Possibly non-existent device?: the partition table was re-read by the kernel (so lsblk shows mmcblk0p2 correctly), but Alpine's udev didn't create the /dev/ node. partprobe and blockdev --rereadpt won't fix this — they refresh the kernel's view, not /dev/. Two ways out:

# Cleaner: nudge udev to re-emit the add events
udevadm trigger --action=add --subsystem-match=block
udevadm settle

# Brute force: read the major:minor off lsblk and mknod by hand
# (mmcblk0 partitions are always major 179; minor matches partition number)
mknod /dev/mmcblk0p1 b 179 1
mknod /dev/mmcblk0p2 b 179 2

Then re-run e2fsck -f /dev/mmcblk0p2 && resize2fs /dev/mmcblk0p2. Caught on the 25.11 reflash 2026-05-26.

6. Boot from eMMC

poweroff

Pull both USB sticks. Power Sintra back on. systemd-boot loads from the eMMC's ESP, kernel + initrd come up, you reach a kiosk screen showing "ATM unavailable — needs provisioning" (the .env template lands empty by design).

7. Provision LNbits credentials from the dev box

First-time deploy — generate everything fresh:

LNBITS_SERVER_PUBKEY=$(docker logs <lnbits-container> 2>&1 | \
  grep -oP 'Public key \(share this\):\s*\K[a-f0-9]{64}' | tail -1)

RELAY_URL=ws://<dev-lan-ip>:5001/nostrrelay/test \
LNBITS_SERVER_PUBKEY="$LNBITS_SERVER_PUBKEY" \
ATM_PRIVATE_KEY=$(openssl rand -hex 32) \
bash deploy/nixos/provision-atm.sh <sintra-lan-ip> 22

Re-flash — source the values straight from your step-0 backup so the ATM keeps its LNbits wallet identity:

set -a; source ~/sintra-backup-<date>/.env; set +a
ATM_PRIVATE_KEY=$VITE_ATM_PRIVATE_KEY \
LNBITS_SERVER_PUBKEY=$VITE_LNBITS_SERVER_PUBKEY \
RELAY_URL=$VITE_RELAY_URL \
bash deploy/nixos/provision-atm.sh <sintra-lan-ip> 22

Either way the script SSHes to bitspire@<sintra-lan-ip>:22, writes /var/lib/bitspire/.env, and restarts bitspire.service. After a few seconds the kiosk should connect to LNbits over nostr-transport and show the live UI.

Optional re-flash follow-up — restore transaction history:

scp ~/sintra-backup-<date>/state.db bitspire@<sintra-ip>:/tmp/state.db
ssh bitspire@<sintra-ip> 'sudo install -o bitspire -g bitspire -m 600 /tmp/state.db /var/lib/bitspire/state.db && sudo systemctl restart bitspire'

First-time deploys only: save the generated ATM_PRIVATE_KEY. LNbits identifies this ATM by its public key; if you regenerate the key on a re-provision, LNbits will auto-create a fresh wallet and the old wallet's balance becomes inaccessible. (Re-flashes preserve the key via the step-0 backup.)

Auto-upgrade behaviour

The dev-branch flake.nix pins the auto-upgrade source to ?ref=dev so any ATM flashed from dev stays on dev:

system.autoUpgrade = {
  enable = true;
  flake = "git+ssh://forgejo@git.atitlan.io/aiolabs/bitspire.git?ref=dev#${machineModel}-installed";
  dates = "04:00";
  allowReboot = false;
};

Daily at 04:00 local time the ATM runs nixos-rebuild switch against the latest commit on dev. If the build fails (e.g., binary cache miss + local kernel compile failure), the existing system keeps running; nothing destructive happens. allowReboot = false means a kernel update lands but doesn't take effect until the next operator-initiated reboot.

Production ATMs on main continue to read main's flake (no ?ref= pin → resolves to the repo default branch), so they keep pulling main and stay on the LP backend.

Quick reference — runtime layout on a deployed ATM

Path Owner Purpose
/var/lib/bitspire/ bitspire:bitspire, 0750 Service data directory
/var/lib/bitspire/.env bitspire:bitspire, 0600 Runtime config — VITE_RELAY_URL, VITE_LNBITS_SERVER_PUBKEY, VITE_SPIRE_SEED (or dev VITE_ATM_PRIVATE_KEY), …
/var/lib/bitspire/state.db bitspire:bitspire SQLite — cassette inventory, cashbox state, transaction history
/var/lib/bitspire/logs/ bitspire:bitspire, 0750 Service logs (if app writes them)
/var/lib/bitspire/branding/ bitspire:bitspire, 0755 Operator branding override (logo.png + branding.json) — see issue #47
/opt/bitspire/ bitspire:bitspire Optional override drop for app assets (mostly unused — app comes from /nix/store)
/etc/bitspire/config.env root:root Static config emitted by the NixOS module (RELAY_URL, LNBITS_SERVER_PUBKEY — informational; the renderer reads /var/lib/bitspire/.env instead)

Common operations

Service status

sudo systemctl status bitspire
sudo journalctl -u bitspire -f
sudo journalctl -u bitspire --since "5 minutes ago" | grep -E '\['   # filter to renderer logs

Re-provision (rotate credentials, fix wrong relay URL, etc.)

Re-run provision-atm.sh with new env vars. The script overwrites /var/lib/bitspire/.env and restarts the service.

Push code without re-flashing

From the dev box:

nixos-rebuild switch --flake .#sintra-installed \
  --target-host bitspire@<sintra-lan-ip> --use-remote-sudo

Locally builds the new closure (binary-cache where possible), copies it to the ATM over SSH, activates the new generation. A kernel-or-initrd change still requires a reboot to take effect — sudo systemctl reboot over SSH afterwards.

Inspect transactions

sudo bash /etc/nixos/atm-transactions.sh
# → reads /var/lib/bitspire/state.db and prints recent transactions

Check hardware-side health

# udev symlinks expected by upboard.nix
ls -la /dev/ttyJ4 /dev/ttyJ5 /dev/ttyJ7

# Real UART layout
sudo dmesg | grep -E 'ttyS[0-9]'

# All USB serial bridges
ls -la /dev/serial/by-id/

NixOS module reference

services.bitspire options (defined in bitspire-atm.nix):

{
  services.bitspire = {
    enable                    = true;
    relayUrl                  = "";                                     # seed-provided (#70); set to PIN a relay
    lnbitsServerPubkey        = "";                                     # seed-provided (#70); set to PIN a pubkey
    appDir                    = "/opt/bitspire";                         # rarely overridden — defaults via flake
    dataDir                   = "/var/lib/bitspire";                     # rarely overridden
    logLevel                  = "info";                                  # error | warn | info | debug

    billValidator = {
      enable  = true;
      device  = "/dev/ttyJ5";    # symlink emitted by upboard.nix udev rules
      type    = "id003";         # id003 | mei | ccnet
    };

    billDispenser = {
      enable  = true;
      device  = "/dev/ttyJ7";    # symlink for the SoC MMIO UART on Sintra (was ttyS4)
      type    = "f56";           # puloon | genmega | f56
    };

    camera = {
      enable  = true;
      device  = "/dev/video0";
    };
  };
}

Most of these are set automatically by the mkInstalledConfig helper in the root flake.nix. You typically only override them in a one-off NixOS config or for hardware different from what flake.nix knows about.

Hardware support

Component Drivers in packages/hal/ Tested on
Bill validator id003, ccnet, cashflow_sc, bnr_advance, genmega, hcm2, gsr50 iVIZION (id003) on Sintra
Bill dispenser puloon, f56, genmega, hcm2, gsr50 Fujitsu F56 on Sintra
Camera any V4L2 USB device Z-Star 0ac8:0345 on Sintra
Touchscreen any libinput-compatible device ILI 222a:0001 on Sintra, eGalax on douro

Sintra-specific gotchas

  • eMMC controller is ACPI-enumerated. upboard.nix force-loads sdhci-acpi and mmc_block in initrd.kernelModules so root-by-label resolves in stage 1.
  • Dispenser is on ttyS4, the SoC MMIO UART. This is the only on-carrier RS-232 besides the legacy 8250 at ttyS0. The kernel console must not be routed through ttyS4 (use console=tty0 only); routing it through ttyS4 blocks userspace from opening the port for HAL.
  • ttyS1, ttyS2, ttyS3 are placeholder kernel nodes. Opening them returns EIO. Only ttyS0 (legacy 16550A at I/O 0x3f8) and ttyS4 (MMIO 16550A at 0xa171b000) are real UARTs on this hardware.

Security notes

  • bitspire user has wheel/passwordless-sudo to allow remote nixos-rebuild switch via --use-remote-sudo. This is acceptable for a kiosk on a network you control. Remove security.sudo.wheelNeedsPassword = false if you want to require a password.
  • SSH password auth is enabled by default to allow initial provisioning. Once you've baked your dev box's pubkey into /home/bitspire/.ssh/authorized_keys, you can disable password auth: services.openssh.settings.PasswordAuthentication = false.
  • /var/lib/bitspire/.env contains the ATM's nostr private key. It's mode 0600, owned by bitspire:bitspire. Don't scp it off the device; if you need to rotate the key, generate fresh and re-provision.
  • No firewall is configured by default. The ATM is meant to be on an operator-controlled network. If you expose it to a wider network, add a networking.firewall rule set restricting inbound to SSH from the operator's IPs only.