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:
@@ -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 {
|
||||
|
||||
@@ -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, …). */
|
||||
|
||||
Reference in New Issue
Block a user