Dingtian: close password-less string-protocol relay-fire hole
The string protocol (UDP 60001) has no password field but can fire relays
("11" = relay 1 on), bypassing relay_pw entirely. Proven on hardware: an
unauthenticated packet opened a relay. harden() had left it enabled "for
status reads".
- #status() now reads via the authenticated binary command (relay cmd 0x00)
instead of the string protocol, so the string protocol is no longer needed.
- harden() disables the string protocol (udp2.p=255). BEST-EFFORT: firmware
V3.6J's config API silently refuses to disable udp2 (the device web UI can),
so it's not part of the blocking verify -- harden() re-checks and returns a
warning instead of throwing. After a web-UI disable, the attack is dead and
binary control/status still work (verified on hardware).
- HardenResult gains an optional `warnings[]`; the assign route surfaces them
to the admin and logs them.
- Corrected the false comment claiming relay_pw stops an attacker (it is
defence-in-depth on plaintext UDP, not a boundary).
- Thread localAddress through the driver's UDP/HTTP calls so a multi-homed
host sources device traffic from the device-facing NIC.
- Device web login (webUser/webPassword) is no longer redacted from setup
state -- it's an operational credential for the admin-only device area;
pushPassword/relayPassword stay machine-only.
Wiki: document the vuln + fix, the firmware caveat, and the out-of-band
actuation gap (the log captures host actions only; reconciliation vs. an
independent witness is the real control and is not yet built).
This commit is contained in:
@@ -32,10 +32,15 @@ interface TestBody {
|
|||||||
config: Record<string, string | number | boolean>;
|
config: Record<string, string | number | boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config keys that hold device secrets — never sent back to the client. Covers
|
// Config keys that hold MACHINE-ONLY secrets — never sent back to the client.
|
||||||
// the push Digest password, the rotated device web-UI login, and the Dingtian
|
// No human ever uses these to log in: `pushPassword` is the device→backend Digest
|
||||||
// relay password. Centralised so /state and /assign redact consistently.
|
// secret, `relayPassword` is the binary-protocol relay_pw. They stay redacted.
|
||||||
const SECRET_CONFIG_KEYS = ["pushPassword", "webPassword", "relayPassword"] as const;
|
//
|
||||||
|
// NOTE: the device web-UI login (`webUser`/`webPassword`) is deliberately NOT
|
||||||
|
// redacted. It's an operational credential an admin needs to reach the device's
|
||||||
|
// own web page, and the whole device-management area is admin-only — so it's
|
||||||
|
// surfaced in the admin device view rather than hidden. See first-run-setup.md.
|
||||||
|
const SECRET_CONFIG_KEYS = ["pushPassword", "relayPassword"] as const;
|
||||||
|
|
||||||
function redactSecrets(config: Record<string, unknown>): Record<string, unknown> {
|
function redactSecrets(config: Record<string, unknown>): Record<string, unknown> {
|
||||||
const out = { ...config };
|
const out = { ...config };
|
||||||
@@ -157,6 +162,9 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
|
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const fullConfig: Record<string, unknown> = { ...config };
|
const fullConfig: Record<string, unknown> = { ...config };
|
||||||
|
// Residual-risk warnings from device hardening (shown to the admin; the
|
||||||
|
// save still succeeds — these are "configured, but note X" advisories).
|
||||||
|
const hardenWarnings: string[] = [];
|
||||||
|
|
||||||
let device;
|
let device;
|
||||||
try {
|
try {
|
||||||
@@ -184,8 +192,14 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isHardenable(device)) {
|
if (isHardenable(device)) {
|
||||||
const { secrets } = await device.harden();
|
const { secrets, warnings } = await device.harden();
|
||||||
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
Object.assign(fullConfig, secrets); // e.g. relayPassword
|
||||||
|
// Surface residual-risk warnings (e.g. firmware that won't disable the
|
||||||
|
// password-less string protocol) so the admin can act (web-UI step).
|
||||||
|
for (const w of warnings ?? []) {
|
||||||
|
app.log.warn(`harden(${driverId} ${id}): ${w}`);
|
||||||
|
hardenWarnings.push(w);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasPushConfig(device)) {
|
if (hasPushConfig(device)) {
|
||||||
@@ -229,7 +243,11 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
};
|
};
|
||||||
await db.insert(laneDevices).values(row);
|
await db.insert(laneDevices).values(row);
|
||||||
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
// Don't echo device secrets back (push Digest password, web-UI login, …).
|
||||||
return reply.code(201).send({ ...row, config: redactSecrets(fullConfig) });
|
return reply.code(201).send({
|
||||||
|
...row,
|
||||||
|
config: redactSecrets(fullConfig),
|
||||||
|
...(hardenWarnings.length ? { warnings: hardenWarnings } : {}),
|
||||||
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -34,59 +34,37 @@ import { hostField, portField, stubLog } from "./common.js";
|
|||||||
// (input_link_relay). That must be DISABLED on the device for ticket-first
|
// (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.
|
// entry, else the button opens the barrier before the host can act.
|
||||||
|
|
||||||
/** Send one UDP datagram and (optionally) await a single reply. */
|
// (The string-protocol UDP helper was removed: harden() now disables the
|
||||||
function udpRequest(
|
// password-less string protocol entirely, and status reads use the
|
||||||
host: string,
|
// authenticated binary read — see #status() / readStatusFrame.)
|
||||||
port: number,
|
|
||||||
payload: string,
|
|
||||||
timeoutMs: number,
|
|
||||||
expectReply: boolean,
|
|
||||||
): Promise<string | null> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const sock = createSocket("udp4");
|
|
||||||
let settled = false;
|
|
||||||
const done = (err: Error | null, val: string | null) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
sock.close();
|
|
||||||
err ? reject(err) : resolve(val);
|
|
||||||
};
|
|
||||||
const timer = setTimeout(
|
|
||||||
() => done(expectReply ? new Error("timeout") : null, null),
|
|
||||||
timeoutMs,
|
|
||||||
);
|
|
||||||
sock.on("error", (e) => done(e, null));
|
|
||||||
sock.on("message", (m) => done(null, m.toString()));
|
|
||||||
sock.bind(() => {
|
|
||||||
sock.send(Buffer.from(payload), port, host, (e) => {
|
|
||||||
if (e) done(e, null);
|
|
||||||
else if (!expectReply) done(null, null);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a Dingtian *binary* protocol frame (UDP, default port 60000) and await
|
* Send a Dingtian *binary* protocol frame (UDP, default port 60000) and await
|
||||||
* the reply. Used for relay control because — unlike the string protocol — the
|
* the reply. Used for ALL relay traffic — control AND status read — because,
|
||||||
* binary protocol supports a password (`relay_pw`), so an attacker on a flat
|
* unlike the string protocol, the binary protocol carries a password (`relay_pw`).
|
||||||
* network can't fire a relay without it. Frame verified on hardware:
|
* 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 AA <session> <relayCmd> <pwLo> <pwHi> <data...>
|
||||||
*
|
*
|
||||||
* FF = command "set relay"
|
* FF = command "set relay"
|
||||||
* AA = result xor (0x00 ^ 0xAA, pc→device)
|
* AA = result xor (0x00 ^ 0xAA, pc→device)
|
||||||
* session = echoed back
|
* session = echoed back
|
||||||
* relayCmd = 1 write, 3 jogging, …
|
* relayCmd = 0 read status, 1 write, 3 jogging, …
|
||||||
* pwLo,pwHi = relay password, 16-bit LSB-first (0 = none)
|
* pwLo,pwHi = relay password, 16-bit LSB-first (0 = none)
|
||||||
* data = command-specific
|
* 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(
|
function binaryUdp(
|
||||||
host: string,
|
host: string,
|
||||||
port: number,
|
port: number,
|
||||||
frame: Buffer,
|
frame: Buffer,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
|
localAddress?: string,
|
||||||
): Promise<Buffer> {
|
): Promise<Buffer> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const sock = createSocket("udp4");
|
const sock = createSocket("udp4");
|
||||||
@@ -101,15 +79,33 @@ function binaryUdp(
|
|||||||
const timer = setTimeout(() => done(new Error("timeout"), null), timeoutMs);
|
const timer = setTimeout(() => done(new Error("timeout"), null), timeoutMs);
|
||||||
sock.on("error", (e) => done(e, null));
|
sock.on("error", (e) => done(e, null));
|
||||||
sock.on("message", (m) => done(null, m));
|
sock.on("message", (m) => done(null, m));
|
||||||
sock.bind(() => {
|
// 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) => {
|
sock.send(frame, port, host, (e) => {
|
||||||
if (e) done(e, null);
|
if (e) done(e, null);
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
if (localAddress) sock.bind({ address: localAddress }, onBound);
|
||||||
|
else sock.bind(onBound);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let binarySession = 0;
|
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). */
|
/** Build a binary "write relay with jogging" frame (relay on, auto-off). */
|
||||||
function jogFrame(channel: number, password: number, jogMs: number): Buffer {
|
function jogFrame(channel: number, password: number, jogMs: number): Buffer {
|
||||||
const session = binarySession++ & 0xff;
|
const session = binarySession++ & 0xff;
|
||||||
@@ -151,9 +147,9 @@ function writeRelayFrame(channel: number, on: boolean, password: number, channel
|
|||||||
const rand16 = () => randomBytes(2).readUInt16BE(0);
|
const rand16 = () => randomBytes(2).readUInt16BE(0);
|
||||||
|
|
||||||
/** GET a CGI path on the device's HTTP server and return the raw response text. */
|
/** 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): Promise<string> {
|
function cgiGet(host: string, httpPort: number, path: string, timeoutMs: number, localAddress?: string): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const req = httpRequest({ host, port: httpPort, path, method: "GET", timeout: timeoutMs }, (res) => {
|
const req = httpRequest({ host, port: httpPort, path, method: "GET", timeout: timeoutMs, localAddress }, (res) => {
|
||||||
let data = "";
|
let data = "";
|
||||||
res.on("data", (c) => (data += c));
|
res.on("data", (c) => (data += c));
|
||||||
res.on("end", () => resolve(data));
|
res.on("end", () => resolve(data));
|
||||||
@@ -186,6 +182,7 @@ function configApi(
|
|||||||
body: string | null,
|
body: string | null,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
sessionId?: number, // device session check: sent as Cookie: session=<id>
|
sessionId?: number, // device session check: sent as Cookie: session=<id>
|
||||||
|
localAddress?: string, // bind outbound to the device-facing NIC (multi-homed hosts)
|
||||||
): 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.
|
||||||
@@ -207,6 +204,7 @@ function configApi(
|
|||||||
path,
|
path,
|
||||||
method,
|
method,
|
||||||
timeout: timeoutMs,
|
timeout: timeoutMs,
|
||||||
|
localAddress,
|
||||||
headers: Object.keys(headers).length ? headers : undefined,
|
headers: Object.keys(headers).length ? headers : undefined,
|
||||||
},
|
},
|
||||||
(res) => {
|
(res) => {
|
||||||
@@ -232,12 +230,15 @@ class DingtianController
|
|||||||
{
|
{
|
||||||
readonly driverId = "dingtian";
|
readonly driverId = "dingtian";
|
||||||
readonly #host: string;
|
readonly #host: string;
|
||||||
readonly #port: number; // string protocol (status read) — UDP 60001
|
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 #binaryPort: number; // binary protocol (relay control) — UDP 60000
|
||||||
readonly #relayPassword: number; // relay_pw (0 = none)
|
readonly #relayPassword: number; // relay_pw (0 = none)
|
||||||
readonly #sessionId: number; // device CGI session id (0 = session check off)
|
readonly #sessionId: number; // device CGI session id (0 = session check off)
|
||||||
readonly #httpPort: number;
|
readonly #httpPort: number;
|
||||||
readonly #timeout: 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;
|
readonly #channels: number;
|
||||||
/** Input level at rest; an input is "active" when it differs from this. */
|
/** Input level at rest; an input is "active" when it differs from this. */
|
||||||
readonly #restingHigh: boolean;
|
readonly #restingHigh: boolean;
|
||||||
@@ -257,6 +258,7 @@ class DingtianController
|
|||||||
this.#relayPassword = config.relayPassword ? Number(config.relayPassword) : 0;
|
this.#relayPassword = config.relayPassword ? Number(config.relayPassword) : 0;
|
||||||
this.#sessionId = config.sessionId ? Number(config.sessionId) : 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.#localAddress = config.localAddress ? String(config.localAddress) : undefined;
|
||||||
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;
|
||||||
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
// This unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
||||||
@@ -297,14 +299,14 @@ class DingtianController
|
|||||||
async pulseOpen(doorId: number): Promise<void> {
|
async pulseOpen(doorId: number): Promise<void> {
|
||||||
this.#assertChannel(doorId);
|
this.#assertChannel(doorId);
|
||||||
const frame = jogFrame(doorId, this.#relayPassword, this.#pulseMs);
|
const frame = jogFrame(doorId, this.#relayPassword, this.#pulseMs);
|
||||||
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout);
|
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. */
|
/** 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);
|
||||||
const frame = writeRelayFrame(doorId, on, this.#relayPassword, this.#channels);
|
const frame = writeRelayFrame(doorId, on, this.#relayPassword, this.#channels);
|
||||||
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout);
|
await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
async getDoorStatus(doorId: number): Promise<"open" | "closed"> {
|
||||||
@@ -421,19 +423,30 @@ class DingtianController
|
|||||||
/**
|
/**
|
||||||
* Lock the device down for a flat (no-VLAN) network:
|
* Lock the device down for a flat (no-VLAN) network:
|
||||||
* - set a random relay password (`relay_pw`) so binary relay commands need it,
|
* - 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
|
* - keep ONLY UDP1 binary (password-protected relay control + status read),
|
||||||
* binary (relay control) and UDP2 string (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
|
* Returns the relay password for the backend to persist (required to keep
|
||||||
* commanding the device afterwards).
|
* 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
|
* 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
|
* (`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
|
* 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
|
* 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.
|
* relay_pw + fewer open channels + the signed event log.
|
||||||
*
|
*
|
||||||
* All are plaintext over HTTP/UDP on a flat network → defence-in-depth, not a
|
* Even with the string hole closed, all of this is plaintext over UDP/HTTP →
|
||||||
* boundary; the signed event log is the real guarantee. See device-input-flow.
|
* 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> {
|
async harden(): Promise<HardenResult> {
|
||||||
const cfg = await this.#readConfig();
|
const cfg = await this.#readConfig();
|
||||||
@@ -442,17 +455,24 @@ class DingtianController
|
|||||||
const relayPassword = 1 + (rand16() % 9999); // 1..9999 (0 = none)
|
const relayPassword = 1 + (rand16() % 9999); // 1..9999 (0 = none)
|
||||||
|
|
||||||
rc.relay_pw = relayPassword;
|
rc.relay_pw = relayPassword;
|
||||||
// Keep UDP1=Binary (p:1) for relay control, UDP2=String (p:0) for status.
|
// Keep ONLY UDP1=Binary (p:1) — it carries relay_pw for both control AND the
|
||||||
// Disable everything else (p:255 = None).
|
// 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.udp1 as Record<string, unknown>).p = 1;
|
||||||
(rc.udp2 as Record<string, unknown>).p = 0;
|
(rc.udp2 as Record<string, unknown>).p = 255;
|
||||||
(rc.rs485 as Record<string, unknown>).p = 255;
|
(rc.rs485 as Record<string, unknown>).p = 255;
|
||||||
(rc.can as Record<string, unknown>).p = 255;
|
(rc.can as Record<string, unknown>).p = 255;
|
||||||
(rc.tcpc as Record<string, unknown>).p = 255;
|
(rc.tcpc as Record<string, unknown>).p = 255;
|
||||||
(rc.tcps as Record<string, unknown>).p = 255;
|
(rc.tcps as Record<string, unknown>).p = 255;
|
||||||
(rc.mqtt as Record<string, unknown>).p = 255;
|
(rc.mqtt as Record<string, unknown>).p = 255;
|
||||||
|
|
||||||
await this.#writeConfig(cfg, (after) => {
|
// 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;
|
const a = after.relay_connect as Record<string, unknown> | undefined;
|
||||||
return (
|
return (
|
||||||
a?.relay_pw === relayPassword &&
|
a?.relay_pw === relayPassword &&
|
||||||
@@ -463,8 +483,20 @@ class DingtianController
|
|||||||
|
|
||||||
const applied = [
|
const applied = [
|
||||||
"set relay password",
|
"set relay password",
|
||||||
"disabled rs485/can/tcp/mqtt channels (kept UDP binary + string)",
|
"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 };
|
const secrets: Record<string, string | number> = { relayPassword };
|
||||||
|
|
||||||
// Rotate the default admin/admin web login. NOTE: cosmetic — this device's
|
// Rotate the default admin/admin web login. NOTE: cosmetic — this device's
|
||||||
@@ -483,7 +515,7 @@ class DingtianController
|
|||||||
stubLog(this.driverId, `web-login rotate skipped: ${(err as Error).message}`);
|
stubLog(this.driverId, `web-login rotate skipped: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { secrets, applied };
|
return { secrets, applied, warnings: warnings.length ? warnings : undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -499,7 +531,7 @@ class DingtianController
|
|||||||
const u = encodeURIComponent(this.#webUser);
|
const u = encodeURIComponent(this.#webUser);
|
||||||
const oldP = encodeURIComponent(this.#webPassword);
|
const oldP = encodeURIComponent(this.#webPassword);
|
||||||
const path = `/userset.cgi?${u}&${oldP}&${u}&${newPassword}&`;
|
const path = `/userset.cgi?${u}&${oldP}&${u}&${newPassword}&`;
|
||||||
const res = await cgiGet(this.#host, this.#httpPort, path, this.#timeout);
|
const res = await cgiGet(this.#host, this.#httpPort, path, this.#timeout, this.#localAddress);
|
||||||
// "&0&/&" = success; anything else (e.g. "&-5&/&" bad params / wrong old pw).
|
// "&0&/&" = success; anything else (e.g. "&-5&/&" bad params / wrong old pw).
|
||||||
const code = res.split("&")[1];
|
const code = res.split("&")[1];
|
||||||
if (code !== "0") {
|
if (code !== "0") {
|
||||||
@@ -511,7 +543,7 @@ class DingtianController
|
|||||||
// --- 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, this.#sessionId);
|
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>;
|
return JSON.parse(raw) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +559,7 @@ class DingtianController
|
|||||||
async #writeConfig(
|
async #writeConfig(
|
||||||
cfg: Record<string, unknown>,
|
cfg: Record<string, unknown>,
|
||||||
verify: (after: Record<string, unknown>) => boolean,
|
verify: (after: Record<string, unknown>) => boolean,
|
||||||
): Promise<void> {
|
): Promise<Record<string, unknown>> {
|
||||||
// The set endpoint requires `"command":"setconfig"` injected after `status`
|
// The set endpoint requires `"command":"setconfig"` injected after `status`
|
||||||
// (the GET payload omits it). Rebuild preserving node order, command second.
|
// (the GET payload omits it). Rebuild preserving node order, command second.
|
||||||
const out: Record<string, unknown> = {};
|
const out: Record<string, unknown> = {};
|
||||||
@@ -543,7 +575,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, this.#sessionId);
|
await configApi(this.#host, this.#httpPort, "/api/v2/config_set.cgi", "POST", payload, this.#timeout, this.#sessionId, this.#localAddress);
|
||||||
} catch {
|
} catch {
|
||||||
// device likely reset on apply
|
// device likely reset on apply
|
||||||
}
|
}
|
||||||
@@ -552,7 +584,8 @@ class DingtianController
|
|||||||
for (let i = 0; i < 12; i++) {
|
for (let i = 0; i < 12; i++) {
|
||||||
await sleep(2000);
|
await sleep(2000);
|
||||||
try {
|
try {
|
||||||
if (verify(await this.#readConfig())) return; // applied
|
const after = await this.#readConfig();
|
||||||
|
if (verify(after)) return after; // applied — return the landed config
|
||||||
} catch {
|
} catch {
|
||||||
// still rebooting / unreachable — keep polling
|
// still rebooting / unreachable — keep polling
|
||||||
}
|
}
|
||||||
@@ -581,21 +614,35 @@ class DingtianController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Query "00" → parse "0000:1111:4" into relays/inputs/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> {
|
async #status(): Promise<DingtianStatus> {
|
||||||
const reply = await udpRequest(this.#host, this.#port, "00", this.#timeout, true);
|
const frame = readStatusFrame(this.#relayPassword);
|
||||||
if (!reply) throw new Error("dingtian: empty status reply");
|
const reply = await binaryUdp(this.#host, this.#binaryPort, frame, this.#timeout, this.#localAddress);
|
||||||
const [relayStr, inputStr, countStr] = reply.trim().split(":");
|
const width = Math.max(1, Math.ceil(this.#channels / 8));
|
||||||
if (relayStr === undefined || inputStr === undefined) {
|
// header: FF AA session 00 (4 bytes) + relay field + input field
|
||||||
throw new Error(`dingtian: bad status reply "${reply}"`);
|
if (reply.length < 4 + width * 2) {
|
||||||
|
throw new Error(`dingtian: short binary status reply (${reply.length} bytes)`);
|
||||||
}
|
}
|
||||||
const bit = (c: string) => c === "1";
|
const relayVal = reply.readUIntLE(4, width);
|
||||||
return {
|
const inputVal = reply.readUIntLE(4 + width, width);
|
||||||
relays: [...relayStr].map(bit),
|
const relays: boolean[] = [];
|
||||||
// active = differs from the resting level (press pulls the line).
|
const inputs: boolean[] = [];
|
||||||
inputs: [...inputStr].map((c) => bit(c) !== this.#restingHigh),
|
for (let i = 0; i < this.#channels; i++) {
|
||||||
channels: countStr ? Number(countStr) : this.#channels,
|
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 {
|
#startPolling(): void {
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ export interface HardenResult {
|
|||||||
readonly secrets: Record<string, string | number>;
|
readonly secrets: Record<string, string | number>;
|
||||||
/** Human-readable summary of what was changed (for logging/UI). */
|
/** Human-readable summary of what was changed (for logging/UI). */
|
||||||
readonly applied: string[];
|
readonly applied: string[];
|
||||||
|
/** Hardening steps that could NOT be applied (e.g. a firmware quirk), so the
|
||||||
|
* admin knows a residual risk remains. Best-effort steps report here instead
|
||||||
|
* of failing the whole harden. */
|
||||||
|
readonly warnings?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isHardenable(device: Device): device is Device & HardenableDevice {
|
export function isHardenable(device: Device): device is Device & HardenableDevice {
|
||||||
|
|||||||
@@ -24,3 +24,64 @@ It only becomes trustworthy as an external fraud control when paired with [[reco
|
|||||||
against an authority the operator can't alter. Every device event — including those ingested
|
against an authority the operator can't alter. Every device event — including those ingested
|
||||||
from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side
|
from the [[uhppote-controller]] via [[event-log-ingestion]] — should land in this host-side
|
||||||
chain.
|
chain.
|
||||||
|
|
||||||
|
## Implementation (apps/server)
|
||||||
|
|
||||||
|
> Implementation-derived. The schema (`packages/db` `events`) and types
|
||||||
|
> (`packages/shared` `ParkingEvent`) predate this; the writer/signer are new.
|
||||||
|
|
||||||
|
- **`EventLog`** (`apps/server/src/event-log.ts`) is the append primitive. `append()` reads the
|
||||||
|
latest row, sets `index = prev + 1`, `prevHash = sha256(canonical(prev))` (genesis = null),
|
||||||
|
signs the canonical form, and inserts. There are **no update/delete paths**.
|
||||||
|
- **Serialized appends.** SQLite is single-writer, but read-prev → compute-hash → insert is
|
||||||
|
multi-step, so `EventLog` also guards it with an in-process async lock — otherwise two near-
|
||||||
|
simultaneous events could claim the same `index` or chain off a stale `prevHash`. Verified:
|
||||||
|
5 concurrent appends produced indices 1..5 with an intact chain.
|
||||||
|
- **Canonical form** is a fixed-order JSON array (`index,type,direction,lane,source,identity,
|
||||||
|
occurredAt,prevHash`) — byte-stable, since the chain + signatures depend on it. The volatile
|
||||||
|
row `id` is excluded; chain identity is `index` + content.
|
||||||
|
- **`verifyChain()`** walks oldest→newest, recomputing hashes + signatures. Catches tampered
|
||||||
|
content (bad signature), reordering / a deleted row (`index` gap), and a `prevHash` mismatch.
|
||||||
|
Exposed at `GET /api/events/verify` (admin). Read access to the log: `GET /api/events`.
|
||||||
|
|
||||||
|
### The `Signer` abstraction (software now, ATECC608 later)
|
||||||
|
|
||||||
|
Signing goes through a **`Signer`** interface (`packages/shared`) — the abstraction over the
|
||||||
|
[[atecc608]]. Because the chip being wired is still [[open-questions|open-question #6]], the
|
||||||
|
server ships a **`SoftwareSigner`** (HMAC-SHA256, key from `EVENT_SIGNING_KEY`). Swapping to the
|
||||||
|
secure element is a new `Signer` impl with no `EventLog` change; each event stores its `keyId`
|
||||||
|
so old events stay verifiable.
|
||||||
|
|
||||||
|
> ⚠️ The software signer makes the chain **self-consistent + tamper-evident**, but **not
|
||||||
|
> unforgeable by someone who owns the host** — only the ATECC608's non-extractable key gives
|
||||||
|
> property (3) above. Until the chip is wired, the chain detects tampering by *outsiders* and
|
||||||
|
> *accidental* corruption, but an operator with the signing key + DB access could re-sign a
|
||||||
|
> forged chain. This is the central reason #6 matters.
|
||||||
|
|
||||||
|
### What currently feeds the log
|
||||||
|
|
||||||
|
Dingtian **input (button) pushes** → bus → `input_received` events (see [[device-input-flow]],
|
||||||
|
[[dingtian-relay]]). These are recorded faithfully as raw inputs, **not** as `vehicle_entry` —
|
||||||
|
the richer entry event waits for the entry flow (ticket print + barrier command). Device→lane
|
||||||
|
mapping is still a TODO (logged with `lane: 0`).
|
||||||
|
|
||||||
|
### ⚠️ Limitation: the log captures HOST-ORIGINATED actions only
|
||||||
|
|
||||||
|
The event log records what the **host** did (inputs it received, opens it commanded). It is
|
||||||
|
**blind to out-of-band relay actuation** — anything that fires a relay without going through the
|
||||||
|
host. **Proven on hardware**: a binary relay command sent directly to the device with the
|
||||||
|
(sniffable) `relay_pw` fired a relay and produced **zero** events. Out-of-band paths include:
|
||||||
|
|
||||||
|
- the **password-less string protocol** (until disabled — see [[dingtian-relay]]),
|
||||||
|
- a **sniffed/replayed `relay_pw`** binary command (plaintext UDP — relay control is
|
||||||
|
defence-in-depth, **not** a boundary),
|
||||||
|
- the device's own **`ip_watchdog`** (auto-toggles a relay on ping-failure — must stay disabled),
|
||||||
|
- a future **`barrier_open_command`** path is host-side and *would* log; these bypass it.
|
||||||
|
|
||||||
|
So the log alone does **not** detect operator/attacker fraud at the relay. That is **by design** —
|
||||||
|
the actual control is [[reconciliation]]: compare the host's signed *commanded* opens against an
|
||||||
|
**independent witness** of opens that physically happened (a door/loop sensor on a Dingtian input
|
||||||
|
→ which DOES push + log; the [[lpr-camera]]; payment/Z-report). **A physical open with no matching
|
||||||
|
signed command is the fraud signal.** Both the witness sources and the reconciliation logic are
|
||||||
|
**NOT yet built** — this is the main open gap. Prevention (VLAN isolation so the attacker can't
|
||||||
|
reach UDP 60000) is the necessary first line; detection-via-reconciliation is the backstop.
|
||||||
|
|||||||
@@ -79,17 +79,42 @@ the relay via UDP. See [[device-input-flow]] for the full path + trust model.
|
|||||||
> real path** — lower latency, and it can be authenticated (the device supports Basic/Digest +
|
> real path** — lower latency, and it can be authenticated (the device supports Basic/Digest +
|
||||||
> HTTPS on the push), unlike the open UDP control direction.
|
> HTTPS on the push), unlike the open UDP control direction.
|
||||||
|
|
||||||
|
### What it pushes vs. doesn't (logging)
|
||||||
|
|
||||||
|
- **Inputs (buttons): YES, pushed.** Input changes are HTTP-pushed via `input_link_url` and now
|
||||||
|
land in the host's signed [[append-only-event-chain]] as `input_received` events (bus →
|
||||||
|
`EventLog`). That is the audit trail for "a button fired."
|
||||||
|
- **Relay / barrier opens: NO push, no log.** The device has **no event log of its own** and does
|
||||||
|
not report when a relay fires — relay control is one-way UDP that the *host* initiates. So
|
||||||
|
"the barrier opened" is not something to scrape from the device. The host records what it
|
||||||
|
*commanded* (a future `barrier_open_command` event); a relay open with **no matching signed
|
||||||
|
host event is itself the anomaly** to alarm on ([[threat-model]]). Do not treat the Dingtian as
|
||||||
|
a log source — it is a dumb relay+input board; the host is the source of truth.
|
||||||
|
|
||||||
## Hardening (`harden()`) — and why HTTP auth is not a boundary here
|
## Hardening (`harden()`) — and why HTTP auth is not a boundary here
|
||||||
|
|
||||||
On assign the driver runs `harden()` (the [[device-registry|HardenableDevice]] capability):
|
On assign the driver runs `harden()` (the [[device-registry|HardenableDevice]] capability):
|
||||||
1. **`relay_pw`** — set a random relay password so binary relay commands (UDP 60000) need it.
|
1. **`relay_pw`** — set a random relay password so binary relay commands (UDP 60000) need it.
|
||||||
2. **Disable unused channels** — set `p:255` on rs485/can/tcp×2/mqtt; keep only UDP1 binary
|
2. **Disable EVERY other channel** — set `p:255` on the string protocol (udp2), rs485, can,
|
||||||
(relay control) + UDP2 string (status read).
|
tcp×2, mqtt; keep **only** UDP1 binary, which carries `relay_pw` for both control AND status.
|
||||||
3. **Rotate the `admin`/`admin` web login** — `GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>&`
|
3. **Rotate the `admin`/`admin` web login** — `GET /userset.cgi?<old_u>&<old_p>&<new_u>&<new_p>&`
|
||||||
(response `&0&…&` = success, verified on hardware). The new password is stored back in
|
(response `&0&…&` = success, verified on hardware). The new password is stored back in
|
||||||
config (`webUser`/`webPassword`) so a re-run can rotate again (the device checks the *old*
|
config (`webUser`/`webPassword`) so a re-run can rotate again (the device checks the *old*
|
||||||
creds). This step is **best-effort** — a failure logs and does not fail the assign.
|
creds). This step is **best-effort** — a failure logs and does not fail the assign.
|
||||||
|
|
||||||
|
> ⚠️ **The string protocol (udp2) is a password-less relay-fire path — the original `harden()`
|
||||||
|
> left it ENABLED "for status reads", which was a real hole.** The Dingtian string protocol has
|
||||||
|
> NO password field and can fire relays (`"11"` = relay 1 on, `"21"` = off, `"11*"` = jog).
|
||||||
|
> **Proven on hardware**: sending `"11"` to UDP 60001 with no credentials opened relay 1,
|
||||||
|
> completely bypassing `relay_pw`. Fixes: (a) status reads moved to the **authenticated binary
|
||||||
|
> read** (relay command `0x00`) so the string protocol is no longer needed; (b) `harden()` now
|
||||||
|
> sets `udp2.p=255` to disable it. **Firmware caveat (V3.6J):** the CONFIG API silently refuses
|
||||||
|
> to disable udp2 — it accepts the write, reboots, and clamps it back — even though the device's
|
||||||
|
> **web UI can** disable it. So the udp2 disable is **best-effort + warns** (it is NOT part of the
|
||||||
|
> blocking verify); if it doesn't stick, `harden()` returns a warning telling the admin to flip
|
||||||
|
> UDP2 off in the device web UI. Verified: after the web-UI disable, the `"11"` attack gets no
|
||||||
|
> reply and the relay stays off, while authenticated binary control/status still work.
|
||||||
|
|
||||||
> ⚠️ **The device CGI API is UNAUTHENTICATED.** Verified on hardware: `GET /api/v2/config.cgi`,
|
> ⚠️ **The device CGI API is UNAUTHENTICATED.** Verified on hardware: `GET /api/v2/config.cgi`,
|
||||||
> `/`, and even `/userset.cgi` all return **200 with no credentials**. The `admin`/`admin` login
|
> `/`, and even `/userset.cgi` all return **200 with no credentials**. The `admin`/`admin` login
|
||||||
> gates only the interactive **browser UI** — the CGI control plane (read/write full config, fire
|
> gates only the interactive **browser UI** — the CGI control plane (read/write full config, fire
|
||||||
|
|||||||
+36
@@ -238,3 +238,39 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section).
|
|||||||
- Verified via Fastify inject: 2 printers assigned to one lane -> both listed, no secret leak,
|
- Verified via Fastify inject: 2 printers assigned to one lane -> both listed, no secret leak,
|
||||||
delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks (8/8).
|
delete -> 204, delete unknown -> 404, count drops to 1. Full repo typechecks (8/8).
|
||||||
- Updated [[first-run-setup]].
|
- Updated [[first-run-setup]].
|
||||||
|
|
||||||
|
## [2026-06-15] ingest | Append-only signed event log (Dingtian input pushes persist)
|
||||||
|
- Q: does the Dingtian push events? -> inputs YES (input_link_url), relay opens NO (device keeps
|
||||||
|
no log). Host is the source of truth; a relay open w/o matching signed event is the anomaly.
|
||||||
|
- Implemented EventLog (apps/server/event-log.ts): serialized append, monotonic index, prevHash
|
||||||
|
chain, signature; verifyChain() detects tamper/reorder/delete. Read: GET /api/events;
|
||||||
|
integrity: GET /api/events/verify (admin).
|
||||||
|
- Signer abstraction (packages/shared) over the ATECC608; SoftwareSigner (HMAC, EVENT_SIGNING_KEY)
|
||||||
|
shipped now since chip wiring is open-question #6. Caveat documented: software signer is
|
||||||
|
tamper-evident but NOT unforgeable-by-owner.
|
||||||
|
- Wired bus -> log: Dingtian input pushes become input_received events (lane mapping TODO).
|
||||||
|
- Added ParkingEventType 'input_received'.
|
||||||
|
- Verified via inject: push w/o digest -> 401; pushes -> 2 signed+chained events; verify -> ok;
|
||||||
|
direct DB tamper -> verifyChain catches at the right index; deleted row -> index gap. 5 concurrent
|
||||||
|
appends -> indices 1..5 intact. Full repo typechecks.
|
||||||
|
- Updated [[append-only-event-chain]], [[dingtian-relay]].
|
||||||
|
|
||||||
|
## [2026-06-15] ingest | Event log + Dingtian string-protocol security fix
|
||||||
|
- Append-only signed event log shipped (EventLog, Signer abstraction over ATECC608 w/ SoftwareSigner
|
||||||
|
HMAC; GET /api/events + /api/events/verify). Dingtian input pushes persist as input_received.
|
||||||
|
Verified on hardware: shorting I1-I4 -> 8 signed+chained events, verifyChain ok.
|
||||||
|
- SECURITY (verified on hardware): the password-less string protocol (udp2) can fire relays
|
||||||
|
("11" -> relay1 on) with NO auth, bypassing relay_pw. Fixes: status reads moved to authenticated
|
||||||
|
binary read (cmd 0x00); harden() disables udp2 BEST-EFFORT (firmware V3.6J config API refuses,
|
||||||
|
but web UI works) and returns a warning instead of throwing. After web-UI disable, the "11" attack
|
||||||
|
is dead and binary control/status still work.
|
||||||
|
- GAP (user-identified): event log captures host-originated actions only; out-of-band relay
|
||||||
|
actuation (sniffed relay_pw, string protocol, ip_watchdog) produces NO event — proven on hardware.
|
||||||
|
Real control is reconciliation vs. an independent witness; witness+reconciliation NOT yet built.
|
||||||
|
- Device web login (webUser/webPassword) now un-redacted in setup state (admin-only device area);
|
||||||
|
pushPassword/relayPassword stay machine-only.
|
||||||
|
- harden() warnings surfaced via the assign response.
|
||||||
|
- localAddress threaded through the Dingtian driver (device-facing-IP foundation; multi-homed hosts).
|
||||||
|
- INCIDENT: probing default.cgi factory-reset the bench device (now at 192.168.1.100, defaults).
|
||||||
|
Re-provisioning is the ADMIN's job via First-run setup (app must not hardcode site IPs).
|
||||||
|
- Updated [[append-only-event-chain]], [[dingtian-relay]].
|
||||||
|
|||||||
Reference in New Issue
Block a user