generated from Dicken/dickendock
930 lines
46 KiB
TypeScript
930 lines
46 KiB
TypeScript
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<string | null> {
|
||
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<typeof getJobStatus>): ReturnType<typeof getJobStatus> {
|
||
if (!status.lastResult) return status;
|
||
const r = status.lastResult as Record<string, unknown>;
|
||
|
||
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<string, unknown>;
|
||
return !dismissed.has(`${c[idField]}:${c.field}:${c.suggested}`);
|
||
});
|
||
};
|
||
|
||
const patched: Record<string, unknown> = { ...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<void> {
|
||
// 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<ReturnType<typeof performDeviceScan>>;
|
||
let created = 0;
|
||
let updated = 0;
|
||
let scannedCount = 0;
|
||
const allStale: DeviceScanResult["staleServices"] = [];
|
||
const allNameChanges: DeviceScanResult["nameChanges"] = [];
|
||
const allNew: DeviceScanResult["newServices"] = [];
|
||
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<string, HttpxResult> | 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);
|
||
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<number, HttpxResult>();
|
||
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);
|
||
} 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, korrigiert round61): Vergleich mit dem VORHERIGEN
|
||
// Sammel-Scan (siehe gleiches Prinzip beim FritzBox-Scan oben) -
|
||
// Mengenvergleich der Dienst-IDs statt reiner Zahlenvergleich.
|
||
//
|
||
// WICHTIG: Als "aktueller Stand" wird eine FRISCHE, VOLLSTÄNDIGE
|
||
// Abfrage aller Dienste aus der Datenbank genutzt (serviceRepo.
|
||
// listServices()) - NICHT die Liste der in DIESEM Lauf tatsächlich
|
||
// erfolgreich gescannten Dienste. Der Unterschied ist entscheidend:
|
||
// Geräte, die in diesem Lauf übersprungen wurden (z. B. weil parallel
|
||
// schon ein Einzelgeräte-Scan für sie lief, siehe tryLockDevice) oder
|
||
// bei denen der Scan mit einem Fehler abgebrochen ist, haben ihre
|
||
// bestehenden Dienste NIE in eine nur-aus-diesem-Lauf gesammelte
|
||
// Liste geschafft - beim NÄCHSTEN Lauf sahen deren ganz normalen,
|
||
// unveränderten Dienste dann fälschlich wie "neu" aus (siehe
|
||
// Bugreport: Zusammenfassung zeigte "3 neue Dienste", obwohl nur 1
|
||
// Dienst tatsächlich neu angelegt wurde). Eine frische DB-Abfrage hat
|
||
// dieses Problem nicht, sie spiegelt immer den WIRKLICHEN
|
||
// Gesamtstand wider, unabhängig davon, welche Geräte in diesem
|
||
// konkreten Lauf erfolgreich waren.
|
||
//
|
||
// Das ändert auch die Bedeutung von "nicht mehr da": Diese Dienste
|
||
// wurden nicht in diesem Scan einfach nicht gefunden (dafür gibt es
|
||
// ja bereits staleServices mit eigener Karenzzeit und eigenem
|
||
// Bestätigen/Verwerfen-Dialog weiter unten) - sie wurden
|
||
// TATSÄCHLICH AUS DER DATENBANK GELÖSCHT, entweder über genau diesen
|
||
// Bestätigen-Dialog oder manuell. Das ist eine reine Feststellung
|
||
// ("das ist seit dem letzten Mal passiert"), keine neue
|
||
// Entscheidung, die hier nochmal angezeigt/genehmigt werden müsste -
|
||
// die Genehmigung ist ja bereits an anderer Stelle erfolgt.
|
||
const previousServiceIdsRaw = settingsRepo.getSetting("deviceScanLastServiceIds");
|
||
const previousServiceIds: string[] | null = previousServiceIdsRaw ? JSON.parse(previousServiceIdsRaw) : null;
|
||
const currentServiceIds = serviceRepo.listServices().map((s) => s.id);
|
||
const currentServiceIdSet = new Set(currentServiceIds);
|
||
|
||
let changesSinceLastScan: string | null;
|
||
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 = currentServiceIds.filter((id) => !previousSet.has(id));
|
||
const deletedSinceLastScan = previousServiceIds.filter((id) => !currentServiceIdSet.has(id));
|
||
// Der Satz ist reine Wiederholung, wenn er GENAU dasselbe aussagt
|
||
// wie die Standard-Zusammenfassung ("X neue(r) Dienst(e) gefunden")
|
||
// ohnehin schon (newlyAppeared === created, nichts gelöscht) - dann
|
||
// komplett weglassen statt zweimal dasselbe zu sagen. Weicht die
|
||
// Zahl dagegen ab (z. B. weil zwischen zwei Sammel-Scans ein
|
||
// Einzelgeräte-Scan lief, oder weil ein vorheriger Lauf
|
||
// abgebrochen wurde und die Vergleichsbasis dadurch älter ist),
|
||
// ist das selbst eine nützliche Information und bleibt sichtbar.
|
||
const isRedundant = newlyAppeared.length === created && deletedSinceLastScan.length === 0;
|
||
if (isRedundant) {
|
||
changesSinceLastScan = null;
|
||
} else if (newlyAppeared.length === 0 && deletedSinceLastScan.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 (deletedSinceLastScan.length > 0)
|
||
parts.push(`${deletedSinceLastScan.length} Dienst(e) seither gelöscht`);
|
||
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 melden, dass
|
||
// Dienste "gelöscht" wurden, nur weil noch nicht alle Geräte an der
|
||
// Reihe waren.
|
||
if (!cancelled) {
|
||
settingsRepo.setSetting("deviceScanLastServiceIds", JSON.stringify(currentServiceIds));
|
||
}
|
||
|
||
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-<ip>"-Platzhalter, sofern vorhanden).
|
||
*/
|
||
function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
|
||
const byMac = new Map<string, FritzBoxHost[]>();
|
||
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 });
|
||
});
|
||
}
|