diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index e26ecdf..3c27db2 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -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 { // 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 { } } + 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); diff --git a/packages/devices/src/drivers/access-dingtian.ts b/packages/devices/src/drivers/access-dingtian.ts index 4107a6a..00e4ea2 100644 --- a/packages/devices/src/drivers/access-dingtian.ts +++ b/packages/devices/src/drivers/access-dingtian.ts @@ -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 + * + * 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 { + 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= ): Promise { 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 = {}; + 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 { 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 { 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 { + const cfg = await this.#readConfig(); + const rc = cfg.relay_connect as Record; + + 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).p = 1; + (rc.udp2 as Record).p = 0; + (rc.rs485 as Record).p = 255; + (rc.can as Record).p = 255; + (rc.tcpc as Record).p = 255; + (rc.tcps as Record).p = 255; + (rc.mqtt as Record).p = 255; + + await this.#writeConfig(cfg, (after) => { + const a = after.relay_connect as Record | undefined; + return ( + a?.relay_pw === relayPassword && + (a?.rs485 as Record | undefined)?.p === 255 && + (a?.mqtt as Record | 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> { - 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; } @@ -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 }, { diff --git a/packages/devices/src/interfaces.ts b/packages/devices/src/interfaces.ts index c11457c..81aa1b0 100644 --- a/packages/devices/src/interfaces.ts +++ b/packages/devices/src/interfaces.ts @@ -126,6 +126,28 @@ export function hasPushConfig( return typeof (device as Partial).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; +} + +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; + /** 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).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, …). */ diff --git a/wiki/concepts/device-input-flow.md b/wiki/concepts/device-input-flow.md index b07fc0f..8a1edfd 100644 --- a/wiki/concepts/device-input-flow.md +++ b/wiki/concepts/device-input-flow.md @@ -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 diff --git a/wiki/entities/dingtian-relay.md b/wiki/entities/dingtian-relay.md index 6235065..cc59773 100644 --- a/wiki/entities/dingtian-relay.md +++ b/wiki/entities/dingtian-relay.md @@ -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 03 ` — 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 diff --git a/wiki/log.md b/wiki/log.md index 355ce20..454f595 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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 03 ). 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]].