round35: Vollstaendiger Portscan (1-65535) pro Geraet als Option, fuer Geraete mit vielen Diensten auf unueblichen Ports

This commit is contained in:
2026-07-24 21:21:07 +02:00
parent fedb3e9127
commit 645dcf3af8
4 changed files with 63 additions and 21 deletions

View File

@@ -5,6 +5,7 @@ import * as categoryRepo from "../db/repositories/categories.js";
import * as logRepo from "../db/repositories/logs.js"; import * as logRepo from "../db/repositories/logs.js";
import * as settingsRepo from "../db/repositories/settings.js"; import * as settingsRepo from "../db/repositories/settings.js";
import { scanDeviceServices } from "../scanner/networkScanner.js"; import { scanDeviceServices } from "../scanner/networkScanner.js";
import { fullPortRange } from "../scanner/ports.js";
import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js"; import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js";
import { import {
startJob, startJob,
@@ -26,14 +27,14 @@ import type { Device } from "@launchpad/shared";
* Einzelgerät-Endpunkt als auch der Sammel-Scan (siehe /api/scan/devices/all * Einzelgerät-Endpunkt als auch der Sammel-Scan (siehe /api/scan/devices/all
* weiter unten) dieselbe Logik nutzen, ohne sie zu duplizieren. * 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 // Bestehende Dienste dieses Geräts VOR dem Scan merken, um danach zu
// 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 } = await scanDeviceServices(
device, device,
undefined, fullScan ? fullPortRange() : undefined,
servicesBeforeScan.map((s) => s.port) servicesBeforeScan.map((s) => s.port)
); );
@@ -138,6 +139,7 @@ async function performDeviceScan(device: Device) {
export async function scanRoutes(app: FastifyInstance): Promise<void> { export async function scanRoutes(app: FastifyInstance): Promise<void> {
app.post("/api/scan/devices/:id", async (request, reply) => { app.post("/api/scan/devices/:id", async (request, reply) => {
const { id } = request.params as { id: string }; const { id } = request.params as { id: string };
const { full } = request.query as { full?: string };
const device = deviceRepo.getDevice(id); const device = deviceRepo.getDevice(id);
if (!device) { if (!device) {
return reply.code(404).send({ error: "Gerät nicht gefunden" }); return reply.code(404).send({ error: "Gerät nicht gefunden" });
@@ -148,7 +150,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
} }
try { try {
return await performDeviceScan(device); return await performDeviceScan(device, full === "true");
} catch (err) { } catch (err) {
const detail = err instanceof Error ? err.message : String(err); const detail = err instanceof Error ? err.message : String(err);
logRepo.logScan({ logRepo.logScan({

View File

@@ -1,5 +1,5 @@
import { resolveHostname, reverseLookup } from "./dns.js"; 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 { probeHttp } from "./http.js";
import { detectSoftware } from "./softwareDetection.js"; import { detectSoftware } from "./softwareDetection.js";
import { findBestIconMatch } from "./iconDb.js"; import { findBestIconMatch } from "./iconDb.js";
@@ -102,17 +102,11 @@ export async function scanDeviceServices(
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts, ...priorityPorts])); const candidatePorts = Array.from(new Set([80, 443, ...extraPorts, ...priorityPorts]));
// Die offenen Ports werden PARALLEL geprüft, nicht nacheinander - bei // Batches statt alles auf einmal parallel - bei der "typischen Ports"-Liste
// einem nicht erreichbaren/gefilterten Gerät (z. B. ein schlafender // (~30 Ports) macht das kaum einen Unterschied, ist aber zwingend nötig für
// Laptop) würde ein sequenzieller Durchlauf sonst bis zu // den optionalen VOLLSTÄNDIGEN Portscan (1-65535, siehe Admin -> Geräte),
// Portanzahl × Timeout dauern (bei 20 Ports und 800ms Timeout: 16+ // wo alle Ports gleichzeitig öffnen würde.
// Sekunden PRO GERÄT) und den Scan wie hängengeblieben wirken lassen. let openPorts = await scanPortsInBatches(device.ip, candidatePorts);
// 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);
// Bereits bekannte Dienst-Ports (priorityPorts, vom Aufrufer übergeben - // Bereits bekannte Dienst-Ports (priorityPorts, vom Aufrufer übergeben -
// siehe scan.ts), die im ersten, stark parallelen Durchlauf NICHT als // siehe scan.ts), die im ersten, stark parallelen Durchlauf NICHT als

View File

@@ -36,3 +36,36 @@ export function isPortOpen(host: string, port: number, timeoutMs = 800): Promise
socket.once("error", () => finish(false)); 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<number[]> {
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;
}

View File

@@ -1,6 +1,6 @@
import { useMemo, useState, type FormEvent } from "react"; import { useMemo, useState, type FormEvent } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 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 { useMutation, useQueryClient } from "@tanstack/react-query";
import { Button, Favicon } from "@launchpad/ui"; import { Button, Favicon } from "@launchpad/ui";
import type { Service } from "@launchpad/shared"; import type { Service } from "@launchpad/shared";
@@ -79,8 +79,8 @@ interface ScanResult {
deviceNameSuggestion: string | null; deviceNameSuggestion: string | null;
} }
async function scanDevice(id: string, signal?: AbortSignal): Promise<ScanResult> { async function scanDevice(id: string, signal?: AbortSignal, full = false): Promise<ScanResult> {
const res = await fetch(`/api/scan/devices/${id}`, { method: "POST", signal }); const res = await fetch(`/api/scan/devices/${id}${full ? "?full=true" : ""}`, { method: "POST", signal });
const body = await res.json(); const body = await res.json();
if (!res.ok) { if (!res.ok) {
throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`); 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<AbortController | null>(null); const [scanAbort, setScanAbort] = useState<AbortController | null>(null);
const scanMutation = useMutation({ const scanMutation = useMutation({
mutationFn: () => { mutationFn: (full: boolean) => {
const controller = new AbortController(); const controller = new AbortController();
setScanAbort(controller); setScanAbort(controller);
return scanDevice(device.id, controller.signal); return scanDevice(device.id, controller.signal, full);
}, },
onSuccess: (result) => { onSuccess: (result) => {
const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine"; const portsText = result.ports.length > 0 ? result.ports.join(", ") : "keine";
@@ -414,12 +414,25 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
size="sm" size="sm"
onClick={() => { onClick={() => {
setScanMessage(null); setScanMessage(null);
scanMutation.mutate(); scanMutation.mutate(false);
}} }}
disabled={scanMutation.isPending} disabled={scanMutation.isPending}
> >
{scanMutation.isPending ? "Scanne " : "Jetzt scannen"} {scanMutation.isPending ? "Scanne " : "Jetzt scannen"}
</Button> </Button>
<Button
size="icon"
variant="ghost"
onClick={() => {
setScanMessage("Vollständiger Portscan (1-65535) läuft, kann etwas dauern ");
scanMutation.mutate(true);
}}
disabled={scanMutation.isPending}
title="Vollständigen Portscan (alle 65535 Ports statt nur der üblichen) durchführen - für Geräte mit vielen Diensten auf unüblichen Ports"
aria-label="Vollständigen Portscan durchführen"
>
<FontAwesomeIcon icon={faMagnifyingGlassPlus} />
</Button>
{scanMutation.isPending && scanAbort ? ( {scanMutation.isPending && scanAbort ? (
<Button <Button
size="icon" size="icon"