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:
@@ -0,0 +1,97 @@
|
|||||||
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
|
||||||
|
// HTTP Digest auth (RFC 2617, MD5, qop=auth) — verified against the Dingtian
|
||||||
|
// device, which CAN do Digest but CANNOT do HTTPS to a self-signed cert. On
|
||||||
|
// this flat network Digest is the strongest available push auth: the password
|
||||||
|
// is never sent (only a nonce-keyed hash). It is defence-in-depth; the signed
|
||||||
|
// event log is the real anti-fraud guarantee. See wiki/concepts/device-input-flow.md.
|
||||||
|
|
||||||
|
export const DIGEST_REALM = "parking";
|
||||||
|
|
||||||
|
const md5 = (s: string) => createHash("md5").update(s).digest("hex");
|
||||||
|
|
||||||
|
/** Nonces we've issued and not yet consumed (single-use → replay resistance). */
|
||||||
|
const issuedNonces = new Map<string, number>(); // nonce → issuedAt (ms epoch is unavailable in scripts but fine at runtime)
|
||||||
|
const NONCE_TTL_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
function issueNonce(): string {
|
||||||
|
const nonce = randomBytes(16).toString("hex");
|
||||||
|
issuedNonces.set(nonce, Date.now());
|
||||||
|
// opportunistic cleanup
|
||||||
|
if (issuedNonces.size > 1000) {
|
||||||
|
const cutoff = Date.now() - NONCE_TTL_MS;
|
||||||
|
for (const [n, t] of issuedNonces) if (t < cutoff) issuedNonces.delete(n);
|
||||||
|
}
|
||||||
|
return nonce;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDigest(header: string): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
const re = /(\w+)=(?:"([^"]*)"|([^,]*))/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(header))) out[m[1]!] = (m[2] ?? m[3] ?? "").trim();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eq(a: string, b: string): boolean {
|
||||||
|
const ab = Buffer.from(a);
|
||||||
|
const bb = Buffer.from(b);
|
||||||
|
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DigestCreds {
|
||||||
|
readonly user: string;
|
||||||
|
readonly password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a Digest Authorization header. Returns true on success. On failure (or
|
||||||
|
* a missing/expired header) sets a 401 challenge on `reply` and returns false —
|
||||||
|
* the caller should stop. `creds` is the device's stored push credentials.
|
||||||
|
*/
|
||||||
|
export function verifyDigest(
|
||||||
|
req: FastifyRequest,
|
||||||
|
reply: FastifyReply,
|
||||||
|
creds: DigestCreds,
|
||||||
|
): boolean {
|
||||||
|
const header = req.headers["authorization"];
|
||||||
|
|
||||||
|
if (!header || !/^Digest /i.test(header)) {
|
||||||
|
challenge(reply);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = parseDigest(header.replace(/^Digest /i, ""));
|
||||||
|
// Nonce must be one we issued and not yet consumed (single-use).
|
||||||
|
const issuedAt = p.nonce ? issuedNonces.get(p.nonce) : undefined;
|
||||||
|
if (!p.nonce || issuedAt === undefined || Date.now() - issuedAt > NONCE_TTL_MS) {
|
||||||
|
challenge(reply, true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ha1 = md5(`${creds.user}:${DIGEST_REALM}:${creds.password}`);
|
||||||
|
const ha2 = md5(`${req.method}:${p.uri ?? req.url}`);
|
||||||
|
const expected =
|
||||||
|
p.qop === "auth"
|
||||||
|
? md5(`${ha1}:${p.nonce}:${p.nc}:${p.cnonce}:${p.qop}:${ha2}`)
|
||||||
|
: md5(`${ha1}:${p.nonce}:${ha2}`);
|
||||||
|
|
||||||
|
if (!p.response || !eq(expected, p.response) || !eq(p.username ?? "", creds.user)) {
|
||||||
|
challenge(reply);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Consume the nonce so it can't be replayed.
|
||||||
|
issuedNonces.delete(p.nonce);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function challenge(reply: FastifyReply, stale = false): void {
|
||||||
|
const nonce = issueNonce();
|
||||||
|
reply.header(
|
||||||
|
"www-authenticate",
|
||||||
|
`Digest realm="${DIGEST_REALM}", qop="auth", nonce="${nonce}", algorithm=MD5${stale ? ", stale=true" : ""}`,
|
||||||
|
);
|
||||||
|
reply.code(401).send("authentication required");
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { networkInterfaces } from "node:os";
|
||||||
|
|
||||||
|
// Figure out which local IP a device should call back on. For input-push, the
|
||||||
|
// device needs OUR address on ITS subnet — pick the local IPv4 interface whose
|
||||||
|
// network contains the device's IP. Override with BACKEND_HOST_IP if the
|
||||||
|
// auto-pick is wrong (e.g. multi-homed host). See wiki/concepts/device-input-flow.md.
|
||||||
|
|
||||||
|
export function backendIpForDevice(deviceHost: string): string | null {
|
||||||
|
if (process.env.BACKEND_HOST_IP) return process.env.BACKEND_HOST_IP;
|
||||||
|
|
||||||
|
const ip = deviceHost.split(".").map(Number);
|
||||||
|
if (ip.length !== 4 || ip.some((o) => Number.isNaN(o))) return null;
|
||||||
|
|
||||||
|
for (const ifaces of Object.values(networkInterfaces())) {
|
||||||
|
for (const i of ifaces ?? []) {
|
||||||
|
if (i.family !== "IPv4" || i.internal) continue;
|
||||||
|
const addr = i.address.split(".").map(Number);
|
||||||
|
const mask = i.netmask.split(".").map(Number);
|
||||||
|
if (addr.length !== 4 || mask.length !== 4) continue;
|
||||||
|
const sameNet = ip.every((o, k) => (o & mask[k]!) === (addr[k]! & mask[k]!));
|
||||||
|
if (sameNet) return i.address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backend port the device should call (the server's listen port). */
|
||||||
|
export function backendPort(): number {
|
||||||
|
return Number(process.env.PORT ?? 3000);
|
||||||
|
}
|
||||||
@@ -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 { deviceEvents } from "../device-events.js";
|
||||||
|
import { verifyDigest } from "../digest-auth.js";
|
||||||
|
|
||||||
// Inbound device push endpoints. The Dingtian board's "Input Link URL" feature
|
// 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
|
// 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
|
// 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
|
// AUTH: HTTP Digest (the device can do Digest but not HTTPS-to-self-signed —
|
||||||
// SPA's cookie/CSRF auth. They are intentionally NOT behind requireRole. Trust
|
// both tested on hardware). The password is never sent on the wire; the secret
|
||||||
// does NOT come from this request — every barrier open is a host decision
|
// is NOT in the URL. Per-device credentials live in lane_devices (written on
|
||||||
// recorded as a signed event, so an out-of-band/forged open has no matching
|
// assign). This is defence-in-depth on a flat network; the signed event log is
|
||||||
// signed event and shows up as an anomaly (see wiki/concepts/append-only-event-chain
|
// the real anti-fraud guarantee (an open with no matching signed event is an
|
||||||
// and threat-model). A shared-secret check can be layered on later as
|
// anomaly). Source-IP is also checked. NOT behind the SPA cookie/CSRF auth
|
||||||
// defence-in-depth; on a flat network it isn't the security boundary.
|
// (machine call from the device).
|
||||||
|
|
||||||
interface InputParams {
|
interface InputParams {
|
||||||
deviceId: string;
|
deviceId: string;
|
||||||
n: string;
|
n: string;
|
||||||
|
edge: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deviceRoutes(app: FastifyInstance): Promise<void> {
|
interface DingtianDeviceConfig {
|
||||||
// Dingtian input ON-edge push (button pressed). The device is configured
|
host?: string;
|
||||||
// (via input_link_url) to call this path for each input. GET or POST both
|
pushUser?: string;
|
||||||
// accepted — the device's method is configurable; the URL carries the input.
|
pushPassword?: string;
|
||||||
const handler = (edge: "on" | "off") =>
|
}
|
||||||
async (req: FastifyRequest<{ Params: InputParams }>) => {
|
|
||||||
const { deviceId, n } = req.params;
|
function clientIp(req: FastifyRequest): string {
|
||||||
const input = Number(n);
|
return req.ip.replace(/^::ffff:/, "");
|
||||||
app.log.info(`[dingtian:${deviceId}] input ${input} ${edge} (push)`);
|
}
|
||||||
deviceEvents.emitInput({
|
|
||||||
driverId: "dingtian",
|
export async function deviceRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
||||||
deviceId,
|
const handle = async (req: FastifyRequest<{ Params: InputParams }>, reply: FastifyReply) => {
|
||||||
input,
|
const { deviceId, n, edge } = req.params;
|
||||||
edge,
|
|
||||||
at: new Date().toISOString(),
|
const row = await db.select().from(laneDevices).where(eq(laneDevices.id, deviceId)).get();
|
||||||
source: "push",
|
const cfg = row?.config as DingtianDeviceConfig | undefined;
|
||||||
});
|
|
||||||
return { ok: true };
|
// 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) {
|
for (const method of ["GET", "POST"] as const) {
|
||||||
app.route({
|
app.route({
|
||||||
method,
|
method,
|
||||||
url: "/api/devices/dingtian/:deviceId/input/:n/on",
|
url: "/api/devices/dingtian/:deviceId/input/:n/:edge",
|
||||||
handler: handler("on"),
|
handler: handle,
|
||||||
});
|
|
||||||
app.route({
|
|
||||||
method,
|
|
||||||
url: "/api/devices/dingtian/:deviceId/input/:n/off",
|
|
||||||
handler: handler("off"),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomBytes, randomUUID } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
import { eq, laneDevices, setupState, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
|
hasPushConfig,
|
||||||
isDiscoverable,
|
isDiscoverable,
|
||||||
registerBuiltinDrivers,
|
registerBuiltinDrivers,
|
||||||
registry,
|
registry,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
type DeviceCategory,
|
type DeviceCategory,
|
||||||
} from "@parking/devices";
|
} from "@parking/devices";
|
||||||
import { requireRole } from "../auth.js";
|
import { requireRole } from "../auth.js";
|
||||||
|
import { backendIpForDevice, backendPort } from "../net.js";
|
||||||
|
|
||||||
// First-run setup API. The admin reads the driver catalog and assigns devices
|
// First-run setup API. The admin reads the driver catalog and assigns devices
|
||||||
// per lane. See wiki/concepts/first-run-setup.md.
|
// 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
|
// Assign a device to a lane. Validates the chosen driver + config against the
|
||||||
// registry before persisting; rejects unknown drivers / missing config.
|
// 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 }>(
|
app.post<{ Body: AssignBody }>(
|
||||||
"/api/setup/assign",
|
"/api/setup/assign",
|
||||||
{ preHandler: adminGuard },
|
{ preHandler: adminGuard },
|
||||||
@@ -89,21 +95,60 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
if (!driver || driver.category !== category) {
|
if (!driver || driver.category !== category) {
|
||||||
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
return reply.code(400).send({ error: `invalid driver for ${category}: ${driverId}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const id = randomUUID();
|
||||||
|
const fullConfig: Record<string, unknown> = { ...config };
|
||||||
|
|
||||||
|
let device;
|
||||||
try {
|
try {
|
||||||
registry.create(driverId, config); // validates required fields
|
device = registry.create(driverId, config); // validates required fields
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.code(400).send({ error: (err as Error).message });
|
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 = {
|
const row = {
|
||||||
id: randomUUID(),
|
id,
|
||||||
lane,
|
lane,
|
||||||
category,
|
category,
|
||||||
driverId,
|
driverId,
|
||||||
config,
|
config: fullConfig,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
await db.insert(laneDevices).values(row);
|
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 });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
// catalog at first-run. See wiki/concepts/first-run-setup.md.
|
||||||
await setupRoutes(app, db);
|
await setupRoutes(app, db);
|
||||||
|
|
||||||
// Inbound device pushes (e.g. Dingtian Input Link URL → button events).
|
// Inbound device pushes (e.g. Dingtian Input Link URL → button events),
|
||||||
await deviceRoutes(app);
|
// guarded by source-IP allowlist + a shared-secret path token, both read from
|
||||||
|
// the device's lane_devices config (written on assign).
|
||||||
|
await deviceRoutes(app, db);
|
||||||
|
|
||||||
// TODO: entry flow (input event → signed event → print → relay), event-log routes.
|
// TODO: entry flow (input event → signed event → print → relay), event-log routes.
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import type {
|
|||||||
InputEvent,
|
InputEvent,
|
||||||
PreconditionDevice,
|
PreconditionDevice,
|
||||||
PreconditionResult,
|
PreconditionResult,
|
||||||
|
PushConfig,
|
||||||
|
PushConfigurableDevice,
|
||||||
} from "../interfaces.js";
|
} from "../interfaces.js";
|
||||||
import type { AccessDriver, DeviceConfig } from "../registry.js";
|
import type { AccessDriver, DeviceConfig } from "../registry.js";
|
||||||
import { hostField, portField, stubLog } from "./common.js";
|
import { hostField, portField, stubLog } from "./common.js";
|
||||||
@@ -85,6 +87,10 @@ function configApi(
|
|||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
// The device's embedded HTTP server does NOT support chunked request bodies.
|
||||||
|
// Node uses chunked encoding when Content-Length is absent, so the device
|
||||||
|
// silently ignores the body (POST returns {"status":0} but nothing changes).
|
||||||
|
// Always set Content-Length explicitly.
|
||||||
const req = httpRequest(
|
const req = httpRequest(
|
||||||
{
|
{
|
||||||
host,
|
host,
|
||||||
@@ -92,7 +98,12 @@ function configApi(
|
|||||||
path,
|
path,
|
||||||
method,
|
method,
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
headers: body ? { "content-type": "application/json" } : undefined,
|
headers: body
|
||||||
|
? {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"content-length": Buffer.byteLength(body),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
let data = "";
|
let data = "";
|
||||||
@@ -108,7 +119,11 @@ function configApi(
|
|||||||
}
|
}
|
||||||
|
|
||||||
class DingtianController
|
class DingtianController
|
||||||
implements AccessControlDevice, InputDevice, PreconditionDevice
|
implements
|
||||||
|
AccessControlDevice,
|
||||||
|
InputDevice,
|
||||||
|
PreconditionDevice,
|
||||||
|
PushConfigurableDevice
|
||||||
{
|
{
|
||||||
readonly driverId = "dingtian";
|
readonly driverId = "dingtian";
|
||||||
readonly #host: string;
|
readonly #host: string;
|
||||||
@@ -229,21 +244,19 @@ class DingtianController
|
|||||||
ilr.on_action_on = (ilr.on_action_on as unknown[]).map(() => []);
|
ilr.on_action_on = (ilr.on_action_on as unknown[]).map(() => []);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.#writeConfig(cfg);
|
await this.#writeConfig(cfg, (after) => this.#linkDisabled(after));
|
||||||
return this.checkPreconditions();
|
return this.checkPreconditions();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure the device to HTTP-push input (button) events to our backend —
|
* Configure the device to HTTP-push input (button) events to our backend —
|
||||||
* the "Input Link URL" feature. Each input N calls `${pathBase}/<N>/on` (and
|
* the "Input Link URL" feature. Each input N calls `${pathBase}/<N>/on` (and
|
||||||
* `/off`) on the given host:port via GET. Enables the feature and disables TLS
|
* `/off`) on host:port via GET, authenticated with **HTTP Digest** (the device
|
||||||
* (plain HTTP to the local backend). Replaces polling.
|
* does Digest but not HTTPS-to-self-signed; both verified on hardware). The
|
||||||
|
* password is never sent on the wire and the secret is not in the URL.
|
||||||
|
* Enables the feature, plain HTTP, active-LOW. Replaces polling.
|
||||||
*/
|
*/
|
||||||
async configureInputPush(opts: {
|
async configureInputPush(opts: PushConfig): Promise<void> {
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
pathBase: string; // e.g. "/api/devices/dingtian/<deviceId>/input"
|
|
||||||
}): Promise<void> {
|
|
||||||
const cfg = await this.#readConfig();
|
const cfg = await this.#readConfig();
|
||||||
const ilu = cfg.input_link_url as Record<string, unknown>;
|
const ilu = cfg.input_link_url as Record<string, unknown>;
|
||||||
const n = Number((ilu.cnt as number) ?? this.#channels);
|
const n = Number((ilu.cnt as number) ?? this.#channels);
|
||||||
@@ -251,12 +264,12 @@ class DingtianController
|
|||||||
|
|
||||||
ilu.en = 1;
|
ilu.en = 1;
|
||||||
ilu.active_level = fill(0); // active-LOW (matches this board's wiring)
|
ilu.active_level = fill(0); // active-LOW (matches this board's wiring)
|
||||||
ilu.tls = fill(0);
|
ilu.tls = fill(0); // plain HTTP (device can't do HTTPS to self-signed)
|
||||||
ilu.auth = fill(0);
|
ilu.auth = fill(2); // 2 = Digest
|
||||||
ilu.server = fill(opts.host);
|
ilu.server = fill(opts.host);
|
||||||
ilu.port = fill(opts.port);
|
ilu.port = fill(opts.port);
|
||||||
ilu.user = fill("");
|
ilu.user = fill(opts.auth.user);
|
||||||
ilu.pass = fill("");
|
ilu.pass = fill(opts.auth.password);
|
||||||
ilu.on_method = fill(0); // GET
|
ilu.on_method = fill(0); // GET
|
||||||
ilu.off_method = fill(0);
|
ilu.off_method = fill(0);
|
||||||
ilu.on_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/on`);
|
ilu.on_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/on`);
|
||||||
@@ -264,7 +277,21 @@ class DingtianController
|
|||||||
ilu.on_body = fill("");
|
ilu.on_body = fill("");
|
||||||
ilu.off_body = fill("");
|
ilu.off_body = fill("");
|
||||||
|
|
||||||
await this.#writeConfig(cfg);
|
const wantPath = `${opts.pathBase}/1/on`;
|
||||||
|
await this.#writeConfig(cfg, (after) => {
|
||||||
|
const a = after.input_link_url as Record<string, unknown> | undefined;
|
||||||
|
const paths = a?.on_path as string[] | undefined;
|
||||||
|
const pass = a?.pass as string[] | undefined;
|
||||||
|
// Verify both the path and the (secret) password landed — the password is
|
||||||
|
// what the backend's Digest check depends on.
|
||||||
|
return (
|
||||||
|
a?.en === 1 &&
|
||||||
|
Array.isArray(paths) &&
|
||||||
|
paths[0] === wantPath &&
|
||||||
|
Array.isArray(pass) &&
|
||||||
|
pass[0] === opts.auth.password
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- config api internals ----------------------------------------------
|
// --- config api internals ----------------------------------------------
|
||||||
@@ -274,9 +301,19 @@ class DingtianController
|
|||||||
return JSON.parse(raw) as Record<string, unknown>;
|
return JSON.parse(raw) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Write full config back. Injects the required `command:setconfig` and
|
/**
|
||||||
* tolerates the device resetting on apply. */
|
* Write full config back, then WAIT for the device to apply it. The device
|
||||||
async #writeConfig(cfg: Record<string, unknown>): Promise<void> {
|
* reboots on apply (~10s) and back-to-back writes onto a rebooting device are
|
||||||
|
* silently lost — so we poll until the device is reachable again AND `verify`
|
||||||
|
* confirms the change landed, retrying the write if needed.
|
||||||
|
*
|
||||||
|
* @param verify predicate over the re-read config; should return true once the
|
||||||
|
* intended change is present.
|
||||||
|
*/
|
||||||
|
async #writeConfig(
|
||||||
|
cfg: Record<string, unknown>,
|
||||||
|
verify: (after: Record<string, unknown>) => boolean,
|
||||||
|
): Promise<void> {
|
||||||
// The set endpoint requires `"command":"setconfig"` injected after `status`
|
// The set endpoint requires `"command":"setconfig"` injected after `status`
|
||||||
// (the GET payload omits it). Rebuild preserving node order, command second.
|
// (the GET payload omits it). Rebuild preserving node order, command second.
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
@@ -285,22 +322,31 @@ class DingtianController
|
|||||||
if (k === "status") out.command = "setconfig";
|
if (k === "status") out.command = "setconfig";
|
||||||
}
|
}
|
||||||
if (!("command" in out)) out.command = "setconfig";
|
if (!("command" in out)) out.command = "setconfig";
|
||||||
|
const payload = JSON.stringify(out);
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
// Device resets/applies after a write, so the connection may drop — that's
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
// success, not failure. Swallow the post-write reset.
|
// POST. The device resets on apply, so the connection may drop — that's
|
||||||
try {
|
// expected, not failure.
|
||||||
await configApi(
|
try {
|
||||||
this.#host,
|
await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout);
|
||||||
this.#httpPort,
|
} catch {
|
||||||
"/api/v2/config_set.cgi",
|
// device likely reset on apply
|
||||||
"POST",
|
}
|
||||||
JSON.stringify(out),
|
|
||||||
this.#timeout,
|
// Poll for the device to come back and the change to be present.
|
||||||
);
|
for (let i = 0; i < 12; i++) {
|
||||||
} catch {
|
await sleep(2000);
|
||||||
// device likely reset on apply
|
try {
|
||||||
|
if (verify(await this.#readConfig())) return; // applied
|
||||||
|
} catch {
|
||||||
|
// still rebooting / unreachable — keep polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Not applied within the window — likely the POST hit a rebooting device.
|
||||||
|
// Loop and re-POST (now that it's reachable again).
|
||||||
}
|
}
|
||||||
await new Promise((r) => setTimeout(r, 4000));
|
throw new Error("dingtian: config write did not apply after retries");
|
||||||
}
|
}
|
||||||
|
|
||||||
#linkDisabled(cfg: Record<string, unknown>): boolean {
|
#linkDisabled(cfg: Record<string, unknown>): boolean {
|
||||||
|
|||||||
@@ -99,6 +99,33 @@ export function hasPreconditions(
|
|||||||
return typeof (device as Partial<PreconditionDevice>).checkPreconditions === "function";
|
return typeof (device as Partial<PreconditionDevice>).checkPreconditions === "function";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Push configuration (device → backend) -------------------------------
|
||||||
|
// Optional capability: a device that can be told to HTTP-push its input/button
|
||||||
|
// events to our backend (vs. the host polling it). The backend configures the
|
||||||
|
// device with where to call and a shared-secret token embedded in the path.
|
||||||
|
// The Dingtian board implements this via its "Input Link URL" feature.
|
||||||
|
// See wiki/concepts/device-input-flow.md.
|
||||||
|
export interface PushConfigurableDevice {
|
||||||
|
configureInputPush(opts: PushConfig): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PushConfig {
|
||||||
|
/** Backend host the device should call (our IP on the device's subnet). */
|
||||||
|
readonly host: string;
|
||||||
|
readonly port: number;
|
||||||
|
/** Path prefix the device appends `/<input>/<on|off>` to,
|
||||||
|
* e.g. `/api/devices/dingtian/<deviceId>/input`. */
|
||||||
|
readonly pathBase: string;
|
||||||
|
/** HTTP Digest credentials the device authenticates the push with. */
|
||||||
|
readonly auth: { user: string; password: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPushConfig(
|
||||||
|
device: Device,
|
||||||
|
): device is Device & PushConfigurableDevice {
|
||||||
|
return typeof (device as Partial<PushConfigurableDevice>).configureInputPush === "function";
|
||||||
|
}
|
||||||
|
|
||||||
// --- Readers (RF / optical; TCP-IP or Wiegand) ---------------------------
|
// --- Readers (RF / optical; TCP-IP or Wiegand) ---------------------------
|
||||||
export interface ReaderDevice extends Device {
|
export interface ReaderDevice extends Device {
|
||||||
/** Emits when a credential is read (card number, plate, QR payload, …). */
|
/** Emits when a credential is read (card number, plate, QR payload, …). */
|
||||||
|
|||||||
@@ -42,12 +42,46 @@ the device or the network. Instead:
|
|||||||
allows), there is **no matching signed event → a detectable anomaly**. The anti-fraud guarantee
|
allows), there is **no matching signed event → a detectable anomaly**. The anti-fraud guarantee
|
||||||
is the **signed log**, not device/network auth.
|
is the **signed log**, not device/network auth.
|
||||||
- The inbound push endpoint is intentionally **not behind the SPA's cookie/CSRF auth** (it's a
|
- The inbound push endpoint is intentionally **not behind the SPA's cookie/CSRF auth** (it's a
|
||||||
machine call from the device). A **shared-secret / Basic-auth** on the push is available as
|
machine call from the device). It is guarded by **HTTP Digest auth** + a **source-IP allowlist**
|
||||||
defence-in-depth (the device supports it) — worth adding, but it is *not* the security boundary.
|
(defence-in-depth), but these are *not* the security boundary.
|
||||||
- This sharpens under the [[autonomous-direction|unmanned]] roadmap: with no operator, tamper
|
- This sharpens under the [[autonomous-direction|unmanned]] roadmap: with no operator, tamper
|
||||||
detection via the signed log matters more than perimeter auth.
|
detection via the signed log matters more than perimeter auth.
|
||||||
|
|
||||||
|
## Push authentication — Digest (decided by hardware testing)
|
||||||
|
|
||||||
|
The secret must not be in the URL (sniffable, logged) and the password must not cross the wire in
|
||||||
|
the clear. We **empirically tested the device** to pick the strongest achievable option:
|
||||||
|
|
||||||
|
| Option | Device result |
|
||||||
|
| --- | --- |
|
||||||
|
| HTTPS (self-signed) | ❌ device won't push to a self-signed cert |
|
||||||
|
| **Digest auth** (`auth=2`) | ✅ **works** — full 401-nonce challenge/response |
|
||||||
|
| Basic auth | ✅ works (but password base64 on the wire) |
|
||||||
|
| URL token | rejected by design (visible in URL/logs) |
|
||||||
|
|
||||||
|
→ **HTTP Digest** (MD5, qop=auth). The password is never sent (only a nonce-keyed hash); nonces
|
||||||
|
are **single-use** (replay resistance). Per-device credentials (`pushUser`/`pushPassword`) are
|
||||||
|
generated by the backend on **device assign**, written to the device's `input_link_url` config,
|
||||||
|
and stored in `lane_devices` — the admin never types a URL or secret. HTTPS would be stronger but
|
||||||
|
the device can't do it here; Digest + the signed log is the practical answer on a flat network.
|
||||||
|
See `apps/server/src/digest-auth.ts`.
|
||||||
|
|
||||||
|
## Dingtian config-write gotchas (cost a lot of debugging)
|
||||||
|
|
||||||
|
Writing the device's config API (`/api/v2/config_set.cgi`) has two non-obvious traps — both now
|
||||||
|
handled in the driver:
|
||||||
|
|
||||||
|
1. **Content-Length is mandatory.** The device's embedded HTTP server does **not** accept chunked
|
||||||
|
request bodies. Node uses chunked encoding when `Content-Length` is absent, so the device
|
||||||
|
silently ignores the body and returns `{"status":0}` anyway — the write looks successful but
|
||||||
|
nothing changes. Always set `Content-Length`.
|
||||||
|
2. **The `pass` field caps at 31 chars** (longer is silently truncated → Digest mismatch). The
|
||||||
|
generated push password is 24 hex chars (96 bits).
|
||||||
|
3. (Also: the device reboots on apply, so the driver writes then **polls until the change is
|
||||||
|
verified**, retrying — back-to-back writes onto a rebooting device are lost.)
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Input push **verified on hardware** (all 4 inputs, real presses reaching the backend). The entry
|
Input push **verified on hardware** with Digest auth (all 4 inputs, real presses authenticated, no
|
||||||
|
failures). The entry
|
||||||
flow itself (signed event + ticket print + `pulseOpen`) is the next build — see [[dingtian-relay]].
|
flow itself (signed event + ticket print + `pulseOpen`) is the next build — see [[dingtian-relay]].
|
||||||
|
|||||||
+17
@@ -145,3 +145,20 @@ model recorded in [[device-input-flow]]: flat network / no VLAN → backend is s
|
|||||||
of truth, every open is a signed event (out-of-band open = anomaly); push endpoint
|
of truth, every open is a signed event (out-of-band open = anomaly); push endpoint
|
||||||
not behind cookie auth (machine call), shared-secret available as defence-in-depth.
|
not behind cookie auth (machine call), shared-secret available as defence-in-depth.
|
||||||
Next: wire signed event + ticket print + pulseOpen.
|
Next: wire signed event + ticket print + pulseOpen.
|
||||||
|
|
||||||
|
## [2026-06-15] feature | Dingtian push auth via HTTP Digest (hardware-tested)
|
||||||
|
Secured the device→backend input push. Empirically tested auth options on the
|
||||||
|
device: HTTPS-to-self-signed FAILS, Basic works, **Digest works** → chose Digest
|
||||||
|
(MD5, qop=auth): password never on the wire, single-use nonces. Backend
|
||||||
|
digest-auth.ts (challenge/verify) + source-IP allowlist on the push route;
|
||||||
|
per-device pushUser/pushPassword generated on assign, written to the device and
|
||||||
|
stored in lane_devices (admin never types a URL/secret). Driver
|
||||||
|
configureInputPush now sets auth=2 + creds; the assign flow auto-configures the
|
||||||
|
device and persists the creds (net.ts derives the backend IP on the device's
|
||||||
|
subnet). Removed the earlier URL-token approach (token in URL is sniffable/logged).
|
||||||
|
TWO HARD-WON DEVICE BUGS fixed: (1) config_set requires an explicit Content-Length
|
||||||
|
— the device silently ignores chunked bodies (Node's default), which masqueraded
|
||||||
|
as "writes don't apply" all session; (2) the `pass` field caps at 31 chars →
|
||||||
|
use a 24-char password. Driver #writeConfig now polls-until-verified (device
|
||||||
|
reboots on apply). VERIFIED on hardware: assign auto-configures the device, then
|
||||||
|
all 4 inputs push with Digest auth, zero failures. Recorded in [[device-input-flow]].
|
||||||
|
|||||||
Reference in New Issue
Block a user