generated from Dicken/dickendock
Commit 5: Scanner-Engine (DNS, Portscan, Titel/Favicon, Softwareerkennung, FritzBox TR-064)
This commit is contained in:
139
apps/backend/src/scanner/digestAuth.ts
Normal file
139
apps/backend/src/scanner/digestAuth.ts
Normal 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;
|
||||
}
|
||||
25
apps/backend/src/scanner/dns.ts
Normal file
25
apps/backend/src/scanner/dns.ts
Normal 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;
|
||||
}
|
||||
66
apps/backend/src/scanner/fritzbox.ts
Normal file
66
apps/backend/src/scanner/fritzbox.ts
Normal 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;
|
||||
}
|
||||
84
apps/backend/src/scanner/http.ts
Normal file
84
apps/backend/src/scanner/http.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
66
apps/backend/src/scanner/networkScanner.ts
Normal file
66
apps/backend/src/scanner/networkScanner.ts
Normal 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;
|
||||
}
|
||||
26
apps/backend/src/scanner/ports.ts
Normal file
26
apps/backend/src/scanner/ports.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
49
apps/backend/src/scanner/softwareDetection.ts
Normal file
49
apps/backend/src/scanner/softwareDetection.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user