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,
hasPushConfig,
isDiscoverable,
isHardenable,
registerBuiltinDrivers,
registry,
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
// for a device we couldn't configure):
// 1. fix preconditions (e.g. disable input_link_relay so a button press
// doesn't auto-fire its relay — host must decide first), and
// 2. set up input push (Digest creds + push URLs).
// doesn't auto-fire its relay — host must decide first),
// 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 {
if (hasPreconditions(device)) {
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)) {
const host = String(config.host ?? "");
const backendIp = backendIpForDevice(host);
+172 -17
View File
@@ -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 },
{
+22
View File
@@ -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, …). */
+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)
The relay-control direction (host → device) is **unauthenticated UDP**, and the site is a **flat
network with no VLAN** ([[network-isolation]] is not yet enforceable here). So we do **not** trust
the device or the network. Instead:
The site is a **flat network with no VLAN** ([[network-isolation]] is not yet enforceable here),
so we do **not** trust the device or the network. Both directions now have defence-in-depth, but
neither is the real boundary:
- **Every barrier open is a host decision, recorded as a signed event BEFORE the relay fires**
([[append-only-event-chain]]). If anyone opens the relay out-of-band (which the flat network
allows), there is **no matching signed event → a detectable anomaly**. The anti-fraud guarantee
is the **signed log**, not device/network auth.
- The inbound push endpoint is intentionally **not behind the SPA's cookie/CSRF auth** (it's a
machine call from the device). It is guarded by **HTTP Digest auth** + a **source-IP allowlist**
(defence-in-depth), but these are *not* the security boundary.
- **Relay control (host → device)** — UDP, now via the Dingtian **binary protocol on :60000 with a
`relay_pw`** (the only authenticated relay option; the string protocol has none). Set on the
device + stored in `lane_devices` by the harden step (below).
- **Input push (device → host)** — guarded by **HTTP Digest auth** + a **source-IP allowlist**.
- **The real guarantee is the signed log:** every barrier open is a host decision, recorded as a
signed event BEFORE the relay fires ([[append-only-event-chain]]). An out-of-band open (which a
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
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)
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** —
see [[dingtian-vs-mqtt]].
- **Relay control — UDP ASCII, port 60001:** `1`+ch = ON, `2`+ch = OFF, `T`+ch = toggle.
Pulse/jog `11*` (default 500 ms), delay `11:30` (30 s then off), flash `11F5`. `X` = all relays.
Intent-only pulse for a barrier = `pulseOpen` ([[barrier-not-a-door]]).
- **Relay control — UDP *binary*, port 60000 (authenticated):** the driver's `pulseOpen` sends a
binary "write relay with jogging" frame carrying the `relay_pw` (the only relay option with a
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:
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
+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
(push path matches the saved device id). Admin never logs into the device web UI.
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]].