6ceaadfbf2
park-buzi field observation: after a power cut the Hikvision cameras reboot at the 1970 epoch (no/dead RTC battery, no NTP) and stay there until a human logs into the web UI (which silently pushes the browser clock) — corrupting the snapshot OSD timestamps (the evidence trail) and ANPR push times meanwhile. The host is the site's time authority (offline-first, no NTP infra): - Device monitor triggers a sync at each camera's offline→ready edge — exactly the power-restored moment — plus a 24h backstop; the attempt is stamped before the async call so a failing camera retries at backstop cadence, never every poll. - HikvisionCamera.syncClock: GET /ISAPI/System/time; drift ≤60s → leave alone; beyond (or unparseable = infinite drift) → PUT timeMode=manual with the site wall-clock now WITH explicit utc offset (localIsoWithOffset), echoing the camera's timeZone verbatim — correct the clock, never fight its tz/DST config. - Jumps >1h (the power-cut signature) log warn (persisted to app_logs); small corrections info. Capability-guarded (isClockSyncable) — hikvision only; dahua's CGI has no such endpoint. - http-digest generalised to digestRequest (GET/PUT/POST + body); the handshake was already method-aware. digestGet delegates unchanged. 8 new tests: in-sync no-op, 1970 PUT shape (manual + host instant + echoed tz), unparseable→sync, failed-set surfaces, dahua non-capability, DST-both-sides pins on the offset formatter. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
161 lines
5.6 KiB
TypeScript
161 lines
5.6 KiB
TypeScript
import { createHash, randomBytes } from "node:crypto";
|
|
import { request as httpRequest } from "node:http";
|
|
import type { IncomingMessage } from "node:http";
|
|
|
|
// Client-side HTTP Digest auth (RFC 2617, MD5, qop=auth) for talking TO devices
|
|
// that challenge with `WWW-Authenticate: Digest` — e.g. Hikvision ISAPI cameras.
|
|
// (The server-side counterpart, which VERIFIES device→backend pushes, lives in
|
|
// apps/server/src/digest-auth.ts.) Devices on the isolated VLAN can't present a
|
|
// trusted TLS cert, so plain-HTTP Digest is the available auth: the password is
|
|
// never on the wire, only a nonce-keyed hash. See wiki/concepts/network-isolation.md.
|
|
|
|
const md5 = (s: string) => createHash("md5").update(s).digest("hex");
|
|
|
|
/** Parse a `WWW-Authenticate: Digest …` header into its k=v fields. */
|
|
function parseChallenge(header: string): Record<string, string> {
|
|
const out: Record<string, string> = {};
|
|
const re = /(\w+)=(?:"([^"]*)"|([^,]*))/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = re.exec(header))) out[m[1]!] = (m[2] ?? m[3] ?? "").trim();
|
|
return out;
|
|
}
|
|
|
|
/** Build the `Authorization: Digest …` response value for a challenge. */
|
|
function buildAuthHeader(
|
|
c: Record<string, string>,
|
|
user: string,
|
|
password: string,
|
|
method: string,
|
|
uri: string,
|
|
): string {
|
|
const realm = c.realm ?? "";
|
|
const nonce = c.nonce ?? "";
|
|
const qop = c.qop?.split(",")[0]?.trim(); // server may offer "auth,auth-int"
|
|
const ha1 = md5(`${user}:${realm}:${password}`);
|
|
const ha2 = md5(`${method}:${uri}`);
|
|
|
|
const parts: string[] = [
|
|
`username="${user}"`,
|
|
`realm="${realm}"`,
|
|
`nonce="${nonce}"`,
|
|
`uri="${uri}"`,
|
|
];
|
|
|
|
let response: string;
|
|
if (qop === "auth") {
|
|
const cnonce = randomBytes(8).toString("hex");
|
|
const nc = "00000001";
|
|
response = md5(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`);
|
|
parts.push(`qop=${qop}`, `nc=${nc}`, `cnonce="${cnonce}"`);
|
|
} else {
|
|
// Legacy RFC 2069 (no qop) — Hikvision uses qop=auth, but be tolerant.
|
|
response = md5(`${ha1}:${nonce}:${ha2}`);
|
|
}
|
|
parts.push(`response="${response}"`);
|
|
if (c.opaque) parts.push(`opaque="${c.opaque}"`);
|
|
return `Digest ${parts.join(", ")}`;
|
|
}
|
|
|
|
export interface DigestGetResult {
|
|
readonly status: number;
|
|
readonly contentType: string;
|
|
readonly body: Buffer;
|
|
}
|
|
|
|
export interface DigestGetOptions {
|
|
readonly host: string;
|
|
readonly port: number;
|
|
readonly path: string;
|
|
readonly user: string;
|
|
readonly password: string;
|
|
readonly timeoutMs: number;
|
|
/** Bind outbound to the device-facing NIC on a multi-homed host. */
|
|
readonly localAddress?: string;
|
|
}
|
|
|
|
/** digestGet + a method and optional body — for ISAPI configuration writes
|
|
* (e.g. PUT /ISAPI/System/time). The digest handshake is method-aware (HA2
|
|
* hashes the method), so this generalisation is the real one, not a shortcut. */
|
|
export interface DigestRequestOptions extends DigestGetOptions {
|
|
readonly method: "GET" | "PUT" | "POST";
|
|
readonly body?: Buffer | string;
|
|
readonly contentType?: string;
|
|
}
|
|
|
|
function requestOnce(
|
|
o: DigestRequestOptions,
|
|
authHeader?: string,
|
|
): Promise<{ res: IncomingMessage; body: Buffer }> {
|
|
return new Promise((resolve, reject) => {
|
|
const payload = o.body == null ? null : Buffer.isBuffer(o.body) ? o.body : Buffer.from(o.body, "utf8");
|
|
const headers: Record<string, string> = {};
|
|
if (authHeader) headers["authorization"] = authHeader;
|
|
if (payload) {
|
|
headers["content-type"] = o.contentType ?? "application/xml";
|
|
headers["content-length"] = String(payload.length);
|
|
}
|
|
const req = httpRequest(
|
|
{
|
|
host: o.host,
|
|
port: o.port,
|
|
path: o.path,
|
|
method: o.method,
|
|
timeout: o.timeoutMs,
|
|
localAddress: o.localAddress,
|
|
headers,
|
|
},
|
|
(res) => {
|
|
const chunks: Buffer[] = [];
|
|
res.on("data", (c) => chunks.push(c as Buffer));
|
|
res.on("end", () => resolve({ res, body: Buffer.concat(chunks) }));
|
|
},
|
|
);
|
|
req.on("error", reject);
|
|
req.on("timeout", () => req.destroy(new Error(`digest ${o.method} timeout`)));
|
|
if (payload) req.write(payload);
|
|
req.end();
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Request a resource with HTTP Digest auth. Does the standard two-shot handshake:
|
|
* the first request (no Authorization) draws a 401 + challenge, the second
|
|
* carries the computed response (the body is sent BOTH times — the challenge shot
|
|
* needs the same request shape). If the server doesn't challenge (200 straight
|
|
* away, or no auth required), the first response is returned as-is.
|
|
*/
|
|
export async function digestRequest(o: DigestRequestOptions): Promise<DigestGetResult> {
|
|
const first = await requestOnce(o);
|
|
if (first.res.statusCode !== 401) {
|
|
return {
|
|
status: first.res.statusCode ?? 0,
|
|
contentType: String(first.res.headers["content-type"] ?? ""),
|
|
body: first.body,
|
|
};
|
|
}
|
|
|
|
const challengeHeader = String(first.res.headers["www-authenticate"] ?? "");
|
|
if (!/^digest/i.test(challengeHeader)) {
|
|
// 401 but not Digest (e.g. Basic-only) — surface it; caller decides.
|
|
return {
|
|
status: 401,
|
|
contentType: String(first.res.headers["content-type"] ?? ""),
|
|
body: first.body,
|
|
};
|
|
}
|
|
|
|
const challenge = parseChallenge(challengeHeader);
|
|
const auth = buildAuthHeader(challenge, o.user, o.password, o.method, o.path);
|
|
const second = await requestOnce(o, auth);
|
|
return {
|
|
status: second.res.statusCode ?? 0,
|
|
contentType: String(second.res.headers["content-type"] ?? ""),
|
|
body: second.body,
|
|
};
|
|
}
|
|
|
|
/** GET with Digest auth (the original entry point; snapshots and status reads). */
|
|
export function digestGet(o: DigestGetOptions): Promise<DigestGetResult> {
|
|
return digestRequest({ ...o, method: "GET" });
|
|
}
|