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:
2026-06-14 18:34:35 +02:00
parent 0375227a16
commit 7fd407ac82
6 changed files with 259 additions and 32 deletions
+10 -2
View File
@@ -5,6 +5,7 @@ import {
hasPreconditions, hasPreconditions,
hasPushConfig, hasPushConfig,
isDiscoverable, isDiscoverable,
isHardenable,
registerBuiltinDrivers, registerBuiltinDrivers,
registry, registry,
setDeviceLogSink, setDeviceLogSink,
@@ -139,8 +140,10 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Configure the device on save (before persisting, so we don't store a row // Configure the device on save (before persisting, so we don't store a row
// for a device we couldn't configure): // for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press // 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first), and // doesn't auto-fire its relay — host must decide first),
// 2. set up input push (Digest creds + push URLs). // 2. harden (relay password + disable unused protocol channels), and
// 3. set up input push (Digest creds + push URLs).
// Each step is a device config write (the device reboots on apply).
try { try {
if (hasPreconditions(device)) { if (hasPreconditions(device)) {
const fixed = await device.fixPreconditions(); const fixed = await device.fixPreconditions();
@@ -152,6 +155,11 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
} }
} }
if (isHardenable(device)) {
const { secrets } = await device.harden();
Object.assign(fullConfig, secrets); // e.g. relayPassword
}
if (hasPushConfig(device)) { if (hasPushConfig(device)) {
const host = String(config.host ?? ""); const host = String(config.host ?? "");
const backendIp = backendIpForDevice(host); const backendIp = backendIpForDevice(host);
+172 -17
View File
@@ -1,8 +1,11 @@
import { randomBytes } from "node:crypto";
import { createSocket } from "node:dgram"; import { createSocket } from "node:dgram";
import { request as httpRequest } from "node:http"; import { request as httpRequest } from "node:http";
import type { import type {
AccessControlDevice, AccessControlDevice,
DeviceHealth, DeviceHealth,
HardenableDevice,
HardenResult,
InputDevice, InputDevice,
InputEvent, InputEvent,
PreconditionDevice, 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 { interface DingtianStatus {
relays: boolean[]; // true = on relays: boolean[]; // true = on
inputs: boolean[]; // true = active (after resting-level normalisation) inputs: boolean[]; // true = active (after resting-level normalisation)
@@ -85,12 +171,21 @@ function configApi(
method: "GET" | "POST", method: "GET" | "POST",
body: string | null, body: string | null,
timeoutMs: number, timeoutMs: number,
sessionId?: number, // device session check: sent as Cookie: session=<id>
): 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. // The device's embedded HTTP server does NOT support chunked request bodies.
// Node uses chunked encoding when Content-Length is absent, so the device // Node uses chunked encoding when Content-Length is absent, so the device
// silently ignores the body (POST returns {"status":0} but nothing changes). // silently ignores the body (POST returns {"status":0} but nothing changes).
// Always set Content-Length explicitly. // 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( const req = httpRequest(
{ {
host, host,
@@ -98,12 +193,7 @@ function configApi(
path, path,
method, method,
timeout: timeoutMs, timeout: timeoutMs,
headers: body headers: Object.keys(headers).length ? headers : undefined,
? {
"content-type": "application/json",
"content-length": Buffer.byteLength(body),
}
: undefined,
}, },
(res) => { (res) => {
let data = ""; let data = "";
@@ -123,11 +213,15 @@ class DingtianController
AccessControlDevice, AccessControlDevice,
InputDevice, InputDevice,
PreconditionDevice, PreconditionDevice,
PushConfigurableDevice PushConfigurableDevice,
HardenableDevice
{ {
readonly driverId = "dingtian"; readonly driverId = "dingtian";
readonly #host: string; 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 #httpPort: number;
readonly #timeout: number; readonly #timeout: number;
readonly #channels: number; readonly #channels: number;
@@ -142,6 +236,9 @@ class DingtianController
constructor(config: DeviceConfig) { constructor(config: DeviceConfig) {
this.#host = String(config.host); this.#host = String(config.host);
this.#port = config.port ? Number(config.port) : 60001; 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.#httpPort = config.httpPort ? Number(config.httpPort) : 80;
this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 2000; this.#timeout = config.timeoutMs ? Number(config.timeoutMs) : 2000;
this.#channels = config.channels ? Number(config.channels) : 4; this.#channels = config.channels ? Number(config.channels) : 4;
@@ -170,19 +267,22 @@ class DingtianController
// --- relay / barrier ---------------------------------------------------- // --- 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> { async pulseOpen(doorId: number): Promise<void> {
this.#assertChannel(doorId); this.#assertChannel(doorId);
// Jog/pulse: "{1}{ch}*{units}" — ON then auto-OFF after pulseMs. const frame = jogFrame(doorId, this.#relayPassword, this.#pulseMs);
// units are 100ms each (5 = 500ms). Device self-releases the relay. await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout);
const units = Math.max(1, Math.round(this.#pulseMs / 100));
await udpRequest(this.#host, this.#port, `1${doorId}*${units}`, this.#timeout, false);
} }
/** Latch a relay on/off (e.g. for a held-open mode). Channel is 1-based. */ /** Latch a relay on/off (e.g. for a held-open mode). Channel is 1-based. */
async setRelay(doorId: number, on: boolean): Promise<void> { async setRelay(doorId: number, on: boolean): Promise<void> {
this.#assertChannel(doorId); 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"> { 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 ---------------------------------------------- // --- config api internals ----------------------------------------------
async #readConfig(): Promise<Record<string, unknown>> { 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>; 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 // POST. The device resets on apply, so the connection may drop — that's
// expected, not failure. // expected, not failure.
try { 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 { } catch {
// device likely reset on apply // device likely reset on apply
} }
@@ -429,7 +583,8 @@ export const dingtianDriver: AccessDriver = {
transports: ["udp"], transports: ["udp"],
configFields: [ configFields: [
hostField, 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: "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: "channels", label: "Channels (relays/inputs)", type: "number", required: true, default: 4 },
{ {
+22
View File
@@ -126,6 +126,28 @@ export function hasPushConfig(
return typeof (device as Partial<PushConfigurableDevice>).configureInputPush === "function"; 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) --------------------------- // --- 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, …). */
+29 -10
View File
@@ -33,20 +33,39 @@ car arrives → driver presses button (input I_N, dry contact to GND)
## Trust model (important — flat network, no VLAN) ## Trust model (important — flat network, no VLAN)
The relay-control direction (host → device) is **unauthenticated UDP**, and the site is a **flat The site is a **flat network with no VLAN** ([[network-isolation]] is not yet enforceable here),
network with no VLAN** ([[network-isolation]] is not yet enforceable here). So we do **not** trust so we do **not** trust the device or the network. Both directions now have defence-in-depth, but
the device or the network. Instead: neither is the real boundary:
- **Every barrier open is a host decision, recorded as a signed event BEFORE the relay fires** - **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
([[append-only-event-chain]]). If anyone opens the relay out-of-band (which the flat network `relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
allows), there is **no matching signed event → a detectable anomaly**. The anti-fraud guarantee device + stored in `lane_devices` by the harden step (below).
is the **signed log**, not device/network auth. - **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
- The inbound push endpoint is intentionally **not behind the SPA's cookie/CSRF auth** (it's a - **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
machine call from the device). It is guarded by **HTTP Digest auth** + a **source-IP allowlist** signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
(defence-in-depth), but these are *not* the security boundary. flat network allows) has **no matching signed event → a detectable anomaly**. Device/network
auth is just speed bumps; both are plaintext over a sniffable network.
- 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.
## Device hardening (on assign)
The assign/Save step configures the device end-to-end (admin never touches the device web UI):
fix preconditions (disable `input_link_relay`) → **harden** → set up input push. The `harden`
capability ([[device-registry|HardenableDevice]]):
- **Sets a random `relay_pw`** (1–9999) so binary relay commands need it; stores it in
`lane_devices` so the backend can keep commanding the relay.
- **Disables unused protocol channels** (rs485, can, tcp×2, mqtt → `p:255`), keeping only UDP1
binary (relay control) + UDP2 string (status read) — fewer open doors.
> **⚠️ Lesson (the hard way):** do **NOT** enable the device's HTTP CGI session check
> (`session_en`). On this firmware (DT-R004) it makes the config-**read** API drop connections
> (`ECONNRESET`), locking the backend out of the very API it depends on — it required a **factory
> reset** to recover. The harden step deliberately leaves `session_en` off. The CGI config API
> being open is accepted as part of the flat-network reality (the signed log is the guarantee);
> the proper fix is network isolation, not this fragile device feature.
## Push authentication — Digest (decided by hardware testing) ## 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 secret must not be in the URL (sniffable, logged) and the password must not cross the wire in
+8 -3
View File
@@ -28,9 +28,14 @@ the ticket-first entry flow. See [[autonomous-direction]].
Transport options: UDP/TCP string, UDP binary, HTTP CGI, Modbus, MQTT. We use **HTTP + UDP** — Transport options: UDP/TCP string, UDP binary, HTTP CGI, Modbus, MQTT. We use **HTTP + UDP** —
see [[dingtian-vs-mqtt]]. see [[dingtian-vs-mqtt]].
- **Relay control — UDP ASCII, port 60001:** `1`+ch = ON, `2`+ch = OFF, `T`+ch = toggle. - **Relay control — UDP *binary*, port 60000 (authenticated):** the driver's `pulseOpen` sends a
Pulse/jog `11*` (default 500 ms), delay `11:30` (30 s then off), flash `11F5`. `X` = all relays. binary "write relay with jogging" frame carrying the `relay_pw` (the only relay option with a
Intent-only pulse for a barrier = `pulseOpen` ([[barrier-not-a-door]]). password). Frame (verified on hardware):
`FF AA <session> 03 <pwLo> <pwHi> <relayByte> <jogLo> <jogHi>` — relayByte bit0=on, bits1-7=
channel-1; jog is 100 ms units, LSB-first; password 16-bit LSB-first (0 = none). The relay jogs
ON then auto-releases, so we never time a close ([[barrier-not-a-door]]). *(The simpler string
protocol — `1`+ch on, `2`+ch off, `11*` jog — works too but has no auth; we use it only for the
read-only status query.)*
- **Status / inputs — send `00`** → `「relays」:「inputs」:「count」`, e.g. **`0000:1111:4`** (4ch: - **Status / inputs — send `00`** → `「relays」:「inputs」:「count」`, e.g. **`0000:1111:4`** (4ch:
relays off, inputs high). `0` = OFF/Low, `1` = ON/High. Poll-based. relays off, inputs high). `0` = OFF/Low, `1` = ON/High. Poll-based.
- **Input push — `input_link_url`:** device **HTTP POSTs to a host URL on input change** — the - **Input push — `input_link_url`:** device **HTTP POSTs to a host URL on input change** — the
+18
View File
@@ -174,3 +174,21 @@ button. Verified in-browser against the real device: Test shows ● ready +
preconditions OK; Save persists the row AND writes the device's Input Link URL preconditions OK; Save persists the row AND writes the device's Input Link URL
(push path matches the saved device id). Admin never logs into the device web UI. (push path matches the saved device id). Admin never logs into the device web UI.
Updated [[first-run-setup]]. Updated [[first-run-setup]].
## [2026-06-15] feature | Device hardening: binary relay + relay_pw + disable channels
Hardened the Dingtian relay control for the flat (no-VLAN) network. Switched
pulseOpen from the unauthenticated string protocol (:60001) to the **binary
protocol (:60000) with a relay password** — the only authenticated relay option
(frame verified on hardware: FF AA <sess> 03 <pwLE> <relayByte> <jogLE>). New
HardenableDevice capability: harden() sets a random relay_pw + disables unused
channels (rs485/can/tcp×2/mqtt → p:255, keep UDP binary+string). Folded into the
assign/Save flow (preconditions → harden → push); relayPassword stored in
lane_devices. Verified end to end: assign configures + hardens the device, config
API stays reachable, pulseOpen with the stored password fires the relay, without
it is rejected.
⚠️ LESSON: enabling the device's HTTP CGI session check (session_en) on this
firmware breaks the config-READ API (ECONNRESET) — locked us out, needed a FACTORY
RESET to recover. harden() deliberately does NOT touch session_en. The open CGI
API is accepted as flat-network reality; the signed log is the real guarantee.
Recorded in [[device-input-flow]] + [[dingtian-relay]].