6 Commits

Author SHA1 Message Date
julian 59bfe2013f Event log: resolve input_received lane from the firing device
Replace the hardcoded lane: 0 on input_received events with a real
device->lane lookup. A new LaneMap caches lane_devices.id -> lane,
built at startup and refreshed by the setup routes on assign/unassign.
An unmapped device logs lane: -1 + a warning (0 is a real lane) and is
still recorded faithfully (append-only chain).

source stays null for raw inputs by design: it's an IdentitySource
(how a vehicle was identified), not a device field; device provenance
remains in identity. Documented both in the wiki.
2026-06-15 12:51:21 +02:00
julian f5fd61984a Dingtian web password: set the admin's chosen password, verified
Fix two bugs found running the real assign flow: the saved web password
didn't match the device (login stayed admin/admin), and the UDP2 warning
never reached the admin.

Web password:
- Split the conflated field into webPassword (the DESIRED login; blank ->
  auto-generate) and webPasswordCurrent (the device's EXISTING password used
  as the old cred, default admin). Before, an admin typing a desired password
  made harden send it as the old cred -> rotation failed -> but the DB still
  saved the typed value, so it claimed a password the device never accepted.
- harden() now rotates current -> desired, VERIFIES by re-authenticating with
  the new password, and only returns secrets.webPassword on success (else a
  warning, nothing saved). Stores webPasswordCurrent for future re-runs.
- assign strips the typed webPassword/webPasswordCurrent and persists only the
  verified secret -- the DB never claims an unapplied password.

Warnings to the UI:
- assignDevice returns warnings[]; SetupWizard shows them in an amber
  "saved, but action needed" banner per category. This is how the admin learns
  the firmware wouldn't disable UDP2 (finish in the device web UI).

Verified on hardware: after harden the device rejects admin/admin and accepts
the chosen password; the UDP2 warning surfaces.
2026-06-15 12:26:34 +02:00
julian 7db5cfa0e4 Dingtian: close password-less string-protocol relay-fire hole
The string protocol (UDP 60001) has no password field but can fire relays
("11" = relay 1 on), bypassing relay_pw entirely. Proven on hardware: an
unauthenticated packet opened a relay. harden() had left it enabled "for
status reads".

- #status() now reads via the authenticated binary command (relay cmd 0x00)
  instead of the string protocol, so the string protocol is no longer needed.
- harden() disables the string protocol (udp2.p=255). BEST-EFFORT: firmware
  V3.6J's config API silently refuses to disable udp2 (the device web UI can),
  so it's not part of the blocking verify -- harden() re-checks and returns a
  warning instead of throwing. After a web-UI disable, the attack is dead and
  binary control/status still work (verified on hardware).
- HardenResult gains an optional `warnings[]`; the assign route surfaces them
  to the admin and logs them.
- Corrected the false comment claiming relay_pw stops an attacker (it is
  defence-in-depth on plaintext UDP, not a boundary).
- Thread localAddress through the driver's UDP/HTTP calls so a multi-homed
  host sources device traffic from the device-facing NIC.
- Device web login (webUser/webPassword) is no longer redacted from setup
  state -- it's an operational credential for the admin-only device area;
  pushPassword/relayPassword stay machine-only.

Wiki: document the vuln + fix, the firmware caveat, and the out-of-band
actuation gap (the log captures host actions only; reconciliation vs. an
independent witness is the real control and is not yet built).
2026-06-15 11:29:55 +02:00
julian add5fc0166 Append-only signed event log; persist Dingtian input pushes
Implement the core anti-fraud primitive: an append-only, hash-chained,
signed event log (the schema + types predated this; the writer/signer are new).

- EventLog (apps/server): serialized append, monotonic index, prevHash chain,
  signature; verifyChain() detects tamper/reorder/delete. No update/delete paths.
- Signer abstraction (packages/shared) over the ATECC608 secure element, with a
  SoftwareSigner (HMAC, EVENT_SIGNING_KEY) shipped now since the chip is still
  open-question #6. Documented: software signer is tamper-evident but NOT
  unforgeable-by-owner.
- Add ParkingEventType "input_received" for raw device inputs (not yet a
  vehicle_entry, which the entry flow will append later).
- Read API: GET /api/events; integrity self-check: GET /api/events/verify (admin).

Verified on hardware: shorting the Dingtian inputs produced signed, chained
input_received events; verifyChain ok; direct DB tamper/delete detected.

NOTE: the log captures host-originated actions only. Out-of-band relay
actuation (sniffed relay_pw, string protocol, ip_watchdog) produces no event
by design -- the control is reconciliation vs. an independent witness, which is
not yet built. See wiki/concepts/append-only-event-chain.md.
2026-06-15 11:29:36 +02:00
julian 39d4bac419 Setup: manage multiple device instances per category (add/remove)
The data model was already multi-instance (lane_devices = one row per
instance; assign always inserts) -- the limitation was UI-only. Make the
whole flow support more than one of every category:

- Backend: add DELETE /api/setup/assign/:id (unassign by id). /state now
  redacts secrets (pushPassword/webPassword/relayPassword) via a shared
  redactSecrets() also used by /assign -- it was returning raw config rows.
- Web: SetupWizard reworked from one fixed slot per category into a list of
  assigned instances (driver/role/host + Remove) plus an "Add another" form.
  select-type config fields (e.g. printer role) now render as dropdowns.
- api.ts: add fetchState(), unassignDevice(), Assignment/SetupState types.

Verified via Fastify inject: two printers assigned to one lane both list,
no secret leak, delete -> 204, delete unknown -> 404, count drops to 1.
Full repo typechecks.

Wiki: first-run-setup documents multi-instance + delete + redaction.
2026-06-14 20:39:39 +02:00
julian b2a0471b08 Rongta 80mm printer: driver, role-based failover, live status monitoring
Add the rongta PrinterDevice driver (ESC/POS over raw TCP 9100) and the
device-agnostic pieces around it:

- Roles + failover: each printer declares a role (entry-dispenser/booth-
  receipt) and failoverRank; printer-routing.ts picks the best healthy printer
  and falls back outside->booth for entry tickets (never the reverse).
- Live status: MonitorableDevice.readStatus()/PrinterStatus capability. The
  Rongta driver scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper
  End/Near End/Off-Line) rather than hand-decoding DLE EOT, whose reply bytes
  on this clone don't match the canonical ESC/POS bit layout (verified on
  hardware) -- avoids a false-healthy. Maps to ready/degraded/offline, fail
  safe on an unreachable or unexpected page.
- Server PrinterMonitor polls enabled printers (PRINTER_POLL_MS, default 5s),
  caches latest, emits "printer-status" on change. Exposed via
  GET /api/printers/status and an SSE stream for the booth UI.

Verified against 10.0.10.6: ready when healthy, offline when unreachable
(no throw), bus emits on change and suppresses unchanged reads.

