round49: masscan/httpx-Timeout-Erkennung und Nebenlaeufigkeits-Begrenzung, httpx verwirft offene Ports nicht mehr, FritzBox-Namens-Downgrade auf Platzhalter verhindert

This commit is contained in:
2026-07-26 09:17:57 +02:00
parent 28fedb2572
commit cf31ea4402
4 changed files with 327 additions and 161 deletions

View File

@@ -394,8 +394,31 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
// Vorschläge für abweichende Hostnamen/IPs bereits bekannter Geräte // Vorschläge für abweichende Hostnamen/IPs bereits bekannter Geräte
// einsammeln (siehe upsertDeviceFromScan) - werden NICHT automatisch // einsammeln (siehe upsertDeviceFromScan) - werden NICHT automatisch
// übernommen, nur zur Bestätigung zurückgegeben. // übernommen, nur zur Bestätigung zurückgegeben.
const deviceChanges = results.flatMap((r) => //
r.changes.map((c) => ({ deviceId: r.device.id, deviceHostname: r.device.hostname, ...c })) // WICHTIG: Ein Vorschlag, der einen bereits bekannten ECHTEN Namen
// (z. B. "cam-reolink-einfahrt") auf den generischen
// FritzBox-Platzhalter "PC-192-168-1-39" zurückstufen würde, wird
// hier ausgefiltert und gar nicht erst vorgeschlagen. Das war die
// Ursache der Namens-Regression: mergeFritzboxHosts() bevorzugt den
// echten Namen nur, wenn IN DEMSELBEN Scan-Durchlauf mehrere
// TR-064-Einträge für dieselbe MAC vorliegen - hat die FritzBox in
// einem Durchlauf für ein Gerät NUR den Platzhalter gemeldet (z. B.
// kurz nach einem Neustart/DHCP-Lease-Renewal, bevor sie den
// mDNS/NetBIOS-Namen kennt), gab es bisher keine zweite Quelle zum
// Vergleich - der Platzhalter wurde als ganz normale "Namensänderung"
// vorgeschlagen und konnte (z. B. über "Alle bestätigen") den guten
// Namen dauerhaft überschreiben.
const deviceChanges = results.flatMap((r, i) => {
const scannedIp = mergedHosts[i].ip;
return r.changes
.filter((c) => {
if (c.field !== "hostname") return true;
const suggestedIsPlaceholder = isGenericFritzboxHostname(c.suggested, scannedIp);
const currentIsPlaceholder = isGenericFritzboxHostname(c.current, scannedIp);
return !(suggestedIsPlaceholder && !currentIsPlaceholder);
})
.map((c) => ({ deviceId: r.device.id, deviceHostname: r.device.hostname, ...c }));
}
); );
// Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht // Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht

View File

