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

@@ -257,7 +257,32 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
// masscan-/httpx-Lauf arbeitete (siehe Bugreport). // masscan-/httpx-Lauf arbeitete (siehe Bugreport).
updateJobProgress("devices", 0, `masscan läuft… (${deviceIps.length} Geräte, vollständiger Portscan)`); updateJobProgress("devices", 0, `masscan läuft… (${deviceIps.length} Geräte, vollständiger Portscan)`);
const masscanStartedAt = Date.now(); 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 masscanDurationMs = Date.now() - masscanStartedAt;
const masscanBatchError = masscanBatch === null ? getMasscanLastError() : null; const masscanBatchError = masscanBatch === null ? getMasscanLastError() : null;
const masscanPortsFound = masscanBatch const masscanPortsFound = masscanBatch
@@ -294,7 +319,24 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
updateJobProgress("devices", 0, `httpx läuft… (${httpxTargets.length} Port(s) über alle Geräte)`); updateJobProgress("devices", 0, `httpx läuft… (${httpxTargets.length} Port(s) über alle Geräte)`);
const httpxStartedAt = Date.now(); 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; const httpxDurationMs = Date.now() - httpxStartedAt;
if (httpxBatch === null) httpxBatchError = getHttpxLastError(); if (httpxBatch === null) httpxBatchError = getHttpxLastError();

View File

@@ -107,7 +107,8 @@ function computeTimeoutMs(targetCount: number, threads: number, perProbeTimeoutS
* selbst nach, statt ihn zu verwerfen. * selbst nach, statt ihn zu verwerfen.
*/ */
export async function probeManyWithHttpx( 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> { ): Promise<Map<string, HttpxResult> | null> {
if (httpxConfirmedUnavailable || targets.length === 0) { if (httpxConfirmedUnavailable || targets.length === 0) {
return targets.length === 0 ? new Map() : null; return targets.length === 0 ? new Map() : null;
@@ -140,6 +141,15 @@ export async function probeManyWithHttpx(
let stdout = ""; let stdout = "";
let stderr = ""; let stderr = "";
let settled = false; 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) => { const finish = (result: Map<string, HttpxResult> | null) => {
if (settled) return; if (settled) return;
@@ -158,7 +168,19 @@ export async function probeManyWithHttpx(
}); });
child.stdout.on("data", (chunk: Buffer) => { 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) => { child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf-8"); 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; let masscanConfirmedUnavailable = false;
// Klartext-Grund des letzten Fehlschlags - wird im Scan-Log angezeigt, damit // Klartext-Grund des letzten Fehlschlags - wird im Scan-Log angezeigt, damit
// man sieht WARUM masscan nicht greift, statt nur "nicht genutzt/verfügbar" // 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 // ohne jede Erklärung.
// 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.
let masscanLastError: string | null = null; let masscanLastError: string | null = null;
export function getMasscanLastError(): string | 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 * Mehrfach-Ziele als Argumente). Der Semaphore hier schützt nur noch den
* seltenen Fall, dass zwei UNABHÄNGIGE Scan-Jobs (z. B. Sammel-Scan + * seltenen Fall, dass zwei UNABHÄNGIGE Scan-Jobs (z. B. Sammel-Scan +
* gleichzeitig ein manueller Einzelgeräte-Scan per Button) zufällig * gleichzeitig ein manueller Einzelgeräte-Scan per Button) zufällig
* überlappen - kostet dann fast nie Wartezeit, verhindert aber weiterhin, * überlappen.
* dass zwei masscan-Prozesse sich dieselbe Netzwerkschnittstelle streitig
* machen.
*/ */
class Semaphore { class Semaphore {
private available: number; private available: number;
@@ -59,14 +50,10 @@ class Semaphore {
const masscanSemaphore = new Semaphore(1); const masscanSemaphore = new Semaphore(1);
// Rate war bisher 2000 Pakete/Sekunde - ein sehr konservativer, für // Rate war bisher 2000 Pakete/Sekunde - ein sehr konservativer, für
// Internet-weite Scans gedachter Wert. Auf einem lokalen Heimnetz (kein // Internet-weite Scans gedachter Wert. Auf einem lokalen Heimnetz verkraftet
// WAN-Rauschen, keine Provider-Drosselung) verkraftet masscan problemlos // masscan problemlos deutlich mehr, gerade jetzt wo nur noch EIN Prozess
// deutlich mehr, gerade jetzt wo nur noch EIN Prozess gleichzeitig läuft // gleichzeitig läuft (siehe Semaphore oben). 20.000 pps ist für
// (siehe Semaphore oben) statt sich mit bis zu 9 anderen die Bandbreite zu // masscan-Verhältnisse immer noch sehr moderat.
// 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; 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 * Schätzt einen angemessenen Timeout für einen masscan-Lauf über mehrere
* Hosts: Sendezeit (Gesamtzahl Probes / Rate) plus großzügiger * Hosts: Sendezeit (Gesamtzahl Probes / Rate) plus großzügiger
* Sicherheitsfaktor (x2) plus masscans eigene Nachlaufzeit für spät * Sicherheitsfaktor (x2) plus masscans eigene Nachlaufzeit für spät
* eintreffende Antworten (~15s), gedeckelt auf sinnvolle Grenzen (60s..15min) * 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 { function computeTimeoutMs(hostCount: number, portRange: string, rate: number): number {
const totalProbes = hostCount * countPortsInRange(portRange); const totalProbes = hostCount * countPortsInRange(portRange);
const estimatedSeconds = totalProbes / rate; const estimatedSeconds = totalProbes / rate;
const withMargin = estimatedSeconds * 2 + 15; const withMargin = estimatedSeconds * 2 + 15;
// Math.round() ist Pflicht: execFile()s "timeout"-Option verlangt eine // Math.round() ist Pflicht: eine Kommazahl hier führte zuvor zu "The value
// ganze Zahl (Node validiert das strikt) - estimatedSeconds ist aus einer // of "timeout" is out of range" (siehe Bugreport round53).
// Division entstanden und damit fast immer eine Kommazahl, die sonst mit
// "The value of "timeout" is out of range" abgelehnt wird.
return Math.round(Math.min(Math.max(withMargin * 1000, 60_000), 15 * 60_000)); 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 * Nutzt masscan (falls im Container installiert, siehe Dockerfile) für den
* vollständigen Portscan (1-65535) EINER GANZEN GRUPPE von Hosts in einem * vollständigen Portscan (1-65535) EINER GANZEN GRUPPE von Hosts in einem
* einzigen Prozesslauf - masscan unterstützt mehrere Ziel-IPs nativ als * einzigen Prozesslauf - masscan unterstützt mehrere Ziel-IPs nativ als
* Argumente und verschickt SYN-Pakete asynchron per Rohsocket, ohne auf jede * Argumente und verschickt SYN-Pakete asynchron per Rohsocket, ohne auf jede
* einzelne TCP-Verbindung zu warten. Das ist der Grund, warum ein einziger * einzelne TCP-Verbindung zu warten.
* 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 * 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 * (braucht CAP_NET_RAW, siehe docker-compose.yml) oder aus anderem Grund
* fehlschlägt - der Aufrufer nutzt dann stattdessen scanPortsInBatches PRO * fehlschlägt - der Aufrufer nutzt dann stattdessen scanPortsInBatches PRO
* GERÄT. Kein hartes Erfordernis, nur eine Beschleunigung. Der genaue Grund * GERÄT. Der genaue Grund eines Fehlschlags steht danach in
* eines Fehlschlags steht danach in getMasscanLastError(). * 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( export async function scanHostsWithMasscan(
hosts: string[], hosts: string[],
portRange: string, portRange: string,
rate: number = DEFAULT_RATE rate: number = DEFAULT_RATE,
onProgress?: (progress: MasscanProgress) => void
): Promise<Map<string, number[]> | null> { ): Promise<Map<string, number[]> | null> {
if (masscanConfirmedUnavailable || hosts.length === 0) { if (masscanConfirmedUnavailable || hosts.length === 0) {
return hosts.length === 0 ? new Map() : null; return hosts.length === 0 ? new Map() : null;
@@ -133,50 +139,92 @@ export async function scanHostsWithMasscan(
await masscanSemaphore.acquire(); await masscanSemaphore.acquire();
try { try {
const timeoutMs = computeTimeoutMs(hosts.length, portRange, rate); const timeoutMs = computeTimeoutMs(hosts.length, portRange, rate);
return await new Promise((resolve) => { return await new Promise((resolve) => {
execFile( const child = spawn("masscan", [...hosts, "-p", portRange, "--rate", String(rate), "-oL", "-"], {
"masscan", stdio: ["ignore", "pipe", "pipe"],
[...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);
if (wasTimeout) { let stdout = "";
// Explizit als Zeitüberschreitung benennen - die mitgelieferte // Nur die letzten ~2000 Zeichen von stderr vorhalten (für die
// stderr-Ausgabe ist nur der letzte Stand VOR dem Abbruch // Fehlermeldung bei einem Abbruch) - masscan schreibt bei langen
// (masscans eigene Fortschrittsanzeige), keine Fehlermeldung. // Läufen laufend Statuszeilen, eine unbegrenzte Sammlung würde bei
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)"}"`; // vielen Minuten Laufzeit unnötig Speicher aufbauen.
} else { let stderrTail = "";
// Echter Fehlschlag (Exit-Code ≠ 0, nicht durch Timeout) - let stderrParseBuffer = "";
// stderr enthält bei masscan i. d. R. den eigentlichen Grund let settled = false;
// (z. B. "FAIL:.. Permission denied", "could not detect
// interface"), error.message oft nur den generischen Exit-Code. const finish = (result: Map<string, number[]> | null) => {
const exitCode = error.code ?? "unbekannt"; if (settled) return;
masscanLastError = `Exit-Code ${exitCode}: ${(stderr?.trim() || error.message).slice(0, 350)}`; settled = true;
resolve(result);
};
child.on("error", (err) => {
masscanLastError = err.message.slice(0, 400);
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
masscanConfirmedUnavailable = true;
} }
finish(null);
});
// ENOENT (Programm fehlt) oder fehlende Rechte (EPERM/"PCAP" o. ä. child.stdout.on("data", (chunk: Buffer) => {
// in stderr) - beides bedeutet: masscan für diese Installation // Defensive Obergrenze - siehe frühere maxBuffer-Absicherung bei
// dauerhaft nicht nutzbar, künftig direkt auf den Fallback gehen. // execFile, hier von Hand nachgebildet, da spawn() das nicht kennt.
// Ein Timeout zählt ausdrücklich NICHT dazu - das kann if (stdout.length < 64 * 1024 * 1024) {
// situationsabhängig sein (Netzlast) und beim nächsten Mal klappen. stdout += chunk.toString("utf-8");
const msg = `${error.message} ${stderr ?? ""}`.toLowerCase(); }
});
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 ( if (
!wasTimeout &&
(msg.includes("enoent") ||
msg.includes("not found") ||
msg.includes("permission") || msg.includes("permission") ||
msg.includes("operation not permitted") || msg.includes("operation not permitted") ||
msg.includes("pcap")) msg.includes("pcap")
) { ) {
masscanConfirmedUnavailable = true; masscanConfirmedUnavailable = true;
} }
resolve(null); finish(null);
return; return;
} }
@@ -198,9 +246,9 @@ export async function scanHostsWithMasscan(
} }
} }
masscanLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen masscanLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen
resolve(portsByHost); finish(portsByHost);
} });
);
}); });
} finally { } finally {
masscanSemaphore.release(); masscanSemaphore.release();
@@ -209,8 +257,7 @@ export async function scanHostsWithMasscan(
/** /**
* Einzelgerät-Variante von scanHostsWithMasscan() - für den "Jetzt scannen"- * Einzelgerät-Variante von scanHostsWithMasscan() - für den "Jetzt scannen"-
* Button auf der Geräteseite (ein Gerät, kein Sammel-Scan). Intern nur ein * Button auf der Geräteseite (ein Gerät, kein Sammel-Scan).
* Aufruf von scanHostsWithMasscan() mit genau einem Host.
*/ */
export async function scanWithMasscan( export async function scanWithMasscan(
host: string, host: string,