diff --git a/README.md b/README.md index 3b16574..beb23f0 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,11 @@ DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (nul POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät; Antwort enthält zusätzlich staleServices (nicht mehr gefundene - Dienste, werden NICHT automatisch gelöscht) + Dienste), nameChanges (abweichender Name/ + Kategorie ggü. gespeichertem Wert) und + deviceNameSuggestion (per Reverse-DNS) - + nichts davon wird automatisch übernommen, + nur zur Bestätigung im Adminbereich zurückgegeben POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064; Antwort enthält zusätzlich staleDevices diff --git a/apps/backend/src/db/repositories/services.ts b/apps/backend/src/db/repositories/services.ts index 7ecf26b..4596af4 100644 --- a/apps/backend/src/db/repositories/services.ts +++ b/apps/backend/src/db/repositories/services.ts @@ -159,9 +159,22 @@ export interface ServiceScanInput { suggestedVisible?: boolean; } +export interface ScanNameChange { + field: "displayName" | "category"; + current: string | null; + suggested: string; +} + export interface ScanUpsertResult { service: Service; created: boolean; + /** + * Wird nur bei bereits bekannten Diensten befüllt, wenn ein frischer Scan + * einen anderen Namen/Kategorie vorschlagen würde als aktuell gespeichert. + * Wird NICHT automatisch übernommen (siehe updateService-Dokumentation + * oben) - nur zur manuellen Durchsicht/Bestätigung im Adminbereich. + */ + nameChanges: ScanNameChange[]; } function findByDeviceAndPort(deviceId: string, port: number): Service | null { @@ -176,9 +189,12 @@ function findByDeviceAndPort(deviceId: string, port: number): Service | null { * Alles andere – auch hostname/url/https, die früher bei jedem Scan * überschrieben wurden – bleibt unangetastet, damit manuelle Anpassungen * (z. B. IP/Port/Protokoll korrigieren) einen erneuten Scan überleben. + * Falls der frische Scan einen anderen Namen/Kategorie vorschlagen würde, + * wird das über nameChanges zur manuellen Bestätigung zurückgegeben, statt + * entweder stillschweigend ignoriert oder automatisch übernommen zu werden. * * suggestedDisplayName/suggestedCategory/suggestedIcon/suggestedVisible - * wirken NUR beim erstmaligen Anlegen. + * bestimmen den Anfangswert NUR beim erstmaligen Anlegen. */ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult { const existing = findByDeviceAndPort(input.deviceId, input.port); @@ -193,7 +209,28 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult }) .where(eq(services.id, existing.id)) .run(); - return { service: getService(existing.id)!, created: false }; + + const nameChanges: ScanNameChange[] = []; + if (input.suggestedDisplayName && input.suggestedDisplayName !== existing.displayName) { + nameChanges.push({ + field: "displayName", + current: existing.displayName, + suggested: input.suggestedDisplayName, + }); + } + if ( + input.suggestedCategory !== undefined && + (input.suggestedCategory ?? null) !== existing.category && + input.suggestedCategory + ) { + nameChanges.push({ + field: "category", + current: existing.category, + suggested: input.suggestedCategory, + }); + } + + return { service: getService(existing.id)!, created: false, nameChanges }; } const id = randomUUID(); @@ -219,5 +256,5 @@ export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult }) .run(); - return { service: getService(id)!, created: true }; + return { service: getService(id)!, created: true, nameChanges: [] }; } diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts index cd65b37..b5c3123 100644 --- a/apps/backend/src/routes/scan.ts +++ b/apps/backend/src/routes/scan.ts @@ -25,7 +25,7 @@ export async function scanRoutes(app: FastifyInstance): Promise { // erkennen, welche davon diesmal nicht mehr gefunden wurden ("stale"). const servicesBeforeScan = serviceRepo.listServicesByDevice(device.id); - const discovered = await scanDeviceServices(device); + 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 @@ -70,11 +70,28 @@ export async function scanRoutes(app: FastifyInstance): Promise { 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` : ""}`, + 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 { @@ -85,6 +102,8 @@ export async function scanRoutes(app: FastifyInstance): Promise { updated, services: results.map((r) => r.service), staleServices, + nameChanges, + deviceNameSuggestion, }; } catch (err) { const detail = err instanceof Error ? err.message : String(err); diff --git a/apps/backend/src/scanner/dns.ts b/apps/backend/src/scanner/dns.ts index 313bd4e..caa89e4 100644 --- a/apps/backend/src/scanner/dns.ts +++ b/apps/backend/src/scanner/dns.ts @@ -1,4 +1,4 @@ -import { lookup } from "node:dns/promises"; +import { lookup, reverse } from "node:dns/promises"; export interface DnsResolution { hostname: string; @@ -23,3 +23,19 @@ export async function resolveHostname(shortName: string): Promise IP) nichts findet. Nützlich als + * zusätzliche Quelle für Gerätenamen, siehe scanner/networkScanner.ts. + */ +export async function reverseLookup(ip: string): Promise { + try { + const names = await reverse(ip); + return names[0] ?? null; + } catch { + return null; + } +} diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts index 523a5f2..9f15b71 100644 --- a/apps/backend/src/scanner/networkScanner.ts +++ b/apps/backend/src/scanner/networkScanner.ts @@ -1,4 +1,4 @@ -import { resolveHostname } from "./dns.js"; +import { resolveHostname, reverseLookup } from "./dns.js"; import { isPortOpen, TYPICAL_PORTS } from "./ports.js"; import { probeHttp } from "./http.js"; import { detectSoftware } from "./softwareDetection.js"; @@ -37,6 +37,17 @@ function isLikelyJunkTitle(title: string | undefined): boolean { return false; } +export interface DeviceScanResult { + services: DiscoveredService[]; + /** + * Vorschlag für einen besseren Gerätenamen per Reverse-DNS (PTR), falls die + * normale Vorwärtsauflösung nichts Besseres als die reine IP gefunden hat. + * Wird NICHT automatisch übernommen - nur zur manuellen Bestätigung + * zurückgegeben (siehe routes/scan.ts). + */ + suggestedHostname: string | null; +} + /** * Scannt ein einzelnes Gerät: versucht zuerst eine schönere DNS-Adresse * (hostname / hostname.home / hostname.local) aufzulösen, prüft dann Port 80, @@ -46,11 +57,17 @@ function isLikelyJunkTitle(title: string | undefined): boolean { export async function scanDeviceServices( device: ScanTarget, extraPorts: number[] = TYPICAL_PORTS -): Promise { +): Promise { const dnsResult = await resolveHostname(device.hostname); // Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist. const address = dnsResult?.hostname ?? device.ip; + // Nur wenn die Vorwärtsauflösung nichts Besseres als die IP gefunden hat, + // zusätzlich per Reverse-DNS (PTR) versuchen - viele Router/DHCP-Server + // (FritzBox, Pi-hole, AdGuard Home, ...) tragen hier den vom Gerät + // gemeldeten Namen ein. + const suggestedHostname = dnsResult ? null : await reverseLookup(device.ip); + const candidatePorts = Array.from(new Set([80, 443, ...extraPorts])); const found: DiscoveredService[] = []; @@ -65,6 +82,7 @@ export async function scanDeviceServices( const software = detectSoftware({ server: probe.server, body: probe.bodySnippet, + title: probe.title, port, }); @@ -90,5 +108,5 @@ export async function scanDeviceServices( }); } - return found; + return { services: found, suggestedHostname }; } diff --git a/apps/backend/src/scanner/softwareDetection.ts b/apps/backend/src/scanner/softwareDetection.ts index d5f1ccb..36b922b 100644 --- a/apps/backend/src/scanner/softwareDetection.ts +++ b/apps/backend/src/scanner/softwareDetection.ts @@ -1,6 +1,7 @@ export interface SoftwareSignatureInput { server?: string; body?: string; + title?: string; port: number; } @@ -16,12 +17,29 @@ function bodyContains(input: SoftwareSignatureInput, pattern: RegExp): boolean { return !!input.body && pattern.test(input.body); } +function titleMatches(input: SoftwareSignatureInput, pattern: RegExp): boolean { + return !!input.title && pattern.test(input.title.trim()); +} + /** * Automatische Softwareerkennung anhand von HTTP-Response-Merkmalen * (Server-Header, HTML-Inhalt). Liste gemäß Spezifikation. + * + * Grundsatz: der ``-Tag ist ein viel präziseres Signal als der + * komplette Seiteninhalt und wird deshalb wo möglich zuerst geprüft. Reine + * Volltextsuche im Body (bodyContains) besonders bei Marken-/Produktnamen + * aus generischen Alltagswörtern (z. B. "Home Assistant") kann in + * minifiziertem JavaScript anderer Web-UIs zufällig zutreffen - siehe + * Bugreport: FritzBox-Repeater wurden fälschlich als "Home Assistant" + * erkannt, weil beide Wörter irgendwo im ausgelieferten JS vorkamen. */ export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [ - { name: "Home Assistant", category: "Smart Home", matches: (i) => bodyContains(i, /home\s*assistant/i) }, + { + name: "Home Assistant", + category: "Smart Home", + matches: (i) => + titleMatches(i, /^home\s*assistant$/i) || bodyContains(i, /frontend_latest\//i), + }, { name: "Synology DSM", category: "NAS", diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx index 1bb67cb..c462476 100644 --- a/apps/frontend/src/routes/admin/DevicesPage.tsx +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -44,12 +44,35 @@ async function deleteServiceRequest(id: string) { } } +async function patchService(id: string, patch: Record<string, unknown>) { + const res = await fetch(`/api/services/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + throw new Error(`Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`); + } + return res.json(); +} + +interface ScanNameChange { + serviceId: string; + serviceHostname: string; + servicePort: number; + field: "displayName" | "category"; + current: string | null; + suggested: string; +} + interface ScanResult { scannedPorts: number; ports: number[]; created: number; updated: number; staleServices: Service[]; + nameChanges: ScanNameChange[]; + deviceNameSuggestion: string | null; } async function scanDevice(id: string): Promise<ScanResult> { @@ -150,6 +173,109 @@ function EditDeviceForm({ device, onDone }: { device: DeviceWithServices; onDone ); } +function NameChangesReview({ + nameChanges, + onDone, +}: { + nameChanges: ScanNameChange[]; + onDone: () => void; +}) { + const queryClient = useQueryClient(); + const [handled, setHandled] = useState<Set<string>>(new Set()); + + const applyMutation = useMutation({ + mutationFn: (change: ScanNameChange) => + patchService(change.serviceId, { [change.field]: change.suggested }), + onSuccess: (_data, change) => { + setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`)); + queryClient.invalidateQueries({ queryKey: ["services"] }); + }, + }); + + const remaining = nameChanges.filter( + (c) => !handled.has(`${c.serviceId}:${c.field}`) + ); + if (remaining.length === 0) return null; + + const fieldLabel = (field: ScanNameChange["field"]) => + field === "displayName" ? "Name" : "Kategorie"; + + return ( + <div className="mt-2 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs"> + <p className="mb-2 font-medium text-blue-700 dark:text-blue-400"> + {remaining.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab: + </p> + <ul className="space-y-1"> + {remaining.map((c) => ( + <li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2"> + <span className="text-black/70 dark:text-white/70"> + {c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „{c.suggested}" + </span> + <div className="flex gap-1"> + <Button + size="sm" + variant="ghost" + onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))} + > + Behalten + </Button> + <Button + size="sm" + variant="primary" + onClick={() => applyMutation.mutate(c)} + disabled={applyMutation.isPending} + > + Übernehmen + </Button> + </div> + </li> + ))} + </ul> + <button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40"> + Hinweis schließen + </button> + </div> + ); +} + +function DeviceNameSuggestionBanner({ + deviceId, + suggestion, + onDone, +}: { + deviceId: string; + suggestion: string; + onDone: () => void; +}) { + const queryClient = useQueryClient(); + + const applyMutation = useMutation({ + mutationFn: () => patchDevice(deviceId, { hostname: suggestion }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["devices"] }); + onDone(); + }, + }); + + return ( + <div className="mt-2 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs"> + <div className="flex items-center justify-between gap-2"> + <span className="text-black/70 dark:text-white/70"> + Per Reverse-DNS gefunden: „{suggestion}" als Gerätename? + </span> + <div className="flex gap-1"> + <Button size="sm" variant="ghost" onClick={onDone}> + Behalten + </Button> + <Button size="sm" variant="primary" onClick={() => applyMutation.mutate()} disabled={applyMutation.isPending}> + Übernehmen + </Button> + </div> + </div> + </div> + ); +} + function StaleServicesReview({ staleServices, onDone }: { staleServices: Service[]; onDone: () => void }) { const queryClient = useQueryClient(); const [handled, setHandled] = useState<Set<string>>(new Set()); @@ -203,6 +329,8 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { const queryClient = useQueryClient(); const [scanMessage, setScanMessage] = useState<string | null>(null); const [staleServices, setStaleServices] = useState<Service[]>([]); + const [nameChanges, setNameChanges] = useState<ScanNameChange[]>([]); + const [deviceNameSuggestion, setDeviceNameSuggestion] = useState<string | null>(null); const [expanded, setExpanded] = useState(false); const [editing, setEditing] = useState(false); @@ -214,6 +342,8 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { `Ports offen: ${portsText} · ${result.created} neu · ${result.updated} aktualisiert` ); setStaleServices(result.staleServices); + setNameChanges(result.nameChanges); + setDeviceNameSuggestion(result.deviceNameSuggestion); queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["services"] }); }, @@ -293,12 +423,22 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { </td> </tr> - {(scanMessage || staleServices.length > 0) && ( + {(scanMessage || staleServices.length > 0 || nameChanges.length > 0 || deviceNameSuggestion) && ( <tr className="border-b border-black/5 dark:border-white/5"> <td colSpan={8} className="px-4 pb-3"> {scanMessage ? ( <p className="text-xs text-black/40 dark:text-white/40">{scanMessage}</p> ) : null} + {deviceNameSuggestion ? ( + <DeviceNameSuggestionBanner + deviceId={device.id} + suggestion={deviceNameSuggestion} + onDone={() => setDeviceNameSuggestion(null)} + /> + ) : null} + {nameChanges.length > 0 ? ( + <NameChangesReview nameChanges={nameChanges} onDone={() => setNameChanges([])} /> + ) : null} {staleServices.length > 0 ? ( <StaleServicesReview staleServices={staleServices} onDone={() => setStaleServices([])} /> ) : null} diff --git a/apps/frontend/src/routes/admin/ScannerPage.tsx b/apps/frontend/src/routes/admin/ScannerPage.tsx index 13f3f06..b45e484 100644 --- a/apps/frontend/src/routes/admin/ScannerPage.tsx +++ b/apps/frontend/src/routes/admin/ScannerPage.tsx @@ -33,13 +33,99 @@ async function deleteServiceRequest(id: string) { } } +interface ScanNameChange { + serviceId: string; + serviceHostname: string; + servicePort: number; + field: "displayName" | "category"; + current: string | null; + suggested: string; +} + +async function patchService(id: string, patch: Record<string, unknown>) { + const res = await fetch(`/api/services/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(patch), + }); + if (!res.ok) { + throw new Error(`Dienst konnte nicht aktualisiert werden (HTTP ${res.status})`); + } + return res.json(); +} + async function scanDeviceById(id: string) { const res = await fetch(`/api/scan/devices/${id}`, { method: "POST" }); const body = await res.json(); if (!res.ok) { throw new Error(body.detail ?? body.error ?? `Scan fehlgeschlagen (HTTP ${res.status})`); } - return body as { created: number; updated: number; staleServices: Service[] }; + return body as { + created: number; + updated: number; + staleServices: Service[]; + nameChanges: ScanNameChange[]; + deviceNameSuggestion: string | null; + }; +} + +function NameChangesReview({ + nameChanges, + onDone, +}: { + nameChanges: ScanNameChange[]; + onDone: () => void; +}) { + const queryClient = useQueryClient(); + const [handled, setHandled] = useState<Set<string>>(new Set()); + + const applyMutation = useMutation({ + mutationFn: (change: ScanNameChange) => + patchService(change.serviceId, { [change.field]: change.suggested }), + onSuccess: (_data, change) => { + setHandled((prev) => new Set(prev).add(`${change.serviceId}:${change.field}`)); + queryClient.invalidateQueries({ queryKey: ["services"] }); + }, + }); + + const remaining = nameChanges.filter((c) => !handled.has(`${c.serviceId}:${c.field}`)); + if (remaining.length === 0) return null; + + const fieldLabel = (field: ScanNameChange["field"]) => (field === "displayName" ? "Name" : "Kategorie"); + + return ( + <div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs"> + <p className="mb-2 font-medium text-blue-700 dark:text-blue-400"> + {remaining.length} erkannte Änderung(en) über alle gescannten Geräte hinweg weichen vom + gespeicherten Wert ab: + </p> + <ul className="max-h-60 space-y-1 overflow-y-auto"> + {remaining.map((c) => ( + <li key={`${c.serviceId}:${c.field}`} className="flex items-center justify-between gap-2"> + <span className="text-black/70 dark:text-white/70"> + {c.serviceHostname}:{c.servicePort} – {fieldLabel(c.field)}: „{c.current ?? "–"}" → „ + {c.suggested}" + </span> + <div className="flex gap-1"> + <Button + size="sm" + variant="ghost" + onClick={() => setHandled((prev) => new Set(prev).add(`${c.serviceId}:${c.field}`))} + > + Behalten + </Button> + <Button size="sm" variant="primary" onClick={() => applyMutation.mutate(c)} disabled={applyMutation.isPending}> + Übernehmen + </Button> + </div> + </li> + ))} + </ul> + <button onClick={onDone} className="mt-2 text-black/40 underline dark:text-white/40"> + Hinweis schließen + </button> + </div> + ); } function StaleServicesReview({ @@ -154,6 +240,7 @@ export function ScannerPage() { const [bulkStatus, setBulkStatus] = useState<string | null>(null); const [bulkRunning, setBulkRunning] = useState(false); const [bulkStaleServices, setBulkStaleServices] = useState<Service[]>([]); + const [bulkNameChanges, setBulkNameChanges] = useState<ScanNameChange[]>([]); const [staleDevices, setStaleDevices] = useState<Device[]>([]); const fritzboxMutation = useMutation({ @@ -170,9 +257,11 @@ export function ScannerPage() { if (!devices || devices.length === 0) return; setBulkRunning(true); setBulkStaleServices([]); + setBulkNameChanges([]); let created = 0; let updated = 0; const allStale: Service[] = []; + const allNameChanges: ScanNameChange[] = []; for (const device of devices) { try { @@ -180,6 +269,7 @@ export function ScannerPage() { created += result.created; updated += result.updated; allStale.push(...result.staleServices); + allNameChanges.push(...result.nameChanges); setBulkStatus(`Scanne ${device.hostname} … (${created} neu, ${updated} aktualisiert bisher)`); } catch { // einzelnes fehlgeschlagenes Gerät soll den Rest nicht abbrechen @@ -188,10 +278,11 @@ export function ScannerPage() { setBulkStatus( `Fertig: ${devices.length} Gerät(e) gescannt, ${created} neue Dienste, ${updated} aktualisiert${ - allStale.length > 0 ? `, ${allStale.length} nicht mehr gefunden (siehe unten)` : "" - }.` + allStale.length > 0 ? `, ${allStale.length} nicht mehr gefunden` : "" + }${allNameChanges.length > 0 ? `, ${allNameChanges.length} Namensänderung(en) vorgeschlagen` : ""} (siehe unten).` ); setBulkStaleServices(allStale); + setBulkNameChanges(allNameChanges); setBulkRunning(false); queryClient.invalidateQueries({ queryKey: ["devices"] }); queryClient.invalidateQueries({ queryKey: ["services"] }); @@ -254,6 +345,9 @@ export function ScannerPage() { {bulkStatus ? ( <p className="mt-2 text-sm text-black/50 dark:text-white/50">{bulkStatus}</p> ) : null} + {bulkNameChanges.length > 0 ? ( + <NameChangesReview nameChanges={bulkNameChanges} onDone={() => setBulkNameChanges([])} /> + ) : null} {bulkStaleServices.length > 0 ? ( <StaleServicesReview staleServices={bulkStaleServices}