Dingtian harden(): rotate the admin/admin web login (cosmetic)
harden() now rotates the device's default admin/admin web-UI login via GET /userset.cgi?<old>&<old>&<new>&<new>& (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.
This commit is contained in:
@@ -215,8 +215,8 @@ export async function setupRoutes(app: FastifyInstance, db: Db): Promise<void> {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
await db.insert(laneDevices).values(row);
|
await db.insert(laneDevices).values(row);
|
||||||
// Don't echo the push secret back.
|
// Don't echo device secrets back (push Digest password, web-UI login).
|
||||||
const { pushPassword: _omit, ...safeConfig } = fullConfig;
|
const { pushPassword: _pw, webPassword: _wp, ...safeConfig } = fullConfig;
|
||||||
return reply.code(201).send({ ...row, config: safeConfig });
|
return reply.code(201).send({ ...row, config: safeConfig });
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -150,6 +150,20 @@ 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. */
|
||||||
|
function cgiGet(host: string, httpPort: number, path: string, timeoutMs: number): Promise<string> {
|
||||||
|
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 {
|
interface DingtianStatus {
|
||||||
relays: boolean[]; // true = on
|
relays: boolean[]; // true = on
|
||||||
inputs: boolean[]; // true = active (after resting-level normalisation)
|
inputs: boolean[]; // true = active (after resting-level normalisation)
|
||||||
@@ -228,6 +242,9 @@ class DingtianController
|
|||||||
/** 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;
|
||||||
readonly #pulseMs: number;
|
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<typeof setInterval> | null = null;
|
#poll: ReturnType<typeof setInterval> | null = null;
|
||||||
#last: boolean[] | 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 unit idles with inputs HIGH (status "1111"); a press pulls LOW.
|
||||||
this.#restingHigh = config.inputRestingHigh !== false;
|
this.#restingHigh = config.inputRestingHigh !== false;
|
||||||
this.#pulseMs = config.pulseMs ? Number(config.pulseMs) : 500;
|
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<void> {
|
async connect(): Promise<void> {
|
||||||
@@ -439,13 +461,51 @@ class DingtianController
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
const applied = [
|
||||||
secrets: { relayPassword },
|
"set relay password",
|
||||||
applied: [
|
"disabled rs485/can/tcp/mqtt channels (kept UDP binary + string)",
|
||||||
"set relay password",
|
];
|
||||||
"disabled rs485/can/tcp/mqtt channels (kept UDP binary + string)",
|
const secrets: Record<string, string | number> = { 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?<old_user>&<old_pass>&<new_user>&<new_pass>&`. 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 `&<code>&<redirect>&` with code 0 = success. Password is hex
|
||||||
|
* (URL-safe, no escaping) and ≤31 chars (the device truncates longer).
|
||||||
|
*/
|
||||||
|
async #rotateWebLogin(): Promise<string> {
|
||||||
|
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 ----------------------------------------------
|
// --- config api internals ----------------------------------------------
|
||||||
@@ -604,6 +664,11 @@ export const dingtianDriver: AccessDriver = {
|
|||||||
help: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
|
help: "This board idles inputs HIGH (status 1111); a press pulls LOW.",
|
||||||
},
|
},
|
||||||
{ key: "timeoutMs", label: "Timeout (ms)", type: "number", required: false, default: 2000 },
|
{ 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),
|
create: (c) => new DingtianController(c),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
type: entity
|
type: entity
|
||||||
tags: [parking, hardware, access-control, relay]
|
tags: [parking, hardware, access-control, relay]
|
||||||
sources: []
|
sources: []
|
||||||
updated: 2026-06-15
|
updated: 2026-06-14
|
||||||
---
|
---
|
||||||
|
|
||||||
# Dingtian Relay Controller
|
# Dingtian Relay Controller
|
||||||
@@ -49,8 +49,8 @@ see [[dingtian-vs-mqtt]].
|
|||||||
The `dingtian` driver ([[device-registry]]) implements three capabilities:
|
The `dingtian` driver ([[device-registry]]) implements three capabilities:
|
||||||
`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based
|
`AccessControlDevice` (relay pulse/latch over UDP), `InputDevice` (read inputs + poll-based
|
||||||
press/release events ~50 ms), and `PreconditionDevice` (below). Config fields include a separate
|
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**,
|
**`httpPort`** — the device's web/config API is on a configurable HTTP port (default **80**),
|
||||||
not the default 80), distinct from the UDP control port 60001.
|
distinct from the UDP control port 60001.
|
||||||
|
|
||||||
### Precondition: input_link_relay must be OFF
|
### 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 +
|
> 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.
|
||||||
|
|
||||||
|
## 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?<old_u>&<old_p>&<new_u>&<new_p>&`
|
||||||
|
(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 — 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).
|
- ✅ 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,
|
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 →
|
source = the device IP). No polling. Host-in-the-loop entry (`button → backend → ticket →
|
||||||
backend opens relay`) is real.
|
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`).
|
- ⬜ Next: wire the actual entry flow (input event → signed event + print ticket → `pulseOpen`).
|
||||||
|
|||||||
+13
@@ -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
|
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.
|
API is accepted as flat-network reality; the signed log is the real guarantee.
|
||||||
Recorded in [[device-input-flow]] + [[dingtian-relay]].
|
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?<old_u>&<old_p>&<new_u>&<new_p>&` (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).
|
||||||
|
|||||||
Reference in New Issue
Block a user