import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { eq, devices, setupState, type Db } from "@parking/db"; import { hasPreconditions, hasPushConfig, isCamera, isDiscoverable, isHardenable, registerBuiltinDrivers, registry, setDeviceLogSink, type CameraDevice, type DeviceCategory, type DeviceConfig, } from "@parking/devices"; import { requirePermission } from "../auth.js"; import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.js"; import type { VisionClient } from "../vision-client.js"; // First-run setup API. The admin reads the driver catalog and assigns devices // per lane. See wiki/concepts/first-run-setup.md. interface AssignBody { category: DeviceCategory; driverId: string; // Driver config (opaque JSON, validated by the driver). Carries the model's // direction/binding: access → config.relays=[{relay,direction,button?}]; // reader/camera → config.controllerId + config.relay. See entry-exit-points.md. config: DeviceConfig; /** Optional: the backend IP the device should push to (overrides auto-pick; * matters on multi-NIC hosts). */ backendIp?: string; } interface TestBody { driverId: string; config: Record; /** When editing an EXISTING device, its id — so the test re-merges the stored * machine secrets (relayPassword/pushPassword) the client never received. Without * this, testing an edited device would send no relay password → the device ignores * the probe → a false "offline". Omitted when testing a brand-new device. */ id?: string; } // 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): Record { const out = { ...config }; for (const k of SECRET_CONFIG_KEYS) delete out[k]; return out; } // Connection-identity keys: the fields that decide WHERE a probe is sent. A stored // secret may only be re-merged when these match the stored row — otherwise an admin // could point a test at an attacker host while keeping a real device id and have the // secret sent there (exfiltration). host/port/binaryPort/httpPort cover the Dingtian's // UDP + CGI targets; serial covers serial-bound readers. const IDENTITY_KEYS = ["host", "port", "binaryPort", "httpPort", "serial"] as const; /** Stored machine-only secrets (relayPassword/pushPassword) for a device `id`, but ONLY * when the submitted config addresses the SAME device — same driver, and every * connection-identity field (host/port/…) that the submitted config sets equals the * stored value. If the admin redirected the probe (different host/port) or the driver * doesn't match, NO secret is returned: they must re-enter it explicitly. This stops a * redirected test from exfiltrating the secret to an attacker host. */ export function storedSecrets( db: Db, id: string, driverId: string, submitted: Record, ): Record { const row = db.select().from(devices).where(eq(devices.id, id)).get(); if (!row || row.driverId !== driverId) return {}; const cfg = row.config as Record; // Any identity field the client SENT must equal the stored value. (A field the client // omits falls back to the stored device, so it can't be used to redirect.) for (const k of IDENTITY_KEYS) { const sent = submitted[k]; if (sent !== undefined && sent !== "" && String(sent) !== String(cfg[k] ?? "")) { return {}; } } const out: Record = {}; for (const k of SECRET_CONFIG_KEYS) if (cfg[k] !== undefined) out[k] = cfg[k]; return out; } /** Result of the device configure pipeline: a ready-to-persist config, or an * HTTP error to send back. Shared by assign (create) and patch (edit). */ type ConfigureOutcome = | { config: Record; warnings: string[] } | { error: { code: number; message: string } }; /** * Validate + configure a device, returning the config to persist. Runs the same * pipeline for both create and edit: validate the driver config, fix * preconditions, harden (relay password + protocol lockdown), and set up input * push (Digest creds + push URLs). Each step is a device write (the device * reboots on apply). The caller owns the DB row; this never touches the DB. * * `id` is the assignment id (stable across an edit) — it's baked into the push * URL, so editing in place keeps the device pushing to the same path. * `existingConfig` carries forward secrets the client never sees on edit * (push/relay passwords), so a PATCH that omits them doesn't wipe them. */ async function configureDevice( app: FastifyInstance, args: { id: string; driverId: string; config: DeviceConfig; backendIp?: string; existingConfig?: Record; }, ): Promise { const { id, driverId, config, backendIp, existingConfig } = args; // Start from any machine-only secrets already on the row (push/relay passwords // are redacted out of the client's copy, so an edit would otherwise drop them), // then layer the submitted config on top. const fullConfig: Record = { ...existingConfig, ...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 { device = registry.create(driverId, config); // validates required fields } catch (err) { return { error: { code: 400, message: (err as Error).message } }; } // Configure the device on save (before persisting, so we don't store a row // for a device we couldn't configure): // 1. fix preconditions (e.g. disable input_link_relay so a button press // doesn't auto-fire its relay — host must decide first), // 2. harden (relay password + disable unused protocol channels), and // 3. set up input push (Digest creds + push URLs). // Each step is a device config write (the device reboots on apply). try { if (hasPreconditions(device)) { const fixed = await device.fixPreconditions(); if (!fixed.ok) { const unfixable = fixed.issues.find((i) => !i.fixable); return { error: { code: 502, message: `device precondition not satisfied: ${unfixable?.message ?? fixed.issues[0]?.message}`, }, }; } } if (isHardenable(device)) { 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)) { const host = String(config.host ?? ""); // Admin-provided backend IP wins; else auto-derive (on-subnet NIC). const pushHost = backendIp ?? backendIpForDevice(host); if (!pushHost) { return { error: { code: 400, message: `cannot determine the backend IP on the device's subnet (${host}). Pick one in setup or set BACKEND_HOST_IP.`, }, }; } const pushUser = "dingtian"; // 24 hex chars = 96 bits. The Dingtian `pass` field caps at 31 chars // (longer is silently truncated → auth mismatch), so keep it short. const pushPassword = randomBytes(12).toString("hex"); await device.configureInputPush({ host: pushHost, port: backendPort(), pathBase: `/api/devices/${driverId}/${id}/input`, auth: { user: pushUser, password: pushPassword }, }); fullConfig.pushUser = pushUser; fullConfig.pushPassword = pushPassword; // Record the backend IP the device was told to push to — lets us detect // a later mismatch if the host's IP changes. fullConfig.backendIp = pushHost; } } catch (err) { return { error: { code: 502, message: `device configuration failed: ${(err as Error).message}` } }; } return { config: fullConfig, warnings: hardenWarnings }; } export async function setupRoutes( app: FastifyInstance, db: Db, vision?: VisionClient | null, ): Promise { registerBuiltinDrivers(); setDeviceLogSink((line) => app.log.info(line)); // Device setup is site administration — it changes which hardware the site runs // and how readers bind to relays. Gated on site:update. See ../auth.ts. const adminGuard = requirePermission("site:update"); // Catalog of selectable drivers per category (no secrets — schema only). // `discoverable` flags drivers that can scan the LAN; `pushCapable` flags // drivers that push to the backend (and thus need a backend IP at assign time). app.get("/api/setup/catalog", async () => { const catalog = registry.catalog(); const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id); const pushCapable = registry.pushCapable(); return { ...catalog, discoverable, pushCapable }; }); // Scan the LAN for devices a driver can discover (UDP broadcast, etc). // Each found device is health-checked so the admin sees reachability before // assigning. Admin-only. See wiki/concepts/device-discovery.md. app.get<{ Params: { driverId: string } }>( "/api/setup/discover/:driverId", { preHandler: adminGuard }, async (req, reply) => { const driver = registry.get(req.params.driverId); if (!driver) return reply.code(404).send({ error: `unknown driver: ${req.params.driverId}` }); if (!isDiscoverable(driver)) { return reply.code(400).send({ error: `driver ${driver.id} does not support discovery` }); } try { const found = await driver.discover(); const withHealth = await Promise.all( found.map(async (d) => { let health: { status: string; detail?: string }; try { health = await driver.create(d.config).healthCheck(); } catch (err) { health = { status: "offline", detail: (err as Error).message }; } return { ...d, health }; }), ); return { driverId: driver.id, devices: withHealth }; } catch (err) { return reply.code(502).send({ error: `discovery failed: ${(err as Error).message}` }); } }, ); // 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 rows = await db.select().from(devices).all(); const assignments = rows.map((r) => ({ ...r, config: redactSecrets(r.config) })); return { completedAt: state?.completedAt ?? null, assignments }; }, ); // Test a device config WITHOUT saving or changing the device: validate the // config, probe reachability (healthCheck), and report preconditions // (e.g. input_link_relay state). Lets the admin verify before committing. app.post<{ Body: TestBody }>( "/api/setup/test", { preHandler: adminGuard }, async (req, reply) => { const { driverId, config, id } = req.body; const driver = registry.get(driverId); if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` }); // When editing an existing device, re-merge its stored machine secrets (e.g. // relayPassword) — redacted from the client, so the submitted config omits them. // Submitted values win (an admin can override), but a blank/0 field falls back to // the stored secret so the probe authenticates. Without this, an edited Dingtian // tests with no relay password → false "offline". The submitted-value-wins rule: // only fill a secret from the store when the form didn't send a real one. // Re-merge stored secrets ONLY when this addresses the same device at the same // host/port (storedSecrets enforces identity) — so a redirected probe can't leak // the secret to an attacker host. Submitted values still win. const merged: Record = { ...config }; if (id) { for (const [k, v] of Object.entries(storedSecrets(db, id, driverId, config))) { const sent = merged[k]; if (sent === undefined || sent === "" || sent === 0) merged[k] = v as string | number; } } let device; try { device = registry.create(driverId, merged as Record); } catch (err) { return reply.code(400).send({ error: (err as Error).message }); } const health = await device.healthCheck(); const preconditions = hasPreconditions(device) ? await device.checkPreconditions() : { ok: true, issues: [] }; return { health, preconditions }; }, ); // Test ANPR end-to-end on a camera config WITHOUT saving: capture a live snapshot // off the camera and run it through the vision (ANPR) service, reporting whether a // plate was extracted, the read, and how long it took. Lets the admin verify the // camera→vision pipeline before committing the camera's `anpr` opt-in. Advisory + // fail-soft, exactly like the runtime path (snapshot.ts): a vision failure is a // reported "no plate", never a 500. See wiki/entities/opencv-anpr-service.md. app.post<{ Body: TestBody }>( "/api/setup/test-anpr", { preHandler: adminGuard }, async (req, reply) => { const { driverId, config } = req.body; const driver = registry.get(driverId); if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` }); if (driver.category !== "camera") { return reply.code(400).send({ error: `driver ${driverId} is not a camera` }); } if (!vision?.enabled) { // The vision service is off (VISION_ENABLED unset) — there's nothing to test // against. Report it cleanly so the UI can say "enable vision first". return reply.send({ ok: false, reason: "vision-disabled" }); } let device; try { device = registry.create(driverId, config); } catch (err) { return reply.code(400).send({ error: (err as Error).message }); } if (!isCamera(device)) { return reply.code(400).send({ error: `driver ${driverId} cannot capture snapshots` }); } // 1) Grab a frame off the camera. A camera/network failure here is the failure // we're testing for — report it, don't 500. const startedAt = Date.now(); let shot: Awaited>; try { shot = await device.captureSnapshot({ direction: "entry" }); } catch (err) { return reply.send({ ok: false, reason: "snapshot-failed", detail: (err as Error).message, tookMs: Date.now() - startedAt, }); } // 2) Run the same advisory analyze the runtime path uses. `analyze` is fail-soft // (null on any error/timeout) and applies the confidence floor. const result = await vision.analyze(shot.bytes, shot.contentType); const tookMs = Date.now() - startedAt; if (!result || !result.plate) { return reply.send({ ok: false, reason: "no-plate", tookMs }); } return reply.send({ ok: true, plate: result.plate.text.trim().toUpperCase(), confidence: result.plate.confidence, region: result.plate.region ?? null, lowConfidence: result.lowConfidence, modelVersion: result.modelVersion, tookMs, }); }, ); // Candidate backend IPs the device can push to, for a given device host. The // wizard pre-fills with the on-subnet one and lets the admin override (matters // on multi-NIC hosts). See net.ts / wiki/concepts/device-input-flow.md. app.get<{ Querystring: { host?: string } }>( "/api/setup/backend-ips", { preHandler: adminGuard }, async (req) => { const candidates = backendIpCandidates(req.query.host ?? ""); return { candidates, port: backendPort() }; }, ); // Assign a device. Validates the chosen driver + config, configures the device // (fix preconditions + set up Digest-authenticated input push — no manual device- // web-UI step by the admin), then persists. Fails the save if the device can't be // configured. See wiki/concepts/device-input-flow.md, entry-exit-points.md. app.post<{ Body: AssignBody }>( "/api/setup/assign", { preHandler: adminGuard }, async (req, reply) => { const { category, driverId, config, backendIp } = req.body; const driver = registry.get(driverId); if (!driver || driver.category !== category) { return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` }); } const id = randomUUID(); const outcome = await configureDevice(app, { id, driverId, config, backendIp }); if ("error" in outcome) { return reply.code(outcome.error.code).send({ error: outcome.error.message }); } const row = { id, category, driverId, config: outcome.config, enabled: true, }; await db.insert(devices).values(row); // Don't echo device secrets back (push Digest password, web-UI login, …). return reply.code(201).send({ ...row, config: redactSecrets(outcome.config), ...(outcome.warnings.length ? { warnings: outcome.warnings } : {}), }); }, ); // Edit an assigned device in place. Same configure pipeline as assign, but it // UPDATEs the existing row and KEEPS the id — which matters for controllers, // since the id is baked into the device's input-push URL // (/api/devices/:driverId/:id/input). Delete+re-add would mint a new id and // break push until reconfigured; PATCH re-runs harden/push against the same id. // The category and driver are fixed at create time (an edit can't change what // KIND of device a slot is); only config changes. Admin-only. app.patch<{ Params: { id: string }; Body: Omit }>( "/api/setup/assign/:id", { preHandler: adminGuard }, async (req, reply) => { const existing = await db .select() .from(devices) .where(eq(devices.id, req.params.id)) .get(); if (!existing) return reply.code(404).send({ error: "no such device assignment" }); const { config, backendIp } = req.body; const outcome = await configureDevice(app, { id: existing.id, driverId: existing.driverId, config, backendIp, // Carry forward machine-only secrets the client never received, so an // edit that omits them doesn't blank out push/relay passwords. existingConfig: existing.config, }); if ("error" in outcome) { return reply.code(outcome.error.code).send({ error: outcome.error.message }); } await db.update(devices).set({ config: outcome.config }).where(eq(devices.id, existing.id)); app.log.info(`reconfigured device ${existing.id} (${existing.category}/${existing.driverId})`); return reply.code(200).send({ id: existing.id, category: existing.category, driverId: existing.driverId, config: redactSecrets(outcome.config), enabled: existing.enabled, ...(outcome.warnings.length ? { warnings: outcome.warnings } : {}), }); }, ); // 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(devices) .where(eq(devices.id, req.params.id)) .get(); if (!existing) return reply.code(404).send({ error: "no such device assignment" }); await db.delete(devices).where(eq(devices.id, req.params.id)); app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId})`); return reply.code(204).send(); }, ); // Mark first-run setup complete. app.post( "/api/setup/complete", { preHandler: adminGuard }, async () => { const completedAt = new Date().toISOString(); await db .insert(setupState) .values({ id: 1, completedAt }) .onConflictDoUpdate({ target: setupState.id, set: { completedAt } }); return { completedAt }; }, ); }