Dingtian input push: HTTP Digest auth + auto-config on assign

Secure the device→backend input push, and configure it automatically when the
admin assigns the device (no manual URL/secret entry).

Auth — HTTP Digest (chosen by hardware testing: the device can't push to a
self-signed HTTPS backend, but does Digest correctly; a URL token is sniffable/
logged):
- digest-auth.ts: MD5 qop=auth challenge/verify, single-use nonces (replay
  resistance). Password never crosses the wire.
- push route: Digest + source-IP allowlist; per-device pushUser/pushPassword from
  lane_devices. Still not behind the SPA cookie/CSRF auth (machine call). The
  signed event log remains the real anti-fraud guarantee.

Auto-config on assign:
- setup assign: for push-capable devices, generate Digest creds, call
  configureInputPush to write them + the push URLs to the device, store the creds
  (password not echoed back). net.ts derives the backend IP on the device's
  subnet (BACKEND_HOST_IP override).
- driver configureInputPush sets auth=2 + creds; PushConfig carries the creds.
- removed the earlier URL-token approach.

Two hard-won device-write bugs fixed in the driver:
- configApi now sets an explicit Content-Length — the device silently ignores
  chunked request bodies (Node's default without Content-Length), so every config
  write looked successful ({"status":0}) but did nothing. This was the root cause
  of the session's "writes don't apply" mystery.
- #writeConfig polls until the change is verified, retrying (the device reboots on
  apply; back-to-back writes were lost). The `pass` field caps at 31 chars, so the
  generated password is 24 hex chars.

Verified on hardware: assign auto-configures the device; all 4 inputs then push
with Digest auth, zero failures. wiki/device-input-flow updated.
This commit is contained in:
2026-06-14 16:39:08 +02:00
parent 23919164ee
commit 3294f188dd
9 changed files with 403 additions and 77 deletions
+50 -5
View File
@@ -1,7 +1,8 @@
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { eq, laneDevices, setupState, type Db } from "@parking/db";
import {
hasPushConfig,
isDiscoverable,
registerBuiltinDrivers,
registry,
@@ -9,6 +10,7 @@ import {
type DeviceCategory,
} from "@parking/devices";
import { requireRole } from "../auth.js";
import { 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.
@@ -80,6 +82,10 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Assign a device to a lane. Validates the chosen driver + config against the
// registry before persisting; rejects unknown drivers / missing config.
// For push-capable devices (e.g. Dingtian), the backend generates a secret
// token, configures the device to HTTP-push input events to us (no manual URL
// entry by the admin), and stores the token so the push endpoint can verify
// it. See wiki/concepts/device-input-flow.md.
app.post<{ Body: AssignBody }>(
"/api/setup/assign",
{ preHandler: adminGuard },
@@ -89,21 +95,60 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
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 };
let device;
try {
registry.create(driverId, config); // validates required fields
device = registry.create(driverId, config); // validates required fields
} catch (err) {
return reply.code(400).send({ error: (err as Error).message });
}
// If the device supports input push, set it up now: generate Digest creds,
// configure the device to push to us, store the creds. Done before
// persisting so we don't store half-configured rows.
if (hasPushConfig(device)) {
const host = String(config.host ?? "");
const backendIp = backendIpForDevice(host);
if (!backendIp) {
return reply.code(400).send({
error: `cannot determine the backend IP on the device's subnet (${host}). 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");
try {
await device.configureInputPush({
host: backendIp,
port: backendPort(),
pathBase: `/api/devices/${driverId}/${id}/input`,
auth: { user: pushUser, password: pushPassword },
});
} catch (err) {
return reply
.code(502)
.send({ error: `device push config failed: ${(err as Error).message}` });
}
fullConfig.pushUser = pushUser;
fullConfig.pushPassword = pushPassword;
}
const row = {
id: randomUUID(),
id,
lane,
category,
driverId,
config,
config: fullConfig,
enabled: true,
};
await db.insert(laneDevices).values(row);
return reply.code(201).send(row);
// Don't echo the push secret back.
const { pushPassword: _omit, ...safeConfig } = fullConfig;
return reply.code(201).send({ ...row, config: safeConfig });
},
);