generated from Dicken/dickendock
Fix: FritzBox faelschlich als Home Assistant erkannt + Reverse-DNS + Namens-Aenderungsvorschlaege beim Scan
This commit is contained in:
@@ -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: [] };
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ export async function scanRoutes(app: FastifyInstance): Promise<void> {
|
||||
// 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<void> {
|
||||
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<void> {
|
||||
updated,
|
||||
services: results.map((r) => r.service),
|
||||
staleServices,
|
||||
nameChanges,
|
||||
deviceNameSuggestion,
|
||||
};
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -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<DnsResolution
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Umgekehrte DNS-Abfrage (PTR-Record): fragt anhand der IP nach einem Namen.
|
||||
* Viele Router/DHCP-Server (FritzBox, Pi-hole, AdGuard Home, ...) tragen hier
|
||||
* automatisch den vom Gerät gemeldeten DHCP-Hostnamen ein, auch wenn die
|
||||
* normale Vorwärtsauflösung (Name -> IP) nichts findet. Nützlich als
|
||||
* zusätzliche Quelle für Gerätenamen, siehe scanner/networkScanner.ts.
|
||||
*/
|
||||
export async function reverseLookup(ip: string): Promise<string | null> {
|
||||
try {
|
||||
const names = await reverse(ip);
|
||||
return names[0] ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DiscoveredService[]> {
|
||||
): Promise<DeviceScanResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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 `<title>`-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",
|
||||
|
||||
Reference in New Issue
Block a user