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.
|
# eingebaute (langsamere, aber ohne Zusatzrechte auskommende) Methode zurück.
|
||||||
RUN apk add --no-cache iputils-ping masscan
|
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/deploy/ ./
|
||||||
COPY --from=build /app/apps/backend/dist ./dist
|
COPY --from=build /app/apps/backend/dist ./dist
|
||||||
COPY --from=build /app/packages/shared/dist ./node_modules/@launchpad/shared/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").
|
// erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale").
|
||||||
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
|
const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id);
|
||||||
|
|
||||||
const { services: discovered, suggestedHostname } = await scanDeviceServices(
|
const { services: discovered, suggestedHostname, toolsUsed } = await scanDeviceServices(
|
||||||
device,
|
device,
|
||||||
fullScan ? fullPortRange() : undefined,
|
fullScan ? fullPortRange() : undefined,
|
||||||
servicesBeforeScan.map((s) => s.port)
|
servicesBeforeScan.map((s) => s.port)
|
||||||
@@ -113,7 +113,7 @@ async function performDeviceScan(device: Device, fullScan = false) {
|
|||||||
type: "device",
|
type: "device",
|
||||||
targetId: device.id,
|
targetId: device.id,
|
||||||
level: "info",
|
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 {
|
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 { resolveHostname, reverseLookup } from "./dns.js";
|
||||||
import { isPortOpen, scanPortsInBatches, fullPortRange, TYPICAL_PORTS } from "./ports.js";
|
import { isPortOpen, scanPortsInBatches, fullPortRange, TYPICAL_PORTS } from "./ports.js";
|
||||||
import { scanWithMasscan } from "./masscan.js";
|
import { scanWithMasscan } from "./masscan.js";
|
||||||
|
import { probeWithHttpx } from "./httpx.js";
|
||||||
import { probeHttp } from "./http.js";
|
import { probeHttp } from "./http.js";
|
||||||
import { detectSoftware } from "./softwareDetection.js";
|
import { detectSoftware } from "./softwareDetection.js";
|
||||||
import { findBestIconMatch } from "./iconDb.js";
|
import { findBestIconMatch } from "./iconDb.js";
|
||||||
@@ -78,6 +79,14 @@ export interface DeviceScanResult {
|
|||||||
* zurückgegeben (siehe routes/scan.ts).
|
* zurückgegeben (siehe routes/scan.ts).
|
||||||
*/
|
*/
|
||||||
suggestedHostname: string | null;
|
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
|
// schneller als das eingebaute Batch-Scanning. Bei der kurzen "üblichen
|
||||||
// Ports"-Liste (~30 Ports) macht masscan kaum einen Unterschied, das
|
// Ports"-Liste (~30 Ports) macht masscan kaum einen Unterschied, das
|
||||||
// eingebaute Scanning reicht dafür völlig.
|
// eingebaute Scanning reicht dafür völlig.
|
||||||
|
let usedMasscan = false;
|
||||||
let openPorts: number[];
|
let openPorts: number[];
|
||||||
if (candidatePorts.length > 1000) {
|
if (candidatePorts.length > 1000) {
|
||||||
const masscanResult = await scanWithMasscan(device.ip, "1-65535");
|
const masscanResult = await scanWithMasscan(device.ip, "1-65535");
|
||||||
|
usedMasscan = masscanResult !== null;
|
||||||
openPorts = masscanResult ?? (await scanPortsInBatches(device.ip, candidatePorts));
|
openPorts = masscanResult ?? (await scanPortsInBatches(device.ip, candidatePorts));
|
||||||
} else {
|
} else {
|
||||||
openPorts = await scanPortsInBatches(device.ip, candidatePorts);
|
openPorts = await scanPortsInBatches(device.ip, candidatePorts);
|
||||||
@@ -157,6 +168,22 @@ export async function scanDeviceServices(
|
|||||||
(p) => !AVM_MANAGEMENT_PORTS.has(p) || priorityPorts.includes(p)
|
(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[] = [];
|
const found: DiscoveredService[] = [];
|
||||||
|
|
||||||
// Die HTTP-Abfrage je offenem Port läuft PARALLEL, nicht mehr
|
// 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
|
// Gerät mit vielen offenen Ports (seit dem vollständigen 1-65535-Portscan
|
||||||
// deutlich häufiger) schnell auf mehrere Minuten PRO GERÄT.
|
// deutlich häufiger) schnell auf mehrere Minuten PRO GERÄT.
|
||||||
async function probePort(port: number): Promise<DiscoveredService | null> {
|
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 baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
||||||
let probe = await probeHttp(baseUrl);
|
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
|
// bloß ein 4xx/5xx. In dem Fall lohnt sich ein zweiter Versuch mit dem
|
||||||
// jeweils anderen Protokoll, bevor der Port als "kein Web-Dienst"
|
// jeweils anderen Protokoll, bevor der Port als "kein Web-Dienst"
|
||||||
// übersprungen wird. Der Port war laut isPortOpen offen, also lohnt sich
|
// übersprungen wird. Der Port war laut isPortOpen offen, also lohnt sich
|
||||||
// der zusätzliche Versuch.
|
// der zusätzliche Versuch. Übersprungen, wenn httpx das Schema schon
|
||||||
if (probe.status === undefined) {
|
// 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;
|
isHttps = !isHttps;
|
||||||
baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
|
||||||
const retryProbe = await probeHttp(baseUrl);
|
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