round50: masscan/httpx laufen im Sammel-Scan nur noch einmal fuer alle Geraete statt pro Geraet, Rate deutlich erhoeht

This commit is contained in:
2026-07-26 10:49:35 +02:00
parent cf31ea4402
commit 0287a7cc5c
4 changed files with 333 additions and 107 deletions

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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