Compare commits
2 commits
7236017e87
...
701192b53c
| Author | SHA1 | Date | |
|---|---|---|---|
| 701192b53c | |||
| 94fa804bd2 |
9 changed files with 688 additions and 7 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
74
src/lib/serveradresse.ts
Normal 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: '' };
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
266
src/lib/tools/netzwerk/geraete-diff.ts
Normal file
266
src/lib/tools/netzwerk/geraete-diff.ts
Normal 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 (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<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,
|
||||
};
|
||||
}
|
||||
130
src/lib/tools/netzwerk/geraetediff.ts
Normal file
130
src/lib/tools/netzwerk/geraetediff.ts
Normal 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.',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
113
tools/pruefe-geraetediff.mjs
Normal file
113
tools/pruefe-geraetediff.mjs
Normal 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);
|
||||
39
tools/pruefe-serveradresse.mjs
Normal file
39
tools/pruefe-serveradresse.mjs
Normal 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);
|
||||
Loading…
Reference in a new issue