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/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 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 038a599..e6ddaaf 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