From 645dcf3af8df27c2d42266e7e0eb39c9a8891322 Mon Sep 17 00:00:00 2001 From: Dicken Date: Fri, 24 Jul 2026 21:21:07 +0200 Subject: [PATCH] round35: Vollstaendiger Portscan (1-65535) pro Geraet als Option, fuer Geraete mit vielen Diensten auf unueblichen Ports --- apps/backend/src/routes/scan.ts | 8 +++-- apps/backend/src/scanner/networkScanner.ts | 18 ++++------ apps/backend/src/scanner/ports.ts | 33 +++++++++++++++++++ .../frontend/src/routes/admin/DevicesPage.tsx | 25 ++++++++++---- 4 files changed, 63 insertions(+), 21 deletions(-) diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index fa09e2d..23b5c69 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -5,6 +5,7 @@ import * as categoryRepo from "../db/repositories/categories.js"; import * as logRepo from "../db/repositories/logs.js"; import * as settingsRepo from "../db/repositories/settings.js"; import { scanDeviceServices } from "../scanner/networkScanner.js"; +import { fullPortRange } from "../scanner/ports.js"; import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js"; import { startJob, @@ -26,14 +27,14 @@ import type { Device } from "@launchpad/shared"; * Einzelgerät-Endpunkt als auch der Sammel-Scan (siehe /api/scan/devices/all * weiter unten) dieselbe Logik nutzen, ohne sie zu duplizieren. */ -async function performDeviceScan(device: Device) { +async function performDeviceScan(device: Device, fullScan = false) { // Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu // erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale"). const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id); const { services: discovered, suggestedHostname } = await scanDeviceServices( device, - undefined, + fullScan ? fullPortRange() : undefined, servicesBeforeScan.map((s) => s.port) ); @@ -138,6 +139,7 @@ async function performDeviceScan(device: Device) { export async function scanRoutes(app: FastifyInstance): Promise { app.post("/api/scan/devices/:id", async (request, reply) => { const { id } = request.params as { id: string }; + const { full } = request.query as { full?: string }; const device = deviceRepo.getDevice(id); if (!device) { return reply.code(404).send({ error: "Gerät nicht gefunden" }); @@ -148,7 +150,7 @@ export async function scanRoutes(app: FastifyInstance): Promise { } try { - return await performDeviceScan(device); + return await performDeviceScan(device, full === "true"); } catch (err) { const detail = err instanceof Error ? err.message : String(err); logRepo.logScan({ diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index 1b1e562..c0fcbdf 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -1,5 +1,5 @@ import { resolveHostname, reverseLookup } from "./dns.js"; -import { isPortOpen, TYPICAL_PORTS } from "./ports.js"; +import { isPortOpen, scanPortsInBatches, fullPortRange, TYPICAL_PORTS } from "./ports.js"; import { probeHttp } from "./http.js"; import { detectSoftware } from "./softwareDetection.js"; import { findBestIconMatch } from "./iconDb.js"; @@ -102,17 +102,11 @@ export async function scanDeviceServices( const candidatePorts = Array.from(new Set([80, 443, ...extraPorts, ...priorityPorts])); - // Die offenen Ports werden PARALLEL geprüft, nicht nacheinander - bei - // einem nicht erreichbaren/gefilterten Gerät (z. B. ein schlafender - // Laptop) würde ein sequenzieller Durchlauf sonst bis zu - // Portanzahl × Timeout dauern (bei 20 Ports und 800ms Timeout: 16+ - // Sekunden PRO GERÄT) und den Scan wie hängengeblieben wirken lassen. - // Danach wird nur noch für die tatsächlich offenen Ports (meist wenige) - // die eigentliche HTTP-Abfrage gemacht. - const openChecks = await Promise.all( - candidatePorts.map(async (port) => ({ port, open: await isPortOpen(device.ip, port) })) - ); - let openPorts = openChecks.filter((c) => c.open).map((c) => c.port); + // Batches statt alles auf einmal parallel - bei der "typischen Ports"-Liste + // (~30 Ports) macht das kaum einen Unterschied, ist aber zwingend nötig für + // den optionalen VOLLSTÄNDIGEN Portscan (1-65535, siehe Admin -> Geräte), + // wo alle Ports gleichzeitig öffnen würde. + let openPorts = await scanPortsInBatches(device.ip, candidatePorts); // Bereits bekannte Dienst-Ports (priorityPorts, vom Aufrufer übergeben - // siehe scan.ts), die im ersten, stark parallelen Durchlauf NICHT als diff --git a/apps/backend/src/scanner/ports.ts b/apps/backend/src/scanner/ports.ts index 10ee5a6..9b05ed5 100644 --- a/apps/backend/src/scanner/ports.ts +++ b/apps/backend/src/scanner/ports.ts @@ -36,3 +36,36 @@ export function isPortOpen(host: string, port: number, timeoutMs = 800): Promise socket.once("error", () => finish(false)); }); } + +/** + * Prüft eine (ggf. sehr lange) Liste von Ports in Batches mit begrenzter + * Parallelität - für den vollständigen Portscan (1-65535, siehe + * scanDeviceServices mit fullScan=true). Alle Ports auf einmal parallel zu + * prüfen wäre bei 65535 Ports zu viel gleichzeitig offener Sockets; komplett + * nacheinander wäre selbst bei schnellen RSTs zu langsam. Ein Batch von 500 + * ist ein guter Mittelweg für ein Gerät im lokalen Netz. + */ +export async function scanPortsInBatches( + host: string, + ports: number[], + batchSize = 500, + timeoutMs = 600 +): Promise { + const open: number[] = []; + for (let i = 0; i < ports.length; i += batchSize) { + const batch = ports.slice(i, i + batchSize); + const results = await Promise.all( + batch.map(async (port) => ({ port, open: await isPortOpen(host, port, timeoutMs) })) + ); + for (const r of results) if (r.open) open.push(r.port); + } + return open; +} + +/** Alle Ports von 1 bis 65535 - für den vollständigen (statt nur "typische Ports") Scan eines einzelnen Geräts. */ +export function fullPortRange(): number[] { + const ports: number[] = []; + for (let p = 1; p <= 65535; p++) ports.push(p); + return ports; +} + diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx index c7ce664..66ecb5b 100644 --- a/apps/frontend/src/routes/admin/DevicesPage.tsx +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, type FormEvent } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown } from "@fortawesome/free-solid-svg-icons"; +import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faMagnifyingGlassPlus } from "@fortawesome/free-solid-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button, Favicon } from "@launchpad/ui"; import type { Service } from "@launchpad/shared"; @@ -79,8 +79,8 @@ interface ScanResult { deviceNameSuggestion: string | null; } -async function scanDevice(id: string, signal?: AbortSignal): Promise { - const res = await fetch(`/api/scan/devices/${id}`, { method: "POST", signal }); +async function scanDevice(id: string, signal?: AbortSignal, full = false): Promise { + const res = await fetch(`/api/scan/devices/${id}${full ? "?full=true" : ""}`, { method: "POST", signal }); const body = await res.json(); if (!res.ok) { throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`); @@ -326,10 +326,10 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { const [scanAbort, setScanAbort] = useState(null); const scanMutation = useMutation({ - mutationFn: () => { + mutationFn: (full: boolean) => { const controller = new AbortController(); setScanAbort(controller); - return scanDevice(device.id, controller.signal); + return scanDevice(device.id, controller.signal, full); }, onSuccess: (result) => { const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine"; @@ -414,12 +414,25 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { size="sm" onClick={() => { setScanMessage(null); - scanMutation.mutate(); + scanMutation.mutate(false); }} disabled={scanMutation.isPending} > {scanMutation.isPending ? "Scanne …" : "Jetzt scannen"} + {scanMutation.isPending && scanAbort ? (