@@ -23,6 +23,43 @@ export function getHttpxLastError(): string | null {
return httpxLastError; return httpxLastError;
} }
/**
* Einfacher Zähl-Semaphore (siehe auch masscan.ts), hier mit Kapazität 3:
* Beim Sammel-Scan laufen bis zu 10 Geräte-Scans gleichzeitig (siehe
* routes/scan.ts CONCURRENT_DEVICES), jeder startet einen eigenen
* httpx-Prozess mit intern 50 Threads - ohne Deckel also bis zu 500
* gleichzeitige HTTP-Anfragen ALLEIN durch httpx, zusätzlich zum eigenen
* TCP-Verbindungs-Semaphore (ports.ts). Unter dieser Last liefen einzelne
* httpx-Läufe in den 5s-Timeout pro Port und damit leer, obwohl der Dienst
* eigentlich erreichbar war - der Port verschwand dann komplett aus dem
* Ergebnis (siehe networkScanner.ts). Kapazität 3 begrenzt das auf maximal
* 150 gleichzeitige httpx-Anfragen prozessweit.
*/
class Semaphore {
private available: number;
private readonly queue: (() => void)[] = [];
constructor(count: number) {
this.available = count;
}
acquire(): Promise<void> {
if (this.available > 0) {
this.available--;
return Promise.resolve();
}
return new Promise((resolve) => this.queue.push(resolve));
}
release(): void {
const next = this.queue.shift();
if (next) next();
else this.available++;
}
}
const httpxSemaphore = new Semaphore(3);
/** /**
* Nutzt httpx (ProjectDiscovery, falls im Container installiert - siehe * Nutzt httpx (ProjectDiscovery, falls im Container installiert - siehe
* Dockerfile) um von einer Liste offener Ports (z. B. das Ergebnis von * Dockerfile) um von einer Liste offener Ports (z. B. das Ergebnis von
@@ -35,23 +72,29 @@ export function getHttpxLastError(): string | null {
* Liefert null (nicht einen leeren Array!), wenn httpx fehlt oder * Liefert null (nicht einen leeren Array!), wenn httpx fehlt oder
* fehlschlägt - der Aufrufer erkennt daran "kein httpx verfügbar" und fällt * fehlschlägt - der Aufrufer erkennt daran "kein httpx verfügbar" und fällt
* auf die eingebaute Methode zurück, statt fälschlich "keine Webdienste * auf die eingebaute Methode zurück, statt fälschlich "keine Webdienste
* gefunden" anzunehmen. * gefunden" anzunehmen. Wichtig: httpx meldet für einen Port, den es nicht
* innerhalb des Timeouts erreicht, GAR NICHTS - das ist von außen nicht von
* "das ist wirklich kein Webdienst" zu unterscheiden. networkScanner.ts
* behandelt daher von httpx nicht bestätigte, aber offene Ports weiterhin
* mit einer eigenen Nachprüfung, statt sie stillschweigend zu verwerfen.
*/ */
export function probeWithHttpx(ip: string, ports: number[]): Promise<Map<number, HttpxResult> | null> { export async function probeWithHttpx(ip: string, ports: number[]): Promise<Map<number, HttpxResult> | null> {
if (httpxConfirmedUnavailable || ports.length === 0) { if (httpxConfirmedUnavailable || ports.length === 0) {
return Promise.resolve(ports.length === 0 ? new Map() : null); return ports.length === 0 ? new Map() : null;
} }
return new Promise((resolve) => { await httpxSemaphore.acquire();
try {
return await new Promise((resolve) => {
const child = spawn( const child = spawn(
"httpx", "httpx",
[ [
"-silent", "-silent",
"-json", "-json",
"-timeout", "-timeout",
"5", "8",
"-retries", "-retries",
"0", "1",
"-no-color", "-no-color",
"-threads", "-threads",
"50", "50",
@@ -86,14 +129,27 @@ export function probeWithHttpx(ip: string, ports: number[]): Promise<Map<number,
stderr += chunk.toString("utf-8"); stderr += chunk.toString("utf-8");
}); });
// Grober oberer Zeitrahmen für den GESAMTEN httpx-Lauf (alle Ports
// zusammen, dank -threads 50 weitgehend parallel) - unabhängig vom
// Pro-Port-Timeout oben. 60s statt vorheriger 30s, damit ein Gerät mit
// vielen offenen Ports (nach einem vollständigen 1-65535-Portscan)
// nicht schon bei normaler Auslastung abgebrochen wird.
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
httpxLastError = "Zeitüberschreitung (30s) - httpx hat nicht rechtzeitig geantwortet"; httpxLastError = `Zeitüberschreitung (60s) - httpx wurde beendet, bevor alle ${ports.length} Port(s) geprüft waren`;
child.kill(); child.kill();
finish(null); finish(null);
}, 30_000); }, 60_000);
child.on("close", (code) => { child.on("close", (code) => {
clearTimeout(timeout); clearTimeout(timeout);
// War der Lauf schon durch den Timeout oben als fehlgeschlagen
// markiert (settled=true), NICHT mehr überschreiben - sonst geht die
// konkrete "Zeitüberschreitung"-Meldung unter der generischen
// Exit-Code-Meldung verloren, die "close" hier sonst noch setzen
// würde (der Kill selbst löst "close" mit code=null erst danach
// aus).
if (settled) return;
if (code !== 0 && stdout.trim().length === 0) { if (code !== 0 && stdout.trim().length === 0) {
httpxLastError = (stderr.trim() || `httpx beendet mit Exit-Code ${code}`).slice(0, 400); httpxLastError = (stderr.trim() || `httpx beendet mit Exit-Code ${code}`).slice(0, 400);
if (/not found|no such file/i.test(stderr)) httpxConfirmedUnavailable = true; if (/not found|no such file/i.test(stderr)) httpxConfirmedUnavailable = true;
@@ -138,4 +194,7 @@ export function probeWithHttpx(ip: string, ports: number[]): Promise<Map<number,
} }
child.stdin.end(); child.stdin.end();
}); });
} finally {
httpxSemaphore.release();
}
} }

