import { randomBytes, randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { eq, laneDevices, setupState, type Db } from "@parking/db"; import { hasPreconditions, hasPushConfig, isDiscoverable, isHardenable, registerBuiltinDrivers, registry, setDeviceLogSink, type DeviceCategory, } from "@parking/devices"; import { requireRole } from "../auth.js"; import { backendIpCandidates, backendIpForDevice, backendPort } from "../net.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 { lane: number; category: DeviceCategory; driverId: string; config: Record; /** 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; } // 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; } export async function setupRoutes(app: FastifyInstance, db: Db): Promise { registerBuiltinDrivers(); setDeviceLogSink((line) => app.log.info(line)); // Setup endpoints require an admin (cookie-based JWT — see ../auth.ts). const adminGuard = requireRole("admin"); // Catalog of selectable drivers per category (no secrets — schema only). // `discoverable` flags drivers that can scan the LAN. app.get("/api/setup/catalog", async () => { const catalog = registry.catalog(); const discoverable = registry.list().filter(isDiscoverable).map((d) => d.id); return { ...catalog, discoverable }; }); // 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(laneDevices).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 } = req.body; const driver = registry.get(driverId); if (!driver) return reply.code(400).send({ error: `unknown driver: ${driverId}` }); let device; try { device = registry.create(driverId, config); } 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 }; }, ); // 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 to a lane. 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. app.post<{ Body: AssignBody }>( "/api/setup/assign", { preHandler: adminGuard }, async (req, reply) => { const { lane, 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 fullConfig: Record = { ...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 reply.code(400).send({ error: (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 reply.code(502).send({ error: `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 reply.code(400).send({ error: `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 reply .code(502) .send({ error: `device configuration failed: ${(err as Error).message}` }); } const row = { id, lane, category, driverId, config: fullConfig, enabled: true, }; await db.insert(laneDevices).values(row); // 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)); app.log.info(`unassigned device ${req.params.id} (${existing.category}/${existing.driverId}, lane ${existing.lane})`); 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 }; }, ); }