/** * Geräte-Vergleich zweier Protokolle — ohne Laufzeit-Import, damit * `tools/pruefe-geraetediff.mjs` die Zuordnung nachrechnen kann. * * WARUM NICHT ÜBER DIE MAC-ADRESSE * * Der naheliegende Weg wäre, Geräte über ihre MAC wiederzuerkennen. Er * funktioniert hier nachweislich nicht: Im ausgelieferten Protokoll ND2026-0015 * sind bei **allen 68 Geräten** MAC, Hostname und Hersteller leer, weil * `/proc/net/arp` ab Android 10 gesperrt ist. Ein MAC-Diff hätte dort bei jedem * Besuch „alles neu, alles verschwunden" gemeldet — eine Aussage, die schlimmer * ist als gar keine, weil sie im Kundendokument nach einem umgebauten Netz * aussieht. * * ACHTUNG BEIM PRÜFEN: Der Browser-Mock gibt jedem Gerät eine MAC und meldet * `arpAvailable: true`. Ein MAC-Diff sieht dort perfekt aus und beweist nichts. * `tools/pruefe-geraetediff.mjs` arbeitet deshalb ausdrücklich mit Geräten OHNE * MAC — so, wie sie beim Kunden ankommen. * * DIE KASKADE * * Wiedererkannt wird über den ersten belastbaren Anhaltspunkt, den ein Gerät * hergibt. Die Reihenfolge ist nach Verlässlichkeit sortiert, nicht nach * Bequemlichkeit: * * 1. **MAC** — wenn ausnahmsweise vorhanden (eigener Adapter, ältere Android- * Version), ist sie unschlagbar. Nur eben fast nie da. * 2. **mDNS-Name** — „Brother HL-L2350DW", „Wohnzimmer-TV". Vom Gerät selbst * vergeben und über Neustarts hinweg stabil. * 3. **NetBIOS-Name** — dasselbe für Windows-Geräte. * 4. **Hostname** aus dem DNS. * 5. **IP + Portmuster** — der Rückfallwert. Eine IP allein taugt nicht: im * DHCP-Bereich wandert sie. Zusammen mit den offenen Ports wird daraus ein * brauchbarer Fingerabdruck (eine Kamera mit 80/554 an .51 ist mit hoher * Wahrscheinlichkeit dieselbe wie beim letzten Mal). * * Der benutzervergebene Name (`customName`) fließt bewusst NICHT ein: er wird * am Gerät im Protokoll vergeben und wäre beim nächsten Besuch nur dann da, * wenn dasselbe Protokoll fortgeschrieben wird. */ /** Minimalform eines Geräts, wie sie App und API gleichermaßen liefern */ export interface DiffGeraet { ip: string; mac?: string | null; hostname?: string | null; netbiosName?: string | null; mdnsName?: string | null; openPorts?: number[] | null; deviceType?: string | null; vendor?: string | null; } /** Wie ein Gerät wiedererkannt wurde — steht so im Kundendokument */ export type Erkennung = 'mac' | 'mdns' | 'netbios' | 'hostname' | 'ip+ports'; export interface DiffEintrag { ip: string; /** Anzeigename, so gut wie das Gerät ihn hergibt */ name: string; erkennung: Erkennung; /** bei Wiedersehen: die frühere IP, falls sie sich geändert hat */ frühereIp?: string; } export interface DiffErgebnis { neu: DiffEintrag[]; verschwunden: DiffEintrag[]; geblieben: DiffEintrag[]; /** geblieben, aber unter anderer IP */ ipGewechselt: DiffEintrag[]; /** * Gleiche Adresse, aber anderes Portmuster — dasselbe Gerät bietet andere * Dienste an, oder es sitzt ein anderes Gerät auf der Adresse. Ohne diese * Kategorie stünde es gleichzeitig unter „neu" und „verschwunden". */ portsGeaendert: DiffEintrag[]; /** * Anteil der Geräte, die nur über „IP + Portmuster" zugeordnet werden * konnten (0–100). Ab hier wird die Aussage unsicher, und das gehört ins * Dokument statt in eine Fußnote. */ schwacheZuordnungProzent: number; } /** Namen normalisieren: Groß/Klein und .local-Endung sind kein Unterschied */ function normName(v: string | null | undefined): string { return (v ?? '') .trim() .toLowerCase() .replace(/\.local\.?$/, ''); } /** * Schlüssel eines Geräts nach der Kaskade — plus die Angabe, worauf er beruht. */ export function geraeteSchluessel(g: DiffGeraet): { key: string; erkennung: Erkennung } { const mac = normName(g.mac).replace(/[^0-9a-f]/g, ''); if (mac.length === 12) { return { key: 'mac:' + mac, erkennung: 'mac' }; } const mdns = normName(g.mdnsName); if (mdns) { return { key: 'mdns:' + mdns, erkennung: 'mdns' }; } const nb = normName(g.netbiosName); if (nb) { return { key: 'nb:' + nb, erkennung: 'netbios' }; } const hn = normName(g.hostname); if (hn) { return { key: 'host:' + hn, erkennung: 'hostname' }; } // Rückfall: IP + sortiertes Portmuster. Ports sortiert, weil die // Scan-Reihenfolge nicht garantiert ist. const ports = [...(g.openPorts ?? [])].sort((a, b) => a - b).join(','); return { key: `ipp:${g.ip}|${ports}`, erkennung: 'ip+ports' }; } /** Anzeigename für das Protokoll — der erste, den das Gerät hergibt */ export function anzeigeName(g: DiffGeraet): string { for (const kandidat of [g.mdnsName, g.netbiosName, g.hostname]) { const v = (kandidat ?? '').trim(); if (v) return v; } return g.ip; } /** * Zwei Gerätelisten vergleichen. * * @param vorher Geräte des früheren Protokolls * @param jetzt Geräte des aktuellen Protokolls */ export function vergleicheGeraete(vorher: DiffGeraet[], jetzt: DiffGeraet[]): DiffErgebnis { const vorherMap = new Map(); for (const g of vorher) { vorherMap.set(geraeteSchluessel(g).key, g); } const jetztMap = new Map(); for (const g of jetzt) { jetztMap.set(geraeteSchluessel(g).key, g); } const neu: DiffEintrag[] = []; const geblieben: DiffEintrag[] = []; const ipGewechselt: DiffEintrag[] = []; let schwach = 0; for (const g of jetzt) { const { key, erkennung } = geraeteSchluessel(g); if (erkennung === 'ip+ports') schwach++; const alt = vorherMap.get(key); const eintrag: DiffEintrag = { ip: g.ip, name: anzeigeName(g), erkennung }; if (!alt) { neu.push(eintrag); } else if (alt.ip !== g.ip) { ipGewechselt.push({ ...eintrag, frühereIp: alt.ip }); } else { geblieben.push(eintrag); } } const verschwunden: DiffEintrag[] = []; for (const g of vorher) { const { key, erkennung } = geraeteSchluessel(g); if (!jetztMap.has(key)) { verschwunden.push({ ip: g.ip, name: anzeigeName(g), erkennung }); } } /* * Dieselbe Adresse in „neu" UND „verschwunden" — das passiert bei einem * namenlosen Gerät, dessen Portmuster sich geändert hat: der Schlüssel * `ip+ports` ist dann ein anderer, obwohl die Adresse dieselbe blieb. * * Formal ist das richtig, im Kundendokument aber Unsinn: „10.0.2.2 ist neu" * direkt über „10.0.2.2 ist nicht mehr erreichbar" liest sich wie ein * kaputtes Werkzeug. Genau so stand es im ersten erzeugten PDF (ND2026-0016). * Solche Fälle bekommen deshalb eine eigene, ehrliche Kategorie — und sie ist * sogar der interessantere Befund: an dieser Adresse hat sich etwas an den * angebotenen Diensten geändert. */ const portsGeaendert: DiffEintrag[] = []; const verschwundenIps = new Map(verschwunden.map((e) => [e.ip, e])); for (let i = neu.length - 1; i >= 0; i--) { const alt = verschwundenIps.get(neu[i].ip); if (!alt || alt.erkennung !== 'ip+ports' || neu[i].erkennung !== 'ip+ports') continue; portsGeaendert.push(neu[i]); neu.splice(i, 1); const j = verschwunden.findIndex((e) => e.ip === alt.ip); if (j >= 0) verschwunden.splice(j, 1); } return { neu, verschwunden, geblieben, ipGewechselt, portsGeaendert, schwacheZuordnungProzent: jetzt.length ? Math.round((schwach / jetzt.length) * 100) : 0, }; } /** * Ergebnis bewerten. * * Bewusst KEINE Ampel nach „viele Änderungen = schlecht": ein neues Gerät im * Netz ist erst einmal nur eine Tatsache, und der Techniker hat es womöglich * selbst angeschlossen. Rot gibt es hier nicht — Gelb nur, wenn die Aussage * selbst wackelt oder etwas fehlt, das vorher da war. */ export function bewerteDiff(d: DiffErgebnis, vorherAnzahl: number): { status: 0 | 1 | 2 | 3; label: string; hinweis?: string; } { if (vorherAnzahl === 0) { return { status: 3, label: 'Kein früheres Protokoll zum Vergleichen', hinweis: 'Für diesen Kunden liegt kein abgeschlossenes früheres Protokoll vor. Der Vergleich ' + 'ist damit nicht möglich — es wurde nichts über Veränderungen im Netz festgestellt.', }; } const teile: string[] = []; if (d.neu.length) teile.push(`${d.neu.length} neu`); if (d.verschwunden.length) teile.push(`${d.verschwunden.length} nicht mehr erreichbar`); if (d.ipGewechselt.length) teile.push(`${d.ipGewechselt.length} mit neuer IP`); if (d.portsGeaendert.length) teile.push(`${d.portsGeaendert.length} mit anderen offenen Ports`); const label = teile.length ? `Veränderungen: ${teile.join(', ')} (${d.geblieben.length} unverändert)` : `Keine Veränderung — alle ${d.geblieben.length} Geräte wie beim letzten Mal`; const hinweise: string[] = []; if (d.schwacheZuordnungProzent >= 50) { hinweise.push( `Bei ${d.schwacheZuordnungProzent} % der Geräte war keine Kennung verfügbar (kein ` + 'mDNS-, NetBIOS- oder Hostname); sie wurden über IP und Portmuster zugeordnet. ' + 'Wechselt ein Gerät im DHCP-Bereich die Adresse, kann es dabei fälschlich als neu ' + 'gelten. Die Einzelzeilen nennen jeweils die verwendete Kennung.', ); } if (d.verschwunden.length) { hinweise.push( 'Nicht erreichbar heißt nicht zwingend „abgebaut" — ein ausgeschaltetes Gerät, ein ' + 'schlafender Rechner oder eine Kamera im Standby fehlen im Scan genauso.', ); } if (d.portsGeaendert.length) { hinweise.push( 'Bei gleicher Adresse mit anderen offenen Ports kann ein Dienst hinzugekommen oder ' + 'weggefallen sein — oder es sitzt inzwischen ein anderes Gerät auf der Adresse. ' + 'Ohne Gerätenamen lässt sich das nicht unterscheiden.', ); } return { // Gelb nur, wenn etwas fehlt oder die Zuordnung wackelt. status: d.verschwunden.length || d.schwacheZuordnungProzent >= 50 ? 1 : 0, label, hinweis: hinweise.length ? hinweise.join(' ') : undefined, }; }