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:
2026-07-24 13:29:01 +02:00
parent f5845ec762
commit 2106c17301
5 changed files with 534 additions and 288 deletions

View File

@@ -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) => {

View File

@@ -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