From 0287a7cc5c972c7996614691f91cc92cfb8d3fd9 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 26 Jul 2026 10:49:35 +0200 Subject: [PATCH] round50: masscan/httpx laufen im Sammel-Scan nur noch einmal fuer alle Geraete statt pro Geraet, Rate deutlich erhoeht --- apps/backend/src/routes/scan.ts | 76 +++++++++- apps/backend/src/scanner/httpx.ts | 147 +++++++++++++------ apps/backend/src/scanner/masscan.ts | 157 +++++++++++++++------ apps/backend/src/scanner/networkScanner.ts | 60 +++++++- 4 files changed, 333 insertions(+), 107 deletions(-) diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index 136d0ec..b308219 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -4,8 +4,10 @@ import * as serviceRepo from "../db/repositories/services.js"; import * as categoryRepo from "../db/repositories/categories.js"; import * as logRepo from "../db/repositories/logs.js"; import * as settingsRepo from "../db/repositories/settings.js"; -import { scanDeviceServices } from "../scanner/networkScanner.js"; +import { scanDeviceServices, type PreFetchedScanData } from "../scanner/networkScanner.js"; import { fullPortRange } from "../scanner/ports.js"; +import { scanHostsWithMasscan, getMasscanLastError } from "../scanner/masscan.js"; +import { probeManyWithHttpx, getHttpxLastError, type HttpxResult } from "../scanner/httpx.js"; import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js"; import { startJob, @@ -54,7 +56,7 @@ function formatToolsUsed(toolsUsed: { * Einzelgerät-Endpunkt als auch der Sammel-Scan (siehe /api/scan/devices/all * weiter unten) dieselbe Logik nutzen, ohne sie zu duplizieren. */ -async function performDeviceScan(device: Device, fullScan = false) { +async function performDeviceScan(device: Device, fullScan = false, preFetched?: PreFetchedScanData) { // Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu // erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale"). const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id); @@ -62,7 +64,8 @@ async function performDeviceScan(device: Device, fullScan = false) { const { services: discovered, suggestedHostname, toolsUsed } = await scanDeviceServices( device, fullScan ? fullPortRange() : undefined, - servicesBeforeScan.map((s) => s.port) + servicesBeforeScan.map((s) => s.port), + preFetched ); // Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit @@ -230,10 +233,48 @@ export async function scanRoutes(app: FastifyInstance): Promise { let cancelled = false; try { + // NEU (round50): Bis round49 startete JEDES Gerät seinen eigenen + // masscan- bzw. httpx-Prozess - bei bis zu 10 gleichzeitig + // gescannten Geräten (siehe CONCURRENT_DEVICES unten) liefen dadurch + // bis zu 10 masscan- und 10 httpx-Prozesse parallel, die sich + // gegenseitig die Netzwerkschnittstelle streitig machten und in den + // Timeout liefen (siehe Bugreport: "Flaschenhals"). + // + // Jetzt läuft masscan EINMAL für ALLE Geräte dieses Scan-Laufs + // gemeinsam (masscan unterstützt mehrere Ziel-IPs nativ in einem + // Prozess) und httpx EINMAL für die Vereinigung aller dabei + // gefundenen offenen Ports über alle Geräte hinweg. Das Ergebnis + // wird unten pro Gerät als "preFetched" an performDeviceScan + // durchgereicht - der eigentliche Geräte-Scan macht dann selbst + // keine masscan-/httpx-Aufrufe mehr, nur noch die (leichtgewichtigen, + // reinen HTTP-)Detailabfragen pro Port (Titel, Favicon, + // Softwareerkennung). + const deviceIps = devices.map((d) => d.ip); + const masscanBatch = await scanHostsWithMasscan(deviceIps, "1-65535"); + const masscanBatchError = masscanBatch === null ? getMasscanLastError() : null; + + // httpx nur für die Geräte vorab bündeln, für die masscan Ergebnisse + // geliefert hat - ist masscan für den GESAMTEN Batch fehlgeschlagen + // (masscanBatch === null), kennen wir die offenen Ports noch nicht + // und jedes Gerät ermittelt sie unten wie gewohnt selbst per + // TCP-Fallback (scanPortsInBatches) samt eigenem httpx-Aufruf - + // langsamer als der Idealfall, aber weiterhin korrekt. + let httpxBatch: Map | null = null; + let httpxBatchError: string | null = null; + if (masscanBatch) { + const httpxTargets: Array<{ ip: string; port: number }> = []; + for (const device of devices) { + const ports = masscanBatch.get(device.ip) ?? []; + for (const port of ports) httpxTargets.push({ ip: device.ip, port }); + } + httpxBatch = await probeManyWithHttpx(httpxTargets); + if (httpxBatch === null) httpxBatchError = getHttpxLastError(); + } + // Mehrere Geräte GLEICHZEITIG scannen statt strikt nacheinander - - // bei z. B. 118 Geräten und je 10-30 Sekunden pro vollständigem - // Portscan hätte ein rein sequenzieller Durchlauf zusammen leicht - // 30+ Minuten gedauert. Batch-Größe bewusst moderat (nicht alle + // bei z. B. 118 Geräten hätte ein rein sequenzieller Durchlauf der + // (jetzt viel schnelleren, weil rein HTTP-basierten) Detailabfragen + // trotzdem lange gedauert. Batch-Größe bewusst moderat (nicht alle // Geräte auf einmal): jeder einzelne Geräte-Scan öffnet intern schon // bis zu 1000 gleichzeitige Verbindungen (siehe ports.ts) - zu viele // Geräte gleichzeitig könnte das eigene Netz/den Docker-Host @@ -258,7 +299,28 @@ export async function scanRoutes(app: FastifyInstance): Promise { return; } try { - const result = await performDeviceScan(device, true); + const masscanPorts = masscanBatch?.get(device.ip); + const preFetched: PreFetchedScanData = { + masscanAttempted: true, + masscanPorts, + masscanError: masscanPorts ? null : masscanBatchError, + httpxResults: masscanPorts + ? (() => { + // Aus der großen, geräteübergreifenden httpx-Map nur + // die Treffer DIESES Geräts herausfiltern. + if (httpxBatch === null) return null; + const map = new Map(); + for (const port of masscanPorts) { + const hit = httpxBatch.get(`${device.ip}:${port}`); + if (hit) map.set(port, hit); + } + return map; + })() + : undefined, + httpxError: masscanPorts ? httpxBatchError : null, + }; + + const result = await performDeviceScan(device, true, preFetched); created += result.created; updated += result.updated; allStale.push(...result.staleServices); diff --git a/apps/backend/src/scanner/httpx.ts b/apps/backend/src/scanner/httpx.ts index 494c072..a727b9b 100644 --- a/apps/backend/src/scanner/httpx.ts +++ b/apps/backend/src/scanner/httpx.ts @@ -24,16 +24,12 @@ export function getHttpxLastError(): string | null { } /** - * 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. + * Einfacher Zähl-Semaphore (siehe auch masscan.ts), Kapazität 2: Seit + * round50 läuft pro Scan-JOB nur noch EIN httpx-Prozess für ALLE Geräte + * gleichzeitig (siehe probeManyWithHttpx unten), nicht mehr einer PRO Gerät + * wie bis round49. Der Semaphore schützt nur noch den seltenen Fall, dass + * zwei unabhängige Scan-Jobs (Sammel-Scan + gleichzeitig ein manueller + * Einzelgeräte-Scan) überlappen. */ class Semaphore { private available: number; @@ -58,33 +54,69 @@ class Semaphore { } } -const httpxSemaphore = new Semaphore(3); +const httpxSemaphore = new Semaphore(2); + +/** + * Threads für den httpx-Aufruf: skaliert leicht mit der Zielanzahl (mehr + * Ziele -> mehr Parallelität sinnvoll), aber mit Ober-/Untergrenze. Seit nur + * noch EIN httpx-Prozess pro Scan-Job läuft (statt bis zu 10 gleichzeitig), + * ist ein höherer Wert als die bisherigen festen 50 unproblematisch - dieser + * eine Prozess hat die volle Bandbreite für sich. + */ +function computeThreads(targetCount: number): number { + return Math.min(200, Math.max(50, targetCount)); +} + +/** + * Schätzt einen angemessenen Gesamt-Timeout für einen httpx-Lauf über + * mehrere Ziele: (Zielanzahl / Threads) * Zeit pro Versuch (Timeout * + * (1 + Retries)), mit Sicherheitsfaktor und Grenzen (30s..10min). + */ +function computeTimeoutMs(targetCount: number, threads: number, perProbeTimeoutSec: number, retries: number): number { + const perProbeSeconds = perProbeTimeoutSec * (1 + retries); + const estimatedSeconds = (targetCount / threads) * perProbeSeconds; + const withMargin = estimatedSeconds * 1.5 + 10; + return Math.min(Math.max(withMargin * 1000, 30_000), 10 * 60_000); +} /** * Nutzt httpx (ProjectDiscovery, falls im Container installiert - siehe - * Dockerfile) um von einer Liste offener Ports (z. B. das Ergebnis von - * masscan) NUR die herauszufiltern, die tatsächlich HTTP oder HTTPS - * sprechen - inklusive Status-Code/Titel/Server-Header in einem einzigen, - * hochparallelen Durchlauf. Deutlich schneller und präziser als unser - * eigenes Protokoll-Raten (erst HTTP versuchen, bei Fehlschlag HTTPS - * versuchen) für JEDEN einzelnen offenen Port einzeln. + * Dockerfile), um eine LISTE von "ip:port"-Zielen ÜBER MEHRERE GERÄTE HINWEG + * in einem einzigen Prozesslauf zu prüfen, statt pro Gerät einen eigenen + * httpx-Prozess zu starten (das führte bei vielen parallel gescannten + * Geräten - siehe routes/scan.ts CONCURRENT_DEVICES - zu bis zu 500 + * gleichzeitigen HTTP-Anfragen allein durch httpx und damit zu Timeouts + * unter Last, siehe Bugreport). * - * Liefert null (nicht einen leeren Array!), wenn httpx fehlt oder + * Ergebnis ist eine Map, deren Schlüssel "ip:port" ist (z. B. + * "192.168.1.50:8080") - zusammengesetzt aus httpx' eigenem "url"-Feld + * (IMMER Teil der -json-Ausgabe, im Gegensatz zu ggf. je nach Version + * abweichenden Feldern wie "input"), damit die Zuordnung robust gegenüber + * kleineren httpx-Versionsunterschieden ist. + * + * Liefert null (nicht eine leere Map!), wenn httpx fehlt oder komplett * 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. 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. + * für ALLE betroffenen Ports auf die eingebaute Methode zurück. Ein Ziel, + * das httpx innerhalb des Timeouts nicht erreicht, taucht in der Map + * schlicht nicht auf - von hier aus nicht unterscheidbar von "spricht kein + * HTTP". Die Aufrufer (networkScanner.ts) prüfen daher jeden offenen Port, + * der NICHT in der Map auftaucht, sicherheitshalber trotzdem noch einmal + * selbst nach, statt ihn zu verwerfen. */ -export async function probeWithHttpx(ip: string, ports: number[]): Promise | null> { - if (httpxConfirmedUnavailable || ports.length === 0) { - return ports.length === 0 ? new Map() : null; +export async function probeManyWithHttpx( + targets: Array<{ ip: string; port: number }> +): Promise | null> { + if (httpxConfirmedUnavailable || targets.length === 0) { + return targets.length === 0 ? new Map() : null; } await httpxSemaphore.acquire(); try { + const threads = computeThreads(targets.length); + const perProbeTimeoutSec = 8; + const retries = 1; + const timeoutMs = computeTimeoutMs(targets.length, threads, perProbeTimeoutSec, retries); + return await new Promise((resolve) => { const child = spawn( "httpx", @@ -92,12 +124,12 @@ export async function probeWithHttpx(ip: string, ports: number[]): Promise | null) => { + const finish = (result: Map | null) => { if (settled) return; settled = true; resolve(result); @@ -129,25 +161,19 @@ export async function probeWithHttpx(ip: string, ports: number[]): Promise { - httpxLastError = `Zeitüberschreitung (60s) - httpx wurde beendet, bevor alle ${ports.length} Port(s) geprüft waren`; + httpxLastError = `Zeitüberschreitung (${Math.round(timeoutMs / 1000)}s) - httpx wurde beendet, bevor alle ${targets.length} Ziel(e) geprüft waren`; child.kill(); finish(null); - }, 60_000); + }, timeoutMs); 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). + // Exit-Code-Meldung unter, die "close" hier sonst noch setzen würde + // (der Kill selbst löst "close" erst danach aus). if (settled) return; if (code !== 0 && stdout.trim().length === 0) { @@ -159,12 +185,13 @@ export async function probeWithHttpx(ip: string, ports: number[]): Promise(); + 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 { + url?: string; port?: string; scheme?: string; status_code?: number; @@ -172,8 +199,20 @@ export async function probeWithHttpx(ip: string, ports: number[]): Promise | null> { + const result = await probeManyWithHttpx(ports.map((port) => ({ ip, port }))); + if (result === null) return null; + + const map = new Map(); + for (const port of ports) { + const hit = result.get(`${ip}:${port}`); + if (hit) map.set(port, hit); + } + return map; +} diff --git a/apps/backend/src/scanner/masscan.ts b/apps/backend/src/scanner/masscan.ts index dd3a89c..a16c8fc 100644 --- a/apps/backend/src/scanner/masscan.ts +++ b/apps/backend/src/scanner/masscan.ts @@ -11,8 +11,7 @@ let masscanConfirmedUnavailable = false; // 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. +// klar benannter Grund ausgegeben. let masscanLastError: string | null = null; export function getMasscanLastError(): string | null { @@ -20,18 +19,19 @@ export function getMasscanLastError(): string | null { } /** - * 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. + * Einfacher Zähl-Semaphore mit Kapazität 1. Seit round49 lief HIERÜBER noch + * pro GERÄT ein eigener masscan-Prozess - bei bis zu 10 parallel gescannten + * Geräten (siehe routes/scan.ts CONCURRENT_DEVICES) wurden die vollständigen + * Portscans dadurch faktisch serialisiert und der Gesamtscan massiv + * verlangsamt (siehe Bugreport). Seit round50 gibt es dafür + * scanHostsWithMasscan() weiter unten: EIN masscan-Prozess für ALLE Geräte + * eines Scan-Laufs gleichzeitig (masscan kann das nativ, siehe + * Mehrfach-Ziele als Argumente). Der Semaphore hier schützt nur noch den + * seltenen Fall, dass zwei UNABHÄNGIGE Scan-Jobs (z. B. Sammel-Scan + + * gleichzeitig ein manueller Einzelgeräte-Scan per Button) zufällig + * überlappen - kostet dann fast nie Wartezeit, verhindert aber weiterhin, + * dass zwei masscan-Prozesse sich dieselbe Netzwerkschnittstelle streitig + * machen. */ class Semaphore { private available: number; @@ -58,41 +58,85 @@ class Semaphore { 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; +// Rate war bisher 2000 Pakete/Sekunde - ein sehr konservativer, für +// Internet-weite Scans gedachter Wert. Auf einem lokalen Heimnetz (kein +// WAN-Rauschen, keine Provider-Drosselung) verkraftet masscan problemlos +// deutlich mehr, gerade jetzt wo nur noch EIN Prozess gleichzeitig läuft +// (siehe Semaphore oben) statt sich mit bis zu 9 anderen die Bandbreite zu +// teilen. 20.000 pps ist für masscan-Verhältnisse immer noch sehr moderat +// (das Tool ist für Millionen pps ausgelegt), reicht aber, um z. B. 20 +// Geräte à 65535 Ports in guten 60-90 Sekunden statt vielen Minuten +// abzudecken. +const DEFAULT_RATE = 20_000; + +/** + * Zählt die Ports in einem masscan-Portbereich ("1-65535", "80,443,8000-8100" + * usw.) - für die Timeout-Schätzung unten. Fällt auf 65535 zurück, falls das + * Format nicht erkannt wird (sichere Obergrenze, lieber zu großzügig als zu + * knapp beim Timeout). + */ +function countPortsInRange(portRange: string): number { + let count = 0; + for (const part of portRange.split(",")) { + const [a, b] = part.split("-").map(Number); + if (Number.isFinite(a) && Number.isFinite(b)) count += Math.max(0, b - a + 1); + else if (Number.isFinite(a)) count += 1; + } + return count > 0 ? count : 65535; +} + +/** + * Schätzt einen angemessenen Timeout für einen masscan-Lauf über mehrere + * Hosts: Sendezeit (Gesamtzahl Probes / Rate) plus großzügiger + * Sicherheitsfaktor (x2) plus masscans eigene Nachlaufzeit für spät + * eintreffende Antworten (~15s), gedeckelt auf sinnvolle Grenzen (60s..15min) + * - lieber einmal spürbar warten als einen fast fertigen Lauf über Dutzende + * Geräte kurz vor Schluss abwürgen. + */ +function computeTimeoutMs(hostCount: number, portRange: string, rate: number): number { + const totalProbes = hostCount * countPortsInRange(portRange); + const estimatedSeconds = totalProbes / rate; + const withMargin = estimatedSeconds * 2 + 15; + return Math.min(Math.max(withMargin * 1000, 60_000), 15 * 60_000); +} /** * Nutzt masscan (falls im Container installiert, siehe Dockerfile) für den - * vollständigen Portscan (1-65535) - masscan verschickt SYN-Pakete - * asynchron per Rohsocket, ohne auf jede einzelne TCP-Verbindung zu warten, - * und ist dadurch um ein Vielfaches schneller als das eingebaute - * Batch-TCP-Connect-Scanning (scanPortsInBatches in ports.ts). + * vollständigen Portscan (1-65535) EINER GANZEN GRUPPE von Hosts in einem + * einzigen Prozesslauf - masscan unterstützt mehrere Ziel-IPs nativ als + * Argumente und verschickt SYN-Pakete asynchron per Rohsocket, ohne auf jede + * einzelne TCP-Verbindung zu warten. Das ist der Grund, warum ein einziger + * Lauf über z. B. 20 Geräte kaum länger dauert als einer über ein einzelnes + * Gerät (die Gesamt-Paketrate ist der limitierende Faktor, nicht die Anzahl + * Hosts) - und warum das frühere Modell "ein masscan-Prozess PRO Gerät" + * unnötig langsam war. * * 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(). + * fehlschlägt - der Aufrufer nutzt dann stattdessen scanPortsInBatches PRO + * GERÄT. Kein hartes Erfordernis, nur eine Beschleunigung. Der genaue Grund + * eines Fehlschlags steht danach in getMasscanLastError(). */ -export async function scanWithMasscan( - host: string, +export async function scanHostsWithMasscan( + hosts: string[], portRange: string, - rate = 2000 -): Promise { - if (masscanConfirmedUnavailable) return null; + rate: number = DEFAULT_RATE +): Promise | null> { + if (masscanConfirmedUnavailable || hosts.length === 0) { + return hosts.length === 0 ? new Map() : null; + } await masscanSemaphore.acquire(); try { + const timeoutMs = computeTimeoutMs(hosts.length, portRange, rate); return await new Promise((resolve) => { - const child = execFile( + execFile( "masscan", - [host, "-p", portRange, "--rate", String(rate), "-oL", "-"], - { timeout: MASSCAN_TIMEOUT_MS }, + [...hosts, "-p", portRange, "--rate", String(rate), "-oL", "-"], + // maxBuffer hochgesetzt: bei vielen Hosts mit vielen offenen Ports + // (z. B. nach einem vollständigen 1-65535-Scan über 20+ Geräte) + // reicht das node-Standardlimit von 1MB stdout ggf. nicht. + { timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024 }, (error, stdout, stderr) => { if (error) { const wasTimeout = error.killed === true; @@ -102,7 +146,7 @@ export async function scanWithMasscan( // 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)"}"`; + masscanLastError = `Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)}s (${hosts.length} Host(s), Portbereich ${portRange}) - 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 @@ -114,11 +158,9 @@ export async function scanWithMasscan( // 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. + // dauerhaft nicht nutzbar, künftig direkt auf den Fallback gehen. + // 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 && @@ -135,26 +177,47 @@ export async function scanWithMasscan( } // "-oL -" (List-Format) gibt pro offenem Port eine Zeile aus: - // "open tcp 80 192.168.1.50 1700000000" - const ports: number[] = []; + // "open tcp 80 192.168.1.50 1700000000" - die IP-Spalte (Index 3) + // ist bei mehreren Ziel-Hosts der Schlüssel, über den die Treffer + // den einzelnen Geräten zugeordnet werden. + const portsByHost = new Map(); 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); + const ip = parts[3]; + if (Number.isFinite(port) && ip) { + const list = portsByHost.get(ip); + if (list) list.push(port); + else portsByHost.set(ip, [port]); + } } } masscanLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen - resolve(ports); + resolve(portsByHost); } ); - void child; }); } finally { masscanSemaphore.release(); } } +/** + * Einzelgerät-Variante von scanHostsWithMasscan() - für den "Jetzt scannen"- + * Button auf der Geräteseite (ein Gerät, kein Sammel-Scan). Intern nur ein + * Aufruf von scanHostsWithMasscan() mit genau einem Host. + */ +export async function scanWithMasscan( + host: string, + portRange: string, + rate: number = DEFAULT_RATE +): Promise { + const result = await scanHostsWithMasscan([host], portRange, rate); + if (result === null) return null; + return result.get(host) ?? []; +} + export function isMasscanConfirmedUnavailable(): boolean { return masscanConfirmedUnavailable; } diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index 82447d2..2d54ea5 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -1,7 +1,7 @@ import { resolveHostname, reverseLookup } from "./dns.js"; import { isPortOpen, scanPortsInBatches, fullPortRange, TYPICAL_PORTS } from "./ports.js"; import { scanWithMasscan, getMasscanLastError } from "./masscan.js"; -import { probeWithHttpx, getHttpxLastError } from "./httpx.js"; +import { probeWithHttpx, getHttpxLastError, type HttpxResult } from "./httpx.js"; import { probeHttp } from "./http.js"; import { detectSoftware } from "./softwareDetection.js"; import { findBestIconMatch } from "./iconDb.js"; @@ -100,6 +100,25 @@ export interface DeviceScanResult { }; } +/** + * Vorab (außerhalb dieser Funktion) ermittelte Scan-Daten für EIN Gerät - + * genutzt vom Sammel-Scan (siehe routes/scan.ts), der masscan/httpx nicht + * mehr pro Gerät einzeln aufruft, sondern EINMAL für alle Geräte des + * Scan-Laufs gemeinsam (siehe scanHostsWithMasscan/probeManyWithHttpx) und + * das Ergebnis dann pro Gerät hier einspeist. Bleibt dieser Parameter ganz + * weg (Einzelgeräte-Scan über den "Jetzt scannen"-Button), verhält sich die + * Funktion wie zuvor und macht die masscan-/httpx-Aufrufe selbst. + */ +export interface PreFetchedScanData { + masscanAttempted: boolean; + /** undefined = masscan wurde für dieses Gerät nicht vorab versucht (dann selbst versuchen). */ + masscanPorts?: number[]; + masscanError: string | null; + /** undefined = httpx wurde für dieses Gerät nicht vorab versucht (dann selbst versuchen). */ + httpxResults?: Map | null; + httpxError: string | null; +} + /** * Scannt ein einzelnes Gerät: versucht zuerst eine schönere DNS-Adresse * (hostname / hostname.home / hostname.local) aufzulösen, prüft dann Port 80, @@ -109,7 +128,8 @@ export interface DeviceScanResult { export async function scanDeviceServices( device: ScanTarget, extraPorts: number[] = TYPICAL_PORTS, - priorityPorts: number[] = [] + priorityPorts: number[] = [], + preFetched?: PreFetchedScanData ): Promise { const dnsResult = await resolveHostname(device.hostname); // Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist. @@ -134,14 +154,28 @@ export async function scanDeviceServices( // eingebaute Scanning reicht dafür völlig. let usedMasscan = false; let masscanError: string | null = null; - const masscanAttempted = candidatePorts.length > 1000; + let masscanAttempted: boolean; let openPorts: number[]; - if (masscanAttempted) { + if (preFetched) { + // Sammel-Scan: masscan wurde bereits VORAB für alle Geräte gemeinsam + // versucht (siehe routes/scan.ts) - hier nur noch das Ergebnis für DIESES + // Gerät übernehmen, kein eigener masscan-Aufruf mehr. + masscanAttempted = preFetched.masscanAttempted; + if (preFetched.masscanPorts) { + usedMasscan = true; + openPorts = preFetched.masscanPorts; + } else { + masscanError = preFetched.masscanError; + openPorts = await scanPortsInBatches(device.ip, candidatePorts); + } + } else if (candidatePorts.length > 1000) { + masscanAttempted = true; const masscanResult = await scanWithMasscan(device.ip, "1-65535"); usedMasscan = masscanResult !== null; if (!usedMasscan) masscanError = getMasscanLastError(); openPorts = masscanResult ?? (await scanPortsInBatches(device.ip, candidatePorts)); } else { + masscanAttempted = false; openPorts = await scanPortsInBatches(device.ip, candidatePorts); } @@ -193,9 +227,20 @@ export async function scanDeviceServices( // 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; + // + // Beim Sammel-Scan (preFetched gesetzt) wurde httpx bereits VORAB für alle + // Geräte gemeinsam aufgerufen (siehe routes/scan.ts) - hier nur noch das + // Ergebnis für DIESES Gerät übernehmen. Ports, die erst NACH dem + // vorab-Aufruf dazugekommen sind (z. B. durch den Prioritäts-Retry oben), + // fehlen dann in der vorab ermittelten Map - das ist unkritisch, sie + // durchlaufen einfach ganz normal probePort()s eigene Protokollprüfung. + const httpxResults = preFetched + ? preFetched.httpxResults + : openPorts.length > 0 + ? await probeWithHttpx(device.ip, openPorts) + : new Map(); + const usingHttpx = httpxResults !== null && httpxResults !== undefined; + const httpxError = preFetched ? preFetched.httpxError : !usingHttpx ? getHttpxLastError() : null; // 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 @@ -209,6 +254,7 @@ export async function scanDeviceServices( // probePort() ihn ganz normal selbst (identisch zum Verhalten ganz ohne // httpx), statt ihn zu überspringen. + const found: DiscoveredService[] = []; // Die HTTP-Abfrage je offenem Port läuft PARALLEL, nicht mehr