From eed5089a56099d5d288b5df027a16cf234db44a0 Mon Sep 17 00:00:00 2001 From: Dicken Date: Sat, 25 Jul 2026 13:56:48 +0200 Subject: [PATCH] round46: Prozessweite Verbindungs-Obergrenze statt zwei sich multiplizierender Parallelitaets-Werte (behebt verlorene Ports wie FritzBox 443 bei mehreren gleichzeitigen Geraete-Scans) --- apps/backend/src/scanner/ports.ts | 98 ++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 27 deletions(-) diff --git a/apps/backend/src/scanner/ports.ts b/apps/backend/src/scanner/ports.ts index 3c71afc..2879d5e 100644 --- a/apps/backend/src/scanner/ports.ts +++ b/apps/backend/src/scanner/ports.ts @@ -17,32 +17,82 @@ export const TYPICAL_PORTS = [ 8181, 8282, ]; +/** + * Einfacher Zähl-Semaphore, um die GESAMTZAHL gleichzeitig offener Sockets + * über den kompletten Backend-Prozess hinweg zu begrenzen - nicht nur pro + * einzelnem Geräte-Scan. Wichtig, seit mehrere Geräte gleichzeitig gescannt + * werden (siehe routes/scan.ts, CONCURRENT_DEVICES): ohne einen + * PROZESSWEITEN Deckel hätten sich Geräte-Parallelität (bis zu 10) und + * Port-Batch-Größe (bis zu 1000) multipliziert - bis zu 10.000 gleichzeitige + * Verbindungsversuche, die den Container/das Heimnetz überfordern und dabei + * auch eigentlich funktionierende Ports (wie 443 der FritzBox selbst) + * fehlschlagen lassen konnten. + */ +class Semaphore { + private available: number; + private readonly queue: (() => void)[] = []; + + constructor(count: number) { + this.available = count; + } + + acquire(): Promise { + if (this.available > 0) { + this.available--; + return Promise.resolve(); + } + return new Promise((resolve) => this.queue.push(resolve)); + } + + release(): void { + const next = this.queue.shift(); + if (next) { + next(); + } else { + this.available++; + } + } +} + +// Prozessweiter Deckel für gleichzeitig offene Scan-Sockets - unabhängig +// davon, ob das gerade ein einzelner Geräte-Scan oder mehrere parallele +// Geräte-Scans sind (siehe Kommentar oben). +const globalScanSemaphore = new Semaphore(300); + /** * Prüft per TCP-Connect, ob ein Port offen ist. Kein Protokoll-Handshake, * nur "kann eine Verbindung aufgebaut werden" – schnell und protokollunabhängig. + * Wartet ggf. kurz auf ein freies "Ticket" im prozessweiten Semaphore (siehe + * oben), bevor der eigentliche Verbindungsversuch losgeht. */ -export function isPortOpen(host: string, port: number, timeoutMs = 800): Promise { - return new Promise((resolve) => { - const socket = connect({ host, port, timeout: timeoutMs }); +export async function isPortOpen(host: string, port: number, timeoutMs = 800): Promise { + await globalScanSemaphore.acquire(); + try { + return await new Promise((resolve) => { + const socket = connect({ host, port, timeout: timeoutMs }); - const finish = (result: boolean) => { - socket.removeAllListeners(); - socket.destroy(); - resolve(result); - }; + const finish = (result: boolean) => { + socket.removeAllListeners(); + socket.destroy(); + resolve(result); + }; - socket.once("connect", () => finish(true)); - socket.once("timeout", () => finish(false)); - socket.once("error", () => finish(false)); - }); + socket.once("connect", () => finish(true)); + socket.once("timeout", () => finish(false)); + socket.once("error", () => finish(false)); + }); + } finally { + globalScanSemaphore.release(); + } } /** - * 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. + * Prüft eine (ggf. sehr lange) Liste von Ports - für den vollständigen + * Portscan (1-65535, siehe scanDeviceServices mit fullScan=true). Alle + * Verbindungsversuche werden sofort gestartet, die eigentliche Begrenzung + * der Gleichzeitigkeit übernimmt der prozessweite Semaphore in isPortOpen + * (siehe oben) - dadurch bleibt die Gesamtzahl gleichzeitiger Sockets sicher + * gedeckelt, auch wenn mehrere Geräte gleichzeitig gescannt werden. * * Timeout bewusst knapp, aber nicht zu knapp (500ms): im lokalen Netz * (derselbe Subnetz-Bereich) liegt die Round-Trip-Zeit normalerweise im @@ -55,18 +105,12 @@ export function isPortOpen(host: string, port: number, timeoutMs = 800): Promise export async function scanPortsInBatches( host: string, ports: number[], - batchSize = 1000, timeoutMs = 500 ): 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; + const results = await Promise.all( + ports.map(async (port) => ({ port, open: await isPortOpen(host, port, timeoutMs) })) + ); + return results.filter((r) => r.open).map((r) => r.port); } /** Alle Ports von 1 bis 65535 - für den vollständigen (statt nur "typische Ports") Scan eines einzelnen Geräts. */