generated from Dicken/dickendock
round29: Scanner laufen jetzt als Hintergrund-Job im Backend (behebt API-Scan-504-Timeout und macht Scan-Status/-Fortschritt geraeteuebergreifend sichtbar)
This commit is contained in:
@@ -3,46 +3,78 @@ import * as serviceRepo from "../db/repositories/services.js";
|
||||
import * as apiRepo from "../db/repositories/apis.js";
|
||||
import * as logRepo from "../db/repositories/logs.js";
|
||||
import { detectApis } from "../scanner/apiDetector.js";
|
||||
import { startJob, updateJobProgress, finishJob, failJob, getJobStatus, isJobRunning } from "../scanJobs.js";
|
||||
|
||||
/**
|
||||
* API-Scanner: eigener, von den Geräte-/FritzBox-Scannern komplett
|
||||
* unabhängiger manueller Scan (siehe Scanner-Seite) - durchsucht bereits
|
||||
* bekannte Dienste nach üblichen API-Pfaden (siehe scanner/apiDetector.ts)
|
||||
* und speichert die Funde. Läuft NIE automatisch, nur auf Knopfdruck.
|
||||
*
|
||||
* Läuft als Hintergrund-Job im Backend (siehe scanJobs.ts), nicht mehr
|
||||
* innerhalb einer einzigen HTTP-Anfrage: bei vielen Diensten (jeweils ~40
|
||||
* geprüfte Pfade) kann das insgesamt länger dauern, als ein
|
||||
* Reverse-Proxy/Browser auf eine Antwort wartet (führte zu HTTP 504). Der
|
||||
* POST-Aufruf startet den Job nur und antwortet sofort, der Fortschritt wird
|
||||
* über GET .../status abgefragt - das macht den laufenden Scan zudem
|
||||
* geräteübergreifend sichtbar (der Status lebt im Backend, nicht im Browser).
|
||||
*/
|
||||
export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/api/detected-apis", async () => {
|
||||
return apiRepo.listDetectedApis();
|
||||
});
|
||||
|
||||
app.post("/api/scan/apis", async () => {
|
||||
const services = serviceRepo.listServices();
|
||||
let servicesWithApi = 0;
|
||||
let totalFound = 0;
|
||||
const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = [];
|
||||
app.get("/api/scan/apis/status", async () => {
|
||||
return getJobStatus("apis");
|
||||
});
|
||||
|
||||
for (const service of services) {
|
||||
const found = await detectApis(service.url);
|
||||
if (found.length > 0) {
|
||||
const { added } = apiRepo.replaceApisForService(service.id, found);
|
||||
servicesWithApi++;
|
||||
totalFound += found.length;
|
||||
if (added.length > 0) {
|
||||
newFindings.push({ serviceId: service.id, serviceName: service.displayName, apis: added });
|
||||
}
|
||||
} else {
|
||||
apiRepo.deleteApisForService(service.id);
|
||||
}
|
||||
app.post("/api/scan/apis", async (_request, reply) => {
|
||||
if (isJobRunning("apis")) {
|
||||
return reply.code(409).send({ error: "Es läuft bereits ein API-Scan." });
|
||||
}
|
||||
|
||||
logRepo.logScan({
|
||||
type: "api",
|
||||
targetId: null,
|
||||
level: "info",
|
||||
message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`,
|
||||
});
|
||||
const services = serviceRepo.listServices();
|
||||
startJob("apis", services.length);
|
||||
|
||||
return { checked: services.length, servicesWithApi, totalFound, newFindings };
|
||||
// Bewusst NICHT awaited - der Job läuft im Hintergrund weiter, die
|
||||
// HTTP-Antwort kommt sofort zurück (siehe Doku oben).
|
||||
void (async () => {
|
||||
let servicesWithApi = 0;
|
||||
let totalFound = 0;
|
||||
const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = [];
|
||||
|
||||
try {
|
||||
for (let i = 0; i < services.length; i++) {
|
||||
const service = services[i];
|
||||
updateJobProgress("apis", i, service.displayName);
|
||||
|
||||
const found = await detectApis(service.url);
|
||||
if (found.length > 0) {
|
||||
const { added } = apiRepo.replaceApisForService(service.id, found);
|
||||
servicesWithApi++;
|
||||
totalFound += found.length;
|
||||
if (added.length > 0) {
|
||||
newFindings.push({ serviceId: service.id, serviceName: service.displayName, apis: added });
|
||||
}
|
||||
} else {
|
||||
apiRepo.deleteApisForService(service.id);
|
||||
}
|
||||
}
|
||||
|
||||
logRepo.logScan({
|
||||
type: "api",
|
||||
targetId: null,
|
||||
level: "info",
|
||||
message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`,
|
||||
});
|
||||
|
||||
finishJob("apis", { checked: services.length, servicesWithApi, totalFound, newFindings });
|
||||
} catch (err) {
|
||||
failJob("apis", err instanceof Error ? err.message : "Unbekannter Fehler");
|
||||
}
|
||||
})();
|
||||
|
||||
return reply.code(202).send({ started: true });
|
||||
});
|
||||
|
||||
app.post("/api/scan/apis/:serviceId", async (request, reply) => {
|
||||
|
||||
@@ -6,6 +6,230 @@ 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";
|
||||
import {
|
||||
startJob,
|
||||
updateJobProgress,
|
||||
finishJob,
|
||||
failJob,
|
||||
getJobStatus,
|
||||
isJobRunning,
|
||||
isCancelRequested,
|
||||
requestCancel,
|
||||
} from "../scanJobs.js";
|
||||
import type { Device } from "@launchpad/shared";
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
// 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,
|
||||
undefined,
|
||||
servicesBeforeScan.map((s) => s.port)
|
||||
);
|
||||
|
||||
// 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 staleServices = servicesBeforeScan.filter((s) => {
|
||||
if (foundPorts.has(s.port)) return false;
|
||||
const lastSeen = s.updatedAt ? new Date(s.updatedAt).getTime() : 0;
|
||||
return now - lastSeen >= staleThresholdMs;
|
||||
});
|
||||
|
||||
// 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),
|
||||
newServices: results.filter((r) => r.created).map((r) => r.service),
|
||||
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.
|
||||
*/
|
||||
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 {
|
||||
return await performDeviceScan(device);
|
||||
} 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 });
|
||||
}
|
||||
});
|
||||
|
||||
// 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 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 {
|
||||
for (const device of devices) {
|
||||
if (isCancelRequested("devices")) {
|
||||
cancelled = true;
|
||||
break;
|
||||
}
|
||||
updateJobProgress("devices", scannedCount, device.hostname);
|
||||
try {
|
||||
const result = await performDeviceScan(device);
|
||||
created += result.created;
|
||||
updated += result.updated;
|
||||
allStale.push(...result.staleServices);
|
||||
allNameChanges.push(...result.nameChanges);
|
||||
allNew.push(...result.newServices);
|
||||
} catch {
|
||||
// einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen
|
||||
}
|
||||
scannedCount++;
|
||||
}
|
||||
|
||||
finishJob("devices", {
|
||||
totalDevices: devices.length,
|
||||
scannedCount,
|
||||
cancelled,
|
||||
created,
|
||||
updated,
|
||||
staleServices: allStale,
|
||||
nameChanges: allNameChanges,
|
||||
newServices: allNew,
|
||||
});
|
||||
} catch (err) {
|
||||
failJob("devices", err instanceof Error ? err.message : "Unbekannter Fehler");
|
||||
}
|
||||
})();
|
||||
|
||||
return reply.code(202).send({ started: true, totalDevices: devices.length });
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* Erkennt den von der FritzBox selbst generierten Platzhalter-Namen für
|
||||
@@ -52,133 +276,6 @@ function mergeFritzboxHosts(hosts: FritzBoxHost[]): FritzBoxHost[] {
|
||||
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,
|
||||
undefined,
|
||||
servicesBeforeScan.map((s) => s.port)
|
||||
);
|
||||
|
||||
// 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 staleServices = servicesBeforeScan.filter((s) => {
|
||||
if (foundPorts.has(s.port)) return false;
|
||||
const lastSeen = s.updatedAt ? new Date(s.updatedAt).getTime() : 0;
|
||||
return now - lastSeen >= staleThresholdMs;
|
||||
});
|
||||
|
||||
// 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),
|
||||
newServices: results.filter((r) => r.created).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
|
||||
|
||||
Reference in New Issue
Block a user