generated from Dicken/dickendock
round50: masscan/httpx laufen im Sammel-Scan nur noch einmal fuer alle Geraete statt pro Geraet, Rate deutlich erhoeht
This commit is contained in:
@@ -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<Map<number, HttpxResult> | null> {
|
||||
if (httpxConfirmedUnavailable || ports.length === 0) {
|
||||
return ports.length === 0 ? new Map() : null;
|
||||
export async function probeManyWithHttpx(
|
||||
targets: Array<{ ip: string; port: number }>
|
||||
): Promise<Map<string, HttpxResult> | 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<Map<n
|
||||
"-silent",
|
||||
"-json",
|
||||
"-timeout",
|
||||
"8",
|
||||
String(perProbeTimeoutSec),
|
||||
"-retries",
|
||||
"1",
|
||||
String(retries),
|
||||
"-no-color",
|
||||
"-threads",
|
||||
"50",
|
||||
String(threads),
|
||||
],
|
||||
{ stdio: ["pipe", "pipe", "pipe"] }
|
||||
);
|
||||
@@ -106,7 +138,7 @@ export async function probeWithHttpx(ip: string, ports: number[]): Promise<Map<n
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: Map<number, HttpxResult> | null) => {
|
||||
const finish = (result: Map<string, HttpxResult> | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
@@ -129,25 +161,19 @@ export async function probeWithHttpx(ip: string, ports: number[]): Promise<Map<n
|
||||
stderr += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
// Grober oberer Zeitrahmen für den GESAMTEN httpx-Lauf (alle Ports
|
||||
// zusammen, dank -threads 50 weitgehend parallel) - unabhängig vom
|
||||
// Pro-Port-Timeout oben. 60s statt vorheriger 30s, damit ein Gerät mit
|
||||
// vielen offenen Ports (nach einem vollständigen 1-65535-Portscan)
|
||||
// nicht schon bei normaler Auslastung abgebrochen wird.
|
||||
const timeout = setTimeout(() => {
|
||||
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<Map<n
|
||||
|
||||
httpxLastError = null; // erfolgreicher Lauf - alten Fehler nicht mehr anzeigen
|
||||
|
||||
const results = new Map<number, HttpxResult>();
|
||||
const results = new Map<string, HttpxResult>();
|
||||
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<Map<n
|
||||
webserver?: string;
|
||||
};
|
||||
const port = Number(obj.port);
|
||||
if (!Number.isFinite(port)) continue;
|
||||
results.set(port, {
|
||||
if (!Number.isFinite(port) || !obj.url) continue;
|
||||
|
||||
// IP aus dem "url"-Feld ziehen (immer vorhanden, z. B.
|
||||
// "https://192.168.1.50:8080") statt aus ggf. je nach
|
||||
// httpx-Version abweichenden Zusatzfeldern - siehe Kommentar
|
||||
// oben an der Funktion.
|
||||
let ip: string;
|
||||
try {
|
||||
ip = new URL(obj.url).hostname;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.set(`${ip}:${port}`, {
|
||||
port,
|
||||
scheme: obj.scheme === "https" ? "https" : "http",
|
||||
statusCode: obj.status_code,
|
||||
@@ -189,8 +228,8 @@ export async function probeWithHttpx(ip: string, ports: number[]): Promise<Map<n
|
||||
|
||||
// Eingabe: "ip:port" pro Zeile, httpx erkennt Schema (http/https) je
|
||||
// Eintrag selbst (Standardverhalten: erst https versuchen, sonst http).
|
||||
for (const port of ports) {
|
||||
child.stdin.write(`${ip}:${port}\n`);
|
||||
for (const t of targets) {
|
||||
child.stdin.write(`${t.ip}:${t.port}\n`);
|
||||
}
|
||||
child.stdin.end();
|
||||
});
|
||||
@@ -198,3 +237,19 @@ export async function probeWithHttpx(ip: string, ports: number[]): Promise<Map<n
|
||||
httpxSemaphore.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einzelgerät-Variante von probeManyWithHttpx() - für den "Jetzt scannen"-
|
||||
* Button auf der Geräteseite (ein Gerät, kein Sammel-Scan).
|
||||
*/
|
||||
export async function probeWithHttpx(ip: string, ports: number[]): Promise<Map<number, HttpxResult> | null> {
|
||||
const result = await probeManyWithHttpx(ports.map((port) => ({ ip, port })));
|
||||
if (result === null) return null;
|
||||
|
||||
const map = new Map<number, HttpxResult>();
|
||||
for (const port of ports) {
|
||||
const hit = result.get(`${ip}:${port}`);
|
||||
if (hit) map.set(port, hit);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
@@ -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<number[] | null> {
|
||||
if (masscanConfirmedUnavailable) return null;
|
||||
rate: number = DEFAULT_RATE
|
||||
): Promise<Map<string, number[]> | 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<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]);
|
||||
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<number[] | null> {
|
||||
const result = await scanHostsWithMasscan([host], portRange, rate);
|
||||
if (result === null) return null;
|
||||
return result.get(host) ?? [];
|
||||
}
|
||||
|
||||
export function isMasscanConfirmedUnavailable(): boolean {
|
||||
return masscanConfirmedUnavailable;
|
||||
}
|
||||
|
||||
@@ -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<number, HttpxResult> | 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<DeviceScanResult> {
|
||||
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<number, HttpxResult>();
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user