Harden Dingtian: authenticated binary relay + disable unused channels
Lock down the relay device for the flat (no-VLAN) network. Relay control: - pulseOpen/setRelay now use the Dingtian BINARY protocol (:60000) with a relay password — the only relay option with auth (string :60001 has none, and is kept only for the read-only status query). Frame verified on hardware. HardenableDevice capability (driver harden()): - set a random relay_pw (1-9999); disable unused channels (rs485/can/tcp x2/mqtt -> p:255), keeping UDP1 binary (control) + UDP2 string (status). - write-verified (device reboots on apply). Assign/Save flow now does: fix preconditions -> harden -> set up input push; the relay password is stored in lane_devices so the runtime device can command the relay. DELIBERATELY NOT touching the device's HTTP CGI session check (session_en): enabling it on this firmware breaks the config-READ API (ECONNRESET) and locked the backend out — required a factory reset to recover. The open CGI API is accepted as flat-network reality; the signed event log is the real guarantee. Verified end to end on hardware: assign hardens + configures the device, config API stays reachable, pulseOpen with the stored password fires the relay, without it is rejected. wiki: device-input-flow + dingtian-relay updated.
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
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,
|
||||
@@ -64,6 +67,89 @@ function udpRequest(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Dingtian *binary* protocol frame (UDP, default port 60000) and await
|
||||
* the reply. Used for relay control because — unlike the string protocol — the
|
||||
* binary protocol supports a password (`relay_pw`), so an attacker on a flat
|
||||
* network can't fire a relay without it. 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 = 1 write, 3 jogging, …
|
||||
* pwLo,pwHi = relay password, 16-bit LSB-first (0 = none)
|
||||
* data = command-specific
|
||||
*/
|
||||
function binaryUdp(
|
||||
host: string,
|
||||
port: number,
|
||||
frame: Buffer,
|
||||
timeoutMs: number,
|
||||
): 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));
|
||||
sock.bind(() => {
|
||||
sock.send(frame, port, host, (e) => {
|
||||
if (e) done(e, null);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let binarySession = 0;
|
||||
/** 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);
|
||||
|
||||
interface DingtianStatus {
|
||||
relays: boolean[]; // true = on
|
||||
inputs: boolean[]; // true = active (after resting-level normalisation)
|
||||
@@ -85,12 +171,21 @@ function configApi(
|
||||
method: "GET" | "POST",
|
||||
body: string | null,
|
||||
timeoutMs: number,
|
||||
sessionId?: number, // device session check: sent as Cookie: session=<id>
|
||||
): 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,
|
||||
@@ -98,12 +193,7 @@ function configApi(
|
||||
path,
|
||||
method,
|
||||
timeout: timeoutMs,
|
||||
headers: body
|
||||
? {
|
||||
"content-type": "application/json",
|
||||
"content-length": Buffer.byteLength(body),
|
||||
}
|
||||
: undefined,
|
||||
headers: Object.keys(headers).length ? headers : undefined,
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
@@ -123,11 +213,15 @@ class DingtianController
|
||||
AccessControlDevice,
|
||||
InputDevice,
|
||||
PreconditionDevice,
|
||||
PushConfigurableDevice
|
||||
PushConfigurableDevice,
|
||||
HardenableDevice
|
||||
{
|
||||
readonly driverId = "dingtian";
|
||||
readonly #host: string;
|
||||
readonly #port: number;
|
||||
readonly #port: number; // string protocol (status read) — UDP 60001
|
||||
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;
|
||||
readonly #channels: number;
|
||||
@@ -142,6 +236,9 @@ class DingtianController
|
||||
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.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 2000;
|
||||
this.#channels = config.channels ? Number(config.channels) : 4;
|
||||
@@ -170,19 +267,22 @@ class DingtianController
|
||||
|
||||
// --- relay / barrier ----------------------------------------------------
|
||||
|
||||
/** Pulse a relay open (momentary). Channel is 1-based. Intent only. */
|
||||
/**
|
||||
* 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);
|
||||
// Jog/pulse: "{1}{ch}*{units}" — ON then auto-OFF after pulseMs.
|
||||
// units are 100ms each (5 = 500ms). Device self-releases the relay.
|
||||
const units = Math.max(1, Math.round(this.#pulseMs / 100));
|
||||
await udpRequest(this.#host, this.#port, `1${doorId}*${units}`, this.#timeout, false);
|
||||
const frame = jogFrame(doorId, this.#relayPassword, this.#pulseMs);
|
||||
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
await udpRequest(this.#host, this.#port, `${on ? 1 : 2}${doorId}`, this.#timeout, false);
|
||||
const frame = writeRelayFrame(doorId, on, this.#relayPassword, this.#channels);
|
||||
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout);
|
||||
}
|
||||
|
||||
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
||||
@@ -294,10 +394,64 @@ class DingtianController
|
||||
});
|
||||
}
|
||||
|
||||
// --- hardening ----------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Lock the device down for a flat (no-VLAN) network:
|
||||
* - set a random relay password (`relay_pw`) so binary relay commands need it,
|
||||
* - disable unused protocol channels (rs485/can/tcp×2/mqtt) — keep only UDP1
|
||||
* binary (relay control) and UDP2 string (status read).
|
||||
* Returns the relay password for the backend to persist (required to keep
|
||||
* commanding the device afterwards).
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* All are plaintext over HTTP/UDP on a flat network → defence-in-depth, not a
|
||||
* boundary; the signed event log is the real guarantee. See device-input-flow.
|
||||
*/
|
||||
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 UDP1=Binary (p:1) for relay control, UDP2=String (p:0) for status.
|
||||
// Disable everything else (p:255 = None).
|
||||
(rc.udp1 as Record<string, unknown>).p = 1;
|
||||
(rc.udp2 as Record<string, unknown>).p = 0;
|
||||
(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;
|
||||
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
secrets: { relayPassword },
|
||||
applied: [
|
||||
"set relay password",
|
||||
"disabled rs485/can/tcp/mqtt channels (kept UDP binary + string)",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
const raw = await configApi(this.#host, this.#httpPort, "/api/v2/config.cgi", "GET", null, this.#timeout, this.#sessionId);
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -329,7 +483,7 @@ class DingtianController
|
||||
// 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);
|
||||
await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout, this.#sessionId);
|
||||
} catch {
|
||||
// device likely reset on apply
|
||||
}
|
||||
@@ -429,7 +583,8 @@ export const dingtianDriver: AccessDriver = {
|
||||
transports: ["udp"],
|
||||
configFields: [
|
||||
hostField,
|
||||
{ ...portField(60001), required: false, help: "Dingtian string protocol UDP port (default 60001)." },
|
||||
{ ...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 },
|
||||
{
|
||||
|
||||
@@ -126,6 +126,28 @@ export function hasPushConfig(
|
||||
return typeof (device as Partial<PushConfigurableDevice>).configureInputPush === "function";
|
||||
}
|
||||
|
||||
// --- Hardening (lock the device down) ------------------------------------
|
||||
// Optional capability: a device that can be hardened against a flat (no-VLAN)
|
||||
// network — disable unused protocols/channels, set a relay password, and change
|
||||
// the default web/config login. Returns any secrets the backend must persist to
|
||||
// keep talking to the device. See wiki/concepts/device-input-flow.md.
|
||||
export interface HardenableDevice {
|
||||
harden(): Promise<HardenResult>;
|
||||
}
|
||||
|
||||
export interface HardenResult {
|
||||
/** Secrets to persist in lane_devices so the backend can keep operating the
|
||||
* device (relay password, new web login). The backend merges these into the
|
||||
* stored config. */
|
||||
readonly secrets: Record<string, string | number>;
|
||||
/** Human-readable summary of what was changed (for logging/UI). */
|
||||
readonly applied: string[];
|
||||
}
|
||||
|
||||
export function isHardenable(device: Device): device is Device & HardenableDevice {
|
||||
return typeof (device as Partial<HardenableDevice>).harden === "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