Dingtian web password: set the admin's chosen password, verified

Fix two bugs found running the real assign flow: the saved web password
didn't match the device (login stayed admin/admin), and the UDP2 warning
never reached the admin.

Web password:
- Split the conflated field into webPassword (the DESIRED login; blank ->
  auto-generate) and webPasswordCurrent (the device's EXISTING password used
  as the old cred, default admin). Before, an admin typing a desired password
  made harden send it as the old cred -> rotation failed -> but the DB still
  saved the typed value, so it claimed a password the device never accepted.
- harden() now rotates current -> desired, VERIFIES by re-authenticating with
  the new password, and only returns secrets.webPassword on success (else a
  warning, nothing saved). Stores webPasswordCurrent for future re-runs.
- assign strips the typed webPassword/webPasswordCurrent and persists only the
  verified secret -- the DB never claims an unapplied password.

Warnings to the UI:
- assignDevice returns warnings[]; SetupWizard shows them in an amber
  "saved, but action needed" banner per category. This is how the admin learns
  the firmware wouldn't disable UDP2 (finish in the device web UI).

Verified on hardware: after harden the device rejects admin/admin and accepts
the chosen password; the UDP2 warning surfaces.
This commit is contained in:
2026-06-15 12:26:34 +02:00
parent 7db5cfa0e4
commit f5fd61984a
6 changed files with 140 additions and 38 deletions
+67 -31
View File
@@ -243,9 +243,15 @@ 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). */
/** Device web-UI login user (gates the browser UI only, not the CGI API). */
readonly #webUser: string;
readonly #webPassword: string;
/** The password the admin WANTS the device to have (the rotation target). If
* blank, harden() generates a random one. */
readonly #webPassword: string | undefined;
/** The device's CURRENT password, used as the OLD cred for userset.cgi. Defaults
* to "admin" (factory). Distinct from #webPassword (the desired new value) so an
* admin typing a desired password doesn't break rotation. */
readonly #webPasswordCurrent: string;
#poll: ReturnType<typeof setInterval> | null = null;
#last: boolean[] | null = null;
@@ -264,11 +270,15 @@ 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";
// webPassword = the DESIRED login (admin's choice; blank → harden generates).
this.#webPassword = config.webPassword ? String(config.webPassword) : undefined;
// webPasswordCurrent = the device's EXISTING password (the old cred userset.cgi
// checks). Defaults to admin (factory). After a successful rotation, assign
// stores the new value back here so a re-run can rotate again.
this.#webPasswordCurrent = config.webPasswordCurrent
? String(config.webPasswordCurrent)
: "admin";
}
async connect(): Promise<void> {
@@ -499,43 +509,66 @@ class DingtianController
}
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.
// Set the device web login to the admin's chosen password (or a random one).
// NOTE: cosmetic for the control plane — the CGI API needs NO auth (config
// read/write + relay fire all work unauthenticated), so the login only gates
// the interactive browser UI. We set it anyway (defence-in-depth) but it is
// NOT a boundary; the signed event log is. See dingtian-relay.md.
//
// CRITICAL: only persist webPassword if the rotation VERIFIABLY took effect.
// Otherwise the DB would claim a password the device doesn't have (the bug:
// admin types a new pw, rotation fails on the wrong old-cred, DB still saves
// the typed value, login stays admin/admin). On failure we warn instead.
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)");
// The new password is now the device's CURRENT one — store it so a future
// re-harden uses the right old cred.
secrets.webPasswordCurrent = newPassword;
applied.push("set the device web-UI login (verified on the device)");
} 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}`);
warnings.push(
`could not set the device web-UI login: ${(err as Error).message} ` +
`The device login is UNCHANGED (still its previous password). The saved web password was NOT updated.`,
);
}
return { secrets, applied, warnings: warnings.length ? warnings : undefined };
}
/**
* 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).
* Set the device web-UI login to the DESIRED password (the admin's choice, or a
* random one if none was given) via
* `userset.cgi?<user>&<old_pass>&<user>&<new_pass>&`. The device validates the
* OLD credentials, so we send #webPasswordCurrent (admin on a fresh device).
* Response `&<code>&…&`, code 0 = success.
*
* After the rotation we VERIFY by attempting a no-op rotate using the NEW
* password as the old cred — if that succeeds, the device really has the new
* password (this is what catches the "DB says X but device is still admin/admin"
* bug: a wrong old-cred makes the first call fail, and we never claim success).
* Returns the password now live on the device.
*/
async #rotateWebLogin(): Promise<string> {
const newPassword = randomBytes(12).toString("hex"); // 24 hex chars
const newPassword = this.#webPassword ?? randomBytes(12).toString("hex");
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, this.#localAddress);
// "&0&/&" = success; anything else (e.g. "&-5&/&" bad params / wrong old pw).
const setPath = (oldP: string, newP: string) =>
`/userset.cgi?${u}&${encodeURIComponent(oldP)}&${u}&${encodeURIComponent(newP)}&`;
const res = await cgiGet(this.#host, this.#httpPort, setPath(this.#webPasswordCurrent, newPassword), this.#timeout, this.#localAddress);
const code = res.split("&")[1];
if (code !== "0") {
throw new Error(`userset.cgi rejected (response "${res.trim()}")`);
throw new Error(
`userset.cgi rejected (response "${res.trim()}") — the device's current password is probably not "${this.#webPasswordCurrent}". ` +
`Set the correct current password, or factory-reset the device.`,
);
}
// VERIFY: a no-op rotate (new → new) only succeeds if the device truly has it.
const verify = await cgiGet(this.#host, this.#httpPort, setPath(newPassword, newPassword), this.#timeout, this.#localAddress);
if (verify.split("&")[1] !== "0") {
throw new Error(`web-login change did not take effect (verify response "${verify.trim()}")`);
}
return newPassword;
}
@@ -711,11 +744,14 @@ 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.)
// Device web-UI login. webPassword = the password you WANT (blank → a random
// one is generated). webPasswordCurrent = the device's EXISTING password, used
// as the old credential to change it (defaults to "admin" on a fresh device).
// On a verified change, the new password is stored as both the saved login and
// the current one. (Gates only the browser UI — CGI control plane is open.)
{ 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)." },
{ key: "webPassword", label: "New device web password", type: "secret", required: false, help: "The password to SET on the device web UI. Leave blank to auto-generate. Applied + verified on save." },
{ key: "webPasswordCurrent", label: "Current device web password", type: "secret", required: false, help: "The device's existing web password (default admin on a fresh device). Needed to change it." },
],
create: (c) => new DingtianController(c),
};