From 2a86e578a8611c395ccc07b39dacd492cc5a6b3a Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sun, 14 Jun 2026 19:00:42 +0200 Subject: [PATCH] Dingtian harden(): rotate the admin/admin web login (cosmetic) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit harden() now rotates the device's default admin/admin web-UI login via GET /userset.cgi?&&&& (best-effort: a failure logs and doesn't fail the assign). The new password is stored back in config (webUser/webPassword) so a re-run can rotate again, and is stripped from the assign response like the push secret. Documented the load-bearing caveat: this device's CGI API is fully UNAUTHENTICATED — config read/write, relay fire, and userset.cgi itself all return 200 with no credentials (verified on hardware). admin/admin gates only the browser UI, and there's no inbound-auth setting (only session_en, which bricks the read API). So the rotation is defence-in- depth for the UI, NOT a boundary; the signed event log remains the real anti-fraud guarantee. Verified rotation end-to-end on 10.0.10.5 (success &0&, wrong-old-pw &2&); device left at admin/admin. --- apps/server/src/routes/setup.ts | 4 +- .../devices/src/drivers/access-dingtian.ts | 79 +++++++++++++++++-- wiki/entities/dingtian-relay.md | 34 +++++++- wiki/log.md | 13 +++ 4 files changed, 118 insertions(+), 12 deletions(-) diff --git a/apps/server/src/routes/setup.ts b/apps/server/src/routes/setup.ts index aa3342f..2898048 100644 --- a/apps/server/src/routes/setup.ts +++ b/apps/server/src/routes/setup.ts @@ -215,8 +215,8 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise { enabled: true, }; await db.insert(laneDevices).values(row); - // Don't echo the push secret back. - const { pushPassword: _omit, ...safeConfig } = fullConfig; + // Don't echo device secrets back (push Digest password, web-UI login). + const { pushPassword: _pw, webPassword: _wp, ...safeConfig } = fullConfig; return reply.code(201).send({ ...row, config: safeConfig }); }, ); diff --git a/packages/devices/src/drivers/access-dingtian.ts b/packages/devices/src/drivers/access-dingtian.ts index 00e4ea2..1165194 100644 --- a/packages/devices/src/drivers/access-dingtian.ts +++ b/packages/devices/src/drivers/access-dingtian.ts @@ -150,6 +150,20 @@ function writeRelayFrame(channel: number, on: boolean, password: number, channel const rand16 = () => randomBytes(2).readUInt16BE(0); +/** 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 { + return new Promise((resolve, reject) => { + const req = httpRequest({ host, port: httpPort, path, method: "GET", timeout: timeoutMs }, (res) => { + let data = ""; + res.on("data", (c) => (data += c)); + res.on("end", () => resolve(data)); + }); + req.on("error", reject); + req.on("timeout", () => req.destroy(new Error("cgi timeout"))); + req.end(); + }); +} + interface DingtianStatus { relays: boolean[]; // true = on inputs: boolean[]; // true = active (after resting-level normalisation) @@ -228,6 +242,9 @@ class DingtianController /** Input level at rest; an input is "active" when it differs from this. */ readonly #restingHigh: boolean; readonly #pulseMs: number; + /** Current device web-UI login (gates the browser UI only, not the CGI API). */ + readonly #webUser: string; + readonly #webPassword: string; #poll: ReturnType | null = null; #last: boolean[] | null = null; @@ -245,6 +262,11 @@ class DingtianController // This unit idles with inputs HIGH (status "1111"); a press pulls LOW. this.#restingHigh = config.inputRestingHigh !== false; this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500; + // The device ships with admin/admin. After harden() rotates it, the new + // creds are stored back in config so a re-created driver knows the current + // login (needed to rotate again — userset.cgi checks the old credentials). + this.#webUser = config.webUser ? String(config.webUser) : "admin"; + this.#webPassword = config.webPassword ? String(config.webPassword) : "admin"; } async connect(): Promise { @@ -439,13 +461,51 @@ class DingtianController ); }); - return { - secrets: { relayPassword }, - applied: [ - "set relay password", - "disabled rs485/can/tcp/mqtt channels (kept UDP binary + string)", - ], - }; + const applied = [ + "set relay password", + "disabled rs485/can/tcp/mqtt channels (kept UDP binary + string)", + ]; + const secrets: Record = { relayPassword }; + + // Rotate the default admin/admin web login. NOTE: cosmetic — this device's + // CGI API needs NO auth (config read/write + relay fire + this very call all + // work unauthenticated), so the login only gates the interactive browser UI, + // not the control plane. We rotate it anyway (defence-in-depth: stops a + // casual browser reaching the settings page), but it is NOT a boundary; the + // signed event log is. See dingtian-relay.md. + try { + const newPassword = await this.#rotateWebLogin(); + secrets.webUser = this.#webUser; + secrets.webPassword = newPassword; + applied.push("rotated the admin/admin web-UI login (cosmetic — CGI API is unauthenticated)"); + } catch (err) { + // Don't fail the whole harden over a cosmetic step — log and continue. + stubLog(this.driverId, `web-login rotate skipped: ${(err as Error).message}`); + } + + return { secrets, applied }; + } + + /** + * Rotate the device web-UI login password (keeps the username) via + * `userset.cgi?&&&&`. Returns the new + * password. The device validates the OLD credentials in the query, so we send + * the current ones (admin/admin on first run, the stored pair afterwards). + * Response is `&&&` with code 0 = success. Password is hex + * (URL-safe, no escaping) and ≤31 chars (the device truncates longer). + */ + async #rotateWebLogin(): Promise { + const newPassword = randomBytes(12).toString("hex"); // 24 hex chars + const u = encodeURIComponent(this.#webUser); + const oldP = encodeURIComponent(this.#webPassword); + const path = `/userset.cgi?${u}&${oldP}&${u}&${newPassword}&`; + const res = await cgiGet(this.#host, this.#httpPort, path, this.#timeout); + // "&0&/&" = success; anything else (e.g. "&-5&/&" bad params / wrong old pw). + const code = res.split("&")[1]; + if (code !== "0") { + throw new Error(`userset.cgi rejected (response "${res.trim()}")`); + } + return newPassword; } // --- config api internals ---------------------------------------------- @@ -604,6 +664,11 @@ export const dingtianDriver: AccessDriver = { help: "This board idles inputs HIGH (status 1111); a press pulls LOW.", }, { key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 2000 }, + // Current device web-UI login. Defaults to admin/admin; harden() rotates the + // password and stores the new pair back here so a re-run can rotate again. + // (Gates only the browser UI — the CGI control plane is unauthenticated.) + { key: "webUser", label: "Device web username", type: "string", required: false, default: "admin", help: "Device web-UI login user (default admin)." }, + { key: "webPassword", label: "Device web password", type: "secret", required: false, help: "Device web-UI login password (default admin; rotated on save)." }, ], create: (c) => new DingtianController(c), }; diff --git a/wiki/entities/dingtian-relay.md b/wiki/entities/dingtian-relay.md index cc59773..d4fc452 100644 --- a/wiki/entities/dingtian-relay.md +++ b/wiki/entities/dingtian-relay.md @@ -2,7 +2,7 @@ type: entity tags: [parking, hardware, access-control, relay] sources: [] -updated: 2026-06-15 +updated: 2026-06-14 --- # Dingtian Relay Controller @@ -49,8 +49,8 @@ see [[dingtian-vs-mqtt]]. The `dingtian` driver ([[device-registry]]) implements three capabilities: `AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based press/release events ~50 ms), and `PreconditionDevice` (below). Config fields include a separate -**`httpPort`** — the device's web/config API is on a configurable HTTP port (this unit: **8080**, -not the default 80), distinct from the UDP control port 60001. +**`httpPort`** — the device's web/config API is on a configurable HTTP port (default **80**), +distinct from the UDP control port 60001. ### Precondition: input_link_relay must be OFF @@ -79,6 +79,31 @@ 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 + > HTTPS on the push), unlike the open UDP control direction. +## Hardening (`harden()`) — and why HTTP auth is not a boundary here + +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. +2. **Disable unused channels** — set `p:255` on rs485/can/tcp×2/mqtt; keep only UDP1 binary + (relay control) + UDP2 string (status read). +3. **Rotate the `admin`/`admin` web login** — `GET /userset.cgi?&&&&` + (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* + creds). This step is **best-effort** — a failure logs and does not fail the assign. + +> ⚠️ **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 +> gates only the interactive **browser UI** — the CGI control plane (read/write full config, fire +> relays, change the password) bypasses it entirely. The `http` config block has **no** setting to +> require Basic/Digest on inbound requests; the only inbound gate is `session_en`, which **bricks +> the config-read API on this firmware** (the factory-reset incident — *do not enable it*). So +> **rotating the login is cosmetic** (stops a casual browser reaching settings); it is **not** a +> boundary. On this flat, no-VLAN network the device control plane is effectively open — the +> **signed event log is the real anti-fraud guarantee**. See [[device-input-flow]]. + +> ⚠️ **`session_en` must stay OFF.** Enabling the HTTP CGI session check makes the config-read API +> drop connections (ECONNRESET), locking out the API the driver depends on — recoverable only by +> factory reset. `harden()` deliberately never touches it. + ## Status — VERIFIED on hardware (DT-R004, sw V3.1.5461A, 10.0.10.172) - ✅ status read (`0000:1111:4`), relay pulse, input press/release events (active-LOW, idle HIGH). @@ -89,4 +114,7 @@ the relay via UDP. See [[device-input-flow]] for the full path + trust model. button presses (all 4 inputs) **pushed to the backend** (`/input/N/on` + `/off` per press, source = the device IP). No polling. Host-in-the-loop entry (`button → backend → ticket → backend opens relay`) is real. +- ✅ **Web-login rotation** — `userset.cgi` rotates `admin`/`admin` (response `&0&/&`; wrong old + password → `&2&/&`). Confirmed the device validates the old creds. **Also confirmed the CGI API + needs NO auth** (config dump + `userset.cgi` return 200 unauthenticated) → rotation is cosmetic. - ⬜ Next: wire the actual entry flow (input event → signed event + print ticket → `pulseOpen`). diff --git a/wiki/log.md b/wiki/log.md index 454f595..53fa032 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -192,3 +192,16 @@ firmware breaks the config-READ API (ECONNRESET) — locked us out, needed a FAC 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]]. + +## [2026-06-14] query | Dingtian web-login rotation + CGI API is unauthenticated +While addressing "change the device's default admin/admin", traced the device web +UI JS (system.js) → the change-login endpoint is +`GET /userset.cgi?&&&&` (response `&0&/&` = success, +`&2&/&` = wrong old pw). Added a best-effort `setWebLogin`/`#rotateWebLogin` step +to `harden()` (new pw stored back as config `webPassword`, stripped from API +responses). KEY FINDING: the device CGI API needs NO authentication — config dump, +config write, relay fire, and userset.cgi itself all return 200 unauthenticated +(verified on 10.0.10.5). admin/admin gates only the browser UI; there is no +inbound-auth setting (only session_en, which bricks the read API). So rotating the +login is COSMETIC, not a boundary — the signed event log remains the real +guarantee. Recorded in [[dingtian-relay]] (new Hardening section).