fa65b2df86
Replace the camera stub with HttpCamera: Hikvision ISAPI and Dahua CGI snapshots over client-side HTTP Digest (new drivers/http-digest.ts). healthCheck() now pulls a real frame instead of returning ready/stub. Snapshot carries bytes (driver fetches); storage/imageRef is the caller's job, keeping the adapter free of storage deps. Fix the cosmetic Backend-push-IP field: add pushesToBackend to DeviceDriver (only Dingtian sets it), expose as pushCapable in the catalog, and gate the wizard's backend-IP fetch + field on it so pull-only devices hide it. Verified on hardware (Hikvision 10.0.10.121): healthCheck ready, captureSnapshot returns a valid JPEG.
759 lines
32 KiB
TypeScript
759 lines
32 KiB
TypeScript
import { randomBytes } from "node:crypto";
|
||
import { createSocket } from "node:dgram";
|
||
import { request as httpRequest } from "node:http";
|
||
import type {
|
||
AccessControlDevice,
|
||
DeviceHealth,
|
||
HardenableDevice,
|
||
HardenResult,
|
||
InputDevice,
|
||
InputEvent,
|
||
PreconditionDevice,
|
||
PreconditionResult,
|
||
PushConfig,
|
||
PushConfigurableDevice,
|
||
} from "../interfaces.js";
|
||
import type { AccessDriver, DeviceConfig } from "../registry.js";
|
||
import { hostField, portField, stubLog } from "./common.js";
|
||
|
||
// Dingtian relay+input controller driver. Backed by the "Dingtian string"
|
||
// protocol over UDP. Implements AccessControlDevice (relay/barrier) AND the
|
||
// optional InputDevice capability (host-readable buttons, decoupled from relays)
|
||
// — which is what makes host-in-the-loop entry possible. See
|
||
// wiki/entities/dingtian-relay.md and access-controller-button-flow.md.
|
||
//
|
||
// SAFETY: pulseOpen expresses INTENT only. It uses the device's jog/pulse
|
||
// (momentary) so the relay self-releases; we never time a close against a
|
||
// vehicle — anti-crush/auto-reverse is the barrier operator's firmware.
|
||
// See wiki/concepts/barrier-not-a-door.md.
|
||
//
|
||
// SECURITY: unauthenticated UDP — the board must sit on an isolated VLAN
|
||
// reachable only by the host. See wiki/concepts/network-isolation.md.
|
||
//
|
||
// NOTE: by default Dingtian links each input to auto-fire its relay
|
||
// (input_link_relay). That must be DISABLED on the device for ticket-first
|
||
// entry, else the button opens the barrier before the host can act.
|
||
|
||
// (The string-protocol UDP helper was removed: harden() now disables the
|
||
// password-less string protocol entirely, and status reads use the
|
||
// authenticated binary read — see #status() / readStatusFrame.)
|
||
|
||
/**
|
||
* Send a Dingtian *binary* protocol frame (UDP, default port 60000) and await
|
||
* the reply. Used for ALL relay traffic — control AND status read — because,
|
||
* unlike the string protocol, the binary protocol carries a password (`relay_pw`).
|
||
* harden() disables the string protocol precisely because it has NO password and
|
||
* can fire relays (an unauthenticated `"11"` opens relay 1). With the string path
|
||
* closed, relay_pw actually gates control. Frame verified on hardware:
|
||
*
|
||
* FF AA <session> <relayCmd> <pwLo> <pwHi> <data...>
|
||
*
|
||
* FF = command "set relay"
|
||
* AA = result xor (0x00 ^ 0xAA, pc→device)
|
||
* session = echoed back
|
||
* relayCmd = 0 read status, 1 write, 3 jogging, …
|
||
* pwLo,pwHi = relay password, 16-bit LSB-first (0 = none)
|
||
* data = command-specific
|
||
*
|
||
* NOTE: relay_pw + plaintext UDP is defence-in-depth, NOT a boundary. An attacker
|
||
* who sniffs the VLAN can replay the password. The real guarantee is the signed
|
||
* event log (relay open with no signed command = fraud) + VLAN isolation.
|
||
*/
|
||
function binaryUdp(
|
||
host: string,
|
||
port: number,
|
||
frame: Buffer,
|
||
timeoutMs: number,
|
||
localAddress?: string,
|
||
): Promise<Buffer> {
|
||
return new Promise((resolve, reject) => {
|
||
const sock = createSocket("udp4");
|
||
let settled = false;
|
||
const done = (err: Error | null, val: Buffer | null) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
sock.close();
|
||
err ? reject(err) : resolve(val!);
|
||
};
|
||
const timer = setTimeout(() => done(new Error("timeout"), null), timeoutMs);
|
||
sock.on("error", (e) => done(e, null));
|
||
sock.on("message", (m) => done(null, m));
|
||
// Bind to a specific local address (the device-facing NIC) on multi-homed
|
||
// hosts, so the device replies to the right source IP. See net.ts.
|
||
const onBound = () => {
|
||
sock.send(frame, port, host, (e) => {
|
||
if (e) done(e, null);
|
||
});
|
||
};
|
||
if (localAddress) sock.bind({ address: localAddress }, onBound);
|
||
else sock.bind(onBound);
|
||
});
|
||
}
|
||
|
||
let binarySession = 0;
|
||
|
||
/**
|
||
* Build a binary "read relay status" frame (relay command 0x00). The device
|
||
* replies `FF AA <session> 00 <relayBytes> <inputBytes>` (status widths scale
|
||
* with channel count). This is the *authenticated* status read — unlike the
|
||
* string protocol's `00`, it carries the relay password, so we can disable the
|
||
* password-less string protocol entirely. Frame: `FF AA <session> 00 <pwLo> <pwHi>`.
|
||
* Verified on hardware (4ch): reply `ff aa 00 00 01 0f` = relay1 on, inputs 1111.
|
||
*/
|
||
function readStatusFrame(password: number): Buffer {
|
||
const session = binarySession++ & 0xff;
|
||
return Buffer.from([0xff, 0xaa, session, 0x00, password & 0xff, (password >> 8) & 0xff]);
|
||
}
|
||
|
||
/** Build a binary "write relay with jogging" frame (relay on, auto-off). */
|
||
function jogFrame(channel: number, password: number, jogMs: number): Buffer {
|
||
const session = binarySession++ & 0xff;
|
||
// relay index + on/off: bit0 = on, bits1..7 = (channel-1)
|
||
const relayByte = (((channel - 1) & 0x7f) << 1) | 0x01;
|
||
const units = Math.max(1, Math.round(jogMs / 100)); // 100ms units
|
||
return Buffer.from([
|
||
0xff,
|
||
0xaa,
|
||
session,
|
||
0x03, // jogging
|
||
password & 0xff,
|
||
(password >> 8) & 0xff,
|
||
relayByte,
|
||
units & 0xff,
|
||
(units >> 8) & 0xff,
|
||
]);
|
||
}
|
||
|
||
/** Build a binary "write relay" frame (latch on/off via mask+set). */
|
||
function writeRelayFrame(channel: number, on: boolean, password: number, channels: number): Buffer {
|
||
const session = binarySession++ & 0xff;
|
||
const bit = 1 << (channel - 1);
|
||
const mask = bit; // only this channel updates
|
||
const set = on ? bit : 0;
|
||
// 4ch: mask + set are 1 byte each (bit0→relay1).
|
||
const widthBytes = channels <= 8 ? 1 : channels <= 16 ? 2 : channels <= 24 ? 3 : 4;
|
||
const maskBuf = Buffer.alloc(widthBytes);
|
||
const setBuf = Buffer.alloc(widthBytes);
|
||
maskBuf.writeUIntLE(mask, 0, widthBytes);
|
||
setBuf.writeUIntLE(set, 0, widthBytes);
|
||
return Buffer.concat([
|
||
Buffer.from([0xff, 0xaa, session, 0x01, password & 0xff, (password >> 8) & 0xff]),
|
||
maskBuf,
|
||
setBuf,
|
||
]);
|
||
}
|
||
|
||
const rand16 = () => randomBytes(2).readUInt16BE(0);
|
||
|
||
/** GET a CGI path on the device's HTTP server and return the raw response text. */
|
||
function cgiGet(host: string, httpPort: number, path: string, timeoutMs: number, localAddress?: string): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const req = httpRequest({ host, port: httpPort, path, method: "GET", timeout: timeoutMs, localAddress }, (res) => {
|
||
let data = "";
|
||
res.on("data", (c) => (data += c));
|
||
res.on("end", () => resolve(data));
|
||
});
|
||
req.on("error", reject);
|
||
req.on("timeout", () => req.destroy(new Error("cgi timeout")));
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
interface DingtianStatus {
|
||
relays: boolean[]; // true = on
|
||
inputs: boolean[]; // true = active (after resting-level normalisation)
|
||
channels: number;
|
||
}
|
||
|
||
const INPUT_LINK_ISSUE = {
|
||
key: "input_link_relay",
|
||
message:
|
||
"input_link_relay is ENABLED — a button press will auto-fire its relay (opening the barrier before the host can act). Disable it for ticket-first entry.",
|
||
fixable: true,
|
||
} as const;
|
||
|
||
/** GET/POST the device's JSON config API (HTTP; port is configurable). */
|
||
function configApi(
|
||
host: string,
|
||
httpPort: number,
|
||
path: string,
|
||
method: "GET" | "POST",
|
||
body: string | null,
|
||
timeoutMs: number,
|
||
sessionId?: number, // device session check: sent as Cookie: session=<id>
|
||
localAddress?: string, // bind outbound to the device-facing NIC (multi-homed hosts)
|
||
): 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 headers: Record<string, string | number> = {};
|
||
if (body) {
|
||
headers["content-type"] = "application/json";
|
||
headers["content-length"] = Buffer.byteLength(body);
|
||
}
|
||
// When the device's HTTP session check is enabled, the CGI API requires a
|
||
// matching session cookie (a numeric magic id). See programming manual §3.8.
|
||
if (sessionId) headers["cookie"] = `session=${sessionId}`;
|
||
const req = httpRequest(
|
||
{
|
||
host,
|
||
port: httpPort,
|
||
path,
|
||
method,
|
||
timeout: timeoutMs,
|
||
localAddress,
|
||
headers: Object.keys(headers).length ? headers : undefined,
|
||
},
|
||
(res) => {
|
||
let data = "";
|
||
res.on("data", (c) => (data += c));
|
||
res.on("end", () => resolve(data));
|
||
},
|
||
);
|
||
req.on("error", reject);
|
||
req.on("timeout", () => req.destroy(new Error("config api timeout")));
|
||
if (body) req.write(body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
class DingtianController
|
||
implements
|
||
AccessControlDevice,
|
||
InputDevice,
|
||
PreconditionDevice,
|
||
PushConfigurableDevice,
|
||
HardenableDevice
|
||
{
|
||
readonly driverId = "dingtian";
|
||
readonly #host: string;
|
||
readonly #port: number; // legacy string-protocol port (60001) — protocol now disabled by harden(); kept for config compat
|
||
readonly #binaryPort: number; // binary protocol (relay control) — UDP 60000
|
||
readonly #relayPassword: number; // relay_pw (0 = none)
|
||
readonly #sessionId: number; // device CGI session id (0 = session check off)
|
||
readonly #httpPort: number;
|
||
readonly #timeout: number;
|
||
// Local IP to source outbound device traffic from (the device-facing NIC on a
|
||
// multi-homed host). undefined = let the OS choose. See net.ts / device-facing-ip.
|
||
readonly #localAddress: string | undefined;
|
||
readonly #channels: number;
|
||
/** Input level at rest; an input is "active" when it differs from this. */
|
||
readonly #restingHigh: boolean;
|
||
readonly #pulseMs: number;
|
||
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
|
||
readonly #webUser: string;
|
||
/** The password the admin WANTS the device to have (the rotation target). If
|
||
* blank, harden() generates a random one. */
|
||
readonly #webPassword: string | undefined;
|
||
/** The device's CURRENT password, used as the OLD cred for userset.cgi. Defaults
|
||
* to "admin" (factory). Distinct from #webPassword (the desired new value) so an
|
||
* admin typing a desired password doesn't break rotation. */
|
||
readonly #webPasswordCurrent: string;
|
||
|
||
#poll: ReturnType<typeof setInterval> | null = null;
|
||
#last: boolean[] | null = null;
|
||
#subs = new Set<(e: InputEvent) => void>();
|
||
|
||
constructor(config: DeviceConfig) {
|
||
this.#host = String(config.host);
|
||
this.#port = config.port ? Number(config.port) : 60001;
|
||
this.#binaryPort = config.binaryPort ? Number(config.binaryPort) : 60000;
|
||
this.#relayPassword = config.relayPassword ? Number(config.relayPassword) : 0;
|
||
this.#sessionId = config.sessionId ? Number(config.sessionId) : 0;
|
||
this.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
|
||
this.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
|
||
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 2000;
|
||
this.#channels = config.channels ? Number(config.channels) : 4;
|
||
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
||
this.#restingHigh = config.inputRestingHigh !== false;
|
||
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
||
this.#webUser = config.webUser ? String(config.webUser) : "admin";
|
||
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
|
||
this.#webPassword = config.webPassword ? String(config.webPassword) : undefined;
|
||
// webPasswordCurrent = the device's EXISTING password (the old cred userset.cgi
|
||
// checks). Defaults to admin (factory). After a successful rotation, assign
|
||
// stores the new value back here so a re-run can rotate again.
|
||
this.#webPasswordCurrent = config.webPasswordCurrent
|
||
? String(config.webPasswordCurrent)
|
||
: "admin";
|
||
}
|
||
|
||
async connect(): Promise<void> {
|
||
await this.healthCheck();
|
||
}
|
||
|
||
async disconnect(): Promise<void> {
|
||
this.#stopPolling();
|
||
stubLog(this.driverId, "disconnect");
|
||
}
|
||
|
||
async healthCheck(): Promise<DeviceHealth> {
|
||
try {
|
||
await this.#status();
|
||
return { status: "ready" };
|
||
} catch (err) {
|
||
return { status: "offline", detail: (err as Error).message };
|
||
}
|
||
}
|
||
|
||
// --- relay / barrier ----------------------------------------------------
|
||
|
||
/**
|
||
* Pulse a relay open (momentary). Channel is 1-based. Intent only — the device
|
||
* jogs the relay ON then auto-releases after pulseMs, so we never time a close
|
||
* against a vehicle. Uses the binary protocol + relay password (authenticated).
|
||
*/
|
||
async pulseOpen(doorId: number): Promise<void> {
|
||
this.#assertChannel(doorId);
|
||
const frame = jogFrame(doorId, this.#relayPassword, this.#pulseMs);
|
||
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
||
}
|
||
|
||
/** Latch a relay on/off (e.g. for a held-open mode). Channel is 1-based. */
|
||
async setRelay(doorId: number, on: boolean): Promise<void> {
|
||
this.#assertChannel(doorId);
|
||
const frame = writeRelayFrame(doorId, on, this.#relayPassword, this.#channels);
|
||
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
||
}
|
||
|
||
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
||
this.#assertChannel(doorId);
|
||
const { relays } = await this.#status();
|
||
// "open" here = relay energised. Physical door state needs a sensor input.
|
||
return relays[doorId - 1] ? "open" : "closed";
|
||
}
|
||
|
||
// --- inputs (buttons) ---------------------------------------------------
|
||
|
||
async readInputs(): Promise<boolean[]> {
|
||
return (await this.#status()).inputs;
|
||
}
|
||
|
||
onInput(cb: (event: InputEvent) => void): () => void {
|
||
this.#subs.add(cb);
|
||
this.#startPolling();
|
||
return () => {
|
||
this.#subs.delete(cb);
|
||
if (this.#subs.size === 0) this.#stopPolling();
|
||
};
|
||
}
|
||
|
||
// --- preconditions ------------------------------------------------------
|
||
|
||
/**
|
||
* Parking requires `input_link_relay` DISABLED: otherwise a button press
|
||
* auto-fires its relay, opening the barrier before the host can act (print a
|
||
* ticket / decide). This is the configurable version of the UHPPOTE blocker.
|
||
*/
|
||
async checkPreconditions(): Promise<PreconditionResult> {
|
||
let cfg: Record<string, unknown>;
|
||
try {
|
||
cfg = await this.#readConfig();
|
||
} catch (err) {
|
||
return {
|
||
ok: false,
|
||
issues: [
|
||
{
|
||
key: "config_unreachable",
|
||
message: `could not read device config: ${(err as Error).message}`,
|
||
fixable: false,
|
||
},
|
||
],
|
||
};
|
||
}
|
||
return { ok: this.#linkDisabled(cfg), issues: this.#linkDisabled(cfg) ? [] : [INPUT_LINK_ISSUE] };
|
||
}
|
||
|
||
async fixPreconditions(): Promise<PreconditionResult> {
|
||
const cfg = await this.#readConfig();
|
||
if (this.#linkDisabled(cfg)) return { ok: true, issues: [] };
|
||
|
||
// Disable the master flag AND clear the per-input action maps.
|
||
const ilr = cfg.input_link_relay as Record<string, unknown>;
|
||
ilr.input_link_relay = 0;
|
||
if (Array.isArray(ilr.on_action_on)) {
|
||
ilr.on_action_on = (ilr.on_action_on as unknown[]).map(() => []);
|
||
}
|
||
|
||
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 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: 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);
|
||
const fill = (v: unknown) => Array.from({ length: n }, () => v);
|
||
|
||
ilu.en = 1;
|
||
ilu.active_level = fill(0); // active-LOW (matches this board's wiring)
|
||
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(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`);
|
||
ilu.off_path = Array.from({ length: n }, (_, i) => `${opts.pathBase}/${i + 1}/off`);
|
||
ilu.on_body = fill("");
|
||
ilu.off_body = fill("");
|
||
|
||
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
|
||
);
|
||
});
|
||
}
|
||
|
||
// --- hardening ----------------------------------------------------------
|
||
|
||
/**
|
||
* Lock the device down for a flat (no-VLAN) network:
|
||
* - set a random relay password (`relay_pw`) so binary relay commands need it,
|
||
* - keep ONLY UDP1 binary (password-protected relay control + status read),
|
||
* - disable every other protocol channel: string, rs485, can, tcp×2, mqtt.
|
||
* Returns the relay password for the backend to persist (required to keep
|
||
* commanding the device afterwards).
|
||
*
|
||
* SECURITY — why the string protocol (UDP2) is now DISABLED (was a real hole):
|
||
* the Dingtian string protocol has NO password field and can *fire* relays
|
||
* (`"11"` = relay 1 on, `"21"` = off, `"11*"` = jog). Leaving it enabled — even
|
||
* "just for status reads" — let anyone on the network open any barrier with one
|
||
* unauthenticated UDP packet, completely bypassing relay_pw. Confirmed by
|
||
* sending `"11"` to port 60001 with no credentials and watching relay 1 close.
|
||
* So harden() sets udp2.p=255 and status reads move to the authenticated binary
|
||
* read (relay command 0x00 — see #status()).
|
||
*
|
||
* NOTE: deliberately does NOT touch the device's HTTP CGI session check
|
||
* (`session_en`). On this firmware enabling it makes the config-read API drop
|
||
* connections, locking us out of the very API we depend on (verified the hard
|
||
* way — required a factory reset). So we leave the config API as-is and rely on
|
||
* relay_pw + fewer open channels + the signed event log.
|
||
*
|
||
* Even with the string hole closed, all of this is plaintext over UDP/HTTP →
|
||
* defence-in-depth, NOT a boundary. The real guarantee is the signed event log
|
||
* (a relay open with no matching signed command is the fraud signal) plus VLAN
|
||
* isolation. See device-input-flow / network-isolation.
|
||
*/
|
||
async harden(): Promise<HardenResult> {
|
||
const cfg = await this.#readConfig();
|
||
const rc = cfg.relay_connect as Record<string, unknown>;
|
||
|
||
const relayPassword = 1 + (rand16() % 9999); // 1..9999 (0 = none)
|
||
|
||
rc.relay_pw = relayPassword;
|
||
// Keep ONLY UDP1=Binary (p:1) — it carries relay_pw for both control AND the
|
||
// status read. Disable everything else (p:255 = None), INCLUDING the string
|
||
// protocol (udp2), which is password-less and can fire relays.
|
||
(rc.udp1 as Record<string, unknown>).p = 1;
|
||
(rc.udp2 as Record<string, unknown>).p = 255;
|
||
(rc.rs485 as Record<string, unknown>).p = 255;
|
||
(rc.can as Record<string, unknown>).p = 255;
|
||
(rc.tcpc as Record<string, unknown>).p = 255;
|
||
(rc.tcps as Record<string, unknown>).p = 255;
|
||
(rc.mqtt as Record<string, unknown>).p = 255;
|
||
|
||
// NOTE: udp2 (string protocol) is set to 255 here, but it is NOT part of the
|
||
// blocking verify. On some firmware (e.g. V3.6J) the CONFIG API silently
|
||
// refuses to disable udp2 — it accepts the write, reboots, and clamps it back
|
||
// to enabled — even though every other channel applies and the device's own
|
||
// web UI CAN disable it. We don't want assign to hard-fail over a firmware
|
||
// quirk, so we attempt it, then re-check below and warn if it didn't stick.
|
||
const afterCfg = await this.#writeConfig(cfg, (after) => {
|
||
const a = after.relay_connect as Record<string, unknown> | undefined;
|
||
return (
|
||
a?.relay_pw === relayPassword &&
|
||
(a?.rs485 as Record<string, unknown> | undefined)?.p === 255 &&
|
||
(a?.mqtt as Record<string, unknown> | undefined)?.p === 255
|
||
);
|
||
});
|
||
|
||
const applied = [
|
||
"set relay password",
|
||
"disabled rs485/can/tcp/mqtt channels (kept password-protected UDP binary)",
|
||
];
|
||
const warnings: string[] = [];
|
||
const stringDisabled =
|
||
((afterCfg.relay_connect as Record<string, unknown>)?.udp2 as Record<string, unknown> | undefined)?.p === 255;
|
||
if (stringDisabled) {
|
||
applied.push("disabled the password-less string protocol (udp2)");
|
||
} else {
|
||
warnings.push(
|
||
"could not disable the string protocol (udp2) via the config API — this firmware ignores it. " +
|
||
"An unauthenticated UDP packet to the string port can still fire relays. " +
|
||
"Disable UDP2 in the device web UI, and rely on VLAN isolation + the signed event log. See dingtian-relay.md.",
|
||
);
|
||
}
|
||
const secrets: Record<string, string | number> = { relayPassword };
|
||
|
||
// Set the device web login to the admin's chosen password (or a random one).
|
||
// NOTE: cosmetic for the control plane — the CGI API needs NO auth (config
|
||
// read/write + relay fire all work unauthenticated), so the login only gates
|
||
// the interactive browser UI. We set it anyway (defence-in-depth) but it is
|
||
// NOT a boundary; the signed event log is. See dingtian-relay.md.
|
||
//
|
||
// CRITICAL: only persist webPassword if the rotation VERIFIABLY took effect.
|
||
// Otherwise the DB would claim a password the device doesn't have (the bug:
|
||
// admin types a new pw, rotation fails on the wrong old-cred, DB still saves
|
||
// the typed value, login stays admin/admin). On failure we warn instead.
|
||
try {
|
||
const newPassword = await this.#rotateWebLogin();
|
||
secrets.webUser = this.#webUser;
|
||
secrets.webPassword = newPassword;
|
||
// The new password is now the device's CURRENT one — store it so a future
|
||
// re-harden uses the right old cred.
|
||
secrets.webPasswordCurrent = newPassword;
|
||
applied.push("set the device web-UI login (verified on the device)");
|
||
} catch (err) {
|
||
warnings.push(
|
||
`could not set the device web-UI login: ${(err as Error).message} ` +
|
||
`The device login is UNCHANGED (still its previous password). The saved web password was NOT updated.`,
|
||
);
|
||
}
|
||
|
||
return { secrets, applied, warnings: warnings.length ? warnings : undefined };
|
||
}
|
||
|
||
/**
|
||
* Set the device web-UI login to the DESIRED password (the admin's choice, or a
|
||
* random one if none was given) via
|
||
* `userset.cgi?<user>&<old_pass>&<user>&<new_pass>&`. The device validates the
|
||
* OLD credentials, so we send #webPasswordCurrent (admin on a fresh device).
|
||
* Response `&<code>&…&`, code 0 = success.
|
||
*
|
||
* After the rotation we VERIFY by attempting a no-op rotate using the NEW
|
||
* password as the old cred — if that succeeds, the device really has the new
|
||
* password (this is what catches the "DB says X but device is still admin/admin"
|
||
* bug: a wrong old-cred makes the first call fail, and we never claim success).
|
||
* Returns the password now live on the device.
|
||
*/
|
||
async #rotateWebLogin(): Promise<string> {
|
||
const newPassword = this.#webPassword ?? randomBytes(12).toString("hex");
|
||
const u = encodeURIComponent(this.#webUser);
|
||
const setPath = (oldP: string, newP: string) =>
|
||
`/userset.cgi?${u}&${encodeURIComponent(oldP)}&${u}&${encodeURIComponent(newP)}&`;
|
||
|
||
const res = await cgiGet(this.#host, this.#httpPort, setPath(this.#webPasswordCurrent, newPassword), this.#timeout, this.#localAddress);
|
||
const code = res.split("&")[1];
|
||
if (code !== "0") {
|
||
throw new Error(
|
||
`userset.cgi rejected (response "${res.trim()}") — the device's current password is probably not "${this.#webPasswordCurrent}". ` +
|
||
`Set the correct current password, or factory-reset the device.`,
|
||
);
|
||
}
|
||
|
||
// VERIFY: a no-op rotate (new → new) only succeeds if the device truly has it.
|
||
const verify = await cgiGet(this.#host, this.#httpPort, setPath(newPassword, newPassword), this.#timeout, this.#localAddress);
|
||
if (verify.split("&")[1] !== "0") {
|
||
throw new Error(`web-login change did not take effect (verify response "${verify.trim()}")`);
|
||
}
|
||
return newPassword;
|
||
}
|
||
|
||
// --- config api internals ----------------------------------------------
|
||
|
||
async #readConfig(): Promise<Record<string, unknown>> {
|
||
const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout, this.#sessionId, this.#localAddress);
|
||
return JSON.parse(raw) as Record<string, unknown>;
|
||
}
|
||
|
||
/**
|
||
* 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<Record<string, unknown>> {
|
||
// 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> = {};
|
||
for (const [k, v] of Object.entries(cfg)) {
|
||
out[k] = v;
|
||
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));
|
||
|
||
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, this.#sessionId, this.#localAddress);
|
||
} 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 {
|
||
const after = await this.#readConfig();
|
||
if (verify(after)) return after; // applied — return the landed config
|
||
} 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).
|
||
}
|
||
throw new Error("dingtian: config write did not apply after retries");
|
||
}
|
||
|
||
#linkDisabled(cfg: Record<string, unknown>): boolean {
|
||
const ilr = cfg.input_link_relay as Record<string, unknown> | undefined;
|
||
if (!ilr) return true; // no such block → nothing to link
|
||
const flagOff = ilr.input_link_relay === 0;
|
||
const mapsEmpty =
|
||
!Array.isArray(ilr.on_action_on) ||
|
||
(ilr.on_action_on as unknown[]).every((a) => Array.isArray(a) && a.length === 0);
|
||
return flagOff || mapsEmpty;
|
||
}
|
||
|
||
// --- internals ----------------------------------------------------------
|
||
|
||
#assertChannel(ch: number): void {
|
||
if (!Number.isInteger(ch) || ch < 1 || ch > this.#channels) {
|
||
throw new Error(`dingtian: channel ${ch} out of range (1..${this.#channels})`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Read relay + input status via the AUTHENTICATED binary protocol (relay
|
||
* command 0x00). Reply: `FF AA <session> 00 <relayBytes...> <inputBytes...>`,
|
||
* each field `ceil(channels/8)` bytes, LSB-first (bit0 → relay/input 1).
|
||
*
|
||
* SECURITY: deliberately NOT the string protocol's `00` — that query has no
|
||
* password field AND the string protocol can also *fire* relays, so leaving it
|
||
* enabled defeats relay_pw entirely (an attacker sends `"11"` to open relay 1
|
||
* with no auth). harden() disables the string protocol; status reads come here.
|
||
*/
|
||
async #status(): Promise<DingtianStatus> {
|
||
const frame = readStatusFrame(this.#relayPassword);
|
||
const reply = await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
||
const width = Math.max(1, Math.ceil(this.#channels / 8));
|
||
// header: FF AA session 00 (4 bytes) + relay field + input field
|
||
if (reply.length < 4 + width * 2) {
|
||
throw new Error(`dingtian: short binary status reply (${reply.length} bytes)`);
|
||
}
|
||
const relayVal = reply.readUIntLE(4, width);
|
||
const inputVal = reply.readUIntLE(4 + width, width);
|
||
const relays: boolean[] = [];
|
||
const inputs: boolean[] = [];
|
||
for (let i = 0; i < this.#channels; i++) {
|
||
const high = (inputVal & (1 << i)) !== 0;
|
||
relays.push((relayVal & (1 << i)) !== 0);
|
||
// active = differs from the resting level (a press pulls the line).
|
||
inputs.push(high !== this.#restingHigh);
|
||
}
|
||
return { relays, inputs, channels: this.#channels };
|
||
}
|
||
|
||
#startPolling(): void {
|
||
if (this.#poll) return;
|
||
const tick = async () => {
|
||
let inputs: boolean[];
|
||
try {
|
||
inputs = await this.readInputs();
|
||
} catch {
|
||
return; // transient; try again next tick
|
||
}
|
||
const prev = this.#last;
|
||
this.#last = inputs;
|
||
if (!prev) return; // first sample establishes a baseline, no events
|
||
const at = new Date().toISOString();
|
||
for (let i = 0; i < inputs.length; i++) {
|
||
if (inputs[i] === prev[i]) continue;
|
||
const event: InputEvent = {
|
||
input: i + 1,
|
||
edge: inputs[i] ? "pressed" : "released",
|
||
at,
|
||
};
|
||
for (const cb of this.#subs) cb(event);
|
||
}
|
||
};
|
||
// ~50ms poll: a button press is held well longer than this.
|
||
this.#poll = setInterval(() => void tick(), 50);
|
||
}
|
||
|
||
#stopPolling(): void {
|
||
if (this.#poll) {
|
||
clearInterval(this.#poll);
|
||
this.#poll = null;
|
||
this.#last = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
export const dingtianDriver: AccessDriver = {
|
||
id: "dingtian",
|
||
category: "access",
|
||
label: "Dingtian relay controller",
|
||
description:
|
||
"Dingtian network relay+input board (UDP). Inputs are decoupled from relays — enables host-in-the-loop entry. Unauthenticated UDP: isolate the VLAN.",
|
||
transports: ["udp"],
|
||
pushesToBackend: true, // HTTP-pushes input/button events to the backend (Input Link URL)
|
||
configFields: [
|
||
hostField,
|
||
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port — status read (default 60001)." },
|
||
{ key: "binaryPort", label: "Binary protocol port", type: "port", required: false, default: 60000, help: "Dingtian binary protocol UDP port — authenticated relay control (default 60000)." },
|
||
{ key: "httpPort", label: "HTTP config port", type: "port", required: false, default: 80, help: "Device web/config-API port (default 80)." },
|
||
{ key: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
|
||
{
|
||
key: "pulseMs",
|
||
label: "Pulse open (ms)",
|
||
type: "number",
|
||
required: false,
|
||
default: 500,
|
||
help: "Momentary relay pulse; the barrier operator owns the close.",
|
||
},
|
||
{
|
||
key: "inputRestingHigh",
|
||
label: "Inputs idle HIGH",
|
||
type: "boolean",
|
||
required: false,
|
||
default: true,
|
||
help: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
|
||
},
|
||
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 2000 },
|
||
// Device web-UI login. webPassword = the password you WANT (blank → a random
|
||
// one is generated). webPasswordCurrent = the device's EXISTING password, used
|
||
// as the old credential to change it (defaults to "admin" on a fresh device).
|
||
// On a verified change, the new password is stored as both the saved login and
|
||
// the current one. (Gates only the browser UI — CGI control plane is open.)
|
||
{ key: "webUser", label: "Device web username", type: "string", required: false, default: "admin", help: "Device web-UI login user (default admin)." },
|
||
{ key: "webPassword", label: "New device web password", type: "secret", required: false, help: "The password to SET on the device web UI. Leave blank to auto-generate. Applied + verified on save." },
|
||
{ key: "webPasswordCurrent", label: "Current device web password", type: "secret", required: false, help: "The device's existing web password (default admin on a fresh device). Needed to change it." },
|
||
],
|
||
create: (c) => new DingtianController(c),
|
||
};
|