View File

@@ -2,16 +2,70 @@ import { execFile } from "node:child_process";
let masscanConfirmedUnavailable = false; let masscanConfirmedUnavailable = false;
// Klartext-Grund des letzten Fehlschlags (stderr/Fehlermeldung von masscan // Klartext-Grund des letzten Fehlschlags - wird im Scan-Log angezeigt, damit
// selbst) - wird im Scan-Log angezeigt, damit man sieht WARUM masscan nicht // man sieht WARUM masscan nicht greift, statt nur "nicht genutzt/verfügbar"
// greift (z. B. fehlende Rechte trotz cap_add, kein Interface gefunden), // ohne jede Erklärung. WICHTIG: node's execFile hängt bei einem per Timeout
// statt nur "nicht genutzt/verfügbar" ohne jede Erklärung. // gekillten Prozess die (oft nur unvollständige) stderr-Ausgabe direkt in
// error.message - das sieht bei masscan wie eine ganz normale
// Fortschrittsanzeige aus ("Starting masscan...", "rate: ..., found=0"),
// ist aber KEINE Fehlermeldung von masscan selbst, sondern der letzte Stand
// VOR dem Abbruch. Deshalb wird unten explizit geprüft, ob der Prozess per
// Timeout gekillt wurde (error.killed/error.signal), und das als eigener,
// klar benannter Grund ausgegeben - statt die Restausgabe fälschlich als
// "das ist der Fehler" zu präsentieren.
let masscanLastError: string | null = null; let masscanLastError: string | null = null;
export function getMasscanLastError(): string | null { export function getMasscanLastError(): string | null {
return masscanLastError; return masscanLastError;
} }
/**
* Einfacher Zähl-Semaphore (Kopie des Patterns aus ports.ts), hier mit
* Kapazität 1: masscan verschickt SYN-Pakete per Rohsocket direkt über das
* Netzwerk-Interface des Containers - laufen mehrere masscan-Prozesse
* GLEICHZEITIG (z. B. weil der Sammel-Scan bis zu 10 Geräte parallel
* abarbeitet, siehe routes/scan.ts CONCURRENT_DEVICES), konkurrieren sie um
* dasselbe Interface. Das führt zu Paketverlust und dazu, dass einzelne
* Läufe den 90-Sekunden-Timeout reißen und fälschlich als "nicht
* verfügbar" erscheinen, obwohl masscan grundsätzlich funktioniert -
* genau das beobachtete "mal geht's, mal nicht". Mit Kapazität 1 laufen
* masscan-Aufrufe aus mehreren parallelen Geräte-Scans strikt nacheinander,
* der Rest des jeweiligen Geräte-Scans (DNS, httpx, Softwareerkennung)
* bleibt weiterhin parallel.
*/
class Semaphore {
private available: number;
private readonly queue: (() => void)[] = [];
constructor(count: number) {
this.available = count;
}
acquire(): Promise<void> {
if (this.available > 0) {
this.available--;
return Promise.resolve();
}
return new Promise((resolve) => this.queue.push(resolve));
}
release(): void {
const next = this.queue.shift();
if (next) next();
else this.available++;
}
}
const masscanSemaphore = new Semaphore(1);
// masscan braucht bei rate=2000 für den vollen Portbereich (1-65535) rein
// rechnerisch ca. 33s Sendezeit + einige Sekunden Nachlaufzeit für späte
// Antworten. 60s waren in der Praxis (Docker-in-LXC, ggf. wartende
// Konkurrenz-Scans durch den neuen Semaphore oben) zu knapp bemessen und
// haben laufende, aber noch nicht fertige Scans abgewürgt. 90s geben Luft,
// ohne einen wirklich hängenden Prozess ewig weiterlaufen zu lassen.
const MASSCAN_TIMEOUT_MS = 90_000;
/** /**
* Nutzt masscan (falls im Container installiert, siehe Dockerfile) für den * Nutzt masscan (falls im Container installiert, siehe Dockerfile) für den
* vollständigen Portscan (1-65535) - masscan verschickt SYN-Pakete * vollständigen Portscan (1-65535) - masscan verschickt SYN-Pakete
@@ -19,10 +73,11 @@ export function getMasscanLastError(): string | null {
* und ist dadurch um ein Vielfaches schneller als das eingebaute * und ist dadurch um ein Vielfaches schneller als das eingebaute
* Batch-TCP-Connect-Scanning (scanPortsInBatches in ports.ts). * Batch-TCP-Connect-Scanning (scanPortsInBatches in ports.ts).
* *
* Fällt automatisch (und leise) auf null zurück, wenn masscan fehlt, keine * Fällt automatisch auf null zurück, wenn masscan fehlt, keine Rechte hat
* Rechte hat (braucht CAP_NET_RAW, siehe docker-compose.yml) oder aus * (braucht CAP_NET_RAW, siehe docker-compose.yml) oder aus anderem Grund
* anderem Grund fehlschlägt - der Aufrufer nutzt dann stattdessen * fehlschlägt - der Aufrufer nutzt dann stattdessen scanPortsInBatches. Kein
* scanPortsInBatches. Kein hartes Erfordernis, nur eine Beschleunigung. * hartes Erfordernis, nur eine Beschleunigung. Der genaue Grund eines
* Fehlschlags steht danach in getMasscanLastError().
*/ */
export async function scanWithMasscan( export async function scanWithMasscan(
host: string, host: string,
@@ -31,32 +86,47 @@ export async function scanWithMasscan(
): Promise<number[] | null> { ): Promise<number[] | null> {
if (masscanConfirmedUnavailable) return null; if (masscanConfirmedUnavailable) return null;
return new Promise((resolve) => { await masscanSemaphore.acquire();
execFile( try {
return await new Promise((resolve) => {
const child = execFile(
"masscan", "masscan",
[host, "-p", portRange, "--rate", String(rate), "-oL", "-"], [host, "-p", portRange, "--rate", String(rate), "-oL", "-"],
{ timeout: 60_000 }, { timeout: MASSCAN_TIMEOUT_MS },
(error, stdout, stderr) => { (error, stdout, stderr) => {
if (error) { if (error) {
// Klartext-Fehler für den Aufrufer/das Scan-Log merken - stderr const wasTimeout = error.killed === true;
// enthält bei masscan i. d. R. den eigentlichen Grund (z. B. const lastOutput = (stderr?.trim() || stdout?.trim() || "").slice(0, 300);
// "FAIL:.. Permission denied", "could not detect interface"),
// error.message oft nur den generischen Exit-Code. if (wasTimeout) {
const rawReason = (stderr?.trim() || error.message).slice(0, 400); // Explizit als Zeitüberschreitung benennen - die mitgelieferte
masscanLastError = rawReason; // stderr-Ausgabe ist nur der letzte Stand VOR dem Abbruch
// (masscans eigene Fortschrittsanzeige), keine Fehlermeldung.
masscanLastError = `Zeitüberschreitung nach ${MASSCAN_TIMEOUT_MS / 1000}s - masscan wurde per ${error.signal ?? "Signal"} beendet, bevor der Scan fertig war. Letzter Stand vor Abbruch: "${lastOutput || "(keine Ausgabe)"}"`;
} else {
// Echter Fehlschlag (Exit-Code ≠ 0, nicht durch Timeout) -
// stderr enthält bei masscan i. d. R. den eigentlichen Grund
// (z. B. "FAIL:.. Permission denied", "could not detect
// interface"), error.message oft nur den generischen Exit-Code.
const exitCode = error.code ?? "unbekannt";
masscanLastError = `Exit-Code ${exitCode}: ${(stderr?.trim() || error.message).slice(0, 350)}`;
}
// ENOENT (Programm fehlt) oder fehlende Rechte (EPERM/"PCAP" o. ä. // ENOENT (Programm fehlt) oder fehlende Rechte (EPERM/"PCAP" o. ä.
// in stderr) - beides bedeutet: masscan für diese Installation // in stderr) - beides bedeutet: masscan für diese Installation
// dauerhaft nicht nutzbar, künftig direkt auf den Fallback gehen // dauerhaft nicht nutzbar, künftig direkt auf den Fallback gehen
// statt bei jedem Scan erneut den (dann meist schnell fehlschlagenden) // statt bei jedem Scan erneut den (dann meist schnell
// Versuch zu machen. // fehlschlagenden) Versuch zu machen. Ein Timeout zählt
// ausdrücklich NICHT dazu - das kann situationsabhängig sein
// (Netzlast) und beim nächsten Mal klappen.
const msg = `${error.message} ${stderr ?? ""}`.toLowerCase(); const msg = `${error.message} ${stderr ?? ""}`.toLowerCase();
if ( if (
msg.includes("enoent") || !wasTimeout &&
(msg.includes("enoent") ||
msg.includes("not found") || msg.includes("not found") ||
msg.includes("permission") || msg.includes("permission") ||
msg.includes("operation not permitted") || msg.includes("operation not permitted") ||
msg.includes("pcap") msg.includes("pcap"))
) { ) {
masscanConfirmedUnavailable = true; masscanConfirmedUnavailable = true;
} }
@@ -78,7 +148,11 @@ export async function scanWithMasscan(
resolve(ports); resolve(ports);
} }
); );
void child;
}); });
} finally {
masscanSemaphore.release();
}
} }
export function isMasscanConfirmedUnavailable(): boolean { export function isMasscanConfirmedUnavailable(): boolean {

View File

@@ -182,22 +182,32 @@ export async function scanDeviceServices(
(p) => !AVM_MANAGEMENT_PORTS.has(p) || priorityPorts.includes(p) (p) => !AVM_MANAGEMENT_PORTS.has(p) || priorityPorts.includes(p)
); );
// httpx (falls installiert, siehe Dockerfile) filtert aus den offenen // httpx (falls installiert, siehe Dockerfile) prüft die offenen Ports
// Ports NUR die tatsächlich HTTP/HTTPS-sprechenden heraus - deutlich // vorab in einem einzigen, hochparallelen Durchlauf und liefert für die
// schneller UND präziser als unser eigenes Protokoll-Raten (erst HTTP // tatsächlich HTTP/HTTPS-sprechenden gleich Schema/Titel/Server-Header
// versuchen, bei Fehlschlag HTTPS versuchen) für JEDEN einzelnen offenen // mit - das spart probePort() unten den sonst nötigen Protokoll-Rate-
// Port einzeln. Ports wie SSH/SMB/NFS, die zwar offen sind aber gar kein // Versuch (erst HTTP, bei Fehlschlag HTTPS) für JEDEN Port einzeln.
// HTTP sprechen, werden so gar nicht erst an die aufwendigere // httpxResults === null heißt "httpx nicht verfügbar" - dann bleibt es
// Detail-Pipeline (probeHttp, Softwareerkennung, Icon-Abgleich) // beim bisherigen Verhalten (jeder offene Port wird von probePort()
// weitergereicht. httpxResults === null heißt "httpx nicht verfügbar" - // geprüft, Protokoll wird pro Port erraten). Ein von httpx NICHT
// dann bleibt es beim bisherigen Verhalten (jeder offene Port wird // bestätigter Port wird (seit der Regression mit stillschweigend
// geprüft, Protokoll wird pro Port erraten). // verschwundenen Diensten unter Last) trotzdem an probePort() weiter-
// gereicht statt verworfen - siehe Kommentar unten.
const httpxResults = openPorts.length > 0 ? await probeWithHttpx(device.ip, openPorts) : new Map(); const httpxResults = openPorts.length > 0 ? await probeWithHttpx(device.ip, openPorts) : new Map();
const usingHttpx = httpxResults !== null; const usingHttpx = httpxResults !== null;
const httpxError = !usingHttpx ? getHttpxLastError() : null; const httpxError = !usingHttpx ? getHttpxLastError() : null;
if (usingHttpx) { // WICHTIG: openPorts wird NICHT mehr auf die von httpx bestätigten Ports
openPorts = Array.from(httpxResults!.keys()); // eingeschränkt. httpx meldet für einen Port, den es nicht innerhalb
} // seines Timeouts erreicht (z. B. unter Last durch mehrere gleichzeitige
// Geräte-Scans, siehe httpx.ts), schlicht GAR NICHTS - das ist von hier
// aus nicht von "spricht wirklich kein HTTP" zu unterscheiden. Frühere
// Version hat in diesem Fall den kompletten Port verworfen und damit
// echte, aber unter Last verpasste Webdienste stillschweigend als "kein
// Dienst gefunden" gemeldet. Jetzt bleiben alle offenen Ports erhalten;
// httpx liefert nur noch (wo vorhanden) das bereits bekannte Schema als
// Abkürzung für probePort() unten - fehlt ein Port in httpxResults, prüft
// probePort() ihn ganz normal selbst (identisch zum Verhalten ganz ohne
// httpx), statt ihn zu überspringen.
const found: DiscoveredService[] = []; const found: DiscoveredService[] = [];