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:
@@ -1,54 +1,82 @@
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { eq, laneDevices, type Db } from "@parking/db";
|
||||
import { deviceEvents } from "../device-events.js";
|
||||
import { verifyDigest } from "../digest-auth.js";
|
||||
|
||||
// Inbound device push endpoints. The Dingtian board's "Input Link URL" feature
|
||||
// HTTP-calls us when an input (button) fires — no polling. We translate the
|
||||
// push into an internal device event; the entry flow decides what to do
|
||||
// (print a ticket, then command the relay). See wiki/entities/dingtian-relay.md.
|
||||
// (print a ticket, then command the relay). See wiki/concepts/device-input-flow.md.
|
||||
//
|
||||
// AUTH: these are machine-to-machine calls FROM the device, which can't do the
|
||||
// SPA's cookie/CSRF auth. They are intentionally NOT behind requireRole. Trust
|
||||
// does NOT come from this request — every barrier open is a host decision
|
||||
// recorded as a signed event, so an out-of-band/forged open has no matching
|
||||
// signed event and shows up as an anomaly (see wiki/concepts/append-only-event-chain
|
||||
// and threat-model). A shared-secret check can be layered on later as
|
||||
// defence-in-depth; on a flat network it isn't the security boundary.
|
||||
// AUTH: HTTP Digest (the device can do Digest but not HTTPS-to-self-signed —
|
||||
// both tested on hardware). The password is never sent on the wire; the secret
|
||||
// is NOT in the URL. Per-device credentials live in lane_devices (written on
|
||||
// assign). This is defence-in-depth on a flat network; the signed event log is
|
||||
// the real anti-fraud guarantee (an open with no matching signed event is an
|
||||
// anomaly). Source-IP is also checked. NOT behind the SPA cookie/CSRF auth
|
||||
// (machine call from the device).
|
||||
|
||||
interface InputParams {
|
||||
deviceId: string;
|
||||
n: string;
|
||||
edge: string;
|
||||
}
|
||||
|
||||
export async function deviceRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Dingtian input ON-edge push (button pressed). The device is configured
|
||||
// (via input_link_url) to call this path for each input. GET or POST both
|
||||
// accepted — the device's method is configurable; the URL carries the input.
|
||||
const handler = (edge: "on" | "off") =>
|
||||
async (req: FastifyRequest<{ Params: InputParams }>) => {
|
||||
const { deviceId, n } = req.params;
|
||||
const input = Number(n);
|
||||
app.log.info(`[dingtian:${deviceId}] input ${input} ${edge} (push)`);
|
||||
deviceEvents.emitInput({
|
||||
driverId: "dingtian",
|
||||
deviceId,
|
||||
input,
|
||||
edge,
|
||||
at: new Date().toISOString(),
|
||||
source: "push",
|
||||
});
|
||||
return { ok: true };
|
||||
};
|
||||
interface DingtianDeviceConfig {
|
||||
host?: string;
|
||||
pushUser?: string;
|
||||
pushPassword?: string;
|
||||
}
|
||||
|
||||
function clientIp(req: FastifyRequest): string {
|
||||
return req.ip.replace(/^::ffff:/, "");
|
||||
}
|
||||
|
||||
export async function deviceRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
|
||||
const { deviceId, n, edge } = req.params;
|
||||
|
||||
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
||||
const cfg = row?.config as DingtianDeviceConfig | undefined;
|
||||
|
||||
// Unknown device / not a dingtian / no push creds / wrong source IP → 404.
|
||||
if (
|
||||
!row ||
|
||||
row.driverId !== "dingtian" ||
|
||||
!cfg?.pushUser ||
|
||||
!cfg.pushPassword ||
|
||||
!cfg.host ||
|
||||
clientIp(req) !== cfg.host
|
||||
) {
|
||||
app.log.warn(`rejected device push: device=${deviceId} ip=${clientIp(req)}`);
|
||||
return reply.code(404).send({ error: "not found" });
|
||||
}
|
||||
|
||||
// Digest auth — issues a 401 challenge on first hit; the device retries with
|
||||
// the hashed response (verifyDigest sends the challenge + returns false).
|
||||
if (!verifyDigest(req, reply, { user: cfg.pushUser, password: cfg.pushPassword })) {
|
||||
return; // 401 already sent
|
||||
}
|
||||
|
||||
const input = Number(n);
|
||||
const ed = edge === "off" ? "off" : "on";
|
||||
app.log.info(`[dingtian:${deviceId}] input ${input} ${ed} (push)`);
|
||||
deviceEvents.emitInput({
|
||||
driverId: "dingtian",
|
||||
deviceId,
|
||||
input,
|
||||
edge: ed,
|
||||
at: new Date().toISOString(),
|
||||
source: "push",
|
||||
});
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
for (const method of ["GET", "POST"] as const) {
|
||||
app.route({
|
||||
method,
|
||||
url: "/api/devices/dingtian/:deviceId/input/:n/on",
|
||||
handler: handler("on"),
|
||||
});
|
||||
app.route({
|
||||
method,
|
||||
url: "/api/devices/dingtian/:deviceId/input/:n/off",
|
||||
handler: handler("off"),
|
||||
url: "/api/devices/dingtian/:deviceId/input/:n/:edge",
|
||||
handler: handle,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user