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:
@@ -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<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 {
|
||||
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<typeof setInterval> | 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<void> {
|
||||
@@ -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<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 ----------------------------------------------
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user