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

@@ -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,107 +72,129 @@ 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) => {
const child = spawn(
"httpx",
[
"-silent",
"-json",
"-timeout",
"5",
"-retries",
"0",
"-no-color",
"-threads",
"50",
],
{ stdio: ["pipe", "pipe", "pipe"] }
);
await httpxSemaphore.acquire();
try {
return await new Promise((resolve) => {
const child = spawn(
"httpx",
[
"-silent",
"-json",
"-timeout",
"8",
"-retries",
"1",
"-no-color",
"-threads",
"50",
],
{ stdio: ["pipe", "pipe", "pipe"] }
);
let stdout = "";
let stderr = "";
let settled = false;
let stdout = "";
let stderr = "";
let settled = false;
const finish = (result: Map<number, HttpxResult> | null) => {
if (settled) return;
settled = true;
resolve(result);
};
const finish = (result: Map<number, HttpxResult> | null) => {
if (settled) return;
settled = true;
resolve(result);
};
child.on("error", (err) => {
httpxLastError = err.message.slice(0, 400);
// ENOENT (Programm fehlt) - künftig direkt auf den Fallback gehen,
// statt bei jedem Scan erneut (dann garantiert erfolglos) zu versuchen.
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
httpxConfirmedUnavailable = true;
}
finish(null);
});
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf-8");
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf-8");
});
const timeout = setTimeout(() => {
httpxLastError = "Zeitüberschreitung (30s) - httpx hat nicht rechtzeitig geantwortet";
child.kill();
finish(null);
}, 30_000);
child.on("close", (code) => {
clearTimeout(timeout);
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;
finish(null);
return;
}
httpxLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen
const results = new Map<number, HttpxResult>();
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed) as {
port?: string;
scheme?: string;
status_code?: number;
title?: string;
webserver?: string;
};
const port = Number(obj.port);
if (!Number.isFinite(port)) continue;
results.set(port, {
port,
scheme: obj.scheme === "https" ? "https" : "http",
statusCode: obj.status_code,
title: obj.title,
webserver: obj.webserver,
});
} catch {
// einzelne kaputte Zeile ignorieren, Rest trotzdem verwerten
child.on("error", (err) => {
httpxLastError = err.message.slice(0, 400);
// ENOENT (Programm fehlt) - künftig direkt auf den Fallback gehen,
// statt bei jedem Scan erneut (dann garantiert erfolglos) zu versuchen.
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
httpxConfirmedUnavailable = true;
}
}
finish(results);
});
finish(null);
});
// Eingabe: "ip:port" pro Zeile, httpx erkennt Schema (http/https) je
// Eintrag selbst (Standardverhalten: erst https versuchen, sonst http).
for (const port of ports) {
child.stdin.write(`${ip}:${port}\n`);
}
child.stdin.end();
});
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf-8");
});
child.stderr.on("data", (chunk: Buffer) => {
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 (60s) - httpx wurde beendet, bevor alle ${ports.length} Port(s) geprüft waren`;
child.kill();
finish(null);
}, 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;
finish(null);
return;
}
httpxLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen
const results = new Map<number, HttpxResult>();
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const obj = JSON.parse(trimmed) as {
port?: string;
scheme?: string;
status_code?: number;
title?: string;
webserver?: string;
};
const port = Number(obj.port);
if (!Number.isFinite(port)) continue;
results.set(port, {
port,
scheme: obj.scheme === "https" ? "https" : "http",
statusCode: obj.status_code,
title: obj.title,
webserver: obj.webserver,
});
} catch {
// einzelne kaputte Zeile ignorieren, Rest trotzdem verwerten
}
}
finish(results);
});
// Eingabe: "ip:port" pro Zeile, httpx erkennt Schema (http/https) je
// Eintrag selbst (Standardverhalten: erst https versuchen, sonst http).
for (const port of ports) {
child.stdin.write(`${ip}:${port}\n`);
}
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,54 +86,73 @@ export async function scanWithMasscan(
): Promise<number[] | null> {
if (masscanConfirmedUnavailable) return null;
return new Promise((resolve) => {
execFile(
"masscan",
[host, "-p", portRange, "--rate", String(rate), "-oL", "-"],
{ timeout: 60_000 },
(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;
await masscanSemaphore.acquire();
try {
return await new Promise((resolve) => {
const child = execFile(
"masscan",
[host, "-p", portRange, "--rate", String(rate), "-oL", "-"],
{ timeout: MASSCAN_TIMEOUT_MS },
(error, stdout, stderr) => {
if (error) {
const wasTimeout = error.killed === true;
const lastOutput = (stderr?.trim() || stdout?.trim() || "").slice(0, 300);
// 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.
const msg = `${error.message} ${stderr ?? ""}`.toLowerCase();
if (
msg.includes("enoent") ||
msg.includes("not found") ||
msg.includes("permission") ||
msg.includes("operation not permitted") ||
msg.includes("pcap")
) {
masscanConfirmedUnavailable = true;
}
resolve(null);
return;
}
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)}`;
}
// "-oL -" (List-Format) gibt pro offenem Port eine Zeile aus:
// "open tcp 80 192.168.1.50 1700000000"
const ports: number[] = [];
for (const line of stdout.split("\n")) {
const parts = line.trim().split(/\s+/);
if (parts[0] === "open" && parts[1] === "tcp") {
const port = Number(parts[2]);
if (Number.isFinite(port)) ports.push(port);
// 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. 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 (
!wasTimeout &&
(msg.includes("enoent") ||
msg.includes("not found") ||
msg.includes("permission") ||
msg.includes("operation not permitted") ||
msg.includes("pcap"))
) {
masscanConfirmedUnavailable = true;
}
resolve(null);
return;
}
// "-oL -" (List-Format) gibt pro offenem Port eine Zeile aus:
// "open tcp 80 192.168.1.50 1700000000"
const ports: number[] = [];
for (const line of stdout.split("\n")) {
const parts = line.trim().split(/\s+/);
if (parts[0] === "open" && parts[1] === "tcp") {
const port = Number(parts[2]);
if (Number.isFinite(port)) ports.push(port);
}
}
masscanLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen
resolve(ports);
}
masscanLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen
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[] = [];