Compare commits

..

3 commits

Author SHA1 Message Date
7000720ae9 Merge pull request 'fix(nfc): make the Bolt Card reader an opt-in machine capability' (#115) from fix/nfc-opt-in into dev
Reviewed-on: #115
2026-09-29 20:51:37 +00:00
bb2ad39628 feat(deploy): services.bitspire.nfc.enable — declare the reader per machine
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.
2026-09-29 22:49:59 +02:00
ce80d75f95 fix(nfc): bail out when pcscd is absent instead of spinning the main thread
nfc-pcsc's pcsclite binding does not fail when pcscd is not running — it
retries SCardEstablishContext in a tight loop on the calling thread, which
here is Electron's main thread. ~12k stat()s a second on
/run/pcscd/pcscd.comm, event loop dead: the window never paints, the
renderer is never reaped, and the watchdog can't fire because it needs the
same event loop. The douro sat like that for 11 hours at 80% CPU (its
CPUQuota ceiling), ignoring SIGTERM, with nothing in the journal after
[StateStore].

Check the socket exists before touching the binding. This is what makes
the "best-effort, every failure swallowed into a status callback" contract
in the module header true, and it also covers pcscd dying at runtime on a
machine that does have a reader.
2026-09-29 22:49:45 +02:00
6 changed files with 160 additions and 95 deletions

View file

@ -952,9 +952,13 @@ app.whenReady().then(() => {
startWatchdog() startWatchdog()
startCommandPoller() startCommandPoller()
// Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Fully // Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Opt-in
// best-effort: if the reader/pcscd is absent it just reports 'unavailable' // per machine via services.bitspire.nfc.enable, which is off unless a CCID
// and the cash-out QR path is unaffected. // reader is actually fitted: nfc-pcsc's pcsclite binding busy-spins this very
// thread when pcscd is absent and wedges the whole app (see nfc-service.ts).
// nfc-service also re-checks the pcscd socket, so this flag is the coarse
// gate, not the only defence. Cash-out via QR never depends on any of it.
if (process.env.BITSPIRE_NFC_ENABLED === 'true') {
void startNfcReader( void startNfcReader(
(lnurlw) => { (lnurlw) => {
// Don't log the value — it carries the card's single-use SUN p/c. // Don't log the value — it carries the card's single-use SUN p/c.
@ -968,6 +972,9 @@ app.whenReady().then(() => {
mainWindow?.webContents.send('nfc:status', status) mainWindow?.webContents.send('nfc:status', status)
} }
) )
} else {
console.log('[NFC] no reader configured (BITSPIRE_NFC_ENABLED not "true") — skipping init')
}
app.on('activate', () => { app.on('activate', () => {
// macOS: re-create window when dock icon clicked // macOS: re-create window when dock icon clicked

View file

@ -14,6 +14,7 @@
*/ */
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable' export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable'
export interface NfcStatus { export interface NfcStatus {
@ -140,12 +141,34 @@ function resetWedgedReader(): void {
* Start listening for Bolt Card taps. Idempotent. Returns a stop function. * Start listening for Bolt Card taps. Idempotent. Returns a stop function.
* Never throws — failures surface via onStatus. * Never throws — failures surface via onStatus.
*/ */
/**
* pcsc-lite's client socket, created by pcscd.
*
* When pcscd is NOT running, the pcsclite binding inside nfc-pcsc does not
* fail — it retries SCardEstablishContext in a tight loop on the calling
* thread (~12k stat()s per second on this path), and that thread is Electron's
* main thread. The event loop then stops turning entirely: the window never
* paints, the dead renderer is never reaped, and main.ts's watchdog can't fire
* either, so nothing recovers it. A douro with no reader fitted sat wedged
* like that for 11 hours, ignoring SIGTERM.
*
* Checking for the socket first is what makes the "best-effort" contract in
* this module's header actually true. It also covers pcscd dying at runtime on
* a machine that does have a reader.
*/
const PCSCD_SOCKET = '/run/pcscd/pcscd.comm'
export async function startNfcReader( export async function startNfcReader(
onCard: CardHandler, onCard: CardHandler,
onStatus: StatusHandler onStatus: StatusHandler
): Promise<() => void> { ): Promise<() => void> {
if (stopFn) return stopFn if (stopFn) return stopFn
if (!existsSync(PCSCD_SOCKET)) {
onStatus({ state: 'unavailable', message: `pcscd not running (${PCSCD_SOCKET} absent)` })
return () => {}
}
let mod: unknown let mod: unknown
try { try {
// Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc // Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc

View file

@ -132,6 +132,28 @@ in
description = "Camera device"; 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 { config = mkIf cfg.enable {
@ -171,6 +193,70 @@ in
ELECTRON_DISABLE_GPU=false 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 # Main ATM service
systemd.services.bitspire = { systemd.services.bitspire = {
description = "bitSpire ATM Application"; description = "bitSpire ATM Application";
@ -181,6 +267,11 @@ in
]; ];
wants = [ "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 = { serviceConfig = {
Type = "simple"; Type = "simple";
User = "bitspire"; User = "bitspire";

View file

@ -85,65 +85,10 @@
cpuFreqGovernor = "performance"; cpuFreqGovernor = "performance";
}; };
# PC/SC daemon for the Feitian KP382 contactless reader (096e:0608, a CCID # The Feitian KP382 contactless reader (096e:0608) is declared as a machine
# smart-card reader) used for Bolt Card tap-to-pay on cash-out. Enabling it # capability, not here: `nfcReaderForModel` in flake.nix drives
# binds the CCID driver to the reader; the app talks to pcscd's socket (via # services.bitspire.nfc.enable, which owns pcscd, the polkit rules and the
# nfc-pcsc) rather than the USB device directly. Harmless if no reader is # wedge-recovery unit (deploy/nixos/bitspire-atm.nix).
# attached — pcscd just idles.
services.pcscd.enable = true;
# pcscd gates client access via polkit; without a rule the sandboxed
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
# it to talk to the daemon and the card. The second rule lets the app trigger
# the NFC reader wedge-recovery service (see nfc-reader-reset below).
security.polkit.extraConfig = ''
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. The Feitian R502-CL CCID reader (and, less often,
# any CCID reader) 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. Reader-agnostic:
# it matches the USB CCID interface class (0x0B), so it also covers a future
# ACR1252U swap without a config change.
systemd.services.nfc-reader-reset = {
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; }
'';
};
};
# Disable suspend/hibernate for kiosk # Disable suspend/hibernate for kiosk
systemd.targets = { systemd.targets = {

View file

@ -91,26 +91,9 @@
cpuFreqGovernor = "performance"; cpuFreqGovernor = "performance";
}; };
# PC/SC daemon for the HID Global OMNIKEY 5022 contactless reader # No pcscd here. This file is shared by sintra (HID Global OMNIKEY 5022
# (076b:5022, a CCID smart-card reader) used for Bolt Card tap-to-enter # fitted) and tejo (no reader), so the reader is declared per model via
# (ADR-003). pcscd binds the CCID driver; the app talks to pcscd's socket # `nfcReaderForModel` in flake.nix → services.bitspire.nfc.enable.
# (via nfc-pcsc) rather than the USB device directly. Device-agnostic —
# same wiring as batm3's Feitian KP382; harmless if no reader is attached,
# pcscd just idles. Shared by every upboard machine (sintra, tejo).
services.pcscd.enable = true;
# pcscd gates client access via polkit; without a rule the sandboxed
# `bitspire` service user is "Rejected unauthorized PC/SC client". Authorize
# it to talk to the daemon and the card.
security.polkit.extraConfig = ''
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;
}
});
'';
# Disable suspend/hibernate for kiosk # Disable suspend/hibernate for kiosk
systemd.targets = { systemd.targets = {

View file

@ -144,6 +144,21 @@
sintra = "04:00 Europe/Paris"; sintra = "04:00 Europe/Paris";
}; };
# Which models have a contactless (CCID) card reader fitted, for Bolt
# Card taps. Same keying caveat as the two tables above.
#
# This can't live in a hardware file: hardware/upboard.nix is shared by
# sintra (HID Global OMNIKEY 5022) and tejo (nothing fitted), so before
# this table the tejo inherited pcscd it had no use for while the douro,
# with its own hardware file, got none and wedged on boot — nfc-pcsc
# busy-spins Electron's main thread when pcscd is absent (see
# apps/machine/electron/nfc-service.ts). Flip a model to true when a
# reader is actually fitted; douro and tejo are planned.
nfcReaderForModel = {
batm3 = true; # Feitian KP382
sintra = true; # HID Global OMNIKEY 5022
};
lib = nixpkgs.lib; lib = nixpkgs.lib;
# Helper to create a live USB NixOS config for a specific machine model # Helper to create a live USB NixOS config for a specific machine model
@ -200,6 +215,7 @@
services.bitspire = { services.bitspire = {
enable = true; enable = true;
appDir = "${atm-app}"; appDir = "${atm-app}";
nfc.enable = nfcReaderForModel.${machineModel} or false;
}; };
# Operator TUI and CLI tools # Operator TUI and CLI tools