import type { FastifyInstance } from "fastify"; import * as deviceRepo from "../db/repositories/devices.js"; import * as serviceRepo from "../db/repositories/services.js"; 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 * as dismissalRepo from "../db/repositories/scanDismissals.js"; import { scanDeviceServices, type PreFetchedScanData } from "../scanner/networkScanner.js"; import { fullPortRange } from "../scanner/ports.js"; import { scanHostsWithMasscan, getMasscanLastError } from "../scanner/masscan.js"; import { probeManyWithHttpx, getHttpxLastError, type HttpxResult } from "../scanner/httpx.js"; import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js"; import { startJob, updateJobProgress, finishJob, failJob, getJobStatus, isJobRunning, isCancelRequested, requestCancel, tryLockDevice, unlockDevice, } from "../scanJobs.js"; import type { Device } from "@launchpad/shared"; /** * Formatiert den masscan/httpx-Status für die Scan-Log-Zeile. Zeigt bei * masscan "nicht relevant" statt "nicht genutzt", wenn es bei diesem Scan * gar nicht versucht wurde (masscan läuft nur beim vollständigen Portscan, * siehe networkScanner.ts) - und hängt bei echten Fehlschlägen die konkrete * Fehlermeldung an, statt nur "nicht genutzt/verfügbar" ohne Erklärung. */ function formatToolsUsed(toolsUsed: { masscan: boolean; masscanAttempted: boolean; masscanError: string | null; httpx: boolean; httpxError: string | null; }): string { const masscanPart = !toolsUsed.masscanAttempted ? "masscan: nicht relevant (kein Volltest)" : toolsUsed.masscan ? "masscan: ✓ genutzt" : `masscan: ✗ fehlgeschlagen${toolsUsed.masscanError ? ` (${toolsUsed.masscanError})` : " (Grund unbekannt)"}`; const httpxPart = toolsUsed.httpx ? "httpx: ✓ genutzt" : `httpx: ✗ fehlgeschlagen${toolsUsed.httpxError ? ` (${toolsUsed.httpxError})` : " (Grund unbekannt)"}`; return `${masscanPart}, ${httpxPart}`; } /** * Löst FRITZBOX_HOST (Umgebungsvariable, siehe /api/scan/fritzbox weiter * unten) zur IP-Adresse des Routers selbst auf - wird genutzt, um genau * dieses eine Gerät vom vollständigen masscan-Portscan im Sammel-Scan * auszunehmen (siehe Kommentar dort). Ist FRITZBOX_HOST bereits eine * IP-Adresse, wird sie direkt übernommen, sonst per DNS aufgelöst. Liefert * null, wenn nicht konfiguriert oder nicht auflösbar - dann greift einfach * keine Ausnahme, wie vor round58. */ async function resolveRouterIp(): Promise { const host = process.env.FRITZBOX_HOST; if (!host) return null; if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host; try { const { lookup } = await import("node:dns/promises"); const { address } = await lookup(host); return address; } catch { return null; } } /** * Scannt EIN Gerät (Ports, Titel/Favicon, Softwareerkennung) und schreibt die * Ergebnisse in die Datenbank. Ausgelagert aus der Route, damit sowohl der * 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, fullScan = false, preFetched?: PreFetchedScanData) { // 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, toolsUsed } = await scanDeviceServices( device, fullScan ? fullPortRange() : undefined, servicesBeforeScan.map((s) => s.port), preFetched ); // Jede erkannte Kategorie auch in der categories-Tabelle anlegen, damit // sie unter Admin -> Kategorien auftaucht und dort umbenannt/sortiert // werden kann (services.category ist reiner Freitext, kein Fremdschlüssel). for (const category of new Set(discovered.map((d) => d.category).filter((c): c is string => !!c))) { categoryRepo.ensureCategory(category); } const results = discovered.map((found) => serviceRepo.upsertServiceFromScan({ deviceId: device.id, hostname: found.hostname, url: found.url, https: found.https, port: found.port, favicon: found.favicon, description: found.description, suggestedDisplayName: found.suggestedDisplayName, suggestedCategory: found.category, suggestedIcon: found.icon, suggestedVisible: found.visible, }) ); deviceRepo.upsertDeviceFromScan({ hostname: device.hostname, ip: device.ip, mac: device.mac, manufacturer: device.manufacturer, model: device.model, online: discovered.length > 0, source: device.source, }); const created = results.filter((r) => r.created).length; const updated = results.filter((r) => !r.created).length; const ports = discovered.map((d) => d.port).sort((a, b) => a - b); // Dienste, die es vorher gab, aber diesmal nicht mehr gefunden wurden // (Port nicht mehr offen) – werden NICHT automatisch gelöscht, sondern // erst nach dem konfigurierten Schwellenwert (gleiche Einstellung wie // bei nicht mehr gemeldeten Geräten, siehe Einstellungen) zur // manuellen Durchsicht zurückgegeben. Ein einzelner verpasster Scan // (z. B. Gerät kurz neu gestartet) soll nicht sofort als "verschwunden" // gemeldet werden. const foundPorts = new Set(discovered.map((d) => d.port)); const staleThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7); const staleThresholdMs = staleThresholdDays * 24 * 60 * 60 * 1000; const now = Date.now(); const staleServiceCandidates = servicesBeforeScan.filter((s) => { if (foundPorts.has(s.port)) return false; const lastSeen = s.updatedAt ? new Date(s.updatedAt).getTime() : 0; return now - lastSeen >= staleThresholdMs; }); // Ein Dienst, der in DIESEM Scan wieder gefunden wurde, ist nicht mehr // "verschwunden" - eine frühere Bestätigung/Ablehnung ("ist ok, weg damit" // bzw. "nein, das war ein Fehlalarm") bezog sich auf DIESE Abwesenheits- // Episode und soll eine KÜNFTIGE (neue) Abwesenheit nicht stumm // unterdrücken. Alte Dismiss-Einträge für wiedergefundene Dienste daher // hier auflösen. dismissalRepo.clearDismissals( "stale_service", servicesBeforeScan.filter((s) => foundPorts.has(s.port)).map((s) => s.id) ); const dismissedStaleServiceIds = dismissalRepo.getDismissedKeys("stale_service"); const staleServices = staleServiceCandidates.filter((s) => !dismissedStaleServiceIds.has(s.id)); // Vorschläge für abweichende Namen/Kategorien bereits bekannter Dienste // einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch // übernommen, nur zur Bestätigung zurückgegeben. dedupeKey enthält den // vorgeschlagenen WERT mit, damit eine einmal verworfene Änderung nicht // auch eine völlig ANDERE künftige Änderung an demselben Feld unterdrückt. const dismissedNameChangeKeys = dismissalRepo.getDismissedKeys("name_change"); const nameChanges = results .flatMap((r) => r.nameChanges.map((change) => ({ serviceId: r.service.id, serviceHostname: r.service.hostname, servicePort: r.service.port, ...change, })) ) .filter((c) => !dismissedNameChangeKeys.has(`${c.serviceId}:${c.field}:${c.suggested}`)); // Gerätename-Vorschlag per Reverse-DNS, falls er sich vom aktuell // gespeicherten Hostnamen unterscheidet - ebenfalls nur zur Bestätigung. const deviceNameSuggestion = suggestedHostname && suggestedHostname !== device.hostname ? suggestedHostname : null; logRepo.logScan({ 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` : ""} [${formatToolsUsed(toolsUsed)}]`, }); const dismissedNewServiceIds = dismissalRepo.getDismissedKeys("new_service"); return { deviceId: device.id, scannedPorts: discovered.length, ports, created, updated, services: results.map((r) => r.service), newServices: results .filter((r) => r.created) .map((r) => r.service) .filter((s) => !dismissedNewServiceIds.has(s.id)), staleServices, nameChanges, deviceNameSuggestion, }; } /** * Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt * scannen") aus der Admin-UI ausgelöst – es gibt keinerlei automatischen/ * zeitgesteuerten Scan. Jeder Versuch (Erfolg oder Fehler) wird in scan_logs * protokolliert und ist unter Admin -> Logs einsehbar. */ /** * Filtert die Vorschlagslisten in einem (beim Scan-Abschluss eingefrorenen) * "lastResult" NOCHMAL gegen den AKTUELLEN Dismiss-Stand, bei jedem * Status-Abruf neu. Nötig, weil ein Bestätigen/Verwerfen NACH Scan-Ende * passieren kann (der Normalfall) - ohne diese Live-Filterung würde ein * anderer Browser/Gerät, der denselben eingefrorenen Snapshot abfragt, * weiterhin den alten (unbestätigten) Stand sehen, obwohl der Vorschlag * anderswo längst bestätigt wurde (siehe Bugreport). */ function reapplyDismissals(status: ReturnType): ReturnType { if (!status.lastResult) return status; const r = status.lastResult as Record; const filterByIdKind = (list: unknown, kind: dismissalRepo.DismissalKind) => { if (!Array.isArray(list)) return list; const dismissed = dismissalRepo.getDismissedKeys(kind); return list.filter((item) => !dismissed.has((item as { id: string }).id)); }; const filterNameChangeLike = ( list: unknown, kind: dismissalRepo.DismissalKind, idField: "serviceId" | "deviceId" ) => { if (!Array.isArray(list)) return list; const dismissed = dismissalRepo.getDismissedKeys(kind); return list.filter((item) => { const c = item as Record; return !dismissed.has(`${c[idField]}:${c.field}:${c.suggested}`); }); }; const patched: Record = { ...r }; if ("newServices" in r) patched.newServices = filterByIdKind(r.newServices, "new_service"); if ("staleServices" in r) patched.staleServices = filterByIdKind(r.staleServices, "stale_service"); if ("nameChanges" in r) patched.nameChanges = filterNameChangeLike(r.nameChanges, "name_change", "serviceId"); if ("staleDevices" in r) patched.staleDevices = filterByIdKind(r.staleDevices, "stale_device"); if ("deviceChanges" in r) patched.deviceChanges = filterNameChangeLike(r.deviceChanges, "device_change", "deviceId"); return { ...status, lastResult: patched }; } export async function scanRoutes(app: FastifyInstance): Promise { // Merkt einen Scan-Vorschlag (neuer Dienst, verschwundener Dienst/Gerät, // Namens-/Kategorie-Änderung) SERVERSEITIG als erledigt - egal ob durch // "Übernehmen" oder "Behalten" ausgelöst, beides heißt hier "nicht mehr // anzeigen". Wird vom Frontend bei jeder Bestätigen/Verwerfen-Aktion // aufgerufen (siehe ScannerPage.tsx) - vorher lief das rein lokal über // sessionStorage, wodurch ein Bestätigen auf einem Gerät/Browser auf einem // anderen wirkungslos blieb (siehe Bugreport). app.post("/api/scan/dismiss", async (request, reply) => { const { kind, key } = request.body as { kind?: string; key?: string }; const validKinds = ["new_service", "stale_service", "stale_device", "name_change", "device_change"]; if (!kind || !validKinds.includes(kind) || !key) { return reply.code(400).send({ error: "kind (eine der bekannten Arten) und key sind erforderlich." }); } dismissalRepo.dismiss(kind as dismissalRepo.DismissalKind, key); return { dismissed: true }; }); 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" }); } if (!tryLockDevice(device.id)) { return reply.code(409).send({ error: "Für dieses Gerät läuft bereits ein Scan." }); } try { return await performDeviceScan(device, full === "true"); } catch (err) { const detail = err instanceof Error ? err.message : String(err); logRepo.logScan({ type: "device", targetId: device.id, level: "error", message: `${device.hostname} (${device.ip}): Scan fehlgeschlagen – ${detail}`, }); request.log.error(err); return reply.code(500).send({ error: "Geräte-Scan fehlgeschlagen", detail }); } finally { unlockDevice(device.id); } }); // Sammel-Scan über ALLE Geräte - läuft als Hintergrund-Job im Backend // (siehe scanJobs.ts), nicht mehr als eine lange, vom Frontend // client-seitig orchestrierte Schleife aus Einzelaufrufen. Zwei Vorteile: // 1) Der Fortschritt ist über GET .../status geräteübergreifend sichtbar - // öffnet jemand den Adminbereich auf einem ANDEREN Gerät/Browser während // ein Scan läuft, sieht er das trotzdem (der Zustand lebt im Backend, // nicht im Browser-sessionStorage). // 2) Ein einzelnes sehr langsames Gerät blockiert nicht die gesamte // HTTP-Anfrage bis zum Timeout. app.get("/api/scan/devices/all/status", async () => { return reapplyDismissals(getJobStatus("devices")); }); app.post("/api/scan/devices/all/cancel", async () => { requestCancel("devices"); return { cancelling: true }; }); app.post("/api/scan/devices/all", async (_request, reply) => { if (isJobRunning("devices")) { return reply.code(409).send({ error: "Es läuft bereits ein Geräte-Scan." }); } const devices = deviceRepo.listDevices(); startJob("devices", devices.length); void (async () => { type DeviceScanResult = Awaited>; let created = 0; let updated = 0; let scannedCount = 0; const allStale: DeviceScanResult["staleServices"] = []; const allNameChanges: DeviceScanResult["nameChanges"] = []; const allNew: DeviceScanResult["newServices"] = []; // Sammelt die IDs ALLER gefundenen Dienste über den kompletten // Sammel-Scan hinweg - für den Vergleich mit dem vorherigen Lauf (siehe // weiter unten bei finishJob). const allServiceIds: string[] = []; let cancelled = false; try { // NEU (round50): Bis round49 startete JEDES Gerät seinen eigenen // masscan- bzw. httpx-Prozess - bei bis zu 10 gleichzeitig // gescannten Geräten (siehe CONCURRENT_DEVICES unten) liefen dadurch // bis zu 10 masscan- und 10 httpx-Prozesse parallel, die sich // gegenseitig die Netzwerkschnittstelle streitig machten und in den // Timeout liefen (siehe Bugreport: "Flaschenhals"). // // Jetzt läuft masscan EINMAL für ALLE Geräte dieses Scan-Laufs // gemeinsam (masscan unterstützt mehrere Ziel-IPs nativ in einem // Prozess) und httpx EINMAL für die Vereinigung aller dabei // gefundenen offenen Ports über alle Geräte hinweg. Das Ergebnis // wird unten pro Gerät als "preFetched" an performDeviceScan // durchgereicht - der eigentliche Geräte-Scan macht dann selbst // keine masscan-/httpx-Aufrufe mehr, nur noch die (leichtgewichtigen, // reinen HTTP-)Detailabfragen pro Port (Titel, Favicon, // Softwareerkennung). // NEU (round58): Die FritzBox (der Router selbst, NICHT ein Repeater // o. ä.) hat eine eingebaute Angriffs-/Portscan-Erkennung // (Sicherheitseinstellungen -> Angriffserkennung). Ein voller // masscan-Lauf über 1-65535 Ports gegen die eigene Router-IP sieht // dafür genau wie ein klassischer Portscan-Angriff aus - die // FritzBox blockt/ignoriert die Scan-Quelle daraufhin offenbar // kurzzeitig, sodass selbst der direkt danach laufende normale // 80/443-Retry (siehe scanDeviceServices) noch nichts findet (siehe // Bugreport: "0 Dienst(e) gefunden (Ports: keine)" bei der FritzBox // selbst, während ein FRITZ!Repeater im selben Lauf ganz normal alle // Dienste liefert - Repeater haben typischerweise nicht dieselbe // Absicherung wie der Router, der ja die Sicherheits-Gateway-Rolle // fürs ganze Netz hat). Die Router-IP wird daher gezielt aus dem // masscan-Batch ausgenommen - für sie greift stattdessen weiter // unten ganz normal die schonende "übliche Portliste"-Prüfung // (kein vollständiger Portscan, kein masscan), ihre echten Dienste // kennen wir ohnehin schon zuverlässig über den eigenen // TR-064-Scanner (siehe scanner/fritzbox.ts). const routerIp = await resolveRouterIp(); const deviceIps = devices.map((d) => d.ip).filter((ip) => ip !== routerIp); if (routerIp && devices.some((d) => d.ip === routerIp)) { logRepo.logScan({ type: "device", targetId: null, level: "info", message: `Sammel-Scan: Router-IP ${routerIp} vom vollständigen Portscan ausgenommen (FritzOS-Angriffserkennung reagiert empfindlich auf volle Portscans gegen sich selbst) - wird stattdessen mit der üblichen Portliste geprüft.`, }); } // Sichtbarer Fortschritt VOR dem eigentlichen Geräte-Scan: ohne das // stand die Anzeige die ganze Vorabscan-Zeit über bei "1 von 120" und // wirkte wie hängengeblieben, obwohl im Hintergrund der gemeinsame // masscan-/httpx-Lauf arbeitete (siehe Bugreport). updateJobProgress("devices", 0, `masscan läuft… (${deviceIps.length} Geräte, vollständiger Portscan)`); const masscanStartedAt = Date.now(); // masscan meldet laufend Fortschritt (Prozent, Restzeit, bisherige // Treffer) - wird hier live in die Fortschrittsanzeige gespiegelt, // statt nur den statischen "masscan läuft…"-Text stehen zu lassen // (siehe Bugreport: "ich sehe nicht, ob noch alles richtig läuft"). // Eine feste IP lässt sich dabei bewusst NICHT anzeigen - masscan // arbeitet alle Ziel-Hosts gemeinsam/asynchron ab, es gibt keine // sinnvolle "aktuell bearbeitete IP" wie bei einem sequenziellen // Scanner. let lastMasscanProgressUpdate = 0; const masscanBatch = await scanHostsWithMasscan(deviceIps, "1-65535", undefined, (progress) => { const now = Date.now(); if (now - lastMasscanProgressUpdate < 500) return; // nicht öfter als 2x/s aktualisieren lastMasscanProgressUpdate = now; const etaText = progress.etaSeconds !== null ? progress.etaSeconds > 0 ? `, ~${progress.etaSeconds}s verbleibend` : ", wartet auf letzte Antworten" : ""; updateJobProgress( "devices", 0, `masscan läuft… (${deviceIps.length} Geräte, vollständiger Portscan) – ${progress.percent.toFixed(0)}% erledigt${etaText}, ${progress.foundSoFar} offene(r) Port(s) bisher` ); }); const masscanDurationMs = Date.now() - masscanStartedAt; const masscanBatchError = masscanBatch === null ? getMasscanLastError() : null; const masscanPortsFound = masscanBatch ? Array.from(masscanBatch.values()).reduce((sum, ports) => sum + ports.length, 0) : 0; // Start- und Endzeit fest im Scan-Log protokollieren (nicht nur in // der Konsole) - so ist die tatsächliche Dauer des Vorabscans auch im // Nachhinein unter Admin -> Logs nachvollziehbar, nicht nur während // eines laufenden Scans über die Fortschrittsanzeige. logRepo.logScan({ type: "device", targetId: null, level: masscanBatch ? "info" : "error", message: masscanBatch ? `Sammel-Scan: masscan-Vorabscan über ${deviceIps.length} Geräte abgeschlossen in ${(masscanDurationMs / 1000).toFixed(1)}s, ${masscanPortsFound} offene(r) Port(s) gefunden (Start: ${new Date(masscanStartedAt).toLocaleTimeString("de-DE")}, Ende: ${new Date().toLocaleTimeString("de-DE")})` : `Sammel-Scan: masscan-Vorabscan über ${deviceIps.length} Geräte fehlgeschlagen nach ${(masscanDurationMs / 1000).toFixed(1)}s – ${masscanBatchError} (jedes Gerät fällt einzeln auf den TCP-Fallback zurück)`, }); // httpx nur für die Geräte vorab bündeln, für die masscan Ergebnisse // geliefert hat - ist masscan für den GESAMTEN Batch fehlgeschlagen // (masscanBatch === null), kennen wir die offenen Ports noch nicht // und jedes Gerät ermittelt sie unten wie gewohnt selbst per // TCP-Fallback (scanPortsInBatches) samt eigenem httpx-Aufruf - // langsamer als der Idealfall, aber weiterhin korrekt. let httpxBatch: Map | null = null; let httpxBatchError: string | null = null; if (masscanBatch) { const httpxTargets: Array<{ ip: string; port: number }> = []; for (const device of devices) { const ports = masscanBatch.get(device.ip) ?? []; for (const port of ports) httpxTargets.push({ ip: device.ip, port }); } updateJobProgress("devices", 0, `httpx läuft… (${httpxTargets.length} Port(s) über alle Geräte)`); const httpxStartedAt = Date.now(); // httpx meldet jeden Treffer sofort, sobald er bestätigt ist - // wird hier live in die Fortschrittsanzeige gespiegelt (Anzahl // bereits als HTTP/HTTPS bestätigter Ports von den insgesamt zu // prüfenden Zielen). Eine Zwischenanzeige "noch nicht geprüfte" // Ziele ist dabei nicht sinnvoll möglich - httpx meldet erfolglose // Versuche gar nicht erst zurück (siehe httpx.ts). let lastHttpxProgressUpdate = 0; httpxBatch = await probeManyWithHttpx(httpxTargets, (confirmedSoFar) => { const now = Date.now(); if (now - lastHttpxProgressUpdate < 500) return; lastHttpxProgressUpdate = now; updateJobProgress( "devices", 0, `httpx läuft… (${httpxTargets.length} Port(s) über alle Geräte) – ${confirmedSoFar} bereits als HTTP/HTTPS bestätigt` ); }); const httpxDurationMs = Date.now() - httpxStartedAt; if (httpxBatch === null) httpxBatchError = getHttpxLastError(); logRepo.logScan({ type: "device", targetId: null, level: httpxBatch ? "info" : "error", message: httpxBatch ? `Sammel-Scan: httpx-Vorabscan über ${httpxTargets.length} Ziel(e) (ip:port) abgeschlossen in ${(httpxDurationMs / 1000).toFixed(1)}s, ${httpxBatch.size} als HTTP/HTTPS bestätigt (Start: ${new Date(httpxStartedAt).toLocaleTimeString("de-DE")}, Ende: ${new Date().toLocaleTimeString("de-DE")})` : `Sammel-Scan: httpx-Vorabscan über ${httpxTargets.length} Ziel(e) fehlgeschlagen nach ${(httpxDurationMs / 1000).toFixed(1)}s – ${httpxBatchError} (jeder Port wird stattdessen einzeln von jedem Gerät selbst geprüft)`, }); } else { logRepo.logScan({ type: "device", targetId: null, level: "info", message: `Sammel-Scan: httpx-Vorabscan übersprungen, da der masscan-Vorabscan fehlgeschlagen ist (keine Portliste zum Prüfen vorhanden)`, }); } // Mehrere Geräte GLEICHZEITIG scannen statt strikt nacheinander - // bei z. B. 118 Geräten hätte ein rein sequenzieller Durchlauf der // (jetzt viel schnelleren, weil rein HTTP-basierten) Detailabfragen // trotzdem lange gedauert. Batch-Größe bewusst moderat (nicht alle // Geräte auf einmal): jeder einzelne Geräte-Scan öffnet intern schon // bis zu 1000 gleichzeitige Verbindungen (siehe ports.ts) - zu viele // Geräte gleichzeitig könnte das eigene Netz/den Docker-Host // überlasten. const CONCURRENT_DEVICES = 10; for (let i = 0; i < devices.length; i += CONCURRENT_DEVICES) { if (isCancelRequested("devices")) { cancelled = true; break; } const batch = devices.slice(i, i + CONCURRENT_DEVICES); const stillRunning = new Set(batch.map((d) => d.hostname)); const renderBatchLabel = () => `${batch.map((d) => (stillRunning.has(d.hostname) ? d.hostname : `✓ ${d.hostname}`)).join(", ")}`; updateJobProgress("devices", scannedCount, renderBatchLabel()); await Promise.all( batch.map(async (device) => { if (!tryLockDevice(device.id)) { // Für dieses eine Gerät läuft bereits ein separater Scan // (z. B. über den "Jetzt scannen"-Button auf der // Geräte-Seite) - überspringen statt zu überlappen, sonst // könnten beide gleichzeitig denselben neuen Dienst anlegen // wollen. stillRunning.delete(device.hostname); return; } try { // Router-IP: siehe Kommentar oben bei resolveRouterIp() - // bewusst NICHT preFetched/fullScan nutzen, sondern die // schonende Standard-Portliste (kein voller 1-65535-Scan // gegen den Router selbst, keine Angriffserkennung // getriggert). const isRouter = routerIp !== null && device.ip === routerIp; if (isRouter) { const result = await performDeviceScan(device, false); created += result.created; updated += result.updated; allStale.push(...result.staleServices); allNameChanges.push(...result.nameChanges); allNew.push(...result.newServices); allServiceIds.push(...result.services.map((s) => s.id)); return; } const masscanPorts = masscanBatch ? masscanBatch.get(device.ip) ?? [] : undefined; const preFetched: PreFetchedScanData = { masscanAttempted: true, masscanPorts, masscanError: masscanPorts ? null : masscanBatchError, httpxResults: masscanPorts ? (() => { // Aus der großen, geräteübergreifenden httpx-Map nur // die Treffer DIESES Geräts herausfiltern. if (httpxBatch === null) return null; const map = new Map(); for (const port of masscanPorts) { const hit = httpxBatch.get(`${device.ip}:${port}`); if (hit) map.set(port, hit); } return map; })() : undefined, httpxError: masscanPorts ? httpxBatchError : null, }; const result = await performDeviceScan(device, true, preFetched); created += result.created; updated += result.updated; allStale.push(...result.staleServices); allNameChanges.push(...result.nameChanges); allNew.push(...result.newServices); allServiceIds.push(...result.services.map((s) => s.id)); } catch (err) { // Einzelnes fehlgeschlagenes Gerät soll den Rest nicht // abbrechen - ABER trotzdem persistent loggen, statt // stillschweigend zu überspringen. Vorher war das ein // stummer catch-Block: scheiterte ausgerechnet immer // dasselbe Gerät (z. B. wegen eines kaputten Hostnamens), // gab es dafür nie einen sichtbaren Hinweis. const detail = err instanceof Error ? err.message : String(err); logRepo.logScan({ type: "device", targetId: device.id, level: "error", message: `${device.hostname} (${device.ip}): Scan im Sammel-Lauf fehlgeschlagen – ${detail}`, }); } finally { unlockDevice(device.id); // Live sichtbar machen, WELCHES Gerät im aktuellen Batch // schon fertig ist ("✓ Name") und welche noch laufen - // bisher aktualisierte sich die Anzeige erst wieder, wenn // ALLE 10 Geräte des Batches fertig waren. Blieb eines davon // hängen, sah der komplette Batch wie eingefroren aus, ohne // erkennbar zu machen, WELCHES Gerät das eigentlich betraf // (siehe Bugreport). stillRunning.delete(device.hostname); updateJobProgress("devices", scannedCount, renderBatchLabel()); } }) ); scannedCount += batch.length; updateJobProgress("devices", scannedCount, null); } // NEU (round60): Vergleich mit dem VORHERIGEN Sammel-Scan (siehe // gleiches Prinzip beim FritzBox-Scan oben) - Mengenvergleich der // gefundenen Dienst-IDs statt reiner Zahlenvergleich. const previousServiceIdsRaw = settingsRepo.getSetting("deviceScanLastServiceIds"); const previousServiceIds: string[] | null = previousServiceIdsRaw ? JSON.parse(previousServiceIdsRaw) : null; const currentServiceIdSet = new Set(allServiceIds); let changesSinceLastScan: string; if (cancelled) { changesSinceLastScan = "Abgebrochen - kein vollständiger Vergleich möglich."; } else if (previousServiceIds === null) { changesSinceLastScan = "Erster Scan - kein Vergleich zu einem vorherigen Lauf möglich."; } else { const previousSet = new Set(previousServiceIds); const newlyAppeared = allServiceIds.filter((id) => !previousSet.has(id)); const noLongerSeen = previousServiceIds.filter((id) => !currentServiceIdSet.has(id)); if (newlyAppeared.length === 0 && noLongerSeen.length === 0) { changesSinceLastScan = "Keine Änderungen seit dem letzten Sammel-Scan."; } else { const parts: string[] = []; if (newlyAppeared.length > 0) parts.push(`${newlyAppeared.length} neue(r) Dienst(e)`); if (noLongerSeen.length > 0) parts.push(`${noLongerSeen.length} Dienst(e) fehlen jetzt`); changesSinceLastScan = `Änderungen seit dem letzten Sammel-Scan: ${parts.join(", ")}.`; } } // Bei einem abgebrochenen Lauf NICHT als Vergleichsbasis speichern - // sonst würde ein unvollständiger Durchlauf fälschlich "Dienste // fehlen jetzt" für alle noch gar nicht gescannten Geräte anzeigen. if (!cancelled) { settingsRepo.setSetting("deviceScanLastServiceIds", JSON.stringify(allServiceIds)); } finishJob("devices", { totalDevices: devices.length, scannedCount, cancelled, created, updated, staleServices: allStale, nameChanges: allNameChanges, newServices: allNew, changesSinceLastScan, }); } catch (err) { // WICHTIG: Vorher wurde ein Abbruch des GESAMTEN Sammel-Scans nur im // In-Memory-Jobstatus (lastError) vermerkt, nie in der persistenten // scan_logs-Tabelle - Admin -> Logs blieb dadurch leer, obwohl der // Scan sichtbar fehlgeschlagen war (Badge in der Seitenleiste // verschwindet, weil running=false wird, aber ohne jede Erklärung // warum). Jetzt zusätzlich persistent geloggt. const detail = err instanceof Error ? err.message : String(err); logRepo.logScan({ type: "device", targetId: null, level: "error", message: `Sammel-Scan abgebrochen – ${detail}`, }); failJob("devices", detail); } })(); return reply.code(202).send({ started: true, totalDevices: devices.length }); }); /** * Erkennt den von der FritzBox selbst generierten Platzhalter-Namen für * Geräte ohne bekannten Hostnamen (Muster "PC-192-168-1-73"). Ein solcher * Name ist nie einer echten Namensänderung wert, sondern nur ein Rückfall. */ function isGenericFritzboxHostname(hostname: string, ip: string): boolean { return hostname === `PC-${ip.replace(/\./g, "-")}`; } /** * Die FritzBox liefert für ein einzelnes physisches Gerät oft MEHRERE * TR-064-Einträge (IPv4+IPv6, WLAN+Mesh, oder ein alter Platzhaltername neben * dem aktuellen) - alle mit derselben MAC-Adresse. Werden diese einzeln * nacheinander upgeserted, legt der erste Eintrag das Gerät an und der * zweite wird sofort als "Änderung" gegen das gerade erst selbst angelegte * Gerät vorgeschlagen - verwirrend, besonders bei einer leeren Datenbank * (siehe Bugreport). Deshalb hier VOR dem Upsert je MAC zu einem Eintrag * zusammenführen und dabei den aussagekräftigsten Hostnamen wählen (echter * Name statt "PC-"-Platzhalter, sofern vorhanden). */ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] { const byMac = new Map(); const withoutMac: FritzBoxHost[] = []; for (const host of hosts) { if (!host.mac) { withoutMac.push(host); continue; } const key = host.mac.toLowerCase(); const group = byMac.get(key); if (group) group.push(host); else byMac.set(key, [host]); } const merged: FritzBoxHost[] = Array.from(byMac.values()).map((group) => { const named = group.find((h) => !isGenericFritzboxHostname(h.hostname, h.ip)); const chosen = named ?? group[0]; const online = group.find((h) => h.online) ?? group[0]; return { ip: online.ip, mac: chosen.mac, hostname: chosen.hostname, online: online.online }; }); return [...merged, ...withoutMac]; } // FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte. // Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional // FRITZBOX_PORT, Default 49000) als Umgebungsvariablen. app.get("/api/scan/fritzbox/status", async () => { return reapplyDismissals(getJobStatus("fritzbox")); }); app.post("/api/scan/fritzbox", async (_request, reply) => { const host = process.env.FRITZBOX_HOST; const username = process.env.FRITZBOX_USERNAME; const password = process.env.FRITZBOX_PASSWORD; if (!host || !username || !password) { return reply.code(400).send({ error: "FritzBox nicht konfiguriert. Bitte FRITZBOX_HOST, FRITZBOX_USERNAME und FRITZBOX_PASSWORD setzen.", }); } if (isJobRunning("fritzbox")) { return reply.code(409).send({ error: "Es läuft bereits ein FritzBox-Scan." }); } const port = process.env.FRITZBOX_PORT ? Number(process.env.FRITZBOX_PORT) : 49000; startJob("fritzbox", 1); // Wie bei den anderen Scannern: läuft im Hintergrund weiter, die // HTTP-Antwort kommt sofort - macht den Status auch hier // geräteübergreifend abfragbar statt nur im Browser sichtbar, der ihn // gestartet hat. void (async () => { try { const devicesBeforeScan = deviceRepo.listDevices().filter((d) => d.source === "fritzbox"); const hosts = await fetchFritzBoxHosts({ host, port, username, password }); // Mehrere TR-064-Einträge für dasselbe physische Gerät (gleiche MAC) // werden VOR dem Upsert zusammengeführt (siehe mergeFritzboxHosts oben) - // deshalb kann "gefundene Einträge" > Anzahl eindeutiger Geräte sein. const mergedHosts = mergeFritzboxHosts(hosts); const results = mergedHosts.map((h) => deviceRepo.upsertDeviceFromScan({ hostname: h.hostname, ip: h.ip, mac: h.mac, online: h.online, source: "fritzbox", }) ); const devices = results.map((r) => r.device); // Vorschläge für abweichende Hostnamen/IPs bereits bekannter Geräte // einsammeln (siehe upsertDeviceFromScan) - werden NICHT automatisch // übernommen, nur zur Bestätigung zurückgegeben. // // WICHTIG: Ein Vorschlag, der einen bereits bekannten ECHTEN Namen // (z. B. "cam-reolink-einfahrt") auf den generischen // FritzBox-Platzhalter "PC-192-168-1-39" zurückstufen würde, wird // hier ausgefiltert und gar nicht erst vorgeschlagen. Das war die // Ursache der Namens-Regression: mergeFritzboxHosts() bevorzugt den // echten Namen nur, wenn IN DEMSELBEN Scan-Durchlauf mehrere // TR-064-Einträge für dieselbe MAC vorliegen - hat die FritzBox in // einem Durchlauf für ein Gerät NUR den Platzhalter gemeldet (z. B. // kurz nach einem Neustart/DHCP-Lease-Renewal, bevor sie den // mDNS/NetBIOS-Namen kennt), gab es bisher keine zweite Quelle zum // Vergleich - der Platzhalter wurde als ganz normale "Namensänderung" // vorgeschlagen und konnte (z. B. über "Alle bestätigen") den guten // Namen dauerhaft überschreiben. const dismissedDeviceChangeKeys = dismissalRepo.getDismissedKeys("device_change"); const deviceChanges = results .flatMap((r, i) => { const scannedIp = mergedHosts[i].ip; return r.changes .filter((c) => { if (c.field !== "hostname") return true; const suggestedIsPlaceholder = isGenericFritzboxHostname(c.suggested, scannedIp); const currentIsPlaceholder = isGenericFritzboxHostname(c.current, scannedIp); return !(suggestedIsPlaceholder && !currentIsPlaceholder); }) .map((c) => ({ deviceId: r.device.id, deviceHostname: r.device.hostname, ...c })); }) .filter((c) => !dismissedDeviceChangeKeys.has(`${c.deviceId}:${c.field}:${c.suggested}`)); // Geräte, die die FritzBox früher gemeldet hatte, diesmal aber nicht // mehr in der Liste sind – werden erst nach mindestens // staleDeviceThresholdDays Tagen Abwesenheit zur Durchsicht // vorgeschlagen (nicht schon beim ersten verpassten Scan), damit ein // kurzzeitig offline/im Standby befindliches Gerät (Reboot, // WLAN-Aussetzer, Nacht-Standby) nicht sofort zum Löschen // vorgeschlagen wird. lastScan wird bei jedem Fund aktualisiert (siehe // upsertDeviceFromScan) - bleibt es stehen, war das Gerät seither nicht // mehr da. // // "Gefunden" wird über die ID der in DIESEM Scan tatsächlich // upgeserteten Geräte geprüft (nicht über einen IP-Abgleich!): ändert // sich die IP eines Geräts (z. B. DHCP-Neuvergabe), wird es trotzdem // korrekt über seine MAC wiedererkannt und upgeserted - ein reiner // IP-Vergleich hätte es fälschlich als "nicht mehr gefunden" gemeldet, // obwohl es im selben Scan noch da war, nur unter neuer IP. const foundDeviceIds = new Set(devices.map((d) => d.id)); // Wie bei staleServices (siehe performDeviceScan oben): ein wieder // gefundenes Gerät löst eine frühere "verschwunden"-Bestätigung auf, // damit eine KÜNFTIGE Abwesenheit erneut vorgeschlagen wird, statt // von einem alten Dismiss-Eintrag stumm unterdrückt zu werden. dismissalRepo.clearDismissals("stale_device", Array.from(foundDeviceIds)); const staleDeviceThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7); const staleThresholdMs = staleDeviceThresholdDays * 24 * 60 * 60 * 1000; const now = Date.now(); const dismissedStaleDeviceIds = dismissalRepo.getDismissedKeys("stale_device"); const staleDevices = devicesBeforeScan.filter((d) => { if (foundDeviceIds.has(d.id)) return false; if (dismissedStaleDeviceIds.has(d.id)) return false; const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0; return now - lastSeen >= staleThresholdMs; }); // NEU (round60): Vergleich mit dem VORHERIGEN Scan-Lauf - vorher // stand hier bei jedem Scan nur eine absolute Zahl ("45 Einträge"), // egal ob sich seit dem letzten Mal etwas geändert hatte oder nicht // (siehe Bugreport: "wird immer nur angezeigt wieviel Einträge beim // letzten Scan waren, das die ganze Zeit"). Die Geräte-IDs des // letzten erfolgreichen Laufs werden dafür in den Einstellungen // (app_settings) gemerkt und mit den JETZT gefundenen verglichen - // ein einfacher Mengenvergleich (nicht nur ein Zahlenvergleich), // damit "3 raus, 3 rein" nicht fälschlich als "keine Änderung" // durchgeht. const previousDeviceIdsRaw = settingsRepo.getSetting("fritzboxScanLastDeviceIds"); const previousDeviceIds: string[] = previousDeviceIdsRaw ? JSON.parse(previousDeviceIdsRaw) : null; const currentDeviceIds = devices.map((d) => d.id); const currentDeviceIdSet = new Set(currentDeviceIds); let changesSinceLastScan: string; if (previousDeviceIds === null) { changesSinceLastScan = "Erster Scan - kein Vergleich zu einem vorherigen Lauf möglich."; } else { const previousSet = new Set(previousDeviceIds); const newlyAppeared = currentDeviceIds.filter((id) => !previousSet.has(id)); const noLongerSeen = previousDeviceIds.filter((id) => !currentDeviceIdSet.has(id)); if (newlyAppeared.length === 0 && noLongerSeen.length === 0) { changesSinceLastScan = "Keine Änderungen seit dem letzten Scan."; } else { const parts: string[] = []; if (newlyAppeared.length > 0) parts.push(`${newlyAppeared.length} neue(s) Gerät(e)`); if (noLongerSeen.length > 0) parts.push(`${noLongerSeen.length} Gerät(e) fehlen jetzt`); changesSinceLastScan = `Änderungen seit dem letzten Scan: ${parts.join(", ")}.`; } } settingsRepo.setSetting("fritzboxScanLastDeviceIds", JSON.stringify(currentDeviceIds)); logRepo.logScan({ type: "fritzbox", level: "info", message: `FritzBox-Scan: ${hosts.length} Eintrag/Einträge (${devices.length} eindeutige Geräte)${staleDevices.length > 0 ? `, ${staleDevices.length} nicht mehr gemeldet` : ""}${deviceChanges.length > 0 ? `, ${deviceChanges.length} Änderung(en) vorgeschlagen` : ""} - ${changesSinceLastScan}`, }); finishJob("fritzbox", { found: hosts.length, uniqueDevices: devices.length, staleDevices, deviceChanges, changesSinceLastScan, }); } catch (err) { const detail = err instanceof Error ? err.message : String(err); logRepo.logScan({ type: "fritzbox", level: "error", message: `FritzBox-Scan fehlgeschlagen – ${detail}`, }); failJob("fritzbox", detail); } })(); return reply.code(202).send({ started: true }); }); }