diff --git a/src/lib/api.ts b/src/lib/api.ts index 7fd3182..b366709 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -155,13 +155,62 @@ export function searchCustomers(q: string): Promise<{ customers: Customer[] }> { return request<{ customers: Customer[] }>(`customers.php?q=${encodeURIComponent(q)}`); } +/** Kurzform eines Protokolls, wie sie in der Kundenübersicht steht */ +export interface ProtocolListItem { + id: number; + ref?: string; + label?: string; + clientUuid?: string; + socId?: number | null; + orderId?: number | null; + /** Unix-Sekunden (nicht Millisekunden — so liefert es die API) */ + dateDiag?: number; + location?: string; + subnet?: string; + status?: number; + deviceCount?: number; + /** heißt serverseitig `measureCount` — nicht `measurementCount` (netdiag_api.lib.php) */ + measureCount?: number; +} + /** Einzelnen Kunden mit Aufträgen und Protokollen laden */ export function getCustomer( id: number, -): Promise<{ customer: Customer; orders: Order[]; protocols: unknown[] }> { +): Promise<{ customer: Customer; orders: Order[]; protocols: ProtocolListItem[] }> { return request(`customers.php?id=${id}`); } +/** Ein Gerät, wie es der Server im Protokoll liefert */ +export interface ServerDevice { + id: number; + ip: string; + mac?: string | null; + hostname?: string | null; + netbiosName?: string | null; + mdnsName?: string | null; + mdnsServices?: string[]; + customName?: string | null; + vendor?: string | null; + deviceType?: string | null; + openPorts?: number[]; + foundVia?: string | null; + lastSeen?: number | null; + note?: string | null; +} + +/** + * Ein Protokoll vom Server laden — samt Geräten und Messungen. + * + * Gebraucht vom Geräte-Vergleich, der die Liste des letzten Besuchs braucht. + * Bewusst NICHT in den lokalen Bestand übernommen: das frühere Protokoll wird + * nur gelesen, nie überschrieben. + */ +export function getProtocol( + id: number, +): Promise<{ protocol: Record; devices: ServerDevice[]; measurements: unknown[] }> { + return request(`protocols.php?id=${id}`); +} + export interface SyncResult { ok: boolean; protocolId: number; diff --git a/src/lib/messfelder.ts b/src/lib/messfelder.ts index 832367c..d5e2a55 100644 --- a/src/lib/messfelder.ts +++ b/src/lib/messfelder.ts @@ -65,6 +65,13 @@ const FELDER: Record = { // DNS-Prüfung (je ein Paket an je einen Server) gefragteServer: { label: 'Befragte DNS-Server' }, antworten: { label: 'Antworten der Server' }, + // Geräte-Vergleich zweier Protokolle + verglichenMit: { label: 'Verglichen mit' }, + geraeteVorher: { label: 'Geräte beim letzten Besuch' }, + geraeteJetzt: { label: 'Geräte jetzt' }, + mitNeuerAdresse: { label: 'Mit neuer IP-Adresse' }, + andereOffenePorts: { label: 'Gleiche Adresse, andere offene Ports' }, + unveraendert: { label: 'Unverändert' }, // Netz / DHCP / Dose server: { label: 'DHCP-Server' }, dhcpServer: { label: 'DHCP-Server' }, diff --git a/src/lib/serveradresse.ts b/src/lib/serveradresse.ts new file mode 100644 index 0000000..50fd278 --- /dev/null +++ b/src/lib/serveradresse.ts @@ -0,0 +1,74 @@ +/** + * Prüfung der Server-Adresse — ohne Laufzeit-Import, damit + * `tools/pruefe-serveradresse.mjs` sie nachrechnen kann. + * + * Anlass (16./17.08.2026, im Emulator gefunden): Wird hier eine `http://`-Adresse + * eingetragen, funktioniert die App **stumm nicht**. Sie meldet nur „Keine + * Verbindung zum Server"; der wahre Grund steht ausschließlich im `logcat`, an + * den beim Kunden niemand kommt. Es sind sogar zwei Sperren übereinander: + * + * 1. **Mixed Content** — die App läuft auf der Origin `https://localhost`; der + * WebView blockiert daraus jede `http://`-Anfrage. + * 2. **Androids Cleartext-Richtlinie** — ab API 28 ist unverschlüsseltes HTTP + * ohne `android:usesCleartextTraffic` grundsätzlich verboten. + * + * Deshalb wird die Adresse hier abgewiesen, statt sie anzunehmen und den + * Techniker vor Ort raten zu lassen. + * + * WICHTIG — warum nicht einfach „http verbieten": Es hängt an der Origin der + * App, nicht an einer festen Regel. Ein Debug-Build mit + * `server.androidScheme: 'http'` (der Weg, auf dem die lokale Testinstanz + * überhaupt prüfbar ist, siehe KB #1089) läuft selbst auf `http://localhost` + * und darf HTTP-Server ansprechen. Die Prüfung fragt deshalb die eigene + * Herkunft ab — sonst wäre ausgerechnet der Testbau unbenutzbar. + */ + +export interface AdressPruefung { + ok: boolean; + /** Meldung für den Benutzer; leer wenn alles in Ordnung ist */ + meldung: string; +} + +/** + * Server-Adresse prüfen. + * + * @param eingabe Rohe Eingabe aus dem Feld + * @param appProtokoll Protokoll der App-Origin — `window.location.protocol`, + * also 'https:' oder 'http:' + */ +export function pruefeServerAdresse(eingabe: string, appProtokoll: string): AdressPruefung { + const url = (eingabe ?? '').trim(); + if (!url) { + return { ok: false, meldung: 'Server-Adresse fehlt' }; + } + if (!/^https?:\/\//i.test(url)) { + return { ok: false, meldung: 'Adresse muss mit http:// oder https:// beginnen' }; + } + + // Tippfehler wie "https://" ohne Host früh abfangen — sonst schlägt erst der + // erste Aufruf fehl, und zwar mit derselben nichtssagenden Meldung. + let host = ''; + try { + host = new URL(url).hostname; + } catch { + return { ok: false, meldung: 'Adresse ist keine gültige URL' }; + } + if (!host) { + return { ok: false, meldung: 'In der Adresse fehlt der Servername' }; + } + + const appLaeuftUeberHttps = appProtokoll.toLowerCase().startsWith('https'); + const zielIstHttp = /^http:\/\//i.test(url); + + if (zielIstHttp && appLaeuftUeberHttps) { + return { + ok: false, + meldung: + 'Diese App kann nur HTTPS-Server erreichen — Android blockiert unverschlüsselte ' + + 'Verbindungen aus der App heraus. Mit einer http://-Adresse käme nur „Keine ' + + 'Verbindung zum Server", ohne erkennbaren Grund.', + }; + } + + return { ok: true, meldung: '' }; +} diff --git a/src/lib/tools/index.ts b/src/lib/tools/index.ts index 974e951..ca68dc5 100644 --- a/src/lib/tools/index.ts +++ b/src/lib/tools/index.ts @@ -10,6 +10,7 @@ import type { Tool, ToolCategory } from './types'; import { dnsCheckTool } from './netzwerk/dnscheck'; +import { geraeteDiffTool } from './netzwerk/geraetediff'; import { ipConflictTool } from './netzwerk/ipconflict'; import { ipScanTool } from './netzwerk/ipscan'; import { pingTool } from './netzwerk/ping'; @@ -38,6 +39,7 @@ export const TOOLS: Tool[] = [ snmpTool, tracerouteTool, dnsCheckTool, + geraeteDiffTool, stressTestTool, // Internet — die Kette steht bewusst vorn: sie ist die Erstdiagnose, // wenn der Kunde „Internet geht nicht" sagt. diff --git a/src/lib/tools/netzwerk/geraete-diff.ts b/src/lib/tools/netzwerk/geraete-diff.ts new file mode 100644 index 0000000..5ce3d73 --- /dev/null +++ b/src/lib/tools/netzwerk/geraete-diff.ts @@ -0,0 +1,266 @@ +/** + * 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, + }; +} diff --git a/src/lib/tools/netzwerk/geraetediff.ts b/src/lib/tools/netzwerk/geraetediff.ts new file mode 100644 index 0000000..8647a9b --- /dev/null +++ b/src/lib/tools/netzwerk/geraetediff.ts @@ -0,0 +1,130 @@ +/** + * Tool: Geräte-Vergleich — was hat sich seit dem letzten Besuch geändert? + * + * Holt das jüngste frühere Protokoll desselben Kunden vom Server und + * vergleicht dessen Geräteliste mit der aktuellen. Beim Wiederholungsbesuch + * ist das die Frage, die der Kunde als Erstes stellt („da ist doch nichts + * dazugekommen, oder?") — und die einzige, die ein einzelnes Protokoll nicht + * beantworten kann. + * + * Die Wiedererkennung läuft NICHT über MAC-Adressen; warum, steht ausführlich + * im Kopf von `geraete-diff.ts`. Kurz: `/proc/net/arp` ist ab Android 10 + * gesperrt, im ausgelieferten Protokoll ND2026-0015 hatten alle 68 Geräte + * keine MAC. Ein MAC-Diff meldete dort bei jedem Besuch „alles neu". + * + * Braucht Serververbindung. Ohne die gibt es kein Vergleichsprotokoll — dann + * meldet das Werkzeug „nicht messbar" statt zu raten. + */ + +import { getCustomer, getProtocol } from '../../api'; +import type { Tool } from '../types'; +import { bewerteDiff, vergleicheGeraete, type DiffGeraet } from './geraete-diff'; + +export { bewerteDiff, vergleicheGeraete, geraeteSchluessel } from './geraete-diff'; +export type { DiffGeraet, DiffErgebnis, DiffEintrag, Erkennung } from './geraete-diff'; + +/** Erkennungsart in Klartext fürs Kundendokument */ +const ERKENNUNG_TEXT: Record = { + mac: 'MAC-Adresse', + mdns: 'Netzwerkname (mDNS)', + netbios: 'Windows-Name', + hostname: 'Hostname', + 'ip+ports': 'IP und Portmuster', +}; + +export const geraeteDiffTool: Tool = { + id: 'geraetediff', + category: 'netzwerk', + name: 'Geräte-Vergleich', + icon: 'git-compare', + description: 'Vergleicht die Geräteliste mit dem letzten Protokoll dieses Kunden.', + scope: 'protocol', + quickRun: true, + params: [], + async run(ctx) { + const socId = ctx.protocol.socId; + if (!socId) { + return { + label: 'Kein Kunde am Protokoll — kein Vergleich möglich', + measureStatus: 3, + result: { + hinweis: + 'Das Protokoll ist keinem Kunden zugeordnet. Der Vergleich braucht die ' + + 'früheren Protokolle desselben Kunden.', + }, + }; + } + + // Frühere Protokolle des Kunden holen. Das aktuelle selbst fliegt raus — + // sonst verglichen wir es mit sich selbst und meldeten stets „keine + // Veränderung". + const { protocols } = await getCustomer(socId); + const frühere = (protocols ?? []) + .filter((p) => p.clientUuid !== ctx.protocol.clientUuid && p.id !== ctx.protocol.serverId) + // Nur Protokolle mit Geräten taugen als Vergleich; ein leerer Entwurf + // vom selben Tag würde sonst „alle Geräte neu" ergeben. + .filter((p) => (p.deviceCount ?? 0) > 0) + .sort((a, b) => (b.dateDiag ?? 0) - (a.dateDiag ?? 0)); + + const vorgänger = frühere[0]; + if (!vorgänger) { + const urteil = bewerteDiff(vergleicheGeraete([], []), 0); + return { + label: urteil.label, + measureStatus: urteil.status, + result: { hinweis: urteil.hinweis }, + }; + } + + const alt = await getProtocol(vorgänger.id); + const vorher: DiffGeraet[] = (alt.devices ?? []).map((d) => ({ + ip: d.ip, + mac: d.mac, + hostname: d.hostname, + netbiosName: d.netbiosName, + mdnsName: d.mdnsName, + openPorts: d.openPorts, + deviceType: d.deviceType, + })); + const jetzt: DiffGeraet[] = ctx.protocol.devices.map((d) => ({ + ip: d.ip, + mac: d.mac, + hostname: d.hostname, + netbiosName: d.netbiosName, + mdnsName: d.mdnsName, + openPorts: d.openPorts, + deviceType: d.deviceType, + })); + + const diff = vergleicheGeraete(vorher, jetzt); + const urteil = bewerteDiff(diff, vorher.length); + + const zeile = (e: { name: string; ip: string; erkennung: string; frühereIp?: string }) => + `${e.name === e.ip ? e.ip : `${e.name} (${e.ip})`}` + + (e.frühereIp ? ` — vorher ${e.frühereIp}` : '') + + ` · erkannt über ${ERKENNUNG_TEXT[e.erkennung] ?? e.erkennung}`; + + const vergleichsDatum = vorgänger.dateDiag + ? new Date(vorgänger.dateDiag * 1000).toLocaleDateString('de-DE') + : ''; + + return { + label: urteil.label, + measureStatus: urteil.status, + result: { + verglichenMit: `${vorgänger.ref ?? 'Protokoll'}${vergleichsDatum ? ' vom ' + vergleichsDatum : ''}`, + geraeteVorher: vorher.length, + geraeteJetzt: jetzt.length, + neu: diff.neu.map(zeile), + nichtMehrErreichbar: diff.verschwunden.map(zeile), + mitNeuerAdresse: diff.ipGewechselt.map(zeile), + andereOffenePorts: diff.portsGeaendert.map(zeile), + unveraendert: diff.geblieben.length, + hinweis: urteil.hinweis, + verfahren: + 'Wiedererkennung über Netzwerkname, Windows-Name, Hostname oder IP und Portmuster — ' + + 'nicht über die MAC-Adresse, die Android seit Version 10 nicht mehr herausgibt.', + }, + }; + }, +}; diff --git a/src/routes/einstellungen/+page.svelte b/src/routes/einstellungen/+page.svelte index f03eb2a..d64295d 100644 --- a/src/routes/einstellungen/+page.svelte +++ b/src/routes/einstellungen/+page.svelte @@ -6,6 +6,7 @@ import { Capacitor } from '@capacitor/core'; import { sync } from '$lib/sync.svelte'; import { toast } from '$lib/toast.svelte'; + import { pruefeServerAdresse } from '$lib/serveradresse'; import { APP_VERSION, checkForUpdate, installUpdate, type UpdateInfo } from '$lib/updater'; import { ladeDemoSchalter, setzeDemoSchalter } from '$lib/wifi/demo'; import { onMount } from 'svelte'; @@ -93,12 +94,12 @@ */ async function saveServer() { const url = serverInput.trim(); - if (!url) { - toast.show('Server-Adresse fehlt', 'error'); - return; - } - if (!/^https?:\/\//i.test(url)) { - toast.show('Adresse muss mit http:// oder https:// beginnen', 'error', 5000); + // Prüfung ausgelagert und einzeln nachrechenbar (tools/pruefe-serveradresse.mjs). + // Sie weist unter anderem http://-Adressen ab, solange die App selbst über + // https läuft — die würden sonst stumm scheitern, siehe serveradresse.ts. + const pruefung = pruefeServerAdresse(url, window.location.protocol); + if (!pruefung.ok) { + toast.show(pruefung.meldung, 'error', 8000); return; } await setServerUrl(url); diff --git a/tools/pruefe-geraetediff.mjs b/tools/pruefe-geraetediff.mjs new file mode 100644 index 0000000..9b20696 --- /dev/null +++ b/tools/pruefe-geraetediff.mjs @@ -0,0 +1,113 @@ +/** + * Prueft den Geraete-Vergleich zweier Protokolle. + * + * ALLE Faelle arbeiten mit Geraeten OHNE MAC - genau so kommen sie beim Kunden + * an (/proc/net/arp ist ab Android 10 gesperrt; im ausgelieferten Protokoll + * ND2026-0015 waren bei allen 68 Geraeten MAC, Hostname und Hersteller leer). + * Der Browser-Mock dagegen gibt jedem Geraet eine MAC - dort saehe auch ein + * naiver MAC-Diff perfekt aus und wuerde nichts beweisen. + * + * Start: node tools/pruefe-geraetediff.mjs + */ +const { vergleicheGeraete, bewerteDiff, geraeteSchluessel } = + await import('../src/lib/tools/netzwerk/geraete-diff.ts'); + +// Geraete wie sie real ankommen: keine MAC, oft kein Name. +const kamera = { ip: '192.168.1.51', mdnsName: 'IPC-Hof', openPorts: [80, 554] }; +const drucker = { ip: '192.168.1.20', mdnsName: 'Brother HL-L2350DW', openPorts: [80, 9100, 515] }; +const pc = { ip: '192.168.1.30', netbiosName: 'BUERO-PC', openPorts: [139, 445] }; +const nas = { ip: '192.168.1.10', hostname: 'nas.fritz.box', openPorts: [80, 445, 5000] }; +const namenlos= { ip: '192.168.1.77', openPorts: [502] }; // Wechselrichter, Modbus +const router = { ip: '192.168.1.1', hostname: 'fritz.box', openPorts: [80, 443, 53] }; + +let fehler = 0; +function pruefe(name, ist, soll) { + const ok = JSON.stringify(ist) === JSON.stringify(soll); + if (!ok) fehler++; + console.log(`${ok ? 'OK ' : 'FEHL'} ${name}`); + if (!ok) console.log(` ist=${JSON.stringify(ist)} soll=${JSON.stringify(soll)}`); +} + +// 1. Identisch -> keine Veraenderung +let d = vergleicheGeraete([router, nas, kamera], [router, nas, kamera]); +pruefe('Unveraendertes Netz -> 0 neu, 0 weg, 3 geblieben', + [d.neu.length, d.verschwunden.length, d.geblieben.length], [0, 0, 3]); + +// 2. DER KERNFALL: dieselben Geraete OHNE MAC, aber mit neuen DHCP-Adressen. +// Ein MAC-Diff haette hier "alles neu, alles verschwunden" gemeldet. +const kameraNeueIp = { ...kamera, ip: '192.168.1.99' }; +const druckerNeueIp = { ...drucker, ip: '192.168.1.98' }; +d = vergleicheGeraete([kamera, drucker], [kameraNeueIp, druckerNeueIp]); +pruefe('Beide Geraete mit neuer IP -> 0 neu, 0 weg, 2 mit IP-Wechsel', + [d.neu.length, d.verschwunden.length, d.ipGewechselt.length], [0, 0, 2]); +pruefe('IP-Wechsel nennt die fruehere Adresse', + d.ipGewechselt.map((e) => e.frühereIp).sort(), ['192.168.1.20', '192.168.1.51']); + +// 3. Echtes neues Geraet +d = vergleicheGeraete([router, nas], [router, nas, kamera]); +pruefe('Ein Geraet dazugekommen', [d.neu.length, d.neu[0]?.name], [1, 'IPC-Hof']); + +// 4. Geraet verschwunden +d = vergleicheGeraete([router, nas, kamera], [router, nas]); +pruefe('Ein Geraet verschwunden', [d.verschwunden.length, d.verschwunden[0]?.name], [1, 'IPC-Hof']); + +// 5. Namenloses Geraet: gleiche IP + gleiche Ports -> wiedererkannt +d = vergleicheGeraete([namenlos], [{ ...namenlos }]); +pruefe('Namenlos, IP+Ports gleich -> wiedererkannt', [d.geblieben.length, d.neu.length], [1, 0]); + +// 6. Namenloses Geraet mit neuer IP -> NICHT zuordenbar, ehrlich als neu+weg +d = vergleicheGeraete([namenlos], [{ ...namenlos, ip: '192.168.1.78' }]); +pruefe('Namenlos mit neuer IP -> gilt als neu UND verschwunden (Grenze des Verfahrens)', + [d.neu.length, d.verschwunden.length], [1, 1]); + +// 6b. DER FALL AUS DEM ERSTEN PDF: gleiche IP, anderes Portmuster. +// Darf NICHT gleichzeitig als neu und verschwunden erscheinen. +d = vergleicheGeraete( + [{ ip: '10.0.2.2', openPorts: [80] }], + [{ ip: '10.0.2.2', openPorts: [80, 443] }], +); +pruefe('Gleiche IP, andere Ports -> eigene Kategorie statt "neu UND weg"', + [d.neu.length, d.verschwunden.length, d.portsGeaendert.length], [0, 0, 1]); + +// 7. Erkennungsart wird korrekt benannt +pruefe('Kaskade: mDNS schlaegt Hostname', + geraeteSchluessel({ ip: '1.2.3.4', mdnsName: 'A', hostname: 'B' }).erkennung, 'mdns'); +pruefe('Kaskade: NetBIOS vor Hostname', + geraeteSchluessel({ ip: '1.2.3.4', netbiosName: 'A', hostname: 'B' }).erkennung, 'netbios'); +pruefe('Kaskade: ohne alles -> ip+ports', + geraeteSchluessel({ ip: '1.2.3.4', openPorts: [80] }).erkennung, 'ip+ports'); +pruefe('Kaskade: MAC schlaegt alles (falls ausnahmsweise da)', + geraeteSchluessel({ ip: '1.2.3.4', mac: 'AA:BB:CC:11:22:33', mdnsName: 'A' }).erkennung, 'mac'); + +// 8. Schreibweise: Gross/Klein und .local duerfen kein Unterschied sein +d = vergleicheGeraete( + [{ ip: '10.0.0.5', mdnsName: 'Drucker.local' }], + [{ ip: '10.0.0.5', mdnsName: 'drucker' }], +); +pruefe('mDNS "Drucker.local" == "drucker"', [d.geblieben.length, d.neu.length], [1, 0]); + +// 9. Portmuster-Reihenfolge darf egal sein +d = vergleicheGeraete( + [{ ip: '10.0.0.9', openPorts: [443, 80] }], + [{ ip: '10.0.0.9', openPorts: [80, 443] }], +); +pruefe('Portreihenfolge egal', [d.geblieben.length, d.neu.length], [1, 0]); + +// 10. Bewertung +let b = bewerteDiff(vergleicheGeraete([router, nas], [router, nas]), 2); +pruefe('Keine Veraenderung -> GRUEN', b.status, 0); +b = bewerteDiff(vergleicheGeraete([router, nas, kamera], [router, nas]), 3); +pruefe('Geraet fehlt -> GELB mit Einschraenkung', [b.status, /Standby/.test(b.hinweis || '')], [1, true]); +b = bewerteDiff(vergleicheGeraete([], []), 0); +pruefe('Kein Vorgaengerprotokoll -> NICHT MESSBAR', b.status, 3); +b = bewerteDiff(vergleicheGeraete([namenlos], [namenlos]), 1); +pruefe('Nur schwache Zuordnung -> GELB mit Warnung', [b.status, /IP und Portmuster/.test(b.hinweis || '')], [1, true]); +b = bewerteDiff(vergleicheGeraete([router, nas], [router, nas, kamera]), 2); +pruefe('Nur neue Geraete -> GRUEN (neu ist kein Fehler)', b.status, 0); +b = bewerteDiff(vergleicheGeraete( + [{ ip: '10.0.2.2', openPorts: [80] }], + [{ ip: '10.0.2.2', openPorts: [80, 443] }]), 1); +pruefe('Portwechsel wird im Label genannt', /andere.*Ports/.test(b.label), true); + +console.log(fehler === 0 ? 'ALLE FAELLE KORREKT' : `${fehler} FEHLER`); +process.exit(fehler === 0 ? 0 : 1); diff --git a/tools/pruefe-serveradresse.mjs b/tools/pruefe-serveradresse.mjs new file mode 100644 index 0000000..b84bc09 --- /dev/null +++ b/tools/pruefe-serveradresse.mjs @@ -0,0 +1,39 @@ +/** + * Prueft die Validierung der Server-Adresse. + * + * Kern: eine http://-Adresse wird abgewiesen, solange die App selbst ueber + * https laeuft - sonst scheitert sie beim Kunden stumm mit "Keine Verbindung + * zum Server". Ein Debug-Build auf http://localhost darf HTTP dagegen sehr + * wohl, sonst waere ausgerechnet der Testbau unbenutzbar. + * + * Start: node tools/pruefe-serveradresse.mjs + */ +const { pruefeServerAdresse } = await import('../src/lib/serveradresse.ts'); + +const HTTPS = 'https:'; +const HTTP = 'http:'; + +const faelle = [ + { e: 'https://awl.data-it-solution.de', p: HTTPS, ok: true, name: 'Prod-Adresse ueber https' }, + { e: 'https://10.0.2.2:8443', p: HTTPS, ok: true, name: 'IP mit Port ueber https' }, + { e: 'http://10.0.2.2:8080', p: HTTPS, ok: false, name: 'HTTP bei https-App -> ABGEWIESEN (der eigentliche Fall)' }, + { e: 'http://10.0.2.2:8080', p: HTTP, ok: true, name: 'HTTP bei http-App (Debug-Build) -> ERLAUBT' }, + { e: 'HTTP://10.0.2.2:8080', p: HTTPS, ok: false, name: 'Grossschreibung wird auch erkannt' }, + { e: ' https://awl.example ', p: HTTPS, ok: true, name: 'Leerzeichen aussen stoeren nicht' }, + { e: '', p: HTTPS, ok: false, name: 'Leer' }, + { e: ' ', p: HTTPS, ok: false, name: 'Nur Leerzeichen' }, + { e: 'awl.data-it-solution.de', p: HTTPS, ok: false, name: 'Ohne Schema' }, + { e: 'ftp://server', p: HTTPS, ok: false, name: 'Falsches Schema' }, + { e: 'https://', p: HTTPS, ok: false, name: 'Schema ohne Host' }, +]; + +let fehler = 0; +for (const f of faelle) { + const r = pruefeServerAdresse(f.e, f.p); + const okk = r.ok === f.ok; + if (!okk) fehler++; + console.log(`${okk ? 'OK ' : 'FEHL'} ${f.name}`); + if (!r.ok) console.log(` -> ${r.meldung.slice(0, 90)}`); +} +console.log(fehler === 0 ? 'ALLE FAELLE KORREKT' : `${fehler} FEHLER`); +process.exit(fehler === 0 ? 0 : 1);