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
// einsammeln (siehe upsertDeviceFromScan) - werden NICHT automatisch
// ü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

View File

@@ -23,6 +23,43 @@ export function getHttpxLastError(): string | null {
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
* 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
* fehlschlägt - der Aufrufer erkennt daran "kein httpx verfügbar" und fällt
* 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) {
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(
"httpx",
[
"-silent",
"-json",
"-timeout",
"5",
"8",
"-retries",
"0",
"1",
"-no-color",
"-threads",
"50",
@@ -86,14 +129,27 @@ export function probeWithHttpx(ip: string, ports: number[]): Promise<Map<number,
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(() => {
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();
finish(null);
}, 30_000);
}, 60_000);
child.on("close", (code) => {
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) {
httpxLastError = (stderr.trim() || `httpx beendet mit Exit-Code ${code}`).slice(0, 400);
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();
});
} finally {
httpxSemaphore.release();
}
}

View File

@@ -2,16 +2,70 @@ import { execFile } from "node:child_process";
let masscanConfirmedUnavailable = false;
// Klartext-Grund des letzten Fehlschlags (stderr/Fehlermeldung von masscan
// selbst) - wird im Scan-Log angezeigt, damit man sieht WARUM masscan nicht
// greift (z. B. fehlende Rechte trotz cap_add, kein Interface gefunden),
// statt nur "nicht genutzt/verfügbar" ohne jede Erklärung.
// Klartext-Grund des letzten Fehlschlags - wird im Scan-Log angezeigt, damit
// man sieht WARUM masscan nicht greift, statt nur "nicht genutzt/verfügbar"
// ohne jede Erklärung. WICHTIG: node's execFile hängt bei einem per Timeout
// 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;
export function getMasscanLastError(): string | null {
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
* 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
* Batch-TCP-Connect-Scanning (scanPortsInBatches in ports.ts).
*
* Fällt automatisch (und leise) auf null zurück, wenn masscan fehlt, keine
* Rechte hat (braucht CAP_NET_RAW, siehe docker-compose.yml) oder aus
* anderem Grund fehlschlägt - der Aufrufer nutzt dann stattdessen
* scanPortsInBatches. Kein hartes Erfordernis, nur eine Beschleunigung.
* Fällt automatisch auf null zurück, wenn masscan fehlt, keine Rechte hat
* (braucht CAP_NET_RAW, siehe docker-compose.yml) oder aus anderem Grund
* fehlschlägt - der Aufrufer nutzt dann stattdessen scanPortsInBatches. Kein
* hartes Erfordernis, nur eine Beschleunigung. Der genaue Grund eines
* Fehlschlags steht danach in getMasscanLastError().
*/
export async function scanWithMasscan(
host: string,
@@ -31,32 +86,47 @@ export async function scanWithMasscan(
): Promise<number[] | null> {
if (masscanConfirmedUnavailable) return null;
return new Promise((resolve) => {
execFile(
await masscanSemaphore.acquire();
try {
return await new Promise((resolve) => {
const child = execFile(
"masscan",
[host, "-p", portRange, "--rate", String(rate), "-oL", "-"],
{ timeout: 60_000 },
{ timeout: MASSCAN_TIMEOUT_MS },
(error, stdout, stderr) => {
if (error) {
// Klartext-Fehler für den Aufrufer/das Scan-Log merken - 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 rawReason = (stderr?.trim() || error.message).slice(0, 400);
masscanLastError = rawReason;
const wasTimeout = error.killed === true;
const lastOutput = (stderr?.trim() || stdout?.trim() || "").slice(0, 300);
if (wasTimeout) {
// Explizit als Zeitüberschreitung benennen - die mitgelieferte
// 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. ä.
// in stderr) - beides bedeutet: masscan für diese Installation
// dauerhaft nicht nutzbar, künftig direkt auf den Fallback gehen
// statt bei jedem Scan erneut den (dann meist schnell fehlschlagenden)
// Versuch zu machen.
// statt bei jedem Scan erneut den (dann meist schnell
// 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();
if (
msg.includes("enoent") ||
!wasTimeout &&
(msg.includes("enoent") ||
msg.includes("not found") ||
msg.includes("permission") ||
msg.includes("operation not permitted") ||
msg.includes("pcap")
msg.includes("pcap"))
) {
masscanConfirmedUnavailable = true;
}
@@ -78,7 +148,11 @@ export async function scanWithMasscan(
resolve(ports);
}
);
void child;
});
} finally {
masscanSemaphore.release();
}
}
export function isMasscanConfirmedUnavailable(): boolean {

View File

@@ -182,22 +182,32 @@ export async function scanDeviceServices(
(p) => !AVM_MANAGEMENT_PORTS.has(p) || priorityPorts.includes(p)
);
// httpx (falls installiert, siehe Dockerfile) filtert aus den offenen
// Ports NUR die tatsächlich HTTP/HTTPS-sprechenden heraus - deutlich
// schneller UND präziser als unser eigenes Protokoll-Raten (erst HTTP
// versuchen, bei Fehlschlag HTTPS versuchen) für JEDEN einzelnen offenen
// Port einzeln. Ports wie SSH/SMB/NFS, die zwar offen sind aber gar kein
// HTTP sprechen, werden so gar nicht erst an die aufwendigere
// Detail-Pipeline (probeHttp, Softwareerkennung, Icon-Abgleich)
// weitergereicht. httpxResults === null heißt "httpx nicht verfügbar" -
// dann bleibt es beim bisherigen Verhalten (jeder offene Port wird
// geprüft, Protokoll wird pro Port erraten).
// httpx (falls installiert, siehe Dockerfile) prüft die offenen Ports
// vorab in einem einzigen, hochparallelen Durchlauf und liefert für die
// tatsächlich HTTP/HTTPS-sprechenden gleich Schema/Titel/Server-Header
// mit - das spart probePort() unten den sonst nötigen Protokoll-Rate-
// Versuch (erst HTTP, bei Fehlschlag HTTPS) für JEDEN Port einzeln.
// httpxResults === null heißt "httpx nicht verfügbar" - dann bleibt es
// beim bisherigen Verhalten (jeder offene Port wird von probePort()
// geprüft, Protokoll wird pro Port erraten). Ein von httpx NICHT
// bestätigter Port wird (seit der Regression mit stillschweigend
// 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 usingHttpx = httpxResults !== null;
const httpxError = !usingHttpx ? getHttpxLastError() : null;
if (usingHttpx) {
openPorts = Array.from(httpxResults!.keys());
}
// WICHTIG: openPorts wird NICHT mehr auf die von httpx bestätigten Ports
// 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[] = [];