Compare commits
6 Commits
2a86e578a8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 59bfe2013f | |||
| f5fd61984a | |||
| 7db5cfa0e4 | |||
| add5fc0166 | |||
| 39d4bac419 | |||
| b2a0471b08 |
@@ -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. */
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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(/ /gi, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
||||
const value = m[2].replace(/ /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";
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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]]).
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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]].
|
||||
|
||||
Reference in New Issue
Block a user