round54: Live-Fortschritt (Prozent, Restzeit, gefundene Ports/bestaetigte Dienste) waehrend masscan-/httpx-Vorabscan

This commit is contained in:
2026-07-26 11:50:21 +02:00
parent edc4acaf3b
commit c7398ce2ba
3 changed files with 215 additions and 104 deletions

View File

@@ -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<Map<string, HttpxResult> | 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<string, HttpxResult> | 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");

View File

@@ -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<Map<string, number[]> | 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<string, number[]> | 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<string, 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]);
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<string, 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]);
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,