import { createHash, randomBytes } from "node:crypto"; import http, { type IncomingHttpHeaders } from "node:http"; import https from "node:https"; export interface DigestAuthOptions { host: string; port: number; https?: boolean; username: string; password: string; timeoutMs?: number; } interface DigestChallenge { realm: string; nonce: string; qop?: string; opaque?: string; } function md5(input: string): string { return createHash("md5").update(input).digest("hex"); } function parseDigestHeader(header: string): DigestChallenge { const params: Record = {}; const regex = /(\w+)=("([^"]*)"|[^,]*)/g; let match: RegExpExecArray | null; while ((match = regex.exec(header))) { params[match[1]] = match[3] ?? match[2]; } return { realm: params.realm, nonce: params.nonce, qop: params.qop, opaque: params.opaque }; } interface RawResponse { status: number; headers: IncomingHttpHeaders; body: string; } function rawRequest( options: DigestAuthOptions, path: string, headers: Record, body: string ): Promise { return new Promise((resolve, reject) => { const client = options.https ? https : http; const req = client.request( { host: options.host, port: options.port, path, method: "POST", headers: { ...headers, "Content-Length": Buffer.byteLength(body) }, timeout: options.timeoutMs ?? 4000, rejectUnauthorized: false, }, (res) => { let data = ""; res.on("data", (chunk: Buffer) => (data += chunk.toString("utf-8"))); res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data }) ); res.on("error", reject); } ); req.on("timeout", () => { req.destroy(); reject(new Error("Zeitüberschreitung bei der Verbindung zur FritzBox")); }); req.on("error", reject); req.write(body); req.end(); }); } /** * Führt einen TR-064-SOAP-Request gegen `/upnp/control/hosts` aus. * Läuft zunächst unauthentifiziert; antwortet die FritzBox mit 401 und einer * WWW-Authenticate-Digest-Challenge, wird automatisch mit korrekt berechnetem * Digest-Response-Header erneut angefragt (RFC 2617). */ export async function soapRequest( options: DigestAuthOptions, soapAction: string, body: string ): Promise { const path = "/upnp/control/hosts"; const baseHeaders = { "Content-Type": 'text/xml; charset="utf-8"', SOAPACTION: soapAction, }; const first = await rawRequest(options, path, baseHeaders, body); if (first.status === 200) { return first.body; } if (first.status !== 401) { throw new Error(`Unerwarteter Status von der FritzBox: ${first.status}`); } const wwwAuth = first.headers["www-authenticate"]; if (!wwwAuth) { throw new Error("FritzBox verlangt Authentifizierung, sendet aber keine Digest-Challenge"); } const challenge = parseDigestHeader(Array.isArray(wwwAuth) ? wwwAuth[0] : wwwAuth); if (!challenge.realm || !challenge.nonce) { throw new Error("Digest-Challenge der FritzBox konnte nicht gelesen werden"); } const ha1 = md5(`${options.username}:${challenge.realm}:${options.password}`); const ha2 = md5(`POST:${path}`); const nc = "00000001"; const cnonce = randomBytes(8).toString("hex"); const response = challenge.qop ? md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${challenge.qop}:${ha2}`) : md5(`${ha1}:${challenge.nonce}:${ha2}`); const authHeader = `Digest username="${options.username}", realm="${challenge.realm}", ` + `nonce="${challenge.nonce}", uri="${path}", response="${response}"` + (challenge.qop ? `, qop=${challenge.qop}, nc=${nc}, cnonce="${cnonce}"` : "") + (challenge.opaque ? `, opaque="${challenge.opaque}"` : ""); const second = await rawRequest( options, path, { ...baseHeaders, Authorization: authHeader }, body ); if (second.status !== 200) { throw new Error(`FritzBox-Authentifizierung fehlgeschlagen (Status ${second.status})`); } return second.body; }