generated from Dicken/dickendock
262 lines
11 KiB
TypeScript
262 lines
11 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 { 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-<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];
|
||
}
|
||
|
||
/**
|
||
* 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<void> {
|
||
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 });
|
||
}
|
||
});
|
||
}
|