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 { scanDeviceServices } from "../scanner/networkScanner.js"; import { fetchFritzBoxHosts, type FritzBoxHost } from "../scanner/fritzbox.js"; /** * 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]; } /** * 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. */ export async function scanRoutes(app: FastifyInstance): Promise { app.post("/api/scan/devices/:id", async (request, reply) => { const { id } = request.params as { id: string }; const device = deviceRepo.getDevice(id); if (!device) { return reply.code(404).send({ error: "Gerät nicht gefunden" }); } try { // 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); // 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 // zur manuellen Durchsicht zurückgegeben (siehe Admin -> Scanner). const foundPorts = new Set(discovered.map((d) => d.port)); const staleServices = servicesBeforeScan.filter((s) => !foundPorts.has(s.port)); // Vorschläge für abweichende Namen/Kategorien bereits bekannter Dienste // einsammeln (siehe upsertServiceFromScan) - werden NICHT automatisch // übernommen, nur zur Bestätigung zurückgegeben. const nameChanges = results.flatMap((r) => r.nameChanges.map((change) => ({ serviceId: r.service.id, serviceHostname: r.service.hostname, servicePort: r.service.port, ...change, })) ); // 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` : ""}`, }); return { deviceId: device.id, scannedPorts: discovered.length, ports, created, updated, services: results.map((r) => r.service), staleServices, nameChanges, deviceNameSuggestion, }; } 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 }); } }); // 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.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.", }); } const port = process.env.FRITZBOX_PORT ? Number(process.env.FRITZBOX_PORT) : 49000; 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. const deviceChanges = results.flatMap((r) => r.changes.map((c) => ({ deviceId: r.device.id, deviceHostname: r.device.hostname, ...c })) ); // 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)); const staleDeviceThresholdDays = Number(settingsRepo.listSettings().staleDeviceThresholdDays ?? 7); const staleThresholdMs = staleDeviceThresholdDays * 24 * 60 * 60 * 1000; const now = Date.now(); const staleDevices = devicesBeforeScan.filter((d) => { if (foundDeviceIds.has(d.id)) return false; const lastSeen = d.lastScan ? new Date(d.lastScan).getTime() : 0; return now - lastSeen >= staleThresholdMs; }); 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` : ""}`, }); return { found: hosts.length, uniqueDevices: devices.length, devices, staleDevices, deviceChanges, }; } catch (err) { const detail = err instanceof Error ? err.message : String(err); logRepo.logScan({ type: "fritzbox", level: "error", message: `FritzBox-Scan fehlgeschlagen – ${detail}`, }); request.log.error(err); return reply.code(502).send({ error: "FritzBox-Scan fehlgeschlagen", detail }); } }); }