Commit 5: Scanner-Engine (DNS, Portscan, Titel/Favicon, Softwareerkennung, FritzBox TR-064)

This commit is contained in:
2026-07-19 02:22:54 +02:00
parent 129b4253b5
commit 28bccb9d16
13 changed files with 744 additions and 18 deletions

View File

@@ -0,0 +1,139 @@
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;
}

View File

@@ -0,0 +1,25 @@
import { lookup } from "node:dns/promises";
export interface DnsResolution {
hostname: string;
ip: string;
}
/**
* Versucht der Spezifikation folgend: hostname, hostname.home, hostname.local.
* Gibt die erste erfolgreich aufgelöste Variante zurück, sonst null.
*/
export async function resolveHostname(shortName: string): Promise<DnsResolution | null> {
const candidates = [shortName, `${shortName}.home`, `${shortName}.local`];
for (const hostname of candidates) {
try {
const { address } = await lookup(hostname);
return { hostname, ip: address };
} catch {
// nächste Variante versuchen
}
}
return null;
}

View File

@@ -0,0 +1,66 @@
import { soapRequest, type DigestAuthOptions } from "./digestAuth.js";
const SERVICE_TYPE = "urn:dslforum-org:service:Hosts:1";
function actionEnvelope(action: string, params: Record<string, string | number> = {}): string {
const args = Object.entries(params)
.map(([key, value]) => `<${key}>${value}</${key}>`)
.join("");
return (
`<?xml version="1.0" encoding="utf-8"?>` +
`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ` +
`s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">` +
`<s:Body><u:${action} xmlns:u="${SERVICE_TYPE}">${args}</u:${action}></s:Body>` +
`</s:Envelope>`
);
}
function extractTag(xml: string, tag: string): string | null {
const match = xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`, "i"));
return match ? match[1] : null;
}
export interface FritzBoxHost {
ip: string;
mac: string | null;
hostname: string;
online: boolean;
}
/**
* Liest die vollständige Geräteliste der FritzBox über die TR-064-Aktionen
* GetHostNumberOfEntries + GetGenericHostEntry (urn:dslforum-org:service:Hosts:1).
*/
export async function fetchFritzBoxHosts(options: DigestAuthOptions): Promise<FritzBoxHost[]> {
const countXml = await soapRequest(
options,
`${SERVICE_TYPE}#GetHostNumberOfEntries`,
actionEnvelope("GetHostNumberOfEntries")
);
const countStr = extractTag(countXml, "NewHostNumberOfEntries");
const count = countStr ? parseInt(countStr, 10) : 0;
const hosts: FritzBoxHost[] = [];
for (let index = 0; index < count; index++) {
const entryXml = await soapRequest(
options,
`${SERVICE_TYPE}#GetGenericHostEntry`,
actionEnvelope("GetGenericHostEntry", { NewIndex: index })
);
const ip = extractTag(entryXml, "NewIPAddress");
const hostname = extractTag(entryXml, "NewHostName");
if (!ip || !hostname) continue; // inaktive/unvollständige Einträge überspringen
hosts.push({
ip,
mac: extractTag(entryXml, "NewMACAddress"),
hostname,
online: extractTag(entryXml, "NewActive") === "1",
});
}
return hosts;
}

View File

