netdiag-app/src/lib/tools/netzwerk/geraete-diff.ts
Eduard Wisch 94fa804bd2 Geraete-Vergleich zweier Protokolle - ohne MAC-Adressen
Beim Wiederholungsbesuch die erste Frage des Kunden ("da ist doch nichts
dazugekommen, oder?") und die einzige, die ein einzelnes Protokoll nicht
beantworten kann. Das Werkzeug holt das juengste fruehere Protokoll desselben
Kunden vom Server und vergleicht die Geraetelisten.

KEINE Wiedererkennung ueber MAC: /proc/net/arp ist ab Android 10 gesperrt, im
ausgelieferten Protokoll ND2026-0015 hatten ALLE 68 Geraete keine MAC. Ein
MAC-Diff haette dort bei jedem Besuch "alles neu, alles verschwunden"
gemeldet - schlimmer als gar keine Aussage, weil es im Kundendokument nach
einem umgebauten Netz aussieht. Stattdessen eine Kaskade: MAC (falls
ausnahmsweise da), mDNS-Name, NetBIOS-Name, Hostname, IP+Portmuster. Jede
Zeile nennt, worueber sie zugeordnet wurde - "erkannt ueber IP und Portmuster"
ist eine schwaechere Aussage als "ueber Netzwerkname".

Im Emulator gegen echte Serverdaten geprueft. Dabei ein Fehler gefunden und
behoben: dieselbe Adresse stand gleichzeitig unter "neu" und "nicht mehr
erreichbar", weil sich nur das Portmuster geaendert hatte. Formal richtig, im
Kundendokument aber Unsinn - liest sich wie ein kaputtes Werkzeug. Solche
Faelle haben jetzt eine eigene Kategorie "Gleiche Adresse, andere offene
Ports", die sogar der interessantere Befund ist.

Bewertung bewusst ohne Rot: ein neues Geraet ist erst einmal eine Tatsache,
womoeglich hat der Techniker es selbst angeschlossen. Gelb nur, wenn etwas
fehlt oder die Zuordnung wackelt (ab 50 % schwache Zuordnung).

tools/pruefe-geraetediff.mjs: 20 Faelle, alle mit Geraeten OHNE MAC - so wie
sie beim Kunden ankommen. Der Browser-Mock gibt jedem Geraet eine MAC, dort
saehe auch ein naiver MAC-Diff perfekt aus.

Ausserdem: Server-Adresse wird jetzt geprueft. Eine http://-Adresse wird
abgewiesen, solange die App selbst ueber https laeuft - sie wuerde sonst stumm
scheitern ("Keine Verbindung zum Server", wahrer Grund nur im logcat). Ein
Debug-Build auf http://localhost darf HTTP dagegen, sonst waere der Testbau
unbenutzbar. tools/pruefe-serveradresse.mjs: 11 Faelle.

Neu in api.ts: getProtocol() laedt ein Protokoll samt Geraeten vom Server -
nur lesend, das fruehere Protokoll wird nie ueberschrieben.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:13:13 +02:00

266 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 (0100). 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<string, DiffGeraet>();
for (const g of vorher) {
vorherMap.set(geraeteSchluessel(g).key, g);
}
const jetztMap = new Map<string, DiffGeraet>();
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,
};
}