Files
LaunchPad/apps/backend/src/routes/scan.ts

919 lines
45 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
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));
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 });
});
}