generated from Dicken/dickendock
140 lines
4.0 KiB
TypeScript
140 lines
4.0 KiB
TypeScript
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<string, string> = {};
|
|
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<string, string>,
|
|
body: string
|
|
): Promise<RawResponse> {
|
|
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<string> {
|
|
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;
|
|
}
|