Wiki: new rongta-printer entity, printer-roles-failover and
printer-status-monitoring concepts; BOM/index/log updated.
2026-06-14 20:26:45 +02:00
27 changed files with 1948 additions and 173 deletions
+19
View File
@@ -1,4 +1,5 @@
import { EventEmitter } from "node:events";
import type { PrinterStatus } from "@parking/devices";
// Internal event bus for device-originated events (button presses, etc.).
// Hardware drivers / inbound device pushes emit here; business logic (entry
@@ -14,6 +15,15 @@ export interface DeviceInputEvent {
readonly source: "push" | "poll";
}
/** A printer's status as tracked by the live monitor (status + identity). */
export interface PrinterStatusEvent {
readonly deviceId: string; // lane_devices id
readonly lane: number;
readonly driverId: string;
readonly role?: string; // entry-dispenser | booth-receipt
readonly status: PrinterStatus;
}
class DeviceEventBus extends EventEmitter {
emitInput(event: DeviceInputEvent): void {
this.emit("input", event);
@@ -22,6 +32,15 @@ class DeviceEventBus extends EventEmitter {
this.on("input", cb);
return () => this.off("input", cb);
}
/** Emitted by the printer monitor whenever a printer's status CHANGES. */
emitPrinterStatus(event: PrinterStatusEvent): void {
this.emit("printer-status", event);
}
onPrinterStatus(cb: (event: PrinterStatusEvent) => void): () => void {
this.on("printer-status", cb);
return () => this.off("printer-status", cb);
}
}
/** Process-wide device event bus. */
+147
View File
@@ -0,0 +1,147 @@
import { createHash, randomUUID } from "node:crypto";
import { desc, events, type Db, type EventRow } from "@parking/db";
import type { Direction, IdentitySource, ParkingEventType, Signer } from "@parking/shared";
// The append-only, hash-chained, signed event log — the system's core anti-fraud
// primitive (see wiki/concepts/append-only-event-chain.md). Entry/exit and device
// events are NEVER edited or deleted; a correction/void is a new appended row.
//
// Integrity rules enforced here:
// - monotonic `index` (prev + 1; the unique constraint is the backstop),
// - `prevHash` = hash of the previous row's canonical form (genesis = null),
// - `signature` = signer.sign(canonical) over a STABLE field ordering,
// - appends are SERIALIZED: read-prev -> compute-hash -> insert must not
// interleave, or two events could claim the same index / chain off a stale
// prev. SQLite is single-writer, but the read+compute+insert is multi-step,
// so we guard it with an in-process async lock as well.
export interface AppendInput {
readonly type: ParkingEventType;
readonly lane: number;
readonly direction?: Direction | null;
readonly source?: IdentitySource | null;
readonly identity?: string | null;
/** Event time (ISO-8601). Defaults to now. */
readonly occurredAt?: string;
}
/**
* Canonical serialization of an event's signed/hashed content. Order is FIXED
* and explicit — the hash chain and signatures depend on byte-stable output, so
* this must never change for already-written events (versioned via keyId if it
* ever must). The volatile DB row id is deliberately excluded; identity in the
* chain is `index` + content.
*/
export function canonicalize(e: {
index: number;
type: string;
direction: string | null;
lane: number;
source: string | null;
identity: string | null;
occurredAt: string;
prevHash: string | null;
}): string {
return JSON.stringify([
e.index,
e.type,
e.direction ?? null,
e.lane,
e.source ?? null,
e.identity ?? null,
e.occurredAt,
e.prevHash ?? null,
]);
}
/** SHA-256 of an event's canonical form (hex) — what the NEXT event chains to. */
export function hashEvent(canonical: string): string {
return createHash("sha256").update(canonical, "utf8").digest("hex");
}
export class EventLog {
readonly #db: Db;
readonly #signer: Signer;
/** Serialize appends: each waits for the previous to finish. */
#tail: Promise<unknown> = Promise.resolve();
constructor(db: Db, signer: Signer) {
this.#db = db;
this.#signer = signer;
}
/** Append one event to the chain. Returns the persisted row. Serialized. */
append(input: AppendInput): Promise<EventRow> {
const run = this.#tail.then(() => this.#appendNow(input));
// Keep the chain going even if one append rejects (don't wedge the lock).
this.#tail = run.catch(() => undefined);
return run;
}
#appendNow(input: AppendInput): EventRow {
const prev = this.#db
.select()
.from(events)
.orderBy(desc(events.index))
.limit(1)
.get();
const index = (prev?.index ?? 0) + 1;
const prevHash = prev ? hashEvent(canonicalize(prev)) : null;
const occurredAt = input.occurredAt ?? new Date().toISOString();
const canonical = canonicalize({
index,
type: input.type,
direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null,
identity: input.identity ?? null,
occurredAt,
prevHash,
});
const row = {
id: randomUUID(),
index,
type: input.type,
direction: input.direction ?? null,
lane: input.lane,
source: input.source ?? null,
identity: input.identity ?? null,
occurredAt,
prevHash,
signature: this.#signer.sign(canonical),
};
this.#db.insert(events).values(row).run();
return row as EventRow;
}
/**
* Walk the chain oldest→newest and recompute hashes + signatures. Returns the
* first detected break, or { ok: true }. This is what reconciliation and an
* integrity self-check call. Catches: tampered content, reordering, a deleted
* row (index gap), and a forged/invalid signature.
*/
verifyChain(): { ok: true } | { ok: false; index: number; reason: string } {
const rows = this.#db.select().from(events).orderBy(events.index).all();
let expectedIndex = 1;
let prevHash: string | null = null;
for (const row of rows) {
if (row.index !== expectedIndex) {
return { ok: false, index: row.index, reason: `index gap: expected ${expectedIndex}` };
}
if ((row.prevHash ?? null) !== prevHash) {
return { ok: false, index: row.index, reason: "prevHash does not match chain" };
}
const canonical = canonicalize(row);
if (!this.#signer.verify(canonical, row.signature)) {
return { ok: false, index: row.index, reason: "signature invalid (content tampered or wrong key)" };
}
prevHash = hashEvent(canonical);
expectedIndex += 1;
}
return { ok: true };
}
}
+30
View File
@@ -0,0 +1,30 @@
import { laneDevices, type Db } from "@parking/db";
// Resolves a device instance id (lane_devices.id) to its lane number.
//
// Device pushes/events carry the `lane_devices` id (which device fired), not a
// lane. The event log wants the lane, so we keep a small in-memory id->lane map
// rebuilt from the DB at startup and refreshed whenever assignments change
// (assign/unassign). It's tiny (one row per device) and read on the hot path of
// every input event, so a cached map beats a per-event DB lookup.
export class LaneMap {
readonly #db: Db;
#byDeviceId = new Map<string, number>();
constructor(db: Db) {
this.#db = db;
}
/** (Re)load the id->lane map from the lane_devices table. */
refresh(): void {
const rows = this.#db.select().from(laneDevices).all();
const next = new Map<string, number>();
for (const r of rows) next.set(r.id, r.lane);
this.#byDeviceId = next;
}
/** Lane for a device instance id, or null if the device isn't known. */
laneFor(deviceId: string): number | null {
return this.#byDeviceId.get(deviceId) ?? null;
}
}
+159
View File
@@ -0,0 +1,159 @@
import type { FastifyBaseLogger } from "fastify";
import { eq, laneDevices, type Db } from "@parking/db";
import {
isMonitorable,
registry,
type PrinterStatus,
} from "@parking/devices";
import { deviceEvents, type PrinterStatusEvent } from "./device-events.js";
// Live printer-status monitor. Polls every enabled printer that supports
// readStatus() on an interval, caches the latest status in memory, and emits a
// "printer-status" event on the device bus whenever a printer's status CHANGES
// (so the UI/SSE stream and any future entry-flow logic react without polling
// the device themselves). See wiki/concepts/printer-status-monitoring.md.
//
// The poll is the booth's early warning: it surfaces "paper out" / "cover open"
// BEFORE a driver presses the entry button and no ticket prints. Reachability
// failures degrade to status "offline" — the same signal as a dead printer.
const POLL_MS = Number(process.env.PRINTER_POLL_MS ?? 5000);
/** A cached entry: the last status plus the device's identity for the UI. */
interface CachedStatus extends PrinterStatusEvent {}
export class PrinterMonitor {
readonly #db: Db;
readonly #log: FastifyBaseLogger;
readonly #pollMs: number;
/** Latest status per device id. */
readonly #latest = new Map<string, CachedStatus>();
/** Live adapter per device id (rebuilt when the set of printers changes). */
readonly #devices = new Map<string, { build: () => ReturnType<typeof registry.create>; meta: Omit<PrinterStatusEvent, "status"> }>();
#timer: ReturnType<typeof setInterval> | null = null;
#ticking = false;
constructor(db: Db, log: FastifyBaseLogger, pollMs = POLL_MS) {
this.#db = db;
this.#log = log;
this.#pollMs = pollMs;
}
/** Begin polling. Idempotent. */
start(): void {
if (this.#timer) return;
// Kick an immediate pass so status is populated without waiting a full cycle.
void this.#tick();
this.#timer = setInterval(() => void this.#tick(), this.#pollMs);
// Don't keep the event loop alive solely for the monitor.
this.#timer.unref?.();
this.#log.info(`printer-monitor: polling every ${this.#pollMs}ms`);
}
stop(): void {
if (this.#timer) {
clearInterval(this.#timer);
this.#timer = null;
}
}
/** Current snapshot for the API. */
snapshot(): CachedStatus[] {
return [...this.#latest.values()];
}
/** Reload the set of monitored printers from lane_devices (call after assign). */
async refreshDevices(): Promise<void> {
const rows = await this.#db
.select()
.from(laneDevices)
.where(eq(laneDevices.category, "printer"))
.all();
const seen = new Set<string>();
for (const row of rows) {
if (!row.enabled) continue;
const driver = registry.get(row.driverId);
if (!driver) continue;
const cfg = row.config as Record<string, unknown>;
// Probe-build once to check the driver yields a monitorable device.
let monitorable: boolean;
try {
monitorable = isMonitorable(driver.create(cfg as never));
} catch {
monitorable = false;
}
if (!monitorable) continue;
seen.add(row.id);
this.#devices.set(row.id, {
build: () => driver.create(cfg as never),
meta: {
deviceId: row.id,
lane: row.lane,
driverId: row.driverId,
role: typeof cfg.role === "string" ? cfg.role : undefined,
},
});
}
// Drop devices that are no longer present/enabled.
for (const id of [...this.#devices.keys()]) {
if (!seen.has(id)) {
this.#devices.delete(id);
this.#latest.delete(id);
}
}
}
async #tick(): Promise<void> {
if (this.#ticking) return; // never overlap polls
this.#ticking = true;
try {
await this.refreshDevices();
await Promise.all(
[...this.#devices.entries()].map(([id, entry]) => this.#poll(id, entry)),
);
} catch (err) {
this.#log.warn(`printer-monitor tick failed: ${(err as Error).message}`);
} finally {
this.#ticking = false;
}
}
async #poll(id: string, entry: { build: () => ReturnType<typeof registry.create>; meta: Omit<PrinterStatusEvent, "status"> }): Promise<void> {
let status: PrinterStatus;
try {
const device = entry.build();
if (!isMonitorable(device)) return;
status = await device.readStatus();
} catch (err) {
status = {
status: "offline",
detail: (err as Error).message,
checkedAt: new Date().toISOString(),
};
}
const event: PrinterStatusEvent = { ...entry.meta, status };
const prev = this.#latest.get(id);
this.#latest.set(id, event);
if (!prev || statusChanged(prev.status, status)) {
this.#log.info(
`printer-monitor: ${entry.meta.role ?? "printer"} ${id} (lane ${entry.meta.lane}) -> ${status.status}${status.detail ? ` (${status.detail})` : ""}`,
);
deviceEvents.emitPrinterStatus(event);
}
}
}
/** Did the operator-meaningful status change between two reads? */
function statusChanged(a: PrinterStatus, b: PrinterStatus): boolean {
return (
a.status !== b.status ||
a.paperEnd !== b.paperEnd ||
a.paperNearEnd !== b.paperNearEnd ||
a.coverOpen !== b.coverOpen ||
a.cutterError !== b.cutterError ||
a.offline !== b.offline
);
}
+38
View File
@@ -0,0 +1,38 @@
import type { FastifyInstance } from "fastify";
import { desc, events, type Db } from "@parking/db";
import { requireRole } from "../auth.js";
import type { EventLog } from "../event-log.js";
// Read access to the append-only signed event log. NO write/update/delete routes
// exist by design — events are only ever appended internally (entry flow, device
// pushes). Corrections are new appended events, never edits. See
// wiki/concepts/append-only-event-chain.md.
export async function eventRoutes(
app: FastifyInstance,
db: Db,
eventLog: EventLog,
): Promise<void> {
// Any authenticated role may read the log (it's the audit trail).
const guard = requireRole("admin", "operator", "cashier", "readonly");
// Recent events, newest first. `limit` caps the page (default 100, max 1000).
app.get<{ Querystring: { limit?: string } }>(
"/api/events",
{ preHandler: guard },
async (req) => {
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 1000);
const rows = db.select().from(events).orderBy(desc(events.index)).limit(limit).all();
return { events: rows };
},
);
// Integrity self-check: walk the chain and verify hashes + signatures. Admin-
// only (it's an audit action). Returns the first break, or ok. This is what a
// reconciliation job / "is the log intact?" check calls.
app.get(
"/api/events/verify",
{ preHandler: requireRole("admin") },
async () => eventLog.verifyChain(),
);
}
+50
View File
@@ -0,0 +1,50 @@
import type { FastifyInstance } from "fastify";
import { requireRole } from "../auth.js";
import { deviceEvents } from "../device-events.js";
import type { PrinterMonitor } from "../printer-monitor.js";
// Live printer-status API. The PrinterMonitor polls printers in the background;
// these endpoints expose its cache (snapshot) and a live push stream (SSE) so the
// booth UI shows paper-out / cover-open / offline in real time. Any authenticated
// operator may read status (it's operational, not a setup action).
export async function printerRoutes(
app: FastifyInstance,
monitor: PrinterMonitor,
): Promise<void> {
const guard = requireRole("admin", "operator", "cashier", "readonly");
// Current status of every monitored printer (cached — no device round-trip).
app.get("/api/printers/status", { preHandler: guard }, async () => ({
printers: monitor.snapshot(),
}));
// Live stream: emits the full snapshot on connect, then one event per change.
// Server-Sent Events — one-way, survives proxies, trivially consumed by the SPA.
app.get("/api/printers/status/stream", { preHandler: guard }, (req, reply) => {
reply.raw.writeHead(200, {
"content-type": "text/event-stream",
"cache-control": "no-cache",
connection: "keep-alive",
});
const send = (event: string, data: unknown) => {
reply.raw.write(`event: ${event}\n`);
reply.raw.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Initial state so a fresh client doesn't wait for the next change.
send("snapshot", { printers: monitor.snapshot() });
const unsubscribe = deviceEvents.onPrinterStatus((e) => send("status", e));
// Heartbeat keeps intermediaries from closing an idle connection.
const heartbeat = setInterval(() => reply.raw.write(": ping\n\n"), 25000);
heartbeat.unref?.();
req.raw.on("close", () => {
clearInterval(heartbeat);
unsubscribe();
});
});
}
+80 -7
View File
@@ -32,7 +32,29 @@ interface TestBody {
config: Record<string, string | number | boolean>;
}
export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
// No human ever uses these to log in: `pushPassword` is the device→backend Digest
// secret, `relayPassword` is the binary-protocol relay_pw. They stay redacted.
//
// NOTE: the device web-UI login (`webUser`/`webPassword`) is deliberately NOT
// redacted. It's an operational credential an admin needs to reach the device's
// own web page, and the whole device-management area is admin-only — so it's
// surfaced in the admin device view rather than hidden. See first-run-setup.md.
const SECRET_CONFIG_KEYS = ["pushPassword", "relayPassword"] as const;
function redactSecrets(config: Record<string, unknown>): Record<string, unknown> {
const out = { ...config };
for (const k of SECRET_CONFIG_KEYS) delete out[k];
return out;
}
export async function setupRoutes(
app: FastifyInstance,
db: Db,
// Called after the set of assignments changes (assign/unassign) so the caller
// can refresh anything derived from it — e.g. the device id->lane map.
onAssignmentsChanged: () => void = () => {},
): Promise<void> {
registerBuiltinDrivers();
setDeviceLogSink((line) => app.log.info(line));
@@ -79,13 +101,15 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
},
);
// Current setup status + assignments.
// Current setup status + assignments. Secrets are stripped from each config
// (the UI lists devices; it never needs the stored push/relay/web passwords).
app.get(
"/api/setup/state",
{ preHandler: adminGuard },
async () => {
const state = await db.select().from(setupState).where(eq(setupState.id, 1)).get();
const assignments = await db.select().from(laneDevices).all();
const rows = await db.select().from(laneDevices).all();
const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) }));
return { completedAt: state?.completedAt ?? null, assignments };
},
);
@@ -144,6 +168,18 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
const id = randomUUID();
const fullConfig: Record<string, unknown> = { ...config };
// The web password the admin typed is a DESIRED value, not a stored fact:
// it's passed to the driver (via create(config) below) as the rotation
// target, but we do NOT persist it from the form. Only harden()'s VERIFIED
// secrets.webPassword gets saved — otherwise a failed rotation would leave
// the DB claiming a password the device never accepted (login stays old).
delete fullConfig.webPassword;
// webPasswordCurrent is an input-only credential (the OLD password used to
// authorize the change) — never persist it as typed.
delete fullConfig.webPasswordCurrent;
// Residual-risk warnings from device hardening (shown to the admin; the
// save still succeeds — these are "configured, but note X" advisories).
const hardenWarnings: string[] = [];
let device;
try {
@@ -171,8 +207,14 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
}
if (isHardenable(device)) {
const { secrets } = await device.harden();
const { secrets, warnings } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
// Surface residual-risk warnings (e.g. firmware that won't disable the
// password-less string protocol) so the admin can act (web-UI step).
for (const w of warnings ?? []) {
app.log.warn(`harden(${driverId} ${id}): ${w}`);
hardenWarnings.push(w);
}
}
if (hasPushConfig(device)) {
@@ -215,9 +257,40 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
enabled: true,
};
await db.insert(laneDevices).values(row);
// Don't echo device secrets back (push Digest password, web-UI login).
const { pushPassword: _pw, webPassword: _wp, ...safeConfig } = fullConfig;
return reply.code(201).send({ ...row, config: safeConfig });
onAssignmentsChanged(); // refresh derived state (device->lane map)
// Don't echo device secrets back (push Digest password, web-UI login, …).
return reply.code(201).send({
...row,
config: redactSecrets(fullConfig),
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}),
});
},
);
// Unassign (remove) a device instance. The schema is multi-instance — one row
// per (lane, category, instance) — so removing one is just deleting its row by
// id. Lets the admin manage a LIST of devices per category (add/remove), not a
// fixed one-per-category slot. Admin-only. See wiki/concepts/first-run-setup.md.
//
// NOTE: we only drop our row; we do NOT un-harden / un-configure the device
// itself (e.g. clear the Dingtian push URL). The device keeps its last config
// harmlessly — pushes from an unknown device id are already rejected (see
// routes/devices.ts), and re-assigning reconfigures it. A future "factory
// reset on unassign" can hook here if needed.
app.delete<{ Params: { id: string } }>(
"/api/setup/assign/:id",
{ preHandler: adminGuard },
async (req, reply) => {
const existing = await db
.select()
.from(laneDevices)
.where(eq(laneDevices.id, req.params.id))
.get();
if (!existing) return reply.code(404).send({ error: "no such device assignment" });
await db.delete(laneDevices).where(eq(laneDevices.id, req.params.id));
onAssignmentsChanged(); // refresh derived state (device->lane map)
app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`);
return reply.code(204).send();
},
);
+55 -2
View File
@@ -3,8 +3,15 @@ import jwt from "@fastify/jwt";
import Fastify, { type FastifyInstance } from "fastify";
import { createDb, type Db } from "@parking/db";
import { TOKEN_COOKIE, requireJwtSecret } from "./auth.js";
import { deviceEvents } from "./device-events.js";
import { EventLog } from "./event-log.js";
import { LaneMap } from "./lane-map.js";
import { PrinterMonitor } from "./printer-monitor.js";
import { buildSigner } from "./signer.js";
import { authRoutes } from "./routes/auth.js";
import { deviceRoutes } from "./routes/devices.js";
import { eventRoutes } from "./routes/events.js";
import { printerRoutes } from "./routes/printers.js";
import { setupRoutes } from "./routes/setup.js";
// The backend is Fastify (Node). Hardware drivers live as isolated Fastify
@@ -40,16 +47,62 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// Local username/password login → JWT in an HttpOnly cookie + CSRF cookie.
await authRoutes(app, db);
// device id -> lane resolver. Built from lane_devices at startup and refreshed
// by setupRoutes on assign/unassign, so device events can be stamped with the
// lane the device belongs to (events carry the device id, not a lane).
const laneMap = new LaneMap(db);
laneMap.refresh();
// Device-agnostic setup: the admin selects devices per lane from the driver
// catalog at first-run. See wiki/concepts/first-run-setup.md.
await setupRoutes(app, db);
await setupRoutes(app, db, () => laneMap.refresh());
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
// guarded by source-IP allowlist + a shared-secret path token, both read from
// the device's lane_devices config (written on assign).
await deviceRoutes(app, db);
// TODO: entry flow (input event → signed event → print → relay), event-log routes.
// Live printer-status monitor: polls printers (paper/cover/cutter/offline) and
// pushes changes to the booth UI. setupRoutes() has already registered the
// built-in drivers the monitor needs. See wiki/concepts/printer-status-monitoring.md.
const printerMonitor = new PrinterMonitor(db, app.log);
await printerRoutes(app, printerMonitor);
app.addHook("onReady", async () => printerMonitor.start());
app.addHook("onClose", async () => printerMonitor.stop());
// Append-only signed event log. Subscribe device pushes (e.g. Dingtian button
// presses) into the hash-chained, signed `events` table — the anti-fraud audit
// trail. The device is NOT trusted; the host record is the source of truth, and
// a relay open with no matching signed event is itself the anomaly. We record
// the raw input faithfully as `input_received` (not yet a `vehicle_entry` — that
// comes with the full entry flow). See wiki/concepts/append-only-event-chain.md.
const eventLog = new EventLog(db, buildSigner(app.log));
await eventRoutes(app, db, eventLog);
const unsubscribeInput = deviceEvents.onInput((e) => {
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
// faithfully (the chain is append-only) rather than silently dropped or
// mis-stamped as lane 0, which is a real lane.
const lane = laneMap.laneFor(e.deviceId) ?? -1;
if (lane === -1) {
app.log.warn(`input from unmapped device ${e.driverId}:${e.deviceId} — logged as lane -1`);
}
eventLog
.append({
type: "input_received",
lane,
// `source` is an IdentitySource (wiegand/lpr/qr/ticket/manual) — how a
// VEHICLE was identified. A raw input has none, so it stays null. The
// device provenance lives in `identity` instead.
source: null,
identity: `${e.driverId}:${e.deviceId} input:${e.input}/${e.edge}`,
occurredAt: e.at,
})
.catch((err) => app.log.error(`event-log append failed: ${(err as Error).message}`));
});
app.addHook("onClose", async () => unsubscribeInput());
// TODO: entry flow (input event → signed event → print → relay).
return app;
}
+57
View File
@@ -0,0 +1,57 @@
import { createHmac, timingSafeEqual } from "node:crypto";
import type { Signer } from "@parking/shared";
// Concrete signers for the append-only event chain. The Signer interface is the
// abstraction over the ATECC608 secure element (open-question #6 — chip not yet
// confirmed wired). Until the chip is present we use a software HMAC signer:
// it makes the chain self-consistent + tamper-evident, but is NOT unforgeable by
// someone who owns the host (only the ATECC608's non-extractable key is). The
// swap to hardware is a new Signer impl — no event-log changes.
// See wiki/concepts/append-only-event-chain.md and wiki/entities/atecc608.md.
/** HMAC-SHA256 software signer. Key from env; fail fast if missing in prod. */
export class SoftwareSigner implements Signer {
readonly keyId: string;
readonly #key: Buffer;
constructor(secret: string, keyId = "sw-hmac-v1") {
this.#key = Buffer.from(secret, "utf8");
this.keyId = keyId;
}
sign(payload: string): string {
return createHmac("sha256", this.#key).update(payload, "utf8").digest("hex");
}
verify(payload: string, signature: string): boolean {
const expected = this.sign(payload);
// Constant-time compare; bail on length mismatch (timingSafeEqual throws).
if (expected.length !== signature.length) return false;
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}
}
/**
* Build the process signer. Uses EVENT_SIGNING_KEY (HMAC secret). Falls back to
* the JWT secret only as a last resort so dev works out of the box — logged as a
* warning, because reusing the auth secret for event signing is not ideal.
*
* TODO(atecc608): when the secure element is wired, return an Atecc608Signer here
* (keyId "atecc608-slotN"); existing events stay verifiable via their stored keyId.
*/
export function buildSigner(log?: { warn: (msg: string) => void }): Signer {
const dedicated = process.env.EVENT_SIGNING_KEY;
if (dedicated && dedicated.length >= 16) {
return new SoftwareSigner(dedicated);
}
const jwtSecret = process.env.JWT_SECRET;
if (jwtSecret && jwtSecret.length >= 16) {
log?.warn(
"event signing: EVENT_SIGNING_KEY unset — falling back to JWT_SECRET. Set a dedicated key (and wire the ATECC608) before production.",
);
return new SoftwareSigner(jwtSecret, "sw-hmac-jwtfallback");
}
throw new Error(
"event signing: no signing key. Set EVENT_SIGNING_KEY (>=16 chars) for the append-only event chain.",
);
}
+212 -39
View File
@@ -1,10 +1,13 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import {
assignDevice,
discoverDevices,
fetchBackendIps,
fetchCatalog,
fetchState,
testDevice,
unassignDevice,
type Assignment,
type BackendIpCandidate,
type Catalog,
type CatalogEntry,
@@ -13,32 +16,39 @@ import {
type TestResult,
} from "./api.js";
// First-run setup wizard (scaffold). The admin picks a device per category for a
// lane from the driver catalog and fills in its connection config. Drivers that
// support LAN discovery (e.g. UHPPOTE) get a "Scan" button that lists found
// devices; selecting one auto-fills the config. Auth is via the admin's session
// cookie (the SPA only renders this for admins). See wiki/concepts/first-run-setup.md
// and device-discovery.md.
// First-run setup wizard (scaffold). The admin assigns devices per lane from the
// driver catalog. The data model is multi-instance — one lane_devices row per
// instance — so EVERY category supports more than one device: each section lists
// the already-assigned instances (with Remove) and an "Add" form. Drivers that
// support LAN discovery get a "Scan" button. Auth is via the admin's session
// cookie. See wiki/concepts/first-run-setup.md and device-discovery.md.
const CATEGORIES: { key: DeviceCategory; title: string }[] = [
{ key: "access", title: "Access controller" },
{ key: "reader", title: "Reader" },
{ key: "camera", title: "Camera (entry/exit snapshot)" },
{ key: "printer", title: "Printer" },
const CATEGORIES: { key: DeviceCategory; title: string; noun: string }[] = [
{ key: "access", title: "Access controllers", noun: "access controller" },
{ key: "reader", title: "Readers", noun: "reader" },
{ key: "camera", title: "Cameras (entry/exit snapshot)", noun: "camera" },
{ key: "printer", title: "Printers", noun: "printer" },
];
export function SetupWizard() {
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [assignments, setAssignments] = useState<Assignment[] | null>(null);
const [lane, setLane] = useState(1);
const [picked, setPicked] = useState<Partial<Record<DeviceCategory, string>>>({});
const [error, setError] = useState<string | null>(null);
const reloadState = useCallback(() => {
return fetchState()
.then((s) => setAssignments(s.assignments))
.catch((e: Error) => setError(e.message));
}, []);
useEffect(() => {
fetchCatalog().then(setCatalog).catch((e: Error) => setError(e.message));
}, []);
reloadState();
}, [reloadState]);
if (error) return <p style={{ color: "crimson" }}>Failed to load catalog: {error}</p>;
if (!catalog) return <p>Loading device catalog…</p>;
if (error) return <p style={{ color: "crimson" }}>Failed to load setup: {error}</p>;
if (!catalog || !assignments) return <p>Loading device catalog…</p>;
return (
<section>
@@ -54,41 +64,180 @@ export function SetupWizard() {
style={{ width: "4rem" }}
/>
</label>
<span style={{ color: "#666", fontSize: "0.85em" }}>
Devices are added per lane. Switch lanes to configure another.
</span>
</div>
{CATEGORIES.map(({ key, title }) => (
<CategoryPicker
{CATEGORIES.map(({ key, title, noun }) => (
<CategorySection
key={key}
lane={lane}
category={key}
title={title}
noun={noun}
entries={catalog[key]}
discoverableIds={catalog.discoverable}
selectedId={picked[key]}
onSelect={(id) => setPicked((p) => ({ ...p, [key]: id }))}
assignments={assignments.filter((a) => a.category === key && a.lane === lane)}
onChanged={reloadState}
/>
))}
</section>
);
}
function CategoryPicker({
function CategorySection({
lane,
category,
title,
noun,
entries,
discoverableIds,
selectedId,
onSelect,
assignments,
onChanged,
}: {
lane: number;
category: DeviceCategory;
title: string;
noun: string;
entries: CatalogEntry[];
discoverableIds: string[];
selectedId: string | undefined;
onSelect: (id: string) => void;
assignments: Assignment[];
onChanged: () => Promise<void> | void;
}) {
// Show the add-form automatically when nothing is assigned yet; otherwise it's
// collapsed behind "Add another" so the list stays the focus.
const [adding, setAdding] = useState(false);
// Warnings from the most recent save (e.g. "string protocol could not be
// disabled — finish in the device web UI"). Persist after the form closes.
const [warnings, setWarnings] = useState<string[]>([]);
const showForm = adding || assignments.length === 0;
return (
<fieldset style={{ marginTop: "1rem" }}>
<legend>
{title} <span style={{ color: "#888", fontWeight: 400 }}>· lane {lane}</span>
</legend>
{warnings.length > 0 && (
<div
style={{
margin: "0 0 0.75rem",
padding: "0.5rem 0.75rem",
background: "#fef3c7",
border: "1px solid #f59e0b",
borderRadius: 6,
}}
>
<strong style={{ color: "#92400e" }}>⚠ Saved, but action needed:</strong>
<ul style={{ margin: "0.25rem 0 0", paddingLeft: "1.25rem", color: "#92400e" }}>
{warnings.map((w, i) => (
<li key={i}>{w}</li>
))}
</ul>
<button type="button" onClick={() => setWarnings([])} style={{ marginTop: "0.5rem" }}>
Dismiss
</button>
</div>
)}
{assignments.length > 0 && (
<ul style={{ listStyle: "none", padding: 0, margin: "0 0 0.75rem" }}>
{assignments.map((a) => (
<AssignmentRow key={a.id} assignment={a} onChanged={onChanged} />
))}
</ul>
)}
{showForm ? (
<DeviceForm
lane={lane}
category={category}
entries={entries}
discoverableIds={discoverableIds}
onSaved={async (w) => {
setWarnings(w);
await onChanged();
setAdding(false);
}}
onCancel={assignments.length > 0 ? () => setAdding(false) : undefined}
/>
) : (
<button type="button" onClick={() => setAdding(true)}>
+ Add another {noun}
</button>
)}
</fieldset>
);
}
function AssignmentRow({
assignment,
onChanged,
}: {
assignment: Assignment;
onChanged: () => Promise<void> | void;
}) {
const [removing, setRemoving] = useState(false);
const [error, setError] = useState<string | null>(null);
// A short, human summary of the instance: role (if any) + host.
const cfg = assignment.config;
const role = typeof cfg.role === "string" ? cfg.role : null;
const host = typeof cfg.host === "string" ? cfg.host : null;
async function remove() {
if (!confirm(`Remove this ${assignment.driverId} device?`)) return;
setRemoving(true);
setError(null);
try {
await unassignDevice(assignment.id);
await onChanged();
} catch (e) {
setError((e as Error).message);
setRemoving(false);
}
}
return (
<li
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.4rem 0.5rem",
borderBottom: "1px solid #eee",
}}
>
<strong>{assignment.driverId}</strong>
{role && <span style={{ color: "#0369a1" }}>{role}</span>}
{host && <span style={{ color: "#666" }}>{host}</span>}
{!assignment.enabled && <span style={{ color: "#b45309" }}>(disabled)</span>}
<span style={{ flex: 1 }} />
{error && <span style={{ color: "crimson" }}>{error}</span>}
<button type="button" onClick={remove} disabled={removing}>
{removing ? "Removing…" : "Remove"}
</button>
</li>
);
}
function DeviceForm({
lane,
category,
entries,
discoverableIds,
onSaved,
onCancel,
}: {
lane: number;
category: DeviceCategory;
entries: CatalogEntry[];
discoverableIds: string[];
onSaved: (warnings: string[]) => Promise<void> | void;
onCancel?: () => void;
}) {
const [selectedId, setSelectedId] = useState<string>("");
const selected = entries.find((e) => e.id === selectedId);
const canDiscover = selected != null && discoverableIds.includes(selected.id);
@@ -98,7 +247,6 @@ function CategoryPicker({
const [testing, setTesting] = useState(false);
const [testError, setTestError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [found, setFound] = useState<DiscoveredDevice[] | null>(null);
const [scanning, setScanning] = useState(false);
@@ -124,8 +272,6 @@ function CategoryPicker({
.then(({ candidates }) => {
if (!live) return;
setBackendIps(candidates);
// Pre-fill with the on-subnet auto-pick (the first candidate, since the
// server sorts on-subnet first), unless the admin already chose one.
setBackendIp((cur) => cur || candidates.find((c) => c.onDeviceSubnet)?.ip || "");
})
.catch(() => {
@@ -137,6 +283,13 @@ function CategoryPicker({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [testedHost]);
function selectDriver(id: string) {
setSelectedId(id);
setConfig({});
setFound(null);
resetStatus();
}
async function scan() {
if (!selected) return;
setScanning(true);
@@ -165,11 +318,10 @@ function CategoryPicker({
return out;
}
// Editing config invalidates a prior test/save.
// Editing config invalidates a prior test.
function resetStatus() {
setTested(null);
setTestError(null);
setSaved(false);
setSaveError(null);
}
@@ -192,14 +344,15 @@ function CategoryPicker({
setSaving(true);
setSaveError(null);
try {
await assignDevice({
const result = await assignDevice({
lane,
category,
driverId: selected.id,
config: mergedConfig(),
...(backendIp ? { backendIp } : {}),
});
setSaved(true);
// Hand warnings to the parent so they persist after this form unmounts.
await onSaved(result.warnings ?? []);
} catch (e) {
setSaveError((e as Error).message);
} finally {
@@ -208,12 +361,11 @@ function CategoryPicker({
}
return (
<fieldset style={{ marginTop: "1rem" }}>
<legend>{title}</legend>
<div style={{ padding: "0.5rem", background: "#fafafa", borderRadius: 6 }}>
{entries.length === 0 ? (
<em>No drivers registered.</em>
) : (
<select value={selectedId ?? ""} onChange={(e) => onSelect(e.target.value)}>
<select value={selectedId} onChange={(e) => selectDriver(e.target.value)}>
<option value="" disabled>
Choose a device…
</option>
@@ -258,6 +410,22 @@ function CategoryPicker({
<label>
{f.label}
{f.required ? " *" : ""}{" "}
{f.type === "select" ? (
<select
value={String(config[f.key] ?? (f.default as string | number | undefined) ?? "")}
onChange={(e) => {
const v = e.target.value;
setConfig((c) => ({ ...c, [f.key]: v }));
resetStatus();
}}
>
{f.options?.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
) : (
<input
type={f.type === "secret" ? "password" : f.type === "number" || f.type === "port" ? "number" : "text"}
value={config[f.key] ?? (f.default as string | number | undefined) ?? ""}
@@ -268,6 +436,7 @@ function CategoryPicker({
resetStatus();
}}
/>
)}
</label>
</div>
))}
@@ -277,9 +446,14 @@ function CategoryPicker({
<button type="button" onClick={test} disabled={testing}>
{testing ? "Testing…" : "Test connection"}
</button>
<button type="button" onClick={save} disabled={saving || saved}>
{saving ? "Saving…" : saved ? "Saved ✓" : "Save & configure"}
<button type="button" onClick={save} disabled={saving}>
{saving ? "Saving…" : "Save & configure"}
</button>
{onCancel && (
<button type="button" onClick={onCancel} disabled={saving}>
Cancel
</button>
)}
</div>
{testError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Test failed: {testError}</p>}
@@ -332,10 +506,9 @@ function CategoryPicker({
</div>
)}
{saveError && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>Save failed: {saveError}</p>}
{saved && <p style={{ color: "#16a34a", margin: "0.5rem 0 0" }}>Saved and configured ✓</p>}
</div>
)}
</fieldset>
</div>
);
}
+33 -1
View File
@@ -160,6 +160,38 @@ export interface AssignBody {
}
/** Save + configure the device (preconditions, push setup), then persist. */
export function assignDevice(body: AssignBody): Promise<{ id: string }> {
export function assignDevice(body: AssignBody): Promise<AssignResult> {
return apiFetch("/api/setup/assign", { method: "POST", body: JSON.stringify(body) });
}
/** A persisted device assignment (one per instance; machine-only secrets stripped). */
export interface Assignment {
id: string;
lane: number;
category: DeviceCategory;
driverId: string;
config: DeviceConfig;
enabled: boolean;
createdAt?: string;
}
/** Assign response = the saved assignment plus any residual-risk warnings
* (e.g. "string protocol could not be disabled — finish in the device web UI"). */
export interface AssignResult extends Assignment {
warnings?: string[];
}
export interface SetupState {
completedAt: string | null;
assignments: Assignment[];
}
/** Current setup status + all assigned device instances. */
export function fetchState(): Promise<SetupState> {
return apiFetch<SetupState>("/api/setup/state");
}
/** Remove one assigned device instance by id. */
export function unassignDevice(id: string): Promise<void> {
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
}
+184 -101
View File
@@ -34,59 +34,37 @@ import { hostField, portField, stubLog } from "./common.js";
// (input_link_relay). That must be DISABLED on the device for ticket-first
// entry, else the button opens the barrier before the host can act.
/** Send one UDP datagram and (optionally) await a single reply. */
function udpRequest(
host: string,
port: number,
payload: string,
timeoutMs: number,
expectReply: boolean,
): Promise<string | null> {
return new Promise((resolve, reject) => {
const sock = createSocket("udp4");
let settled = false;
const done = (err: Error | null, val: string | null) => {
if (settled) return;
settled = true;
clearTimeout(timer);
sock.close();
err ? reject(err) : resolve(val);
};
const timer = setTimeout(
() => done(expectReply ? new Error("timeout") : null, null),
timeoutMs,
);
sock.on("error", (e) => done(e, null));
sock.on("message", (m) => done(null, m.toString()));
sock.bind(() => {
sock.send(Buffer.from(payload), port, host, (e) => {
if (e) done(e, null);
else if (!expectReply) done(null, null);
});
});
});
}
// (The string-protocol UDP helper was removed: harden() now disables the
// password-less string protocol entirely, and status reads use the
// authenticated binary read — see #status() / readStatusFrame.)
/**
* Send a Dingtian *binary* protocol frame (UDP, default port 60000) and await
* the reply. Used for relay control because — unlike the string protocol — the
* binary protocol supports a password (`relay_pw`), so an attacker on a flat
* network can't fire a relay without it. Frame verified on hardware:
* the reply. Used for ALL relay traffic — control AND status read — because,
* unlike the string protocol, the binary protocol carries a password (`relay_pw`).
* harden() disables the string protocol precisely because it has NO password and
* can fire relays (an unauthenticated `"11"` opens relay 1). With the string path
* closed, relay_pw actually gates control. Frame verified on hardware:
*
* FF AA <session> <relayCmd> <pwLo> <pwHi> <data...>
*
* FF = command "set relay"
* AA = result xor (0x00 ^ 0xAA, pc→device)
* session = echoed back
* relayCmd = 1 write, 3 jogging, …
* relayCmd = 0 read status, 1 write, 3 jogging, …
* pwLo,pwHi = relay password, 16-bit LSB-first (0 = none)
* data = command-specific
*
* NOTE: relay_pw + plaintext UDP is defence-in-depth, NOT a boundary. An attacker
* who sniffs the VLAN can replay the password. The real guarantee is the signed
* event log (relay open with no signed command = fraud) + VLAN isolation.
*/
function binaryUdp(
host: string,
port: number,
frame: Buffer,
timeoutMs: number,
localAddress?: string,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
const sock = createSocket("udp4");
@@ -101,15 +79,33 @@ function binaryUdp(
const timer = setTimeout(() => done(new Error("timeout"), null), timeoutMs);
sock.on("error", (e) => done(e, null));
sock.on("message", (m) => done(null, m));
sock.bind(() => {
// Bind to a specific local address (the device-facing NIC) on multi-homed
// hosts, so the device replies to the right source IP. See net.ts.
const onBound = () => {
sock.send(frame, port, host, (e) => {
if (e) done(e, null);
});
});
};
if (localAddress) sock.bind({ address: localAddress }, onBound);
else sock.bind(onBound);
});
}
let binarySession = 0;
/**
* Build a binary "read relay status" frame (relay command 0x00). The device
* replies `FF AA <session> 00 <relayBytes> <inputBytes>` (status widths scale
* with channel count). This is the *authenticated* status read — unlike the
* string protocol's `00`, it carries the relay password, so we can disable the
* password-less string protocol entirely. Frame: `FF AA <session> 00 <pwLo> <pwHi>`.
* Verified on hardware (4ch): reply `ff aa 00 00 01 0f` = relay1 on, inputs 1111.
*/
function readStatusFrame(password: number): Buffer {
const session = binarySession++ & 0xff;
return Buffer.from([0xff, 0xaa, session, 0x00, password & 0xff, (password >> 8) & 0xff]);
}
/** Build a binary "write relay with jogging" frame (relay on, auto-off). */
function jogFrame(channel: number, password: number, jogMs: number): Buffer {
const session = binarySession++ & 0xff;
@@ -151,9 +147,9 @@ function writeRelayFrame(channel: number, on: boolean, password: number, channel
const rand16 = () => randomBytes(2).readUInt16BE(0);
/** GET a CGI path on the device's HTTP server and return the raw response text. */
function cgiGet(host: string, httpPort: number, path: string, timeoutMs: number): Promise<string> {
function cgiGet(host: string, httpPort: number, path: string, timeoutMs: number, localAddress?: string): Promise<string> {
return new Promise((resolve, reject) => {
const req = httpRequest({ host, port: httpPort, path, method: "GET", timeout: timeoutMs }, (res) => {
const req = httpRequest({ host, port: httpPort, path, method: "GET", timeout: timeoutMs, localAddress }, (res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => resolve(data));
@@ -186,6 +182,7 @@ function configApi(
body: string | null,
timeoutMs: number,
sessionId?: number, // device session check: sent as Cookie: session=<id>
localAddress?: string, // bind outbound to the device-facing NIC (multi-homed hosts)
): Promise<string> {
return new Promise((resolve, reject) => {
// The device's embedded HTTP server does NOT support chunked request bodies.
@@ -207,6 +204,7 @@ function configApi(
path,
method,
timeout: timeoutMs,
localAddress,
headers: Object.keys(headers).length ? headers : undefined,
},
(res) => {
@@ -232,19 +230,28 @@ class DingtianController
{
readonly driverId = "dingtian";
readonly #host: string;
readonly #port: number; // string protocol (status read) — UDP 60001
readonly #port: number; // legacy string-protocol port (60001) — protocol now disabled by harden(); kept for config compat
readonly #binaryPort: number; // binary protocol (relay control) — UDP 60000
readonly #relayPassword: number; // relay_pw (0 = none)
readonly #sessionId: number; // device CGI session id (0 = session check off)
readonly #httpPort: number;
readonly #timeout: number;
// Local IP to source outbound device traffic from (the device-facing NIC on a
// multi-homed host). undefined = let the OS choose. See net.ts / device-facing-ip.
readonly #localAddress: string | undefined;
readonly #channels: number;
/** Input level at rest; an input is "active" when it differs from this. */
readonly #restingHigh: boolean;
readonly #pulseMs: number;
/** Current device web-UI login (gates the browser UI only, not the CGI API). */
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
readonly #webUser: string;
readonly #webPassword: string;
/** The password the admin WANTS the device to have (the rotation target). If
* blank, harden() generates a random one. */
readonly #webPassword: string | undefined;
/** The device's CURRENT password, used as the OLD cred for userset.cgi. Defaults
* to "admin" (factory). Distinct from #webPassword (the desired new value) so an
* admin typing a desired password doesn't break rotation. */
readonly #webPasswordCurrent: string;
#poll: ReturnType<typeof setInterval> | null = null;
#last: boolean[] | null = null;
@@ -257,16 +264,21 @@ class DingtianController
this.#relayPassword = config.relayPassword ? Number(config.relayPassword) : 0;
this.#sessionId = config.sessionId ? Number(config.sessionId) : 0;
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 2000;
this.#channels = config.channels ? Number(config.channels) : 4;
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
this.#restingHigh = config.inputRestingHigh !== false;
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
// The device ships with admin/admin. After harden() rotates it, the new
// creds are stored back in config so a re-created driver knows the current
// login (needed to rotate again — userset.cgi checks the old credentials).
this.#webUser = config.webUser ? String(config.webUser) : "admin";
this.#webPassword = config.webPassword ? String(config.webPassword) : "admin";
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
this.#webPassword = config.webPassword ? String(config.webPassword) : undefined;
// webPasswordCurrent = the device's EXISTING password (the old cred userset.cgi
// checks). Defaults to admin (factory). After a successful rotation, assign
// stores the new value back here so a re-run can rotate again.
this.#webPasswordCurrent = config.webPasswordCurrent
? String(config.webPasswordCurrent)
: "admin";
}
async connect(): Promise<void> {
@@ -297,14 +309,14 @@ class DingtianController
async pulseOpen(doorId: number): Promise<void> {
this.#assertChannel(doorId);
const frame = jogFrame(doorId, this.#relayPassword, this.#pulseMs);
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout);
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
}
/** Latch a relay on/off (e.g. for a held-open mode). Channel is 1-based. */
async setRelay(doorId: number, on: boolean): Promise<void> {
this.#assertChannel(doorId);
const frame = writeRelayFrame(doorId, on, this.#relayPassword, this.#channels);
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout);
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
}
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
@@ -421,19 +433,30 @@ class DingtianController
/**
* Lock the device down for a flat (no-VLAN) network:
* - set a random relay password (`relay_pw`) so binary relay commands need it,
* - disable unused protocol channels (rs485/can/tcp×2/mqtt) — keep only UDP1
* binary (relay control) and UDP2 string (status read).
* - keep ONLY UDP1 binary (password-protected relay control + status read),
* - disable every other protocol channel: string, rs485, can, tcp×2, mqtt.
* Returns the relay password for the backend to persist (required to keep
* commanding the device afterwards).
*
* SECURITY — why the string protocol (UDP2) is now DISABLED (was a real hole):
* the Dingtian string protocol has NO password field and can *fire* relays
* (`"11"` = relay 1 on, `"21"` = off, `"11*"` = jog). Leaving it enabled — even
* "just for status reads" — let anyone on the network open any barrier with one
* unauthenticated UDP packet, completely bypassing relay_pw. Confirmed by
* sending `"11"` to port 60001 with no credentials and watching relay 1 close.
* So harden() sets udp2.p=255 and status reads move to the authenticated binary
* read (relay command 0x00 — see #status()).
*
* NOTE: deliberately does NOT touch the device's HTTP CGI session check
* (`session_en`). On this firmware enabling it makes the config-read API drop
* connections, locking us out of the very API we depend on (verified the hard
* way — required a factory reset). So we leave the config API as-is and rely on
* relay_pw + fewer open channels + the signed event log.
*
* All are plaintext over HTTP/UDP on a flat network → defence-in-depth, not a
* boundary; the signed event log is the real guarantee. See device-input-flow.
* Even with the string hole closed, all of this is plaintext over UDP/HTTP →
* defence-in-depth, NOT a boundary. The real guarantee is the signed event log
* (a relay open with no matching signed command is the fraud signal) plus VLAN
* isolation. See device-input-flow / network-isolation.
*/
async harden(): Promise<HardenResult> {
const cfg = await this.#readConfig();
@@ -442,17 +465,24 @@ class DingtianController
const relayPassword = 1 + (rand16() % 9999); // 1..9999 (0 = none)
rc.relay_pw = relayPassword;
// Keep UDP1=Binary (p:1) for relay control, UDP2=String (p:0) for status.
// Disable everything else (p:255 = None).
// Keep ONLY UDP1=Binary (p:1) — it carries relay_pw for both control AND the
// status read. Disable everything else (p:255 = None), INCLUDING the string
// protocol (udp2), which is password-less and can fire relays.
(rc.udp1 as Record<string, unknown>).p = 1;
(rc.udp2 as Record<string, unknown>).p = 0;
(rc.udp2 as Record<string, unknown>).p = 255;
(rc.rs485 as Record<string, unknown>).p = 255;
(rc.can as Record<string, unknown>).p = 255;
(rc.tcpc as Record<string, unknown>).p = 255;
(rc.tcps as Record<string, unknown>).p = 255;
(rc.mqtt as Record<string, unknown>).p = 255;
await this.#writeConfig(cfg, (after) => {
// NOTE: udp2 (string protocol) is set to 255 here, but it is NOT part of the
// blocking verify. On some firmware (e.g. V3.6J) the CONFIG API silently
// refuses to disable udp2 — it accepts the write, reboots, and clamps it back
// to enabled — even though every other channel applies and the device's own
// web UI CAN disable it. We don't want assign to hard-fail over a firmware
// quirk, so we attempt it, then re-check below and warn if it didn't stick.
const afterCfg = await this.#writeConfig(cfg, (after) => {
const a = after.relay_connect as Record<string, unknown> | undefined;
return (
a?.relay_pw === relayPassword &&
@@ -463,47 +493,82 @@ class DingtianController
const applied = [
"set relay password",
"disabled rs485/can/tcp/mqtt channels (kept UDP binary + string)",
"disabled rs485/can/tcp/mqtt channels (kept password-protected UDP binary)",
];
const warnings: string[] = [];
const stringDisabled =
((afterCfg.relay_connect as Record<string, unknown>)?.udp2 as Record<string, unknown> | undefined)?.p === 255;
if (stringDisabled) {
applied.push("disabled the password-less string protocol (udp2)");
} else {
warnings.push(
"could not disable the string protocol (udp2) via the config API — this firmware ignores it. " +
"An unauthenticated UDP packet to the string port can still fire relays. " +
"Disable UDP2 in the device web UI, and rely on VLAN isolation + the signed event log. See dingtian-relay.md.",
);
}
const secrets: Record<string, string | number> = { relayPassword };
// Rotate the default admin/admin web login. NOTE: cosmetic — this device's
// CGI API needs NO auth (config read/write + relay fire + this very call all
// work unauthenticated), so the login only gates the interactive browser UI,
// not the control plane. We rotate it anyway (defence-in-depth: stops a
// casual browser reaching the settings page), but it is NOT a boundary; the
// signed event log is. See dingtian-relay.md.
// Set the device web login to the admin's chosen password (or a random one).
// NOTE: cosmetic for the control plane — the CGI API needs NO auth (config
// read/write + relay fire all work unauthenticated), so the login only gates
// the interactive browser UI. We set it anyway (defence-in-depth) but it is
// NOT a boundary; the signed event log is. See dingtian-relay.md.
//
// CRITICAL: only persist webPassword if the rotation VERIFIABLY took effect.
// Otherwise the DB would claim a password the device doesn't have (the bug:
// admin types a new pw, rotation fails on the wrong old-cred, DB still saves
// the typed value, login stays admin/admin). On failure we warn instead.
try {
const newPassword = await this.#rotateWebLogin();
secrets.webUser = this.#webUser;
secrets.webPassword = newPassword;
applied.push("rotated the admin/admin web-UI login (cosmetic — CGI API is unauthenticated)");
// The new password is now the device's CURRENT one — store it so a future
// re-harden uses the right old cred.
secrets.webPasswordCurrent = newPassword;
applied.push("set the device web-UI login (verified on the device)");
} catch (err) {
// Don't fail the whole harden over a cosmetic step — log and continue.
stubLog(this.driverId, `web-login rotate skipped: ${(err as Error).message}`);
warnings.push(
`could not set the device web-UI login: ${(err as Error).message} ` +
`The device login is UNCHANGED (still its previous password). The saved web password was NOT updated.`,
);
}
return { secrets, applied };
return { secrets, applied, warnings: warnings.length ? warnings : undefined };
}
/**
* Rotate the device web-UI login password (keeps the username) via
* `userset.cgi?<old_user>&<old_pass>&<new_user>&<new_pass>&`. Returns the new
* password. The device validates the OLD credentials in the query, so we send
* the current ones (admin/admin on first run, the stored pair afterwards).
* Response is `&<code>&<redirect>&` with code 0 = success. Password is hex
* (URL-safe, no escaping) and ≤31 chars (the device truncates longer).
* Set the device web-UI login to the DESIRED password (the admin's choice, or a
* random one if none was given) via
* `userset.cgi?<user>&<old_pass>&<user>&<new_pass>&`. The device validates the
* OLD credentials, so we send #webPasswordCurrent (admin on a fresh device).
* Response `&<code>&…&`, code 0 = success.
*
* After the rotation we VERIFY by attempting a no-op rotate using the NEW
* password as the old cred — if that succeeds, the device really has the new
* password (this is what catches the "DB says X but device is still admin/admin"
* bug: a wrong old-cred makes the first call fail, and we never claim success).
* Returns the password now live on the device.
*/
async #rotateWebLogin(): Promise<string> {
const newPassword = randomBytes(12).toString("hex"); // 24 hex chars
const newPassword = this.#webPassword ?? randomBytes(12).toString("hex");
const u = encodeURIComponent(this.#webUser);
const oldP = encodeURIComponent(this.#webPassword);
const path = `/userset.cgi?${u}&${oldP}&${u}&${newPassword}&`;
const res = await cgiGet(this.#host, this.#httpPort, path, this.#timeout);
// "&0&/&" = success; anything else (e.g. "&-5&/&" bad params / wrong old pw).
const setPath = (oldP: string, newP: string) =>
`/userset.cgi?${u}&${encodeURIComponent(oldP)}&${u}&${encodeURIComponent(newP)}&`;
const res = await cgiGet(this.#host, this.#httpPort, setPath(this.#webPasswordCurrent, newPassword), this.#timeout, this.#localAddress);
const code = res.split("&")[1];
if (code !== "0") {
throw new Error(`userset.cgi rejected (response "${res.trim()}")`);
throw new Error(
`userset.cgi rejected (response "${res.trim()}") — the device's current password is probably not "${this.#webPasswordCurrent}". ` +
`Set the correct current password, or factory-reset the device.`,
);
}
// VERIFY: a no-op rotate (new → new) only succeeds if the device truly has it.
const verify = await cgiGet(this.#host, this.#httpPort, setPath(newPassword, newPassword), this.#timeout, this.#localAddress);
if (verify.split("&")[1] !== "0") {
throw new Error(`web-login change did not take effect (verify response "${verify.trim()}")`);
}
return newPassword;
}
@@ -511,7 +576,7 @@ class DingtianController
// --- config api internals ----------------------------------------------
async #readConfig(): Promise<Record<string, unknown>> {
const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout, this.#sessionId);
const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout, this.#sessionId, this.#localAddress);
return JSON.parse(raw) as Record<string, unknown>;
}
@@ -527,7 +592,7 @@ class DingtianController
async #writeConfig(
cfg: Record<string, unknown>,
verify: (after: Record<string, unknown>) => boolean,
): Promise<void> {
): Promise<Record<string, unknown>> {
// The set endpoint requires `"command":"setconfig"` injected after `status`
// (the GET payload omits it). Rebuild preserving node order, command second.
const out: Record<string, unknown> = {};
@@ -543,7 +608,7 @@ class DingtianController
// POST. The device resets on apply, so the connection may drop — that's
// expected, not failure.
try {
await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout, this.#sessionId);
await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout, this.#sessionId, this.#localAddress);
} catch {
// device likely reset on apply
}
@@ -552,7 +617,8 @@ class DingtianController
for (let i = 0; i < 12; i++) {
await sleep(2000);
try {
if (verify(await this.#readConfig())) return; // applied
const after = await this.#readConfig();
if (verify(after)) return after; // applied — return the landed config
} catch {
// still rebooting / unreachable — keep polling
}
@@ -581,21 +647,35 @@ class DingtianController
}
}
/** Query "00" → parse "0000:1111:4" into relays/inputs/channels. */
/**
* Read relay + input status via the AUTHENTICATED binary protocol (relay
* command 0x00). Reply: `FF AA <session> 00 <relayBytes...> <inputBytes...>`,
* each field `ceil(channels/8)` bytes, LSB-first (bit0 → relay/input 1).
*
* SECURITY: deliberately NOT the string protocol's `00` — that query has no
* password field AND the string protocol can also *fire* relays, so leaving it
* enabled defeats relay_pw entirely (an attacker sends `"11"` to open relay 1
* with no auth). harden() disables the string protocol; status reads come here.
*/
async #status(): Promise<DingtianStatus> {
const reply = await udpRequest(this.#host, this.#port, "00", this.#timeout, true);
if (!reply) throw new Error("dingtian: empty status reply");
const [relayStr, inputStr, countStr] = reply.trim().split(":");
if (relayStr === undefined || inputStr === undefined) {
throw new Error(`dingtian: bad status reply "${reply}"`);
const frame = readStatusFrame(this.#relayPassword);
const reply = await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
const width = Math.max(1, Math.ceil(this.#channels / 8));
// header: FF AA session 00 (4 bytes) + relay field + input field
if (reply.length < 4 + width * 2) {
throw new Error(`dingtian: short binary status reply (${reply.length} bytes)`);
}
const bit = (c: string) => c === "1";
return {
relays: [...relayStr].map(bit),
// active = differs from the resting level (press pulls the line).
inputs: [...inputStr].map((c) => bit(c) !== this.#restingHigh),
channels: countStr ? Number(countStr) : this.#channels,
};
const relayVal = reply.readUIntLE(4, width);
const inputVal = reply.readUIntLE(4 + width, width);
const relays: boolean[] = [];
const inputs: boolean[] = [];
for (let i = 0; i < this.#channels; i++) {
const high = (inputVal & (1 << i)) !== 0;
relays.push((relayVal & (1 << i)) !== 0);
// active = differs from the resting level (a press pulls the line).
inputs.push(high !== this.#restingHigh);
}
return { relays, inputs, channels: this.#channels };
}
#startPolling(): void {
@@ -664,11 +744,14 @@ export const dingtianDriver: AccessDriver = {
help: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
},
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 2000 },
// Current device web-UI login. Defaults to admin/admin; harden() rotates the
// password and stores the new pair back here so a re-run can rotate again.
// (Gates only the browser UI — the CGI control plane is unauthenticated.)
// Device web-UI login. webPassword = the password you WANT (blank → a random
// one is generated). webPasswordCurrent = the device's EXISTING password, used
// as the old credential to change it (defaults to "admin" on a fresh device).
// On a verified change, the new password is stored as both the saved login and
// the current one. (Gates only the browser UI — CGI control plane is open.)
{ key: "webUser", label: "Device web username", type: "string", required: false, default: "admin", help: "Device web-UI login user (default admin)." },
{ key: "webPassword", label: "Device web password", type: "secret", required: false, help: "Device web-UI login password (default admin; rotated on save)." },
{ key: "webPassword", label: "New device web password", type: "secret", required: false, help: "The password to SET on the device web UI. Leave blank to auto-generate. Applied + verified on save." },
{ key: "webPasswordCurrent", label: "Current device web password", type: "secret", required: false, help: "The device's existing web password (default admin on a fresh device). Needed to change it." },
],
create: (c) => new DingtianController(c),
};
+3
View File
@@ -4,6 +4,7 @@
import { registry } from "../registry.js";
import { dingtianDriver } from "./access-dingtian.js";
import { dahuaDriver, hikvisionDriver } from "./camera.js";
import { rongtaDriver } from "./printer-rongta.js";
import { tcpipReaderDriver, wiegandReaderDriver } from "./reader.js";
let registered = false;
@@ -17,6 +18,7 @@ export function registerBuiltinDrivers(): void {
registry.register(tcpipReaderDriver);
registry.register(hikvisionDriver);
registry.register(dahuaDriver);
registry.register(rongtaDriver);
}
export {
@@ -25,4 +27,5 @@ export {
tcpipReaderDriver,
hikvisionDriver,
dahuaDriver,
rongtaDriver,
};
@@ -0,0 +1,301 @@
import { Socket } from "node:net";
import { request as httpRequest } from "node:http";
import type {
Device,
DeviceHealth,
MonitorableDevice,
PrinterDevice,
PrinterStatus,
TicketData,
} from "../interfaces.js";
import type { ConfigField, DeviceConfig, PrinterDriver } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
// Rongta 80mm network thermal printer driver. Rongta RP-series printers (and the
// many OEM clones that share their firmware) speak ESC/POS over a raw TCP socket
// on port 9100 — the JetDirect/RAW convention. There is no auth on the print
// socket; like the other field devices it lives on the isolated device VLAN.
// See wiki/entities/rongta-printer.md and wiki/concepts/network-isolation.md.
//
// ROLES + FAILOVER: a lane has more than one printer. Each instance declares a
// `role` (entry-dispenser at the lane / booth-receipt in the booth) and a
// `failoverRank`. The entry flow prints on the highest-rank healthy printer for
// the wanted role and falls back to the next — so if the outside dispenser is
// offline, the booth printer prints the entry ticket as a backup. The driver
// itself is role-agnostic; the role/rank live in config and the caller (server)
// owns the failover selection. See wiki/concepts/printer-roles-failover.md.
// --- ESC/POS command bytes ----------------------------------------------------
const ESC = 0x1b;
const GS = 0x1d;
const LF = 0x0a;
const INIT = Buffer.from([ESC, 0x40]); // ESC @ — reset to power-on defaults
const ALIGN_CENTER = Buffer.from([ESC, 0x61, 0x01]); // ESC a 1
const ALIGN_LEFT = Buffer.from([ESC, 0x61, 0x00]); // ESC a 0
const BOLD_ON = Buffer.from([ESC, 0x45, 0x01]); // ESC E 1
const BOLD_OFF = Buffer.from([ESC, 0x45, 0x00]); // ESC E 0
const DOUBLE_ON = Buffer.from([GS, 0x21, 0x11]); // GS ! — double width+height
const DOUBLE_OFF = Buffer.from([GS, 0x21, 0x00]);
const FEED_AND_CUT = Buffer.from([ESC, 0x64, 0x04, GS, 0x56, 0x42, 0x00]); // feed 4, GS V B 0 partial cut
/** Encode a printable line as bytes (CP437/ASCII subset) + a line feed. */
function line(text = ""): Buffer {
return Buffer.concat([Buffer.from(text, "ascii"), Buffer.from([LF])]);
}
/** Build the full ESC/POS byte stream for an entry ticket. */
function renderTicket(data: TicketData): Buffer {
return Buffer.concat([
INIT,
ALIGN_CENTER,
BOLD_ON,
DOUBLE_ON,
line("PARKING"),
DOUBLE_OFF,
BOLD_OFF,
line(),
line(`Lane ${data.lane}`),
line(),
BOLD_ON,
line(data.ticketId),
BOLD_OFF,
ALIGN_LEFT,
line(),
line(`Issued: ${data.issuedAt}`),
FEED_AND_CUT,
]);
}
/** Open a TCP socket, write the bytes, wait for flush, then close. */
function sendRaw(host: string, port: number, payload: Buffer, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const sock = new Socket();
let settled = false;
const done = (err?: Error) => {
if (settled) return;
settled = true;
sock.destroy();
err ? reject(err) : resolve();
};
sock.setTimeout(timeoutMs);
sock.on("timeout", () => done(new Error("timeout")));
sock.on("error", done);
sock.connect(port, host, () => {
sock.write(payload, (err) => (err ? done(err) : done()));
});
});
}
// --- live status via the device's own status web page -------------------------
// The Rongta board serves /prn_stat.htm, a small HTML table where the DEVICE has
// already decoded the ESC/POS status bits into labelled Yes/No rows. We scrape
// that rather than send raw `DLE EOT` ourselves: on this clone the DLE EOT reply
// bytes don't follow the canonical bit layout (verified on hardware), so trusting
// the device's own decode is the safe choice. See printer-status-monitoring.md.
/** The fault flags the status page reports (a subset of PrinterStatus). */
type StatusFlag = "coverOpen" | "cutterError" | "paperEnd" | "paperNearEnd" | "offline";
type StatusFlags = Partial<Record<StatusFlag, boolean>>;
/** Label text on the status page (NBSP/space-normalised, lowercased) → our key. */
const STATUS_FIELDS: Record<string, StatusFlag> = {
"cover is open": "coverOpen",
"cutter error": "cutterError",
"paper end": "paperEnd",
"paper near end": "paperNearEnd",
"printer off-line": "offline",
};
/** GET the status page over HTTP and return the raw HTML. */
function fetchStatusPage(host: string, httpPort: number, timeoutMs: number): Promise<string> {
return new Promise((resolve, reject) => {
const req = httpRequest(
{ host, port: httpPort, path: "/prn_stat.htm", method: "GET", timeout: timeoutMs },
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () =>
res.statusCode === 200
? resolve(data)
: reject(new Error(`status page HTTP ${res.statusCode}`)),
);
},
);
req.on("error", reject);
req.on("timeout", () => req.destroy(new Error("status page timeout")));
req.end();
});
}
/**
* Parse /prn_stat.htm into boolean flags. Each fault is a `<TD>label</TD>
* <TD>Yes|No</TD>` pair. Returns only the recognised fields; a missing field is
* left undefined so the caller can detect an unexpected page (fail safe, not a
* false "ok").
*/
function parseStatusPage(html: string): StatusFlags {
const out: StatusFlags = {};
const rowRe = /<TD[^>]*>([^<]*?)<\/TD>\s*<TD[^>]*>([^<]*?)<\/TD>/gi;
let m: RegExpExecArray | null;
while ((m = rowRe.exec(html))) {
if (m[1] === undefined || m[2] === undefined) continue;
const label = m[1].replace(/&nbsp;/gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
const value = m[2].replace(/&nbsp;/gi, " ").trim().toLowerCase();
const key = STATUS_FIELDS[label];
if (key && (value === "yes" || value === "no")) {
out[key] = value === "yes";
}
}
return out;
}
/** TCP connect probe — the print socket has no status protocol we rely on. */
function probe(host: string, port: number, timeoutMs: number): Promise<void> {
return new Promise((resolve, reject) => {
const sock = new Socket();
let settled = false;
const done = (err?: Error) => {
if (settled) return;
settled = true;
sock.destroy();
err ? reject(err) : resolve();
};
sock.setTimeout(timeoutMs);
sock.on("timeout", () => done(new Error("timeout")));
sock.on("error", done);
sock.connect(port, host, () => done());
});
}
class RongtaPrinter implements PrinterDevice, MonitorableDevice {
readonly driverId = "rongta";
readonly #host: string;
readonly #port: number;
readonly #httpPort: number;
readonly #timeout: number;
constructor(config: DeviceConfig) {
this.#host = String(config.host);
this.#port = config.port ? Number(config.port) : 9100;
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 3000;
}
async connect(): Promise<void> {
await this.healthCheck();
}
async disconnect(): Promise<void> {
stubLog(this.driverId, "disconnect");
}
async healthCheck(): Promise<DeviceHealth> {
try {
await probe(this.#host, this.#port, this.#timeout);
return { status: "ready" };
} catch (err) {
return { status: "offline", detail: (err as Error).message };
}
}
async printTicket(data: TicketData): Promise<void> {
await sendRaw(this.#host, this.#port, renderTicket(data), this.#timeout);
stubLog(this.driverId, `printed ticket ${data.ticketId} (lane ${data.lane})`);
}
/**
* Live operator-actionable status, scraped from the device's own status page.
* The board decodes the ESC/POS status bits itself, so we trust its Yes/No
* over hand-decoding this clone's non-standard DLE EOT reply.
*
* - status page unreachable → offline (the same signal as a dead printer),
* - page reachable but a recognised field missing → degraded (don't claim
* "ready" off a page we didn't fully understand — fail safe),
* - any fault flag true → degraded,
* - otherwise → ready.
*/
async readStatus(): Promise<PrinterStatus> {
const checkedAt = new Date().toISOString();
let html: string;
try {
html = await fetchStatusPage(this.#host, this.#httpPort, this.#timeout);
} catch (err) {
return { status: "offline", detail: (err as Error).message, checkedAt };
}
const flags = parseStatusPage(html);
const expected: StatusFlag[] = ["coverOpen", "cutterError", "paperEnd", "paperNearEnd", "offline"];
const missing = expected.filter((k) => flags[k] === undefined);
if (missing.length > 0) {
return {
status: "degraded",
detail: `unexpected status page (missing: ${missing.join(", ")})`,
checkedAt,
};
}
const faults = expected.filter((k) => flags[k] === true);
const labels: Record<StatusFlag, string> = {
paperEnd: "paper out",
coverOpen: "cover open",
cutterError: "cutter error",
offline: "printer off-line",
paperNearEnd: "paper low",
};
return {
status: faults.length > 0 ? "degraded" : "ready",
...flags,
detail: faults.length > 0 ? faults.map((f) => labels[f]).join(", ") : undefined,
checkedAt,
};
}
}
/** Type guard: does this device carry a printer role (entry vs. booth)? */
export type PrinterRole = "entry-dispenser" | "booth-receipt";
const roleField: ConfigField = {
key: "role",
label: "Role",
type: "select",
required: true,
default: "entry-dispenser",
options: [
{ value: "entry-dispenser", label: "Entry dispenser (outside / at the lane)" },
{ value: "booth-receipt", label: "Booth printer (receipts + backup)" },
],
help: "Entry tickets print on the entry dispenser, falling back to the booth printer if it is offline.",
};
const rankField: ConfigField = {
key: "failoverRank",
label: "Failover rank",
type: "number",
required: false,
default: 0,
help: "Higher = tried first within the same role. The booth printer also backs up the entry dispenser.",
};
export const rongtaDriver: PrinterDriver = {
id: "rongta",
category: "printer",
label: "Rongta 80mm thermal printer",
description:
"Rongta RP-series 80mm thermal printer (and ESC/POS-compatible clones) over raw TCP (port 9100). No auth on the print socket — isolate the VLAN.",
transports: ["tcp-ip"],
configFields: [
hostField,
{ ...portField(9100), required: false, help: "Raw print socket (ESC/POS over JetDirect/RAW, default 9100)." },
{ key: "httpPort", label: "Status web port", type: "port", required: false, default: 80, help: "Device status page (/prn_stat.htm) port for live monitoring (default 80)." },
roleField,
rankField,
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 3000 },
],
create: (c) => new RongtaPrinter(c),
};
/** Type guard exposed for callers that need to read a device's printer role. */
export function isPrinter(device: Device): device is PrinterDevice {
return typeof (device as Partial<PrinterDevice>).printTicket === "function";
}
+8
View File
@@ -15,4 +15,12 @@ export {
tcpipReaderDriver,
hikvisionDriver,
dahuaDriver,
rongtaDriver,
} from "./drivers/index.js";
export { isPrinter, type PrinterRole } from "./drivers/printer-rongta.js";
export {
orderForRole,
printWithFailover,
NoPrinterAvailableError,
type PrinterInstance,
} from "./printer-routing.js";
+38
View File
@@ -142,6 +142,10 @@ export interface HardenResult {
readonly secrets: Record<string, string | number>;
/** Human-readable summary of what was changed (for logging/UI). */
readonly applied: string[];
/** Hardening steps that could NOT be applied (e.g. a firmware quirk), so the
* admin knows a residual risk remains. Best-effort steps report here instead
* of failing the whole harden. */
readonly warnings?: string[];
}
export function isHardenable(device: Device): device is Device & HardenableDevice {
@@ -191,3 +195,37 @@ export interface TicketData {
export interface PrinterDevice extends Device {
printTicket(data: TicketData): Promise<void>;
}
// --- Live printer status (consumable / mechanical faults) ----------------
// Optional capability: a printer that reports the operator-actionable faults a
// basic `healthCheck` (reachability) can't see — paper out, cover open, cutter
// jam. Used by the live status monitor so the booth knows BEFORE a driver presses
// the entry button and no ticket comes out. The Rongta board exposes these via
// its own status web page (it decodes the ESC/POS bits for us — more reliable
// than trusting a clone's DLE EOT bit layout). See wiki/concepts/printer-status-monitoring.md.
export interface PrinterStatus {
/** Reachable + no fault = ready; reachable + fault = degraded; unreachable = offline. */
readonly status: "ready" | "degraded" | "offline";
/** Out of paper — the printer cannot print. */
readonly paperEnd?: boolean;
/** Paper low — still prints, but warn the operator to reload. */
readonly paperNearEnd?: boolean;
/** Cover/lid open — will not print. */
readonly coverOpen?: boolean;
/** Cutter jammed/errored. */
readonly cutterError?: boolean;
/** Printer reports itself off-line (its own flag, distinct from unreachable). */
readonly offline?: boolean;
/** Human-readable summary (e.g. "paper out", or the unreachable error). */
readonly detail?: string;
readonly checkedAt: string; // ISO-8601
}
export interface MonitorableDevice {
/** Richer, operator-actionable status beyond reachability. */
readStatus(): Promise<PrinterStatus>;
}
export function isMonitorable(device: Device): device is Device & MonitorableDevice {
return typeof (device as Partial<MonitorableDevice>).readStatus === "function";
}
+91
View File
@@ -0,0 +1,91 @@
// Printer routing: pick which printer prints a given job across a lane's
// printers, with automatic failover. A lane has more than one printer — an
// entry dispenser outside (where the driver takes the ticket) and a booth
// printer inside (receipts, and a BACKUP for entry tickets if the dispenser is
// offline). See wiki/concepts/printer-roles-failover.md.
//
// This is pure selection logic over (config, health) — no device I/O — so the
// entry/exit flow can decide where to print without coupling to a transport.
import type { PrinterDevice } from "./interfaces.js";
import type { PrinterRole } from "./drivers/printer-rongta.js";
/** A configured printer instance + its live adapter, as the caller holds them. */
export interface PrinterInstance {
readonly id: string;
readonly role: PrinterRole;
/** Higher = preferred within a role. Ties broken by id for determinism. */
readonly failoverRank: number;
readonly device: PrinterDevice;
}
/**
* Order the candidate printers for a job targeting `wantRole`, best-first.
*
* Rule: printers of the wanted role come first (highest rank first); the booth
* printer is also a fallback for entry tickets, so when an entry ticket is
* routed, booth-receipt printers follow the entry dispensers. The reverse is
* deliberately NOT done — a receipt never prints on the outside dispenser.
*/
export function orderForRole(
printers: readonly PrinterInstance[],
wantRole: PrinterRole,
): PrinterInstance[] {
const fallbackRole: PrinterRole | null =
wantRole === "entry-dispenser" ? "booth-receipt" : null;
const rank = (p: PrinterInstance): number => {
if (p.role === wantRole) return 2;
if (p.role === fallbackRole) return 1;
return 0;
};
return printers
.filter((p) => rank(p) > 0)
.sort((a, b) => {
if (rank(a) !== rank(b)) return rank(b) - rank(a); // wanted role first
if (a.failoverRank !== b.failoverRank) return b.failoverRank - a.failoverRank;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0; // stable tiebreak
});
}
export class NoPrinterAvailableError extends Error {
constructor(public readonly attempts: { id: string; error: string }[]) {
super(
attempts.length === 0
? "no printer configured for this job"
: `all ${attempts.length} candidate printer(s) failed: ${attempts
.map((a) => `${a.id} (${a.error})`)
.join(", ")}`,
);
this.name = "NoPrinterAvailableError";
}
}
/**
* Print `job` on the best healthy printer for `wantRole`, failing over down the
* ordered list. Tries each candidate's print directly: a healthCheck race is
* pointless when the print itself is the real reachability test, so we just
* attempt the print and move on if it throws. Returns the id that succeeded.
*
* Throws {@link NoPrinterAvailableError} if every candidate fails — the caller
* (entry flow) decides what that means (e.g. raise the barrier without a paper
* ticket vs. hold). That policy is the flow's, not the printer's.
*/
export async function printWithFailover(
printers: readonly PrinterInstance[],
wantRole: PrinterRole,
job: (device: PrinterDevice) => Promise<void>,
): Promise<string> {
const ordered = orderForRole(printers, wantRole);
const attempts: { id: string; error: string }[] = [];
for (const p of ordered) {
try {
await job(p.device);
return p.id;
} catch (err) {
attempts.push({ id: p.id, error: (err as Error).message });
}
}
throw new NoPrinterAvailableError(attempts);
}
+27
View File
@@ -34,6 +34,10 @@ export interface ParkingEvent {
}
export type ParkingEventType =
// A raw device input (e.g. a Dingtian button press) was received and recorded.
// NOT a confirmed entry — the richer `vehicle_entry` is appended later by the
// entry flow once a ticket prints and the barrier is commanded.
| "input_received"
| "vehicle_entry"
| "vehicle_exit"
| "void"
@@ -48,3 +52,26 @@ export const ROLES: readonly Role[] = [
"cashier",
"readonly",
] as const;
/**
* Signs the canonical bytes of an event for the append-only chain. This is the
* abstraction over the [[atecc608]] secure element: the real, non-extractable
* hardware key is ONE implementation. Whether the chip is wired is still
* open-question #6, so the server ships a software signer in the meantime —
* same interface, swappable with no business-logic change (the device-adapter
* philosophy applied to signing). See wiki/concepts/append-only-event-chain.md.
*
* IMPORTANT: a software signer makes the chain self-consistent and detectably
* tamper-evident, but NOT unforgeable by someone who owns the machine — only the
* ATECC608 provides that. Don't conflate the two.
*/
export interface Signer {
/** Stable id of the signer/key (e.g. "sw-hmac-v1", "atecc608-slot0"). Stored
* alongside events so verification knows which key to check against. */
readonly keyId: string;
/** Sign the canonical payload; returns a hex signature. */
sign(payload: string): string;
/** Verify a signature over the payload (software signers can; the ATECC608
* verifies via its public key). */
verify(payload: string, signature: string): boolean;
}
+72 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, security, integrity]
sources: [parking-system-architecture]
updated: 2026-06-14
updated: 2026-06-15
---
# Append-Only Event Chain
@@ -24,3 +24,74 @@ It only becomes trustworthy as an external fraud control when paired with [[reco
against an authority the operator can't alter. Every device event — including those ingested
from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side
chain.
## Implementation (apps/server)
> Implementation-derived. The schema (`packages/db` `events`) and types
> (`packages/shared` `ParkingEvent`) predate this; the writer/signer are new.
- **`EventLog`** (`apps/server/src/event-log.ts`) is the append primitive. `append()` reads the
latest row, sets `index = prev + 1`, `prevHash = sha256(canonical(prev))` (genesis = null),
signs the canonical form, and inserts. There are **no update/delete paths**.
- **Serialized appends.** SQLite is single-writer, but read-prev → compute-hash → insert is
multi-step, so `EventLog` also guards it with an in-process async lock — otherwise two near-
simultaneous events could claim the same `index` or chain off a stale `prevHash`. Verified:
5 concurrent appends produced indices 1..5 with an intact chain.
- **Canonical form** is a fixed-order JSON array (`index,type,direction,lane,source,identity,
occurredAt,prevHash`) — byte-stable, since the chain + signatures depend on it. The volatile
row `id` is excluded; chain identity is `index` + content.
- **`verifyChain()`** walks oldest→newest, recomputing hashes + signatures. Catches tampered
content (bad signature), reordering / a deleted row (`index` gap), and a `prevHash` mismatch.
Exposed at `GET /api/events/verify` (admin). Read access to the log: `GET /api/events`.
### The `Signer` abstraction (software now, ATECC608 later)
Signing goes through a **`Signer`** interface (`packages/shared`) — the abstraction over the
[[atecc608]]. Because the chip being wired is still [[open-questions|open-question #6]], the
server ships a **`SoftwareSigner`** (HMAC-SHA256, key from `EVENT_SIGNING_KEY`). Swapping to the
secure element is a new `Signer` impl with no `EventLog` change; each event stores its `keyId`
so old events stay verifiable.
> ⚠️ The software signer makes the chain **self-consistent + tamper-evident**, but **not
> unforgeable by someone who owns the host** — only the ATECC608's non-extractable key gives
> property (3) above. Until the chip is wired, the chain detects tampering by *outsiders* and
> *accidental* corruption, but an operator with the signing key + DB access could re-sign a
> forged chain. This is the central reason #6 matters.
### What currently feeds the log
Dingtian **input (button) pushes** → bus → `input_received` events (see [[device-input-flow]],
[[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` —
the richer entry event waits for the entry flow (ticket print + barrier command).
- **`lane`** is now resolved from the firing device. A `LaneMap` (`apps/server/src/lane-map.ts`)
caches `lane_devices.id → lane`, built at startup and refreshed by the setup routes on every
assign/unassign. Device events carry the device instance id, not a lane; the handler looks it
up. A device with no mapping (assigned without a lane, or a stale id) logs **`lane: -1`** and a
warning — never `0`, which is a real lane — and is still recorded (the chain is append-only;
nothing is dropped).
- **`source` stays `null`** for `input_received`, and deliberately so: `source` is an
`IdentitySource` (`wiegand | lpr | qr | ticket | manual`) — *how a vehicle was identified* — not
a device/IP field. A raw button push has no vehicle identity. The device provenance lives in
**`identity`** (e.g. `dingtian:<id> input:1/on`).
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
The event log records what the **host** did (inputs it received, opens it commanded). It is
**blind to out-of-band relay actuation** — anything that fires a relay without going through the
host. **Proven on hardware**: a binary relay command sent directly to the device with the
(sniffable) `relay_pw` fired a relay and produced **zero** events. Out-of-band paths include:
- the **password-less string protocol** (until disabled — see [[dingtian-relay]]),
- a **sniffed/replayed `relay_pw`** binary command (plaintext UDP — relay control is
defence-in-depth, **not** a boundary),
- the device's own **`ip_watchdog`** (auto-toggles a relay on ping-failure — must stay disabled),
- a future **`barrier_open_command`** path is host-side and *would* log; these bypass it.
So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** —
the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an
**independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input
→ which DOES push + log; the [[lpr-camera]]; payment/Z-report). **A physical open with no matching
signed command is the fraud signal.** Both the witness sources and the reconciliation logic are
**NOT yet built** — this is the main open gap. Prevention (VLAN isolation so the attacker can't
reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop.
+23 -7
View File
@@ -27,17 +27,33 @@ each device's connection config.
authenticated input push ([[device-input-flow]]) — the admin never touches the device's own web
UI. **Fails the save** (no DB row) if the device can't be configured, so there are no
orphan/half-configured rows. On success persists to `lane_devices`.
4. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`.
4. **Remove** — `DELETE /api/setup/assign/:id` (admin-only) drops one instance's row. Only our
row is removed; the device itself is not un-hardened/un-configured (a stale push from an
unknown device id is already rejected, and re-assigning reconfigures it).
5. **Complete** — `POST /api/setup/complete` marks the single-row `setup_state`.
## Config granularity
## Config granularity — multi-instance per category
Organized **per lane** — each lane gets an access controller, reader(s), and camera(s), each with
its own connection settings. Matches the architecture's "mixable per lane" reality (a lane can
serve permit holders via [[wiegand]] and casual via host-side reads on one relay — see
[[entry-exit-readers]]).
The data model is **multi-instance**: `lane_devices` holds **one row per instance**, keyed by a
generated `id`, with no one-per-(lane, category) constraint. So a lane can have **more than one of
every category** — e.g. two printers (an entry dispenser + a booth printer; see
[[printer-roles-failover]]), multiple readers, multiple cameras. `assign` always inserts a new row
(never an upsert), and `state` returns the full list.
The `SetupWizard` reflects this: each category shows the **list of assigned instances** for the
current lane (with **Remove**) plus an **Add another** form — not a single fixed slot. `select`-type
config fields (e.g. a printer's role) render as dropdowns.
Organized **per lane** — each lane gets its access controller(s), reader(s), camera(s), and
printer(s), each with its own connection settings. Matches the architecture's "mixable per lane"
reality (a lane can serve permit holders via [[wiegand]] and casual via host-side reads on one
relay — see [[entry-exit-readers]]).
## Security notes
- The assign/state/complete endpoints require the **admin** role ([[local-jwt-auth]]).
- The assign/state/delete/complete endpoints require the **admin** role ([[local-jwt-auth]]).
- Device **credentials are stored in `lane_devices.config`** — protect at rest
([[disk-os-hardening]]); device hosts belong on the isolated VLAN ([[network-isolation]]).
- **Secrets are stripped on the way out**: `assign` and `state` both redact `pushPassword`,
`webPassword`, and `relayPassword` from the returned config (the UI lists devices; it never
needs the stored secrets).
+53
View File
@@ -0,0 +1,53 @@
---
type: concept
tags: [parking, printer, device, reliability]
sources: []
updated: 2026-06-14
---
# Printer roles & failover
A lane runs **more than one printer**, and the system knows each one's job so it can fail over
automatically. This is a reliability decision, not a threat-model one: an entry ticket must
still print when the outside dispenser jams or drops off the network.
## Roles
Each printer instance (a `lane_devices` row, category `printer`) declares a **role** in its
config:
- **`entry-dispenser`** — outside, at the lane. Prints the entry ticket the driver takes.
- **`booth-receipt`** — inside the booth. Prints receipts at exit/payment, AND serves as the
**backup** for entry tickets.
It also declares a **`failoverRank`** (higher = preferred within a role) to order multiple
printers of the same role deterministically (ties broken by id).
## Failover rule (asymmetric, on purpose)
For an **entry ticket** (`wantRole = entry-dispenser`): try the entry dispensers (best rank
first), then fall back to the **booth printer**. So a driver still gets a ticket when the
outside unit is offline — the operator hands it over from the booth.
The reverse is **deliberately not** done: a **receipt** never prints on the outside dispenser.
Receipts are a booth-only job; an entry dispenser falling back to print receipts makes no
physical sense.
## Where the logic lives
- The driver (`rongta`) is **role-agnostic** — role/rank are just config; the transport doesn't
care. Keeps [[device-adapter-pattern|adapters]] swappable.
- Selection is pure logic in `packages/devices/printer-routing.ts`: `orderForRole()` ranks
candidates; `printWithFailover()` attempts the print down the list and throws
`NoPrinterAvailableError` only when every candidate fails.
- It **attempts the print directly** rather than racing a `healthCheck` first — the print is
the real reachability test, and a health probe that passes can still be followed by a failed
print.
## Open: the all-printers-down policy
When `printWithFailover` exhausts every candidate, what should entry do — raise the barrier
with no paper ticket (the plate/[[lpr-camera]] is the independent record), or hold? That policy
belongs to the **entry flow** ([[device-input-flow]], [[fail-state-safety]]), not the printer
layer, and is **not yet decided**. The signed event ([[append-only-event-chain]]) is created
regardless of whether paper prints.
@@ -0,0 +1,74 @@
---
type: concept
tags: [parking, printer, device, monitoring, reliability]
sources: []
updated: 2026-06-14
---
# Printer status monitoring
The booth must know a printer is in trouble **before** a driver presses the entry button and no
ticket comes out. So the system polls each printer's live status (paper out, cover open, cutter
jam, off-line) and pushes changes to the operator UI. A reliability control, like
[[printer-roles-failover]] — not a threat-model one.
## Where the status comes from (the safe-decode decision)
The raw print socket (TCP 9100) is write-only for us — it returns no paper/cover feedback. ESC/POS
printers expose status via real-time queries (`DLE EOT n`). On the [[rongta-printer]] clone we
probed, **`DLE EOT` replies do NOT follow the canonical ESC/POS bit layout** (the spec's fixed
validation bits were wrong, verified on hardware 2026-06-14). Decoding those bits ourselves risked
a **false-healthy** — reporting "paper OK" when it's empty — which is the dangerous direction for
an entry lane.
Instead we scrape the device's **own status web page** (`http://<host>/prn_stat.htm`). The board
decodes the bits itself into labelled Yes/No rows (Cover Is Open, Cutter Error, Paper End, Paper
Near End, Printer Off-Line). We trust the device's decode over hand-decoding an undocumented clone.
This is captured as a device capability: `MonitorableDevice.readStatus(): PrinterStatus` in
`packages/devices`. The Rongta driver implements it; the monitor is device-agnostic via
`isMonitorable()`. A future printer with a different status mechanism just implements the same
interface.
## Status mapping (fail safe)
`readStatus()` maps to `ready | degraded | offline`:
- status page unreachable / times out → **offline** (same signal as a dead printer; never throws),
- page reachable but a recognised field is missing → **degraded** ("unexpected status page") —
we do NOT claim "ready" off a page we didn't fully parse,
- any fault flag true (paper end, cover open, cutter error, off-line) → **degraded** + a detail
string ("paper out", …),
- all five clear → **ready**.
## The monitor (server)
`PrinterMonitor` (`apps/server/src/printer-monitor.ts`):
- reloads the monitored set from `lane_devices` each tick (so a newly-assigned printer is picked
up without a restart), keeping only enabled, monitorable printers;
- polls every `PRINTER_POLL_MS` (default 5000ms), never overlapping ticks;
- caches the latest status per device id;
- emits a `printer-status` event on the device bus **only when status changes** (deduped).
## API / live UI
- `GET /api/printers/status` — cached snapshot of all printers (no device round-trip).
- `GET /api/printers/status/stream` — **Server-Sent Events**: full snapshot on connect, then one
event per change. The booth SPA subscribes for real-time paper-out / offline indicators.
- Any authenticated role may read (operational, not a setup action).
## Verified on hardware (2026-06-14)
`readStatus()` against 10.0.10.6 → `ready` (all flags false); against an unreachable host →
`offline` with "status page timeout" (no throw); bus emits on change and suppresses unchanged
reads. Full repo typechecks.
## Open / not yet done
- **Fault-state capture**: we've only observed the all-clear page. The exact label text for an
active fault (e.g. does "Paper End" flip to "Yes"?) should be confirmed by physically removing
paper / opening the cover, to be 100% sure the scrape catches it. The parser is built to match
Yes/No and degrade on anything unexpected, so this is a confidence check, not a blocker.
- Tying a `degraded`/`offline` entry-dispenser into [[printer-roles-failover]] failover and the
(not-yet-built) entry flow's all-printers-down policy ([[device-input-flow]]).
+2 -2
View File
@@ -17,8 +17,8 @@ payment terminal is dictated by the acquiring bank. (See [[parking-system-archit
| Access controller | [[dingtian-relay]] relay+input board | Decoupled inputs (host-in-the-loop); **isolate the VLAN** ([[network-isolation]]). ([[uhppote-controller]]/[[zkteco-controller]] rejected) |
| Permit readers | Nedap/Kathrein UHF, or Mifare → [[wiegand]] | Hands-free, or autonomous offline decisions |
| Casual identity | [[lpr-camera]] (Milesight, edge AI) | Plate = ticket + independent record |
| Ticket dispenser | Custom VKP80 | Parking-grade thermal/ESC-POS |
| Booth printer | Epson TM / Citizen (USB or network) | ESC/POS; one adapter covers both transports |
| Ticket dispenser | [[rongta-printer]] 80mm (entry-dispenser role) | ESC/POS over raw TCP 9100; driver written |
| Booth printer | [[rongta-printer]] 80mm (booth-receipt role) | Receipts + backup for entry tickets ([[printer-roles-failover]]) |
| Payment | Bank-certified P2PE standalone terminal + cash drawer | Keeps the app out of **PCI-DSS scope** |
| Host machine | Fanless industrial PC + UPS + [[atecc608]] | Reliability, power-loss safety, offline signing |
| Network | Managed VLAN switch, PoE+ | Isolate the open control protocol |
+38 -2
View File
@@ -79,17 +79,53 @@ the relay via UDP. See [[device-input-flow]] for the full path + trust model.
> real path** — lower latency, and it can be authenticated (the device supports Basic/Digest +
> HTTPS on the push), unlike the open UDP control direction.
### What it pushes vs. doesn't (logging)
- **Inputs (buttons): YES, pushed.** Input changes are HTTP-pushed via `input_link_url` and now
land in the host's signed [[append-only-event-chain]] as `input_received` events (bus →
`EventLog`). That is the audit trail for "a button fired."
- **Relay / barrier opens: NO push, no log.** The device has **no event log of its own** and does
not report when a relay fires — relay control is one-way UDP that the *host* initiates. So
"the barrier opened" is not something to scrape from the device. The host records what it
*commanded* (a future `barrier_open_command` event); a relay open with **no matching signed
host event is itself the anomaly** to alarm on ([[threat-model]]). Do not treat the Dingtian as
a log source — it is a dumb relay+input board; the host is the source of truth.
## Hardening (`harden()`) — and why HTTP auth is not a boundary here
On assign the driver runs `harden()` (the [[device-registry|HardenableDevice]] capability):
1. **`relay_pw`** — set a random relay password so binary relay commands (UDP 60000) need it.
2. **Disable unused channels** — set `p:255` on rs485/can/tcp×2/mqtt; keep only UDP1 binary
(relay control) + UDP2 string (status read).
2. **Disable EVERY other channel** — set `p:255` on the string protocol (udp2), rs485, can,
tcp×2, mqtt; keep **only** UDP1 binary, which carries `relay_pw` for both control AND status.
3. **Rotate the `admin`/`admin` web login** — `GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>&`
(response `&0&…&` = success, verified on hardware). The new password is stored back in
config (`webUser`/`webPassword`) so a re-run can rotate again (the device checks the *old*
creds). This step is **best-effort** — a failure logs and does not fail the assign.
> ⚠️ **The string protocol (udp2) is a password-less relay-fire path — the original `harden()`
> left it ENABLED "for status reads", which was a real hole.** The Dingtian string protocol has
> NO password field and can fire relays (`"11"` = relay 1 on, `"21"` = off, `"11*"` = jog).
> **Proven on hardware**: sending `"11"` to UDP 60001 with no credentials opened relay 1,
> completely bypassing `relay_pw`. Fixes: (a) status reads moved to the **authenticated binary
> read** (relay command `0x00`) so the string protocol is no longer needed; (b) `harden()` now
> sets `udp2.p=255` to disable it. **Firmware caveat (V3.6J):** the CONFIG API silently refuses
> to disable udp2 — it accepts the write, reboots, and clamps it back — even though the device's
> **web UI can** disable it. So the udp2 disable is **best-effort + warns** (it is NOT part of the
> blocking verify); if it doesn't stick, `harden()` returns a warning telling the admin to flip
> UDP2 off in the device web UI. Verified: after the web-UI disable, the `"11"` attack gets no
> reply and the relay stays off, while authenticated binary control/status still work.
> 🔑 **Web-login model (bug fixed).** The login set has TWO distinct config keys:
> `webPassword` = the password the admin WANTS (blank → harden generates a random one), and
> `webPasswordCurrent` = the device's EXISTING password (the old cred `userset.cgi` checks;
> defaults to `admin`). The original code conflated them — an admin typing a *desired* password
> made harden send it as the *old* cred, the rotation failed, yet the DB still saved the typed
> value: **the DB claimed a password the device never accepted (login stayed admin/admin).**
> Fix: harden now rotates `current → desired`, **verifies** by re-authenticating with the new
> password, and only then returns `secrets.webPassword`; assign strips the typed inputs and
> persists only the verified value (else a warning, no save). Verified on hardware: device
> rejects `admin/admin` (`&2&`) and accepts the chosen password (`&0&`) after harden.
>
> ⚠️ **The device CGI API is UNAUTHENTICATED.** Verified on hardware: `GET /api/v2/config.cgi`,
> `/`, and even `/userset.cgi` all return **200 with no credentials**. The `admin`/`admin` login
> gates only the interactive **browser UI** — the CGI control plane (read/write full config, fire
+48
View File
@@ -0,0 +1,48 @@
---
type: entity
tags: [parking, hardware, printer, device]
sources: []
updated: 2026-06-14
---
# Rongta 80mm thermal printer
The chosen ticket/receipt printer: a **Rongta RP-series 80mm network thermal printer** (and the
many ESC/POS-compatible OEM clones that share its firmware). Driver `rongta` in
`packages/devices` implements [[device-adapter-pattern|PrinterDevice]].
## Transport & protocol
- **ESC/POS over a raw TCP socket on port 9100** (the JetDirect/RAW convention). The driver
opens the socket, writes the ESC/POS byte stream, waits for flush, closes.
- **No authentication** on the print socket — anyone who can reach port 9100 can print. Like
every other field device it must sit on the **isolated device VLAN** ([[network-isolation]]).
There is no real HTTP/control boundary on the device (same posture as [[dingtian-relay]]).
- **Health check** is a TCP connect probe to 9100. The print socket exposes no status protocol
we rely on; the print itself is the real reachability test (failover attempts the print).
- **Live status** comes from the device's own web page `http://<host>/prn_stat.htm` (port 80),
which decodes Cover Open / Cutter Error / Paper End / Paper Near End / Off-Line into Yes/No.
We scrape that rather than hand-decode `DLE EOT` — this clone's DLE EOT reply bytes do **not**
match the canonical ESC/POS bit layout (verified on hardware), so trusting the device's own
decode avoids a false-healthy. Implemented as `readStatus()`; see [[printer-status-monitoring]].
## Deployment (this site)
- First printer verified reachable at **10.0.10.6:9100** from the host (TCP connect OK,
2026-06-14).
- **At least two printers**, by role — see [[printer-roles-failover]]:
- **entry-dispenser** — outside, at the lane; the driver takes the entry ticket.
- **booth-receipt** — inside the booth; receipts, AND the backup that prints the entry
ticket if the outside dispenser is offline.
## Ticket rendering
`printTicket(TicketData)` builds ESC/POS: `ESC @` init, centered/bold/double-size header,
lane, ticket id, issued-at, feed + partial cut (`GS V B`). CP437/ASCII subset.
## Status
Driver written and compiles; entry-ticket layout is a first pass; live status monitoring is
implemented and verified ([[printer-status-monitoring]]). The receipt/exit layout and the
cash-drawer kick (ESC/POS `ESC p`) are **not yet implemented** — they arrive with the
exit/payment flow. Replaces the generic "Epson TM / Citizen" booth-printer line in [[bom]].
+4 -1
View File
@@ -7,7 +7,7 @@ updated: 2026-06-14
# Index
Content catalog for the wiki. Start at [[overview]]. Maintained on every ingest.
Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
Counts: 1 source · 15 entities · 12 concepts · 2 decision records.
## Overview & navigation
- [[overview]] — the top-level synthesis and entry point.
@@ -39,6 +39,7 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
- [[lpr-camera]] — edge-AI plate recognition; host-side casual-identity source.
- [[zkteco-controller]] — ❌ rejected/historical; aux-input path was a contender, not pursued.
- [[dingtian-relay]] — ✅ CHOSEN access controller; decoupled inputs solve the button blocker (driver verified on hardware).
- [[rongta-printer]] — ✅ CHOSEN 80mm thermal printer; ESC/POS over raw TCP 9100; driver written, one unit reachable at 10.0.10.6.
- [[bom]] — reference bill of materials (barrier, loops, controller, readers, payment, host, network).
## Concepts — foundational forces
@@ -57,6 +58,8 @@ Counts: 1 source · 14 entities · 10 concepts · 2 decision records.
- [[device-input-flow]] — button → device push → backend decides → relay; backend is source of truth.
- [[device-discovery]] — optional driver capability to scan the LAN (no current driver uses it; UHPPOTE was the example).
- [[barrier-not-a-door]] — never timed-close a barrier; safety lives in barrier firmware.
- [[printer-roles-failover]] — ≥2 printers per lane by role; entry ticket falls back outside→booth.
- [[printer-status-monitoring]] — live poll of paper/cover/cutter/offline via the device's status page; SSE to the booth UI.
- [[trust-boundary]] — the core fork: network vs. device; auditable vs. unforgeable.
- [[fail-state-safety]] — entry fails closed, exit fails open; manual override; watchdog.
+92
View File
@@ -205,3 +205,95 @@ config write, relay fire, and userset.cgi itself all return 200 unauthenticated
inbound-auth setting (only session_en, which bricks the read API). So rotating the
login is COSMETIC, not a boundary — the signed event log remains the real
guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
## [2026-06-14] ingest | Rongta 80mm printer driver + printer roles/failover
- Added `rongta` PrinterDevice driver (ESC/POS over raw TCP 9100); registered in registry.
- Decision: ≥2 printers per lane by role (entry-dispenser outside, booth-receipt inside);
entry ticket fails over outside→booth (asymmetric — receipts never print outside).
- Selection logic lives in packages/devices/printer-routing.ts (orderForRole, printWithFailover).
- One unit verified reachable at 10.0.10.6:9100 from host (TCP connect OK).
- New pages: [[rongta-printer]], [[printer-roles-failover]]. Updated [[bom]], [[index]].
- Open: all-printers-down policy belongs to the (not-yet-built) entry flow, not the printer layer.
## [2026-06-14] ingest | Live printer status monitoring
- Added MonitorableDevice.readStatus()/PrinterStatus capability in packages/devices.
- Rongta readStatus() scrapes the device's own /prn_stat.htm (Cover/Cutter/Paper End/Near End/
Off-Line) — chosen over hand-decoding DLE EOT because this clone's DLE EOT bytes don't match
the canonical ESC/POS bit layout (verified on hardware; risk of false-healthy).
- Server PrinterMonitor: polls enabled monitorable printers (PRINTER_POLL_MS, default 5s),
caches latest, emits "printer-status" on change. API: GET /api/printers/status + SSE stream.
- Verified live: 10.0.10.6 -> ready (all flags clear); unreachable host -> offline (no throw);
bus emits on change, suppresses unchanged. Full repo typechecks (8/8).
- New page: [[printer-status-monitoring]]. Updated [[rongta-printer]], [[index]].
- Open: capture the page's actual text for an ACTIVE fault (pull paper / open cover) to confirm
the Yes flip; wire degraded/offline into failover + entry-flow all-down policy.
## [2026-06-15] ingest | Multi-instance device setup (add/remove per category)
- Confirmed the data model was already multi-instance (lane_devices = one row per instance,
assign always inserts); the limitation was UI-only (one slot per category).
- Backend: added DELETE /api/setup/assign/:id (unassign); /state now redacts secrets
(pushPassword/webPassword/relayPassword) via a shared redactSecrets() also used by /assign.
- Web: SetupWizard reworked — each category lists assigned instances (with Remove) + "Add
another" form; select-type config fields now render as dropdowns (fixes printer role input).
- Verified via Fastify inject: 2 printers assigned to one lane -> both listed, no secret leak,
delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks (8/8).
- Updated [[first-run-setup]].
## [2026-06-15] ingest | Append-only signed event log (Dingtian input pushes persist)
- Q: does the Dingtian push events? -> inputs YES (input_link_url), relay opens NO (device keeps
no log). Host is the source of truth; a relay open w/o matching signed event is the anomaly.
- Implemented EventLog (apps/server/event-log.ts): serialized append, monotonic index, prevHash
chain, signature; verifyChain() detects tamper/reorder/delete. Read: GET /api/events;
integrity: GET /api/events/verify (admin).
- Signer abstraction (packages/shared) over the ATECC608; SoftwareSigner (HMAC, EVENT_SIGNING_KEY)
shipped now since chip wiring is open-question #6. Caveat documented: software signer is
tamper-evident but NOT unforgeable-by-owner.
- Wired bus -> log: Dingtian input pushes become input_received events (lane mapping TODO).
- Added ParkingEventType 'input_received'.
- Verified via inject: push w/o digest -> 401; pushes -> 2 signed+chained events; verify -> ok;
direct DB tamper -> verifyChain catches at the right index; deleted row -> index gap. 5 concurrent
appends -> indices 1..5 intact. Full repo typechecks.
- Updated [[append-only-event-chain]], [[dingtian-relay]].
## [2026-06-15] ingest | Event log + Dingtian string-protocol security fix
- Append-only signed event log shipped (EventLog, Signer abstraction over ATECC608 w/ SoftwareSigner
HMAC; GET /api/events + /api/events/verify). Dingtian input pushes persist as input_received.
Verified on hardware: shorting I1-I4 -> 8 signed+chained events, verifyChain ok.
- SECURITY (verified on hardware): the password-less string protocol (udp2) can fire relays
("11" -> relay1 on) with NO auth, bypassing relay_pw. Fixes: status reads moved to authenticated
binary read (cmd 0x00); harden() disables udp2 BEST-EFFORT (firmware V3.6J config API refuses,
but web UI works) and returns a warning instead of throwing. After web-UI disable, the "11" attack
is dead and binary control/status still work.
- GAP (user-identified): event log captures host-originated actions only; out-of-band relay
actuation (sniffed relay_pw, string protocol, ip_watchdog) produces NO event — proven on hardware.
Real control is reconciliation vs. an independent witness; witness+reconciliation NOT yet built.
- Device web login (webUser/webPassword) now un-redacted in setup state (admin-only device area);
pushPassword/relayPassword stay machine-only.
- harden() warnings surfaced via the assign response.
- localAddress threaded through the Dingtian driver (device-facing-IP foundation; multi-homed hosts).
- INCIDENT: probing default.cgi factory-reset the bench device (now at 192.168.1.100, defaults).
Re-provisioning is the ADMIN's job via First-run setup (app must not hardcode site IPs).
- Updated [[append-only-event-chain]], [[dingtian-relay]].
## [2026-06-15] fix | Dingtian web-password: desired-vs-current split + verify + UI warnings
- BUG (found in real assign): admin typed a web password; harden used it as the OLD cred, rotation
failed silently, DB saved the typed value but device login stayed admin/admin. Also UDP2 warning
never reached the admin (frontend discarded the assign response).
- FIX: split config into webPassword (desired; blank→random) and webPasswordCurrent (existing old
cred, default admin). harden() rotates current→desired, VERIFIES by re-auth with the new pw, and
only returns secrets.webPassword on success (else warning, no save). assign strips typed
webPassword/webPasswordCurrent and persists only verified secrets.
- SetupWizard now shows assign-response warnings (amber banner, per category) — closes the
feedback loop for the UDP2-can't-disable case.
- Verified on hardware (192.168.1.100): harden set login to a chosen pw; device then rejects
admin/admin (&2&) and accepts the chosen pw (&0&). UDP2 warning surfaced as designed.
- Updated [[dingtian-relay]].
## [2026-06-15] update | input_received lane resolution + source semantics
- Wired device→lane resolution: `LaneMap` (`apps/server/src/lane-map.ts`) caches
`lane_devices.id → lane`, refreshed by setup routes on assign/unassign. `input_received`
events now carry the firing device's lane instead of a hardcoded `lane: 0`. Unmapped device →
`lane: -1` + warn (0 is a real lane; never mis-stamp).
- Documented that `source` stays null for raw inputs by design (it's an IdentitySource, not a
device field); device provenance is in `identity`.
- Updated [[append-only-event-chain]].