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
+78 -32
View File
@@ -7,6 +7,8 @@ import type {
InputEvent,
PreconditionDevice,
PreconditionResult,
PushConfig,
PushConfigurableDevice,
} from "../interfaces.js";
import type { AccessDriver, DeviceConfig } from "../registry.js";
import { hostField, portField, stubLog } from "./common.js";
@@ -85,6 +87,10 @@ function configApi(
timeoutMs: number,
): Promise<string> {
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(
{
host,
@@ -92,7 +98,12 @@ function configApi(
path,
method,
timeout: timeoutMs,
headers: body ? { "content-type": "application/json" } : undefined,
headers: body
? {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
}
: undefined,
},
(res) => {
let data = "";
@@ -108,7 +119,11 @@ function configApi(
}
class DingtianController
implements AccessControlDevice, InputDevice, PreconditionDevice
implements
AccessControlDevice,
InputDevice,
PreconditionDevice,
PushConfigurableDevice
{
readonly driverId = "dingtian";
readonly #host: string;
@@ -229,21 +244,19 @@ class DingtianController
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();
}
/**
* 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
* `/off`) on the given host:port via GET. Enables the feature and disables TLS
* (plain HTTP to the local backend). Replaces polling.
* `/off`) on host:port via GET, authenticated with **HTTP Digest** (the device
* 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: {
host: string;
port: number;
pathBase: string; // e.g. "/api/devices/dingtian/<deviceId>/input"
}): Promise<void> {
async configureInputPush(opts: PushConfig): Promise<void> {
const cfg = await this.#readConfig();
const ilu = cfg.input_link_url as Record<string, unknown>;
const n = Number((ilu.cnt as number) ?? this.#channels);
@@ -251,12 +264,12 @@ class DingtianController
ilu.en = 1;
ilu.active_level = fill(0); // active-LOW (matches this board's wiring)
ilu.tls = fill(0);
ilu.auth = fill(0);
ilu.tls = fill(0); // plain HTTP (device can't do HTTPS to self-signed)
ilu.auth = fill(2); // 2 = Digest
ilu.server = fill(opts.host);
ilu.port = fill(opts.port);
ilu.user = fill("");
ilu.pass = fill("");
ilu.user = fill(opts.auth.user);
ilu.pass = fill(opts.auth.password);
ilu.on_method = fill(0); // GET
ilu.off_method = fill(0);
ilu.on_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/on`);
@@ -264,7 +277,21 @@ class DingtianController
ilu.on_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 ----------------------------------------------
@@ -274,9 +301,19 @@ class DingtianController
return JSON.parse(raw) as Record<string, unknown>;
}
/** Write full config back. Injects the required `command:setconfig` and
* tolerates the device resetting on apply. */
async #writeConfig(cfg: Record<string, unknown>): Promise<void> {
/**
* Write full config back, then WAIT for the device to apply it. The device
* 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 GET payload omits it). Rebuild preserving node order, command second.
const out: Record<string, unknown> = {};
@@ -285,22 +322,31 @@ class DingtianController
if (k === "status") 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
// success, not failure. Swallow the post-write reset.
try {
await configApi(
this.#host,
this.#httpPort,
"/api/v2/config_set.cgi",
"POST",
JSON.stringify(out),
this.#timeout,
);
} catch {
// device likely reset on apply
for (let attempt = 1; attempt <= 3; attempt++) {
// POST. The device resets on apply, so the connection may drop — that's
// expected, not failure.
try {
await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout);
} catch {
// device likely reset on apply
}
// Poll for the device to come back and the change to be present.
for (let i = 0; i < 12; i++) {
await sleep(2000);
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 {
+27
View File
@@ -99,6 +99,33 @@ export function hasPreconditions(
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) ---------------------------
export interface ReaderDevice extends Device {
/** Emits when a credential is read (card number, plate, QR payload, …). */