From cf31ea440289b1fcbee7cd85f7e50a2bffdf7cab Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 26 Jul 2026 09:17:57 +0200 Subject: [PATCH] round49: masscan/httpx-Timeout-Erkennung und Nebenlaeufigkeits-Begrenzung, httpx verwirft offene Ports nicht mehr, FritzBox-Namens-Downgrade auf Platzhalter verhindert --- apps/backend/src/routes/scan.ts | 27 ++- apps/backend/src/scanner/httpx.ts | 247 +++++++++++++-------- apps/backend/src/scanner/masscan.ts | 178 ++++++++++----- apps/backend/src/scanner/networkScanner.ts | 36 +-- 4 files changed, 327 insertions(+), 161 deletions(-) diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index ab493bf..136d0ec 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -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 diff --git a/apps/backend/src/scanner/httpx.ts b/apps/backend/src/scanner/httpx.ts index 4ec5e61..494c072 100644 --- a/apps/backend/src/scanner/httpx.ts +++ b/apps/backend/src/scanner/httpx.ts @@ -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 { + 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 | null> { +export async function probeWithHttpx(ip: string, ports: number[]): Promise | 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 | null) => { - if (settled) return; - settled = true; - resolve(result); - }; + const finish = (result: Map | 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(); - 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(); + 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(); + } } diff --git a/apps/backend/src/scanner/masscan.ts b/apps/backend/src/scanner/masscan.ts index a39b948..dd3a89c 100644 --- a/apps/backend/src/scanner/masscan.ts +++ b/apps/backend/src/scanner/masscan.ts @@ -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 { + 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 { 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 { diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index 3f75900..82447d2 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -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[] = [];