generated from Dicken/dickendock
round47: httpx-Integration nach masscan (nur bestaetigte Webinterfaces werden weiterverarbeitet), Sichtbarkeit ob masscan/httpx greifen ueber Admin-Logs
This commit is contained in:
@@ -41,6 +41,29 @@ ENV HOST=0.0.0.0
|
||||
# eingebaute (langsamere, aber ohne Zusatzrechte auskommende) Methode zurück.
|
||||
RUN apk add --no-cache iputils-ping masscan
|
||||
|
||||
# httpx (ProjectDiscovery) prüft eine Liste offener Ports und liefert NUR
|
||||
# die, die tatsächlich HTTP/HTTPS sprechen, inkl. Status/Titel/Server-Header
|
||||
# in einem einzigen, hochparallelen Durchlauf (siehe scanner/httpx.ts) -
|
||||
# deutlich schneller als eigenes Protokoll-Raten pro Port, und verhindert,
|
||||
# dass Nicht-Web-Ports (SSH, SMB, NFS, ...) überhaupt erst per HTTP
|
||||
# angesprochen werden. Kein Alpine-Paket vorhanden, daher als
|
||||
# Binary-Release von GitHub geladen. Bei Fehlschlag (z. B. kein
|
||||
# Internetzugang beim Bauen des Images) läuft der Build trotzdem durch -
|
||||
# der Scanner erkennt ein fehlendes httpx zur Laufzeit automatisch und
|
||||
# fällt auf die eingebaute Methode zurück.
|
||||
RUN apk add --no-cache --virtual .httpx-build curl unzip && \
|
||||
( \
|
||||
HTTPX_URL=$(curl -fsSL https://api.github.com/repos/projectdiscovery/httpx/releases/latest \
|
||||
| grep -o 'https://[^"]*httpx_[0-9][^"]*_linux_amd64\.zip' | head -n1) && \
|
||||
test -n "$HTTPX_URL" && \
|
||||
curl -fsSL "$HTTPX_URL" -o /tmp/httpx.zip && \
|
||||
unzip -o /tmp/httpx.zip -d /usr/local/bin httpx && \
|
||||
chmod +x /usr/local/bin/httpx \
|
||||
|| echo "httpx-Installation fehlgeschlagen - Scanner nutzt automatisch die eingebaute Methode" \
|
||||
) && \
|
||||
rm -f /tmp/httpx.zip && \
|
||||
apk del .httpx-build
|
||||
|
||||
COPY --from=build /app/deploy/ ./
|
||||
COPY --from=build /app/apps/backend/dist ./dist
|
||||
COPY --from=build /app/packages/shared/dist ./node_modules/@launchpad/shared/dist
|
||||
|
||||
@@ -32,7 +32,7 @@ async function performDeviceScan(device: Device, fullScan = false) {
|
||||
// erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale").
|
||||
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
|
||||
|
||||
const { services: discovered, suggestedHostname } = await scanDeviceServices(
|
||||
const { services: discovered, suggestedHostname, toolsUsed } = await scanDeviceServices(
|
||||
device,
|
||||
fullScan ? fullPortRange() : undefined,
|
||||
servicesBeforeScan.map((s) => s.port)
|
||||
@@ -113,7 +113,7 @@ async function performDeviceScan(device: Device, fullScan = false) {
|
||||
type: "device",
|
||||
targetId: device.id,
|
||||
level: "info",
|
||||
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}${nameChanges.length > 0 ? `, ${nameChanges.length} Namensänderung(en) vorgeschlagen` : ""}`,
|
||||
message: `${device.hostname} (${device.ip}): ${discovered.length} Dienst(e) gefunden (Ports: ${ports.join(", ") || "keine"}), ${created} neu, ${updated} aktualisiert${staleServices.length > 0 ? `, ${staleServices.length} nicht mehr gefunden` : ""}${nameChanges.length > 0 ? `, ${nameChanges.length} Namensänderung(en) vorgeschlagen` : ""} [masscan: ${toolsUsed.masscan ? "✓ genutzt" : "nicht genutzt/verfügbar"}, httpx: ${toolsUsed.httpx ? "✓ genutzt" : "nicht genutzt/verfügbar"}]`,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
127
apps/backend/src/scanner/httpx.ts
Normal file
127
apps/backend/src/scanner/httpx.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
export interface HttpxResult {
|
||||
port: number;
|
||||
scheme: "http" | "https";
|
||||
statusCode?: number;
|
||||
title?: string;
|
||||
webserver?: string;
|
||||
}
|
||||
|
||||
let httpxConfirmedUnavailable = false;
|
||||
|
||||
export function isHttpxConfirmedUnavailable(): boolean {
|
||||
return httpxConfirmedUnavailable;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Liefert null (nicht einen leeren Array!), wenn httpx fehlt oder
|
||||
* 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.
|
||||
*/
|
||||
export function probeWithHttpx(ip: string, ports: number[]): Promise<Map<number, HttpxResult> | null> {
|
||||
if (httpxConfirmedUnavailable || ports.length === 0) {
|
||||
return Promise.resolve(ports.length === 0 ? new Map() : null);
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(
|
||||
"httpx",
|
||||
[
|
||||
"-silent",
|
||||
"-json",
|
||||
"-timeout",
|
||||
"5",
|
||||
"-retries",
|
||||
"0",
|
||||
"-no-color",
|
||||
"-threads",
|
||||
"50",
|
||||
],
|
||||
{ stdio: ["pipe", "pipe", "pipe"] }
|
||||
);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
|
||||
const finish = (result: Map<number, HttpxResult> | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
child.on("error", (err) => {
|
||||
// ENOENT (Programm fehlt) - künftig direkt auf den Fallback gehen,
|
||||
// statt bei jedem Scan erneut (dann garantiert erfolglos) zu versuchen.
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
httpxConfirmedUnavailable = true;
|
||||
}
|
||||
finish(null);
|
||||
});
|
||||
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
stdout += chunk.toString("utf-8");
|
||||
});
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
stderr += chunk.toString("utf-8");
|
||||
});
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill();
|
||||
finish(null);
|
||||
}, 30_000);
|
||||
|
||||
child.on("close", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (code !== 0 && stdout.trim().length === 0) {
|
||||
if (/not found|no such file/i.test(stderr)) httpxConfirmedUnavailable = true;
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = new Map<number, HttpxResult>();
|
||||
for (const line of stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const obj = JSON.parse(trimmed) as {
|
||||
port?: string;
|
||||
scheme?: string;
|
||||
status_code?: number;
|
||||
title?: string;
|
||||
webserver?: string;
|
||||
};
|
||||
const port = Number(obj.port);
|
||||
if (!Number.isFinite(port)) continue;
|
||||
results.set(port, {
|
||||
port,
|
||||
scheme: obj.scheme === "https" ? "https" : "http",
|
||||
statusCode: obj.status_code,
|
||||
title: obj.title,
|
||||
webserver: obj.webserver,
|
||||
});
|
||||
} catch {
|
||||
// einzelne kaputte Zeile ignorieren, Rest trotzdem verwerten
|
||||
}
|
||||
}
|
||||
finish(results);
|
||||
});
|
||||
|
||||
// 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`);
|
||||
}
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveHostname, reverseLookup } from "./dns.js";
|
||||
import { isPortOpen, scanPortsInBatches, fullPortRange, TYPICAL_PORTS } from "./ports.js";
|
||||
import { scanWithMasscan } from "./masscan.js";
|
||||
import { probeWithHttpx } from "./httpx.js";
|
||||
import { probeHttp } from "./http.js";
|
||||
import { detectSoftware } from "./softwareDetection.js";
|
||||
import { findBestIconMatch } from "./iconDb.js";
|
||||
@@ -78,6 +79,14 @@ export interface DeviceScanResult {
|
||||
* zurückgegeben (siehe routes/scan.ts).
|
||||
*/
|
||||
suggestedHostname: string | null;
|
||||
/**
|
||||
* Welche optionalen Beschleuniger-Werkzeuge bei diesem Scan tatsächlich
|
||||
* genutzt wurden (siehe scanner/masscan.ts, scanner/httpx.ts) - landet im
|
||||
* Scan-Log (Admin -> Logs), damit sichtbar ist, ob z. B. masscan wirklich
|
||||
* gegriffen hat oder (mangels Installation/Rechten) auf die eingebaute
|
||||
* Methode zurückgefallen wurde.
|
||||
*/
|
||||
toolsUsed: { masscan: boolean; httpx: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,9 +121,11 @@ export async function scanDeviceServices(
|
||||
// schneller als das eingebaute Batch-Scanning. Bei der kurzen "üblichen
|
||||
// Ports"-Liste (~30 Ports) macht masscan kaum einen Unterschied, das
|
||||
// eingebaute Scanning reicht dafür völlig.
|
||||
let usedMasscan = false;
|
||||
let openPorts: number[];
|
||||
if (candidatePorts.length > 1000) {
|
||||
const masscanResult = await scanWithMasscan(device.ip, "1-65535");
|
||||
usedMasscan = masscanResult !== null;
|
||||
openPorts = masscanResult ?? (await scanPortsInBatches(device.ip, candidatePorts));
|
||||
} else {
|
||||
openPorts = await scanPortsInBatches(device.ip, candidatePorts);
|
||||
@@ -157,6 +168,22 @@ export async function scanDeviceServices(
|
||||
(p) => !AVM_MANAGEMENT_PORTS.has(p) || priorityPorts.includes(p)
|
||||
);
|
||||
|
||||
// httpx (falls installiert, siehe Dockerfile) filtert aus den offenen
|
||||
// Ports NUR die tatsächlich HTTP/HTTPS-sprechenden heraus - deutlich
|
||||
// schneller UND präziser als unser eigenes Protokoll-Raten (erst HTTP
|
||||
// versuchen, bei Fehlschlag HTTPS versuchen) für JEDEN einzelnen offenen
|
||||
// Port einzeln. Ports wie SSH/SMB/NFS, die zwar offen sind aber gar kein
|
||||
// HTTP sprechen, werden so gar nicht erst an die aufwendigere
|
||||
// Detail-Pipeline (probeHttp, Softwareerkennung, Icon-Abgleich)
|
||||
// weitergereicht. httpxResults === null heißt "httpx nicht verfügbar" -
|
||||
// dann bleibt es beim bisherigen Verhalten (jeder offene Port wird
|
||||
// geprüft, Protokoll wird pro Port erraten).
|
||||
const httpxResults = openPorts.length > 0 ? await probeWithHttpx(device.ip, openPorts) : new Map();
|
||||
const usingHttpx = httpxResults !== null;
|
||||
if (usingHttpx) {
|
||||
openPorts = Array.from(httpxResults!.keys());
|
||||
}
|
||||
|
||||
const found: DiscoveredService[] = [];
|
||||
|
||||
// Die HTTP-Abfrage je offenem Port läuft PARALLEL, nicht mehr
|
||||
@@ -165,7 +192,10 @@ export async function scanDeviceServices(
|
||||
// Gerät mit vielen offenen Ports (seit dem vollständigen 1-65535-Portscan
|
||||
// deutlich häufiger) schnell auf mehrere Minuten PRO GERÄT.
|
||||
async function probePort(port: number): Promise<DiscoveredService | null> {
|
||||
let isHttps = port === 443 || port === 9443 || port === 8006 || port === 8443;
|
||||
const knownScheme = usingHttpx ? httpxResults!.get(port)?.scheme : undefined;
|
||||
let isHttps = knownScheme
|
||||
? knownScheme === "https"
|
||||
: port === 443 || port === 9443 || port === 8006 || port === 8443;
|
||||
let baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
||||
let probe = await probeHttp(baseUrl);
|
||||
|
||||
@@ -175,8 +205,10 @@ export async function scanDeviceServices(
|
||||
// bloß ein 4xx/5xx. In dem Fall lohnt sich ein zweiter Versuch mit dem
|
||||
// jeweils anderen Protokoll, bevor der Port als "kein Web-Dienst"
|
||||
// übersprungen wird. Der Port war laut isPortOpen offen, also lohnt sich
|
||||
// der zusätzliche Versuch.
|
||||
if (probe.status === undefined) {
|
||||
// der zusätzliche Versuch. Übersprungen, wenn httpx das Schema schon
|
||||
// bestätigt hat - dann wissen wir es bereits sicher, ein Fehlschlag hier
|
||||
// wäre ein einmaliger Ausrutscher, kein falsches Protokoll.
|
||||
if (probe.status === undefined && !knownScheme) {
|
||||
isHttps = !isHttps;
|
||||
baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
||||
const retryProbe = await probeHttp(baseUrl);
|
||||
@@ -307,5 +339,5 @@ export async function scanDeviceServices(
|
||||
}
|
||||
}
|
||||
|
||||
return { services: found, suggestedHostname };
|
||||
return { services: found, suggestedHostname, toolsUsed: { masscan: usedMasscan, httpx: usingHttpx } };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user