Compare commits

...

2 commits

Author SHA1 Message Date
701192b53c Trigger: APK-Rebuild [apk]
All checks were successful
Build APK / build-apk (push) Successful in 3m0s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 17:13:14 +02:00
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
9 changed files with 688 additions and 7 deletions

View file

@ -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<string, unknown>; devices: ServerDevice[]; measurements: unknown[] }> {
return request(`protocols.php?id=${id}`);
}
export interface SyncResult {
ok: boolean;
protocolId: number;

View file

@ -65,6 +65,13 @@ const FELDER: Record<string, Feld> = {
// 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' },

74
src/lib/serveradresse.ts Normal file
View file

@ -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: '' };
}

View file

@ -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.

View file

@ -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 (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,
};
}

View file

@ -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<string, string> = {
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.',
},
};
},
};

View file

@ -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);

View file

@ -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);

View file

@ -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);