@@ -0,0 +1,84 @@
import http from "node:http";
import https from "node:https";
export interface HttpProbeResult {
ok: boolean;
status?: number;
title?: string;
faviconUrl?: string;
server?: string;
bodySnippet?: string;
}
const MAX_BODY_BYTES = 65_536;
/**
* Ruft eine URL ab und liest <title> sowie das Favicon aus dem HTML.
* Für HTTPS werden selbstsignierte Zertifikate akzeptiert (rejectUnauthorized:
* false) in Homelabs üblich, es werden keine sensiblen Daten übertragen.
*/
export function probeHttp(baseUrl: string, timeoutMs = 2000): Promise<HttpProbeResult> {
return new Promise((resolve) => {
const isHttps = baseUrl.startsWith("https://");
const client = isHttps ? https : http;
const req = client.get(
baseUrl,
{
timeout: timeoutMs,
rejectUnauthorized: false,
},
(res) => {
let body = "";
let received = 0;
res.on("data", (chunk: Buffer) => {
received += chunk.length;
if (received <= MAX_BODY_BYTES) {
body += chunk.toString("utf-8");
}
});
res.on("end", () => {
const serverHeader = res.headers.server;
resolve({
ok: (res.statusCode ?? 0) < 400,
status: res.statusCode,
title: extractTitle(body),
faviconUrl: extractFaviconUrl(body, baseUrl),
server: Array.isArray(serverHeader) ? serverHeader[0] : serverHeader,
bodySnippet: body,
});
});
res.on("error", () => resolve({ ok: false }));
}
);
req.on("timeout", () => {
req.destroy();
resolve({ ok: false });
});
req.on("error", () => resolve({ ok: false }));
});
}
function extractTitle(html: string): string | undefined {
const match = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const title = match?.[1]?.trim();
return title ? title : undefined;
}
function extractFaviconUrl(html: string, baseUrl: string): string | undefined {
const match = html.match(
/<link[^>]+rel=["'](?:shortcut icon|icon)["'][^>]*href=["']([^"']+)["']/i
);
const href = match?.[1];
try {
return new URL(href ?? "/favicon.ico", baseUrl).toString();
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,66 @@
import { resolveHostname } from "./dns.js";
import { isPortOpen, TYPICAL_PORTS } from "./ports.js";
import { probeHttp } from "./http.js";
import { detectSoftware } from "./softwareDetection.js";
export interface ScanTarget {
hostname: string;
ip: string;
}
export interface DiscoveredService {
hostname: string;
url: string;
https: boolean;
port: number;
favicon?: string;
description?: string;
suggestedDisplayName: string;
category?: string;
}
/**
* Scannt ein einzelnes Gerät: versucht zuerst eine schönere DNS-Adresse
* (hostname / hostname.home / hostname.local) aufzulösen, prüft dann Port 80,
* 443 sowie die typischen Ports, liest bei offenen Ports Titel + Favicon aus
* und versucht die Software zu erkennen.
*/
export async function scanDeviceServices(
device: ScanTarget,
extraPorts: number[] = TYPICAL_PORTS
): Promise<DiscoveredService[]> {
const dnsResult = await resolveHostname(device.hostname);
// Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist.
const address = dnsResult?.hostname ?? device.ip;
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts]));
const found: DiscoveredService[] = [];
for (const port of candidatePorts) {
const open = await isPortOpen(device.ip, port);
if (!open) continue;
const isHttps = port === 443 || port === 9443;
const baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
const probe = await probeHttp(baseUrl);
const software = detectSoftware({
server: probe.server,
body: probe.bodySnippet,
port,
});
found.push({
hostname: address,
url: baseUrl,
https: isHttps,
port,
favicon: probe.faviconUrl,
description: software ? `${software.name} (automatisch erkannt)` : probe.title,
suggestedDisplayName: software?.name ?? probe.title ?? `${address}:${port}`,
category: software?.category,
});
}
return found;
}

View File

@@ -0,0 +1,26 @@
import { connect } from "node:net";
/**
* Typische Ports für den optionalen Portscanner, gemäß Spezifikation.
*/
export const TYPICAL_PORTS = [80, 443, 3000, 3001, 5000, 5001, 8080, 8123, 9000, 9443];
/**
* Prüft per TCP-Connect, ob ein Port offen ist. Kein Protokoll-Handshake,
* nur "kann eine Verbindung aufgebaut werden" schnell und protokollunabhängig.
*/
export function isPortOpen(host: string, port: number, timeoutMs = 800): Promise<boolean> {
return new Promise((resolve) => {
const socket = connect({ host, port, timeout: timeoutMs });
const finish = (result: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(result);
};
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}

View File

@@ -0,0 +1,49 @@
export interface SoftwareSignatureInput {
server?: string;
body?: string;
port: number;
}
export interface SoftwareSignature {
name: string;
category: string;
matches: (input: SoftwareSignatureInput) => boolean;
}
function bodyContains(input: SoftwareSignatureInput, pattern: RegExp): boolean {
return !!input.body && pattern.test(input.body);
}
/**
* Automatische Softwareerkennung anhand von HTTP-Response-Merkmalen
* (Server-Header, HTML-Inhalt). Liste gemäß Spezifikation.
*/
export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [
{ name: "Home Assistant", category: "Smart Home", matches: (i) => bodyContains(i, /home\s*assistant/i) },
{
name: "Synology DSM",
category: "NAS",
matches: (i) => bodyContains(i, /synology/i) || (!!i.server && /synology/i.test(i.server)),
},
{ name: "Frigate", category: "Überwachung", matches: (i) => bodyContains(i, /frigate/i) },
{ name: "Portainer", category: "Container", matches: (i) => bodyContains(i, /portainer/i) },
{ name: "Grafana", category: "Monitoring", matches: (i) => bodyContains(i, /grafana/i) },
{ name: "Proxmox VE", category: "Virtualisierung", matches: (i) => bodyContains(i, /proxmox/i) },
{ name: "Immich", category: "Fotos", matches: (i) => bodyContains(i, /immich/i) },
{ name: "Paperless-ngx", category: "Dokumente", matches: (i) => bodyContains(i, /paperless/i) },
{ name: "Gitea", category: "Entwicklung", matches: (i) => bodyContains(i, /gitea/i) },
{
name: "Vaultwarden",
category: "Passwörter",
matches: (i) => bodyContains(i, /vaultwarden|bitwarden/i),
},
{ name: "Jellyfin", category: "Medien", matches: (i) => bodyContains(i, /jellyfin/i) },
{ name: "Nextcloud", category: "Cloud", matches: (i) => bodyContains(i, /nextcloud/i) },
{ name: "Pi-hole", category: "DNS", matches: (i) => bodyContains(i, /pi-?hole/i) },
{ name: "AdGuard Home", category: "DNS", matches: (i) => bodyContains(i, /adguard/i) },
{ name: "UniFi Network", category: "Netzwerk", matches: (i) => bodyContains(i, /unifi/i) },
];
export function detectSoftware(input: SoftwareSignatureInput): SoftwareSignature | null {
return SOFTWARE_SIGNATURES.find((signature) => signature.matches(input)) ?? null;
}