From c7398ce2ba0e38a5bd334096ae7a18494b8a0f39 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sun, 26 Jul 2026 11:50:21 +0200 Subject: [PATCH] round54: Live-Fortschritt (Prozent, Restzeit, gefundene Ports/bestaetigte Dienste) waehrend masscan-/httpx-Vorabscan --- apps/backend/src/routes/scan.ts | 46 +++++- apps/backend/src/scanner/httpx.ts | 26 ++- apps/backend/src/scanner/masscan.ts | 247 +++++++++++++++++----------- 3 files changed, 215 insertions(+), 104 deletions(-) diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index 37fba91..c9366a1 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -257,7 +257,32 @@ export async function scanRoutes(app: FastifyInstance): Promise { // masscan-/httpx-Lauf arbeitete (siehe Bugreport). updateJobProgress("devices", 0, `masscan läuft… (${deviceIps.length} Geräte, vollständiger Portscan)`); const masscanStartedAt = Date.now(); - const masscanBatch = await scanHostsWithMasscan(deviceIps, "1-65535"); + + // masscan meldet laufend Fortschritt (Prozent, Restzeit, bisherige + // Treffer) - wird hier live in die Fortschrittsanzeige gespiegelt, + // statt nur den statischen "masscan läuft…"-Text stehen zu lassen + // (siehe Bugreport: "ich sehe nicht, ob noch alles richtig läuft"). + // Eine feste IP lässt sich dabei bewusst NICHT anzeigen - masscan + // arbeitet alle Ziel-Hosts gemeinsam/asynchron ab, es gibt keine + // sinnvolle "aktuell bearbeitete IP" wie bei einem sequenziellen + // Scanner. + let lastMasscanProgressUpdate = 0; + const masscanBatch = await scanHostsWithMasscan(deviceIps, "1-65535", undefined, (progress) => { + const now = Date.now(); + if (now - lastMasscanProgressUpdate < 500) return; // nicht öfter als 2x/s aktualisieren + lastMasscanProgressUpdate = now; + const etaText = + progress.etaSeconds !== null + ? progress.etaSeconds > 0 + ? `, ~${progress.etaSeconds}s verbleibend` + : ", wartet auf letzte Antworten" + : ""; + updateJobProgress( + "devices", + 0, + `masscan läuft… (${deviceIps.length} Geräte, vollständiger Portscan) – ${progress.percent.toFixed(0)}% erledigt${etaText}, ${progress.foundSoFar} offene(r) Port(s) bisher` + ); + }); const masscanDurationMs = Date.now() - masscanStartedAt; const masscanBatchError = masscanBatch === null ? getMasscanLastError() : null; const masscanPortsFound = masscanBatch @@ -294,7 +319,24 @@ export async function scanRoutes(app: FastifyInstance): Promise { updateJobProgress("devices", 0, `httpx läuft… (${httpxTargets.length} Port(s) über alle Geräte)`); const httpxStartedAt = Date.now(); - httpxBatch = await probeManyWithHttpx(httpxTargets); + + // httpx meldet jeden Treffer sofort, sobald er bestätigt ist - + // wird hier live in die Fortschrittsanzeige gespiegelt (Anzahl + // bereits als HTTP/HTTPS bestätigter Ports von den insgesamt zu + // prüfenden Zielen). Eine Zwischenanzeige "noch nicht geprüfte" + // Ziele ist dabei nicht sinnvoll möglich - httpx meldet erfolglose + // Versuche gar nicht erst zurück (siehe httpx.ts). + let lastHttpxProgressUpdate = 0; + httpxBatch = await probeManyWithHttpx(httpxTargets, (confirmedSoFar) => { + const now = Date.now(); + if (now - lastHttpxProgressUpdate < 500) return; + lastHttpxProgressUpdate = now; + updateJobProgress( + "devices", + 0, + `httpx läuft… (${httpxTargets.length} Port(s) über alle Geräte) – ${confirmedSoFar} bereits als HTTP/HTTPS bestätigt` + ); + }); const httpxDurationMs = Date.now() - httpxStartedAt; if (httpxBatch === null) httpxBatchError = getHttpxLastError(); diff --git a/apps/backend/src/scanner/httpx.ts b/apps/backend/src/scanner/httpx.ts index 85c9820..afec7af 100644 --- a/apps/backend/src/scanner/httpx.ts +++ b/apps/backend/src/scanner/httpx.ts @@ -107,7 +107,8 @@ function computeTimeoutMs(targetCount: number, threads: number, perProbeTimeoutS * selbst nach, statt ihn zu verwerfen. */ export async function probeManyWithHttpx( - targets: Array<{ ip: string; port: number }> + targets: Array<{ ip: string; port: number }>, + onProgress?: (confirmedSoFar: number) => void ): Promise | null> { if (httpxConfirmedUnavailable || targets.length === 0) { return targets.length === 0 ? new Map() : null; @@ -140,6 +141,15 @@ export async function probeManyWithHttpx( let stdout = ""; let stderr = ""; let settled = false; + // Zählt Zeilen live mit, während sie eintreffen (httpx schreibt jedes + // Ergebnis sofort nach erfolgreicher Prüfung, nicht erst am Ende) - + // für eine Live-Anzeige "X bereits gefunden" im Sammel-Scan-Job. + // Erfasst nur ALS HTTP/HTTPS BESTÄTIGTE Ziele (jede Zeile ist ein + // Treffer), nicht die Gesamtzahl geprüfter Ziele - httpx meldet + // erfolglose Versuche gar nicht erst (siehe Kommentar an der Funktion + // oben). + let confirmedCount = 0; + let progressParseBuffer = ""; const finish = (result: Map | null) => { if (settled) return; @@ -158,7 +168,19 @@ export async function probeManyWithHttpx( }); child.stdout.on("data", (chunk: Buffer) => { - stdout += chunk.toString("utf-8"); + const text = chunk.toString("utf-8"); + stdout += text; + + if (!onProgress) return; + progressParseBuffer += text; + const lines = progressParseBuffer.split("\n"); + progressParseBuffer = lines.pop() ?? ""; + for (const line of lines) { + if (line.trim()) { + confirmedCount += 1; + onProgress(confirmedCount); + } + } }); child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString("utf-8"); diff --git a/apps/backend/src/scanner/masscan.ts b/apps/backend/src/scanner/masscan.ts index ac3fff4..9f24c07 100644 --- a/apps/backend/src/scanner/masscan.ts +++ b/apps/backend/src/scanner/masscan.ts @@ -1,17 +1,10 @@ -import { execFile } from "node:child_process"; +import { spawn } from "node:child_process"; let masscanConfirmedUnavailable = false; // 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. +// ohne jede Erklärung. let masscanLastError: string | null = null; export function getMasscanLastError(): string | null { @@ -29,9 +22,7 @@ export function getMasscanLastError(): string | null { * 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. + * überlappen. */ class Semaphore { private available: number; @@ -59,14 +50,10 @@ class Semaphore { const masscanSemaphore = new Semaphore(1); // 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. +// Internet-weite Scans gedachter Wert. Auf einem lokalen Heimnetz verkraftet +// masscan problemlos deutlich mehr, gerade jetzt wo nur noch EIN Prozess +// gleichzeitig läuft (siehe Semaphore oben). 20.000 pps ist für +// masscan-Verhältnisse immer noch sehr moderat. const DEFAULT_RATE = 20_000; /** @@ -89,42 +76,61 @@ function countPortsInRange(portRange: string): number { * 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. + * eintreffende Antworten (~15s), gedeckelt auf sinnvolle Grenzen (60s..15min). */ function computeTimeoutMs(hostCount: number, portRange: string, rate: number): number { const totalProbes = hostCount * countPortsInRange(portRange); const estimatedSeconds = totalProbes / rate; const withMargin = estimatedSeconds * 2 + 15; - // Math.round() ist Pflicht: execFile()s "timeout"-Option verlangt eine - // ganze Zahl (Node validiert das strikt) - estimatedSeconds ist aus einer - // Division entstanden und damit fast immer eine Kommazahl, die sonst mit - // "The value of "timeout" is out of range" abgelehnt wird. + // Math.round() ist Pflicht: eine Kommazahl hier führte zuvor zu "The value + // of "timeout" is out of range" (siehe Bugreport round53). return Math.round(Math.min(Math.max(withMargin * 1000, 60_000), 15 * 60_000)); } +export interface MasscanProgress { + /** Prozent (0-100) laut masscans eigener Fortschrittsanzeige. */ + percent: number; + /** Bisher gefundene offene Ports, geräteübergreifend. */ + foundSoFar: number; + /** Von masscan geschätzte verbleibende Sekunden, falls bekannt. */ + etaSeconds: number | null; +} + +// masscan gibt während des Laufs LAUFEND eine Statuszeile auf stderr aus +// (standardmäßig per \r überschrieben, für ein Terminal gedacht - beim +// Auslesen per Kindprozess kommen die \r trotzdem an), z. B.: +// "rate: 1234.56-kpps, 45.23% done, 0:00:12 remaining, found=7" +// oder kurz vor Schluss (fertig gesendet, wartet noch auf späte Antworten): +// "rate: 0.00-kpps, 100.00% done, waiting 5-secs, found=7" +// Diese Zeile wird unten live geparst, um echten Fortschritt (Prozent, +// geschätzte Restzeit, bisherige Treffer) nach außen zu geben - vorher +// wusste man während eines laufenden Sammel-Scans nur "masscan läuft…" ohne +// jede weitere Information (siehe Bugreport). +const MASSCAN_PROGRESS_RE = + /rate:\s*([\d.]+)-kpps,\s*([\d.]+)%\s*done,\s*(?:(\d+):(\d+):(\d+)\s*remaining|waiting\s*(-?\d+)-secs),\s*found=(\d+)/; + /** * Nutzt masscan (falls im Container installiert, siehe Dockerfile) für den * 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. + * einzelne TCP-Verbindung zu warten. * * 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 PRO - * GERÄT. Kein hartes Erfordernis, nur eine Beschleunigung. Der genaue Grund - * eines Fehlschlags steht danach in getMasscanLastError(). + * GERÄT. Der genaue Grund eines Fehlschlags steht danach in + * getMasscanLastError(). + * + * @param onProgress Optionaler Callback, der bei JEDER von masscan + * ausgegebenen Statuszeile mit dem aktuellen Fortschritt aufgerufen wird + * (siehe MasscanProgress) - für eine Live-Anzeige im Sammel-Scan-Job. */ export async function scanHostsWithMasscan( hosts: string[], portRange: string, - rate: number = DEFAULT_RATE + rate: number = DEFAULT_RATE, + onProgress?: (progress: MasscanProgress) => void ): Promise | null> { if (masscanConfirmedUnavailable || hosts.length === 0) { return hosts.length === 0 ? new Map() : null; @@ -133,74 +139,116 @@ export async function scanHostsWithMasscan( await masscanSemaphore.acquire(); try { const timeoutMs = computeTimeoutMs(hosts.length, portRange, rate); + return await new Promise((resolve) => { - execFile( - "masscan", - [...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; - const lastOutput = (stderr?.trim() || stdout?.trim() || "").slice(0, 300); + const child = spawn("masscan", [...hosts, "-p", portRange, "--rate", String(rate), "-oL", "-"], { + stdio: ["ignore", "pipe", "pipe"], + }); - 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 ${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 - // (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)}`; - } + let stdout = ""; + // Nur die letzten ~2000 Zeichen von stderr vorhalten (für die + // Fehlermeldung bei einem Abbruch) - masscan schreibt bei langen + // Läufen laufend Statuszeilen, eine unbegrenzte Sammlung würde bei + // vielen Minuten Laufzeit unnötig Speicher aufbauen. + let stderrTail = ""; + let stderrParseBuffer = ""; + let settled = false; - // 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. - // 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; - } + const finish = (result: Map | null) => { + if (settled) return; + settled = true; + resolve(result); + }; - // "-oL -" (List-Format) gibt pro offenem Port eine Zeile aus: - // "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]); - 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(portsByHost); + child.on("error", (err) => { + masscanLastError = err.message.slice(0, 400); + if ((err as NodeJS.ErrnoException).code === "ENOENT") { + masscanConfirmedUnavailable = true; } - ); + finish(null); + }); + + child.stdout.on("data", (chunk: Buffer) => { + // Defensive Obergrenze - siehe frühere maxBuffer-Absicherung bei + // execFile, hier von Hand nachgebildet, da spawn() das nicht kennt. + if (stdout.length < 64 * 1024 * 1024) { + stdout += chunk.toString("utf-8"); + } + }); + + child.stderr.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf-8"); + stderrTail = (stderrTail + text).slice(-2000); + + if (!onProgress) return; + // masscan trennt Status-Updates per \r (Terminal-Konvention) statt + // \n - beide als Trenner behandeln. Der letzte, noch unvollständige + // Rest bleibt im Puffer bis zum nächsten Chunk. + stderrParseBuffer += text; + const parts = stderrParseBuffer.split(/[\r\n]+/); + stderrParseBuffer = parts.pop() ?? ""; + for (const line of parts) { + const match = line.match(MASSCAN_PROGRESS_RE); + if (!match) continue; + const percent = Number(match[2]); + const foundSoFar = Number(match[7]); + let etaSeconds: number | null = null; + if (match[3] !== undefined) { + etaSeconds = Number(match[3]) * 3600 + Number(match[4]) * 60 + Number(match[5]); + } else if (match[6] !== undefined) { + etaSeconds = Math.max(0, Number(match[6])); + } + if (Number.isFinite(percent)) onProgress({ percent, foundSoFar, etaSeconds }); + } + }); + + const timeout = setTimeout(() => { + masscanLastError = `Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)}s (${hosts.length} Host(s), Portbereich ${portRange}) - masscan wurde beendet, bevor der Scan fertig war. Letzter Stand vor Abbruch: "${stderrTail.trim() || "(keine Ausgabe)"}"`; + child.kill(); + finish(null); + }, timeoutMs); + + child.on("close", (code) => { + clearTimeout(timeout); + if (settled) return; // schon per Timeout oben abgeschlossen + + if (code !== 0) { + const exitCode = code ?? "unbekannt"; + masscanLastError = `Exit-Code ${exitCode}: ${stderrTail.trim() || "(keine Fehlerausgabe)"}`.slice(0, 400); + + const msg = stderrTail.toLowerCase(); + if ( + msg.includes("permission") || + msg.includes("operation not permitted") || + msg.includes("pcap") + ) { + masscanConfirmedUnavailable = true; + } + finish(null); + return; + } + + // "-oL -" (List-Format) gibt pro offenem Port eine Zeile aus: + // "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]); + 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 + finish(portsByHost); + }); + }); } finally { masscanSemaphore.release(); @@ -209,8 +257,7 @@ export async function scanHostsWithMasscan( /** * 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. + * Button auf der Geräteseite (ein Gerät, kein Sammel-Scan). */ export async function scanWithMasscan( host: string,