generated from Dicken/dickendock
round54: Live-Fortschritt (Prozent, Restzeit, gefundene Ports/bestaetigte Dienste) waehrend masscan-/httpx-Vorabscan
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user