From ce80d75f95a38abce6fdf54ce511612af784ac1b Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 22:49:45 +0200 Subject: [PATCH 1/2] fix(nfc): bail out when pcscd is absent instead of spinning the main thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/machine/electron/nfc-service.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/apps/machine/electron/nfc-service.ts b/apps/machine/electron/nfc-service.ts index e94ddaf..b128321 100644 --- a/apps/machine/electron/nfc-service.ts +++ b/apps/machine/electron/nfc-service.ts @@ -14,6 +14,7 @@ */ import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' export type NfcState = 'ready' | 'reading' | 'error' | 'card-removed' | 'unavailable' export interface NfcStatus { @@ -140,12 +141,34 @@ function resetWedgedReader(): void { * Start listening for Bolt Card taps. Idempotent. Returns a stop function. * 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( onCard: CardHandler, onStatus: StatusHandler ): Promise<() => void> { if (stopFn) return stopFn + if (!existsSync(PCSCD_SOCKET)) { + onStatus({ state: 'unavailable', message: `pcscd not running (${PCSCD_SOCKET} absent)` }) + return () => {} + } + let mod: unknown try { // Non-literal specifier: nfc-pcsc ships no types; keep it `any` to tsc From bb2ad396289a0daacc3ad5251fc2e3da386aba32 Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 29 Sep 2026 22:49:59 +0200 Subject: [PATCH 2/2] =?UTF-8?q?feat(deploy):=20services.bitspire.nfc.enabl?= =?UTF-8?q?e=20=E2=80=94=20declare=20the=20reader=20per=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/machine/electron/main.ts | 39 +++++++------ deploy/nixos/bitspire-atm.nix | 91 +++++++++++++++++++++++++++++++ deploy/nixos/hardware/batm3.nix | 63 ++------------------- deploy/nixos/hardware/upboard.nix | 23 +------- flake.nix | 16 ++++++ 5 files changed, 137 insertions(+), 95 deletions(-) diff --git a/apps/machine/electron/main.ts b/apps/machine/electron/main.ts index b5801ad..9094eb7 100644 --- a/apps/machine/electron/main.ts +++ b/apps/machine/electron/main.ts @@ -952,22 +952,29 @@ app.whenReady().then(() => { startWatchdog() startCommandPoller() - // Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Fully - // best-effort: if the reader/pcscd is absent it just reports 'unavailable' - // and the cash-out QR path is unaffected. - void startNfcReader( - (lnurlw) => { - // Don't log the value — it carries the card's single-use SUN p/c. - console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`) - mainWindow?.webContents.send('nfc:card-tapped', lnurlw) - }, - (status: NfcStatus) => { - console.log( - `[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}` - ) - mainWindow?.webContents.send('nfc:status', status) - } - ) + // Bolt Card reader — forwards taps (lnurlw) + status to the renderer. Opt-in + // per machine via services.bitspire.nfc.enable, which is off unless a CCID + // 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( + (lnurlw) => { + // Don't log the value — it carries the card's single-use SUN p/c. + console.log(`[NFC] card tapped — lnurlw (${lnurlw.length} chars) → renderer`) + mainWindow?.webContents.send('nfc:card-tapped', lnurlw) + }, + (status: NfcStatus) => { + console.log( + `[NFC] status=${status.state}${status.reader ? ` reader="${status.reader}"` : ''}${status.message ? ` — ${status.message}` : ''}` + ) + mainWindow?.webContents.send('nfc:status', status) + } + ) + } else { + console.log('[NFC] no reader configured (BITSPIRE_NFC_ENABLED not "true") — skipping init') + } app.on('activate', () => { // macOS: re-create window when dock icon clicked diff --git a/deploy/nixos/bitspire-atm.nix b/deploy/nixos/bitspire-atm.nix index fffb92e..8a85ad4 100644 --- a/deploy/nixos/bitspire-atm.nix +++ b/deploy/nixos/bitspire-atm.nix @@ -132,6 +132,28 @@ in 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 { @@ -171,6 +193,70 @@ in 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"; @@ -181,6 +267,11 @@ in ]; 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"; diff --git a/deploy/nixos/hardware/batm3.nix b/deploy/nixos/hardware/batm3.nix index 79f1b4d..ca57f0d 100644 --- a/deploy/nixos/hardware/batm3.nix +++ b/deploy/nixos/hardware/batm3.nix @@ -85,65 +85,10 @@ cpuFreqGovernor = "performance"; }; - # PC/SC daemon for the Feitian KP382 contactless reader (096e:0608, a CCID - # smart-card reader) used for Bolt Card tap-to-pay on cash-out. Enabling it - # binds the CCID driver to the reader; the app talks to pcscd's socket (via - # nfc-pcsc) rather than the USB device directly. Harmless if no reader is - # 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; } - ''; - }; - }; + # The Feitian KP382 contactless reader (096e:0608) is declared as a machine + # capability, not here: `nfcReaderForModel` in flake.nix drives + # services.bitspire.nfc.enable, which owns pcscd, the polkit rules and the + # wedge-recovery unit (deploy/nixos/bitspire-atm.nix). # Disable suspend/hibernate for kiosk systemd.targets = { diff --git a/deploy/nixos/hardware/upboard.nix b/deploy/nixos/hardware/upboard.nix index 499179e..e2a0ddd 100644 --- a/deploy/nixos/hardware/upboard.nix +++ b/deploy/nixos/hardware/upboard.nix @@ -91,26 +91,9 @@ cpuFreqGovernor = "performance"; }; - # PC/SC daemon for the HID Global OMNIKEY 5022 contactless reader - # (076b:5022, a CCID smart-card reader) used for Bolt Card tap-to-enter - # (ADR-003). pcscd binds the CCID driver; the app talks to pcscd's socket - # (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; - } - }); - ''; + # No pcscd here. This file is shared by sintra (HID Global OMNIKEY 5022 + # fitted) and tejo (no reader), so the reader is declared per model via + # `nfcReaderForModel` in flake.nix → services.bitspire.nfc.enable. # Disable suspend/hibernate for kiosk systemd.targets = { diff --git a/flake.nix b/flake.nix index 85895d9..1099ceb 100644 --- a/flake.nix +++ b/flake.nix @@ -144,6 +144,21 @@ 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; # Helper to create a live USB NixOS config for a specific machine model @@ -200,6 +215,7 @@ services.bitspire = { enable = true; appDir = "${atm-app}"; + nfc.enable = nfcReaderForModel.${machineModel} or false; }; # Operator TUI and CLI tools