Files
parking_solution/apps/server/src/routes/setup.ts
T
julian f5fd61984a Dingtian web password: set the admin's chosen password, verified
Fix two bugs found running the real assign flow: the saved web password
didn't match the device (login stayed admin/admin), and the UDP2 warning
never reached the admin.

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

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

Verified on hardware: after harden the device rejects admin/admin and accepts
the chosen password; the UDP2 warning surfaces.
2026-06-15 12:26:34 +02:00

303 lines
12 KiB
TypeScript

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<string, string | number | boolean>;
/** 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<string, string | number | boolean>;
}
// 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): Promise<void> {
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<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 {
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 };
},
);
}