diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt index 11c5f6c..45eeb90 100644 --- a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt +++ b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt @@ -1923,6 +1923,16 @@ class NetDiagScannerPlugin : Plugin() { .put("iface", iface) .put("type", type) .put("isDefault", iface == defaultIface) + // Androids eigenes Urteil über die Verbindung. Das System + // prüft nach jedem Verbindungsaufbau selbst gegen einen + // Erreichbarkeitsdienst — dieses Ergebnis kostet uns hier + // keine einzige Messung und ist die verlässlichste Auskunft + // über ein Gäste-WLAN mit Anmeldeseite, die ohne Root zu + // bekommen ist. + obj.put("validated", caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)) + if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)) { + obj.put("captivePortal", true) + } if (ipv4 != null) obj.put("ipv4", ipv4) if (prefix > 0) obj.put("prefixLength", prefix) if (gateway != null) obj.put("gateway", gateway) @@ -1989,6 +1999,111 @@ class NetDiagScannerPlugin : Plugin() { } } + /* --------------------------------------------------------------------- */ + /* Internet-Kette — Namensauflösung und HTTPS-Abruf */ + /* --------------------------------------------------------------------- */ + + /** + * Namen auflösen über den System-Resolver. + * + * GRENZE, die im Ergebnis stehen muss: welcher DNS-Server befragt wird, ist + * hier NICHT wählbar, und zwischen Aufruf und Server liegt der Cache des + * Systems. Eine zweite Abfrage desselben Namens misst deshalb den Cache, + * nicht das Netz. Für „antwortet dieser bestimmte Server" braucht es einen + * eigenen UDP-Client auf Port 53 (eigener Roadmap-Punkt) — für die Frage + * „funktioniert Namensauflösung überhaupt" reicht dieser Weg. + */ + @PluginMethod + fun dnsResolve(call: PluginCall) { + val host = call.getString("host") ?: return call.reject("host fehlt") + val timeoutMs = (call.getInt("timeoutMs") ?: 5000).coerceIn(500, 30_000) + io.launch { + val t0 = System.nanoTime() + try { + val addrs = withTimeout(timeoutMs.toLong()) { + withContext(Dispatchers.IO) { InetAddress.getAllByName(host) } + } + val ms = (System.nanoTime() - t0) / 1e6 + val arr = JSArray() + for (a in addrs) a.hostAddress?.let { arr.put(it) } + resolve(call, JSObject() + .put("ok", arr.length() > 0) + .put("ips", arr) + .put("latenzMs", round1(ms))) + } catch (e: TimeoutCancellationException) { + resolve(call, JSObject() + .put("ok", false) + .put("ips", JSArray()) + .put("fehler", "Keine Antwort innerhalb von $timeoutMs ms")) + } catch (e: Exception) { + // UnknownHostException ist der Normalfall bei kaputtem DNS und + // keine Ausnahmesituation — deshalb aufgelöst statt reject(). + resolve(call, JSObject() + .put("ok", false) + .put("ips", JSArray()) + .put("fehler", e.message ?: e.javaClass.simpleName)) + } + } + } + + /** + * Eine URL abrufen und Statuszeile plus Laufzeit melden. + * + * `instanceFollowRedirects = false` ist hier keine Feinheit, sondern der + * Kern der Aussage: ein Gäste-WLAN mit Anmeldeseite beantwortet JEDE + * Anfrage mit einer Umleitung auf sein Portal. Folgt man ihr, kommt am Ende + * ein sauberes 200 zurück und die Messung meldet „Internet in Ordnung", + * obwohl ohne Anmeldung nichts geht. Ohne Weiterleitung steht die 302 mit + * ihrem Ziel im Protokoll und ist als Portal erkennbar. + */ + @PluginMethod + fun httpsCheck(call: PluginCall) { + val url = call.getString("url") ?: return call.reject("url fehlt") + val timeoutMs = (call.getInt("timeoutMs") ?: 8000).coerceIn(500, 60_000) + io.launch { + var conn: HttpURLConnection? = null + val t0 = System.nanoTime() + try { + val res = withTimeout(timeoutMs.toLong() + 2000L) { + withContext(Dispatchers.IO) { + val c = URL(url).openConnection() as HttpURLConnection + conn = c + c.instanceFollowRedirects = false + c.connectTimeout = timeoutMs + c.readTimeout = timeoutMs + c.requestMethod = "GET" + c.setRequestProperty("User-Agent", "NetDiag") + // Kein Zwischenspeicher: sonst misst der zweite Aufruf + // beim selben Kunden die eigene Festplatte. + c.useCaches = false + c.setRequestProperty("Cache-Control", "no-cache") + val code = c.responseCode + val ort = c.getHeaderField("Location") + Triple(code, ort, c.responseMessage ?: "") + } + } + val ms = (System.nanoTime() - t0) / 1e6 + val obj = JSObject() + .put("erreichbar", true) + .put("statusCode", res.first) + .put("statusText", res.third) + .put("latenzMs", round1(ms)) + if (!res.second.isNullOrEmpty()) obj.put("weiterleitungAuf", res.second) + resolve(call, obj) + } catch (e: TimeoutCancellationException) { + resolve(call, JSObject() + .put("erreichbar", false) + .put("fehler", "Keine Antwort innerhalb von $timeoutMs ms")) + } catch (e: Exception) { + resolve(call, JSObject() + .put("erreichbar", false) + .put("fehler", e.message ?: e.javaClass.simpleName)) + } finally { + try { conn?.disconnect() } catch (_: Exception) { } + } + } + } + /* --------------------------------------------------------------------- */ /* WLAN-Empfangstracker — RSSI live beim Durchgehen durchs Gebäude */ /* --------------------------------------------------------------------- */ diff --git a/src/lib/messfelder.ts b/src/lib/messfelder.ts index 0551fee..ebe11ca 100644 --- a/src/lib/messfelder.ts +++ b/src/lib/messfelder.ts @@ -54,6 +54,14 @@ const FELDER: Record = { geraete: { label: 'Je Gerät' }, ausfaelle: { label: 'Einzelne Ausfälle' }, anzahlGeraete: { label: 'Überwachte Geräte' }, + // Internet-Kette. `fehlerglied` ist die eigentliche Aussage des Werkzeugs — + // fehlt der Schlüssel hier oder in netdiagKundenfelder(), steht im + // Kundendokument nur noch die Ampel. + fehlerglied: { label: 'Fehlerglied' }, + stufen: { label: 'Geprüfte Stufen' }, + systemUrteil: { label: 'Urteil des Betriebssystems' }, + validated: { label: 'Internetverbindung bestätigt' }, + captivePortal: { label: 'Anmeldeseite erkannt' }, // Netz / DHCP / Dose server: { label: 'DHCP-Server' }, dhcpServer: { label: 'DHCP-Server' }, diff --git a/src/lib/monitor-auswertung.ts b/src/lib/monitor-auswertung.ts new file mode 100644 index 0000000..4370f0e --- /dev/null +++ b/src/lib/monitor-auswertung.ts @@ -0,0 +1,199 @@ +/** + * Rechnung des Geräte-Monitors — bewusst OHNE jeden Laufzeit-Import. + * + * Ausgelagert aus `monitor.ts`, damit `tools/pruefe-monitor.mjs` die Funktion + * direkt laden kann. Vorher importierte der Prüfer eine von Hand erzeugte + * .mjs-Kopie aus einem Sitzungsverzeichnis unter /tmp; nach dessen Wegfall + * lief er nicht mehr — im Repo stand trotzdem „alle Fälle korrekt". Eine + * Zusicherung, die man nicht nachrechnen kann, ist keine. + * + * Alles hier ist reine Rechnung ohne Netz- oder Datenbankzugriff. Was die + * Zahlen bedeuten und welche drei Fehler sie vorher systematisch zugunsten + * des Dokuments verfälscht haben, steht im Kopf von `monitor.ts`. + */ + +import type { DeviceMonitorSession, MeasureStatus, MonitorEvent } from './types'; + +function fmtTime(ts: number): string { + return new Date(ts).toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function fmtDuration(sec: number): string { + if (sec < 60) return Math.round(sec) + ' s'; + const m = Math.floor(sec / 60); + if (m < 60) { + const s = Math.round(sec % 60); + return s ? `${m} min ${s} s` : `${m} min`; + } + const h = Math.floor(m / 60); + const restM = m % 60; + return restM ? `${h} Std ${restM} min` : `${h} Std`; +} + +/** Prozent mit einer Nachkommastelle, deutsches Komma */ +function fmtProzent(p: number): string { + return p.toFixed(1).replace('.', ',') + ' %'; +} + +export interface GeraetAuswertung { + ip: string; + label: string; + ausfallzeitSek: number; + aussetzer: number; + laengsterAusfallSek: number; + verfuegbarkeitProzent: number; + /** einzelne Ausfälle als lesbare Zeilen */ + ausfaelle: string[]; +} + +/** + * Ausfallsegmente eines Geräts aus seinen Ereignissen bilden. + * + * Ein Ausfall beginnt mit 'down' und endet mit dem nächsten 'up'. Ist am Ende + * noch einer offen, wird er bis `bis` zugerechnet — er ist ja nicht dadurch + * verschwunden, dass die Messung aufhörte. + * + * `durationSec` aus dem up-Ereignis wird bewusst NICHT verwendet, sondern die + * Differenz der Zeitstempel: die native Dauer war in einem Fall nachweislich + * 0 (nämlich wenn der Ausfall schon vor dem Start bestand), und zwei Quellen + * für dieselbe Zahl gehen irgendwann auseinander. + */ +function segmenteFuer(events: MonitorEvent[], von: number, bis: number): number[][] { + const segmente: number[][] = []; + let offen: number | null = null; + for (const e of [...events].sort((a, b) => a.ts - b.ts)) { + if (e.type === 'down') { + if (offen === null) offen = e.ts; + } else if (offen !== null) { + segmente.push([offen, e.ts]); + offen = null; + } + } + if (offen !== null) segmente.push([offen, bis]); + // Auf den Messzeitraum begrenzen — ein Ereignis vor dem Start (Wiederauf- + // nahme einer älteren Sitzung) darf die Ausfallzeit nicht aufblähen. + return segmente + .map(([a, b]) => [Math.max(a, von), Math.min(b, bis)]) + .filter(([a, b]) => b > a); +} + +/** + * Aus einer Überwachungssitzung die Felder für die gespeicherte Messung bauen. + * + * @param sitzung die Sitzung mit ihren Ereignissen + * @param endeTs Ende des Messzeitraums (aus dem Plugin, sonst jetzt) + * @param abgerissen true = der Lauf wurde nicht sauber beendet (Prozess tot, + * nativer Lauf verschwunden) — dann steht das im Hinweis, + * statt eine Verfügbarkeit über eine Zeitspanne auszuweisen, + * in der gar nicht gemessen wurde. + */ +export function buildMonitorMeasurement( + sitzung: DeviceMonitorSession, + endeTs: number, + abgerissen = false, +): { label: string; result: Record; measureStatus: MeasureStatus } { + const von = sitzung.startedAt; + const bis = Math.max(von, endeTs); + const dauerSek = Math.max(1, Math.round((bis - von) / 1000)); + + const auswertung: GeraetAuswertung[] = sitzung.targets.map((t) => { + const eigene = sitzung.events.filter((e) => e.ip === t.ip); + const segmente = segmenteFuer(eigene, von, bis); + const ausfallzeitSek = Math.round( + segmente.reduce((s, [a, b]) => s + (b - a), 0) / 1000, + ); + const laengster = segmente.length + ? Math.round(Math.max(...segmente.map(([a, b]) => b - a)) / 1000) + : 0; + return { + ip: t.ip, + label: t.label || t.ip, + ausfallzeitSek, + aussetzer: segmente.length, + laengsterAusfallSek: laengster, + verfuegbarkeitProzent: Math.max( + 0, + Math.min(100, (1 - ausfallzeitSek / dauerSek) * 100), + ), + ausfaelle: segmente.map( + ([a, b]) => + `${t.label || t.ip} ${fmtTime(a)} – ${fmtTime(b)} (${fmtDuration((b - a) / 1000)})`, + ), + }; + }); + + // Schlechtestes Gerät bestimmt Label und Ampel (siehe Kopfkommentar) + const schlechtestes = auswertung.reduce( + (min, g) => (min === null || g.verfuegbarkeitProzent < min.verfuegbarkeitProzent ? g : min), + null, + ); + const gesamtAussetzer = auswertung.reduce((s, g) => s + g.aussetzer, 0); + + /* + * Bewertung. Die Schwellen sind bewusst streng: Für eine Kamera oder eine + * Wallbox ist ein Aussetzer pro Termin bereits das gemeldete Problem, nicht + * ein Schönheitsfehler. 100 % über den ganzen Zeitraum = grün, jeder + * Aussetzer = gelb, unter 95 % oder ein Gerät durchgehend tot = rot. + */ + let status: MeasureStatus = 0; + if (gesamtAussetzer > 0) status = 1; + if ((schlechtestes?.verfuegbarkeitProzent ?? 100) < 95) status = 2; + if (abgerissen) status = 3; // nicht messbar — die Grundlage stimmt nicht + + const hinweise: string[] = []; + if (abgerissen) { + hinweise.push( + 'Die Überwachung wurde nicht regulär beendet (App oder Gerät zwischendurch ' + + 'gestoppt). Der ausgewiesene Zeitraum endet beim letzten aufgezeichneten ' + + 'Ereignis; danach wurde nicht mehr gemessen.', + ); + } + // Messverfahren offenlegen: das gehört zu einer belastbaren Aussage dazu. + hinweise.push( + 'Gemessen durch regelmäßige Erreichbarkeitsprüfung alle ' + + `${sitzung.intervalSec} Sekunden. Ein Gerät, das Ping-Anfragen ` + + 'grundsätzlich nicht beantwortet, erscheint dabei als nicht erreichbar.', + ); + + const label = schlechtestes + ? gesamtAussetzer === 0 + ? `${auswertung.length} Geräte über ${fmtDuration(dauerSek)} durchgehend erreichbar` + : `${schlechtestes.label}: ${fmtProzent(schlechtestes.verfuegbarkeitProzent)} · ` + + `${gesamtAussetzer} ${gesamtAussetzer === 1 ? 'Aussetzer' : 'Aussetzer'} in ${fmtDuration(dauerSek)}` + : 'Überwachung ohne Geräte'; + + return { + label, + measureStatus: status, + result: { + dauerSekunden: dauerSek, + intervallSek: sitzung.intervalSec, + geraeteAnzahl: auswertung.length, + verfuegbarkeitProzent: Number((schlechtestes?.verfuegbarkeitProzent ?? 100).toFixed(1)), + ausfallzeitSek: schlechtestes?.ausfallzeitSek ?? 0, + aussetzer: gesamtAussetzer, + laengsterAusfallSek: Math.max(0, ...auswertung.map((g) => g.laengsterAusfallSek)), + // Vorformatierte Zeilen statt Objekte: MeasurementResult rendert Arrays + // zeilenweise, und dieselben Zeichenketten werden im PDF wiederverwendet. + geraete: auswertung.map( + (g) => + // Nur benannte Geräte bekommen die IP in Klammern dahinter — ohne + // eigenen Namen stand sonst „10.0.2.2 (10.0.2.2)" im Kundendokument. + `${g.label === g.ip ? g.ip : `${g.label} (${g.ip})`}: ` + + `${fmtProzent(g.verfuegbarkeitProzent)}` + + (g.aussetzer + ? ` · ${g.aussetzer} ${g.aussetzer === 1 ? 'Aussetzer' : 'Aussetzer'}` + + ` · ${fmtDuration(g.ausfallzeitSek)} Ausfall` + + ` · längster ${fmtDuration(g.laengsterAusfallSek)}` + : ' · durchgehend erreichbar'), + ), + ausfaelle: auswertung.flatMap((g) => g.ausfaelle), + hinweis: hinweise.join(' '), + }, + }; +} + diff --git a/src/lib/monitor.ts b/src/lib/monitor.ts index 85081c8..df69cd2 100644 --- a/src/lib/monitor.ts +++ b/src/lib/monitor.ts @@ -24,196 +24,13 @@ */ import { addMeasurement } from './protocols'; -import type { - DeviceMonitorSession, - MeasureStatus, - Measurement, - MonitorEvent, - Protocol, -} from './types'; +import { buildMonitorMeasurement } from './monitor-auswertung'; +import type { DeviceMonitorSession, Measurement, Protocol } from './types'; -function fmtTime(ts: number): string { - return new Date(ts).toLocaleTimeString('de-DE', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); -} - -function fmtDuration(sec: number): string { - if (sec < 60) return Math.round(sec) + ' s'; - const m = Math.floor(sec / 60); - if (m < 60) { - const s = Math.round(sec % 60); - return s ? `${m} min ${s} s` : `${m} min`; - } - const h = Math.floor(m / 60); - const restM = m % 60; - return restM ? `${h} Std ${restM} min` : `${h} Std`; -} - -/** Prozent mit einer Nachkommastelle, deutsches Komma */ -function fmtProzent(p: number): string { - return p.toFixed(1).replace('.', ',') + ' %'; -} - -interface GeraetAuswertung { - ip: string; - label: string; - ausfallzeitSek: number; - aussetzer: number; - laengsterAusfallSek: number; - verfuegbarkeitProzent: number; - /** einzelne Ausfälle als lesbare Zeilen */ - ausfaelle: string[]; -} - -/** - * Ausfallsegmente eines Geräts aus seinen Ereignissen bilden. - * - * Ein Ausfall beginnt mit 'down' und endet mit dem nächsten 'up'. Ist am Ende - * noch einer offen, wird er bis `bis` zugerechnet — er ist ja nicht dadurch - * verschwunden, dass die Messung aufhörte. - * - * `durationSec` aus dem up-Ereignis wird bewusst NICHT verwendet, sondern die - * Differenz der Zeitstempel: die native Dauer war in einem Fall nachweislich - * 0 (nämlich wenn der Ausfall schon vor dem Start bestand), und zwei Quellen - * für dieselbe Zahl gehen irgendwann auseinander. - */ -function segmenteFuer(events: MonitorEvent[], von: number, bis: number): number[][] { - const segmente: number[][] = []; - let offen: number | null = null; - for (const e of [...events].sort((a, b) => a.ts - b.ts)) { - if (e.type === 'down') { - if (offen === null) offen = e.ts; - } else if (offen !== null) { - segmente.push([offen, e.ts]); - offen = null; - } - } - if (offen !== null) segmente.push([offen, bis]); - // Auf den Messzeitraum begrenzen — ein Ereignis vor dem Start (Wiederauf- - // nahme einer älteren Sitzung) darf die Ausfallzeit nicht aufblähen. - return segmente - .map(([a, b]) => [Math.max(a, von), Math.min(b, bis)]) - .filter(([a, b]) => b > a); -} - -/** - * Aus einer Überwachungssitzung die Felder für die gespeicherte Messung bauen. - * - * @param sitzung die Sitzung mit ihren Ereignissen - * @param endeTs Ende des Messzeitraums (aus dem Plugin, sonst jetzt) - * @param abgerissen true = der Lauf wurde nicht sauber beendet (Prozess tot, - * nativer Lauf verschwunden) — dann steht das im Hinweis, - * statt eine Verfügbarkeit über eine Zeitspanne auszuweisen, - * in der gar nicht gemessen wurde. - */ -export function buildMonitorMeasurement( - sitzung: DeviceMonitorSession, - endeTs: number, - abgerissen = false, -): { label: string; result: Record; measureStatus: MeasureStatus } { - const von = sitzung.startedAt; - const bis = Math.max(von, endeTs); - const dauerSek = Math.max(1, Math.round((bis - von) / 1000)); - - const auswertung: GeraetAuswertung[] = sitzung.targets.map((t) => { - const eigene = sitzung.events.filter((e) => e.ip === t.ip); - const segmente = segmenteFuer(eigene, von, bis); - const ausfallzeitSek = Math.round( - segmente.reduce((s, [a, b]) => s + (b - a), 0) / 1000, - ); - const laengster = segmente.length - ? Math.round(Math.max(...segmente.map(([a, b]) => b - a)) / 1000) - : 0; - return { - ip: t.ip, - label: t.label || t.ip, - ausfallzeitSek, - aussetzer: segmente.length, - laengsterAusfallSek: laengster, - verfuegbarkeitProzent: Math.max( - 0, - Math.min(100, (1 - ausfallzeitSek / dauerSek) * 100), - ), - ausfaelle: segmente.map( - ([a, b]) => - `${t.label || t.ip} ${fmtTime(a)} – ${fmtTime(b)} (${fmtDuration((b - a) / 1000)})`, - ), - }; - }); - - // Schlechtestes Gerät bestimmt Label und Ampel (siehe Kopfkommentar) - const schlechtestes = auswertung.reduce( - (min, g) => (min === null || g.verfuegbarkeitProzent < min.verfuegbarkeitProzent ? g : min), - null, - ); - const gesamtAussetzer = auswertung.reduce((s, g) => s + g.aussetzer, 0); - - /* - * Bewertung. Die Schwellen sind bewusst streng: Für eine Kamera oder eine - * Wallbox ist ein Aussetzer pro Termin bereits das gemeldete Problem, nicht - * ein Schönheitsfehler. 100 % über den ganzen Zeitraum = grün, jeder - * Aussetzer = gelb, unter 95 % oder ein Gerät durchgehend tot = rot. - */ - let status: MeasureStatus = 0; - if (gesamtAussetzer > 0) status = 1; - if ((schlechtestes?.verfuegbarkeitProzent ?? 100) < 95) status = 2; - if (abgerissen) status = 3; // nicht messbar — die Grundlage stimmt nicht - - const hinweise: string[] = []; - if (abgerissen) { - hinweise.push( - 'Die Überwachung wurde nicht regulär beendet (App oder Gerät zwischendurch ' + - 'gestoppt). Der ausgewiesene Zeitraum endet beim letzten aufgezeichneten ' + - 'Ereignis; danach wurde nicht mehr gemessen.', - ); - } - // Messverfahren offenlegen: das gehört zu einer belastbaren Aussage dazu. - hinweise.push( - 'Gemessen durch regelmäßige Erreichbarkeitsprüfung alle ' + - `${sitzung.intervalSec} Sekunden. Ein Gerät, das Ping-Anfragen ` + - 'grundsätzlich nicht beantwortet, erscheint dabei als nicht erreichbar.', - ); - - const label = schlechtestes - ? gesamtAussetzer === 0 - ? `${auswertung.length} Geräte über ${fmtDuration(dauerSek)} durchgehend erreichbar` - : `${schlechtestes.label}: ${fmtProzent(schlechtestes.verfuegbarkeitProzent)} · ` + - `${gesamtAussetzer} ${gesamtAussetzer === 1 ? 'Aussetzer' : 'Aussetzer'} in ${fmtDuration(dauerSek)}` - : 'Überwachung ohne Geräte'; - - return { - label, - measureStatus: status, - result: { - dauerSekunden: dauerSek, - intervallSek: sitzung.intervalSec, - geraeteAnzahl: auswertung.length, - verfuegbarkeitProzent: Number((schlechtestes?.verfuegbarkeitProzent ?? 100).toFixed(1)), - ausfallzeitSek: schlechtestes?.ausfallzeitSek ?? 0, - aussetzer: gesamtAussetzer, - laengsterAusfallSek: Math.max(0, ...auswertung.map((g) => g.laengsterAusfallSek)), - // Vorformatierte Zeilen statt Objekte: MeasurementResult rendert Arrays - // zeilenweise, und dieselben Zeichenketten werden im PDF wiederverwendet. - geraete: auswertung.map( - (g) => - // Nur benannte Geräte bekommen die IP in Klammern dahinter — ohne - // eigenen Namen stand sonst „10.0.2.2 (10.0.2.2)" im Kundendokument. - `${g.label === g.ip ? g.ip : `${g.label} (${g.ip})`}: ` + - `${fmtProzent(g.verfuegbarkeitProzent)}` + - (g.aussetzer - ? ` · ${g.aussetzer} ${g.aussetzer === 1 ? 'Aussetzer' : 'Aussetzer'}` + - ` · ${fmtDuration(g.ausfallzeitSek)} Ausfall` + - ` · längster ${fmtDuration(g.laengsterAusfallSek)}` - : ' · durchgehend erreichbar'), - ), - ausfaelle: auswertung.flatMap((g) => g.ausfaelle), - hinweis: hinweise.join(' '), - }, - }; -} +// Die Rechnung selbst liegt in monitor-auswertung.ts, damit sie ohne +// Capacitor-Abhängigkeiten geladen und geprüft werden kann (tools/pruefe-monitor.mjs). +export { buildMonitorMeasurement } from './monitor-auswertung'; +export type { GeraetAuswertung } from './monitor-auswertung'; /** * Sitzung abschließen und als Messung ins Protokoll legen. diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 5732dd6..f599419 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -206,6 +206,25 @@ export interface ThroughputResult { measured?: boolean; } +/** Ergebnis einer Namensauflösung */ +export interface DnsResult { + ok: boolean; + ips: string[]; + latenzMs?: number; + fehler?: string; +} + +/** Ergebnis eines HTTP(S)-Abrufs ohne Weiterleitungsverfolgung */ +export interface HttpsResult { + erreichbar: boolean; + statusCode?: number; + statusText?: string; + latenzMs?: number; + /** Ziel einer 30x-Antwort — bei einem Gäste-WLAN die Adresse der Anmeldeseite */ + weiterleitungAuf?: string; + fehler?: string; +} + /** Schnittstelle des nativen Plugins */ export interface NetDiagScannerPlugin { /** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */ @@ -332,6 +351,21 @@ export interface NetDiagScannerPlugin { }>; /** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */ linkInfo(): Promise<{ links: LinkInfo[] }>; + /** + * Namen über den System-Resolver auflösen. + * + * GRENZE: welcher DNS-Server antwortet, ist nicht wählbar, und der + * System-Cache liegt dazwischen — eine zweite Abfrage desselben Namens misst + * den Cache. Für „antwortet dieser bestimmte Server" braucht es einen eigenen + * UDP-Client (eigener Roadmap-Punkt). + */ + dnsResolve(opts: { host: string; timeoutMs?: number }): Promise; + /** + * Eine URL abrufen, ohne Weiterleitungen zu folgen. Das Nichtfolgen ist der + * Kern: ein Gäste-WLAN mit Anmeldeseite beantwortet jede Anfrage mit einer + * Umleitung, und wer ihr folgt, bekommt ein sauberes 200 zurück. + */ + httpsCheck(opts: { url: string; timeoutMs?: number }): Promise; /** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */ startWifiScan(): Promise<{ triggered: boolean }>; /** WLAN-Empfangstracker für ein BSSID starten — live RSSI per `wifiSignal`-Event */ @@ -835,6 +869,7 @@ const mock: NetDiagScannerPlugin = { gateway: '192.168.1.1', dns: ['192.168.1.1', '1.1.1.1'], linkSpeedMbps: 1000, + validated: true, }, { iface: 'wlan0', @@ -851,10 +886,27 @@ const mock: NetDiagScannerPlugin = { ssid: 'AllesWattLaeuft', bssid: 'AA:BB:CC:11:22:33', rssi: -52, + validated: true, }, ], }; }, + /* + * Der Mock liefert für DNS und HTTPS BEWUSST nur den guten Fall — im Browser + * gibt es kein Kundennetz, gegen das sich ein Ausfall ehrlich nachstellen + * ließe. Wer die Internet-Kette hier durchklickt, sieht deshalb immer Grün. + * Die Fehlerpfade (kein Gateway, DNS tot, Anmeldeseite, HTTPS blockiert) + * werden nicht über den Mock geprüft, sondern über `tools/pruefe-kette.mjs` + * gegen konstruierte Stufen mit von Hand ermitteltem Sollwert. Dieselbe + * Falle wie beim geplanten Geräte-Diff: der Mock sieht perfekt aus und + * beweist nichts. + */ + async dnsResolve(opts) { + return { ok: true, ips: ['142.250.185.67'], latenzMs: rnd(8, 40) }; + }, + async httpsCheck() { + return { erreichbar: true, statusCode: 204, statusText: 'No Content', latenzMs: rnd(30, 120) }; + }, async startWifiScan() { return { triggered: true }; }, diff --git a/src/lib/tools/index.ts b/src/lib/tools/index.ts index 591298d..b11df71 100644 --- a/src/lib/tools/index.ts +++ b/src/lib/tools/index.ts @@ -17,6 +17,7 @@ import { snmpTool } from './netzwerk/snmp'; import { stressTestTool } from './netzwerk/stresstest'; import { tracerouteTool } from './netzwerk/traceroute'; +import { internetKetteTool } from './internet/internetkette'; import { iperfTool } from './internet/iperf'; /** @@ -36,7 +37,9 @@ export const TOOLS: Tool[] = [ snmpTool, tracerouteTool, stressTestTool, - // Internet + // Internet — die Kette steht bewusst vorn: sie ist die Erstdiagnose, + // wenn der Kunde „Internet geht nicht" sagt. + internetKetteTool, iperfTool, // Telefonie: folgt (SIP-Registrierung, FreePBX-Check, RTP-Qualität) ]; diff --git a/src/lib/tools/internet/internetkette.ts b/src/lib/tools/internet/internetkette.ts new file mode 100644 index 0000000..5fc6e52 --- /dev/null +++ b/src/lib/tools/internet/internetkette.ts @@ -0,0 +1,156 @@ +/** + * Tool: Internet-Kette — Anschluss → Gateway → Namensauflösung → HTTPS. + * + * Die Standard-Erstdiagnose vor Ort. Die häufigste Aussage beim Kunden ist + * „Internet geht nicht"; bisher musste der Techniker Ping, Traceroute und + * Browser einzeln bedienen und selbst schlussfolgern, welches Glied hakt. + * Dieses Werkzeug misst alle Stufen in einem Lauf und benennt das Fehlerglied. + * + * Zwei Entwurfsentscheidungen, die für die Ehrlichkeit der Aussage wesentlich + * sind und deshalb hier stehen: + * + * 1. **Die Kette bricht nicht ab.** Auch nach einer fehlgeschlagenen Stufe + * laufen die restlichen weiter. Sonst wäre der häufigste Fall überhaupt + * nicht auswertbar: sehr viele Router beantworten Ping grundsätzlich nicht. + * Ein Abbruch dort meldete „Gateway tot" bei völlig gesundem Internet. + * 2. **Eine stille Stufe ist kein Fehler, wenn eine spätere Stufe antwortet.** + * Wer eine Antwort aus dem Internet bekommt, hat sein Gateway benutzt — + * dann ist dessen Schweigen eine Eigenheit, keine Störung, und wird als + * solche formuliert. + */ + +import { scanner } from '../../scanner'; +import type { Tool } from '../types'; +import { bewerteKette, type KettenStufe } from './kette-bewertung'; + +export { bewerteKette } from './kette-bewertung'; +export type { KettenStufe, KettenUrteil } from './kette-bewertung'; + +export const internetKetteTool: Tool = { + id: 'internetkette', + category: 'internet', + name: 'Internet-Kette', + icon: 'link', + description: 'Anschluss → Gateway → Namensauflösung → HTTPS in einem Lauf, mit benanntem Fehlerglied.', + scope: 'protocol', + quickRun: true, + params: [ + { key: 'dnsName', label: 'Name zum Auflösen', type: 'text', default: 'www.google.de' }, + { + key: 'url', + label: 'Adresse für den HTTPS-Abruf', + type: 'text', + default: 'https://connectivitycheck.gstatic.com/generate_204', + }, + ], + async run(ctx) { + const dnsName = String(ctx.params.dnsName || 'www.google.de').trim(); + const url = String(ctx.params.url || 'https://connectivitycheck.gstatic.com/generate_204').trim(); + + /* Stufe 1 — eigener Anschluss */ + const { links } = await scanner.linkInfo(); + const link = + links.find((l) => l.isDefault && l.ipv4) ?? + links.find((l) => l.ipv4 && l.type !== 'cellular') ?? + links.find((l) => l.ipv4); + + const anschlussText = link + ? `${anschlussName(link.type)} ${link.iface}, IP ${link.ipv4}/${link.prefixLength ?? '?'}` + : 'keine IP-Adresse auf keinem Anschluss'; + + const stufen: KettenStufe[] = [ + { name: 'Anschluss', ok: link ? true : null, detail: anschlussText }, + ]; + + /* Stufe 2 — Gateway */ + const gateway = link?.gateway; + if (!gateway) { + stufen.push({ + name: 'Gateway', + ok: null, + detail: 'kein Standard-Gateway gemeldet', + stillErlaubt: true, + }); + } else { + const q = await scanner.pingQuality({ host: gateway, count: 4 }); + stufen.push({ + name: 'Gateway', + ok: q.received > 0, + detail: + q.received > 0 + ? `${gateway} antwortet in ${q.medianMs ?? q.avgMs} ms (${q.received}/${q.sent} Pakete)` + : `${gateway} antwortet auf keines von ${q.sent} Paketen`, + stillErlaubt: true, + }); + } + + /* Stufe 3 — Namensauflösung */ + const dns = await scanner.dnsResolve({ host: dnsName, timeoutMs: 5000 }); + stufen.push({ + name: 'Namensauflösung', + ok: dns.ok, + detail: dns.ok + ? `${dnsName} → ${dns.ips.slice(0, 2).join(', ')}${dns.latenzMs !== undefined ? ` (${dns.latenzMs} ms)` : ''}` + : `${dnsName} nicht auflösbar${dns.fehler ? ` (${dns.fehler})` : ''}`, + }); + + /* Stufe 4 — HTTPS */ + const https = await scanner.httpsCheck({ url, timeoutMs: 8000 }); + // 2xx zählt als Erfolg, 204 ist die erwartete Antwort des Prüfdienstes. + // Eine Weiterleitung ist KEIN Erfolg — genau dort sitzt die Anmeldeseite. + const code = https.statusCode ?? 0; + const httpsOk = https.erreichbar && code >= 200 && code < 300; + const weiterleitung = https.erreichbar && code >= 300 && code < 400; + stufen.push({ + name: 'HTTPS-Abruf', + ok: https.erreichbar ? httpsOk : false, + detail: https.erreichbar + ? `HTTP ${code}${https.statusText ? ' ' + https.statusText : ''}` + + (https.latenzMs !== undefined ? ` in ${https.latenzMs} ms` : '') + + (https.weiterleitungAuf ? ` → Umleitung auf ${https.weiterleitungAuf}` : '') + : `kein Verbindungsaufbau${https.fehler ? ` (${https.fehler})` : ''}`, + }); + + const portalErkannt = weiterleitung || link?.captivePortal === true; + const urteil = bewerteKette(stufen, portalErkannt, link?.validated); + + // Systemurteil als eigene, klar als solche gekennzeichnete Zeile: es ist + // eine Fremdaussage, keine eigene Messung. + const systemText = + link?.validated === undefined + ? undefined + : link.validated + ? 'Android stuft diese Verbindung als bestätigt ein.' + : 'Android stuft diese Verbindung NICHT als bestätigt ein.'; + + return { + label: urteil.label, + measureStatus: urteil.status, + result: { + fehlerglied: urteil.fehlerglied || 'keines', + stufen: stufen.map( + (s, i) => + `${i + 1}. ${s.name}: ${s.detail}` + + (s.ok === true ? '' : s.ok === false ? ' — keine Antwort' : ' — nicht messbar'), + ), + gateway: gateway ?? undefined, + systemUrteil: systemText, + hinweis: urteil.hinweis, + }, + }; + }, +}; + +/** Anschlussart auf Deutsch — dieselbe Sprache wie netdiagKundentext() im Modul */ +function anschlussName(type: string): string { + switch (type) { + case 'ethernet': + return 'LAN (Kabel)'; + case 'wifi': + return 'WLAN'; + case 'cellular': + return 'Mobilfunk'; + default: + return 'Anschluss'; + } +} diff --git a/src/lib/tools/internet/kette-bewertung.ts b/src/lib/tools/internet/kette-bewertung.ts new file mode 100644 index 0000000..95d4a6f --- /dev/null +++ b/src/lib/tools/internet/kette-bewertung.ts @@ -0,0 +1,162 @@ +/** + * Bewertung der Internet-Kette — bewusst OHNE jeden Laufzeit-Import. + * + * Warum eine eigene Datei: die Fehlerpfade dieses Werkzeugs (kein Gateway, + * DNS tot, Anmeldeseite, HTTPS blockiert) lassen sich im Browser-Mock nicht + * ehrlich nachstellen — dort ist immer alles grün. Geprüft wird deshalb mit + * `tools/pruefe-kette.mjs` gegen konstruierte Stufen mit von Hand ermitteltem + * Sollwert. Damit dieser Prüfer die Funktion laden kann, darf hier nichts + * stehen, was `scanner.ts` (und damit Capacitor) nachzieht: nur `import type`, + * das beim Ausführen mit Node restlos verschwindet. + * + * Der Ausweg „Datei von Hand nach .mjs kopieren" wurde beim Monitor gewählt + * und hat sich als Sackgasse erwiesen: die Kopie lag in einem + * Sitzungsverzeichnis, das Prüfwerkzeug lief nach dessen Wegfall nicht mehr, + * und im Repo stand trotzdem „alle Fälle korrekt". + */ + +import type { MeasureStatus } from '../types'; + +/** Eine Stufe der Kette, so wie sie gemessen wurde */ +export interface KettenStufe { + /** Kurzname für das Kundendokument („Gateway", „Namensauflösung" …) */ + name: string; + /** + * true = Stufe hat geantwortet + * false = Stufe hat nicht geantwortet + * null = nicht messbar (die Voraussetzung fehlte, z.B. kein Gateway bekannt) + */ + ok: boolean | null; + /** Messwert/Antwort als Klartext für das Protokoll */ + detail: string; + /** + * true = diese Stufe darf allein kein Rot auslösen, solange eine spätere + * Stufe geantwortet hat. Sehr viele Router beantworten Ping grundsätzlich + * nicht; wer eine Antwort aus dem Internet bekommt, hat sein Gateway aber + * nachweislich benutzt. + */ + stillErlaubt?: boolean; +} + +/** Ergebnis der Kettenbewertung */ +export interface KettenUrteil { + status: MeasureStatus; + label: string; + /** benanntes Fehlerglied, leer wenn die Kette durchgeht */ + fehlerglied: string; + hinweis?: string; +} + +/** + * Aus den gemessenen Stufen das Urteil bilden. + * + * @param stufen die Stufen in Reihenfolge (Anschluss zuerst, HTTPS zuletzt) + * @param portalErkannt Anmeldeseite erkannt (Weiterleitung oder Systemurteil) + * @param systemUrteil Androids NET_CAPABILITY_VALIDATED, falls bekannt + */ +export function bewerteKette( + stufen: KettenStufe[], + portalErkannt: boolean, + systemUrteil?: boolean, +): KettenUrteil { + // Gar nichts messbar (kein Anschluss) — Status 3 statt einer Falschaussage + // über das Kundennetz. Ohne eigene IP haben wir schlicht nicht gemessen. + const anschluss = stufen[0]; + if (!anschluss || anschluss.ok === null) { + return { + status: 3, + label: 'Nicht messbar — kein aktiver Netzwerkanschluss', + fehlerglied: 'Anschluss', + hinweis: + 'Das Messgerät hatte keine IP-Adresse. Es wurde nichts über das Kundennetz gemessen.', + }; + } + + /* + * Die Anmeldeseite wird VOR der Fehlerglied-Suche entschieden. Sie erklärt + * sämtliche anderen Symptome: ein Gäste-WLAN-Portal beantwortet den + * HTTPS-Abruf mit einer Umleitung (die Stufe gilt damit als nicht bestanden) + * und biegt oft auch die Namensauflösung um. Ohne diesen Vorrang stand im + * Kundendokument „Kein Internet — HTTPS-Abruf antwortet nicht", obwohl der + * Server sauber geantwortet hat und der Techniker nur auf „Anmelden" tippen + * muss. Das ist der Unterschied zwischen einem Anruf beim Provider und + * einem erledigten Termin. + */ + if (portalErkannt) { + return { + status: 1, + label: 'Anmeldeseite im Weg — Internet erst nach Anmeldung', + fehlerglied: 'Anmeldeseite', + hinweis: + 'Die Anfrage wurde auf eine Anmeldeseite umgeleitet (typisch für ein ' + + 'Gäste-WLAN). Ohne Anmeldung kommt kein Gerät ins Internet — Kameras, ' + + 'Wechselrichter und Wallboxen können sich dort grundsätzlich nicht melden.', + }; + } + + // Index der letzten Stufe, die geantwortet hat — entscheidet darüber, ob das + // Schweigen einer früheren Stufe entschuldigt ist. + const letzteAntwortIdx = stufen.reduce((idx, s, i) => (s.ok === true ? i : idx), -1); + + // Fehlerglied: erste Stufe ohne Antwort, deren Schweigen nicht durch eine + // spätere Antwort erklärt ist. + let fehlerglied = ''; + for (let i = 0; i < stufen.length; i++) { + const s = stufen[i]; + if (s.ok !== false) continue; + if (s.stillErlaubt && letzteAntwortIdx > i) continue; + fehlerglied = s.name; + break; + } + + if (fehlerglied !== '') { + return { + status: 2, + label: `Kein Internet — ${fehlerglied} antwortet nicht`, + fehlerglied, + }; + } + + const httpsStufe = stufen[stufen.length - 1]; + const internetGeht = httpsStufe?.ok === true; + + // Alles geantwortet, aber Android hält die Verbindung nicht für bestätigt: + // „HTTPS geht, irgendetwas anderes klemmt trotzdem" — gelb, nicht grün. + if (internetGeht && systemUrteil === false) { + return { + status: 1, + label: 'Internet erreichbar, vom System aber nicht bestätigt', + fehlerglied: '', + hinweis: + 'Alle Stufen haben geantwortet, Android stuft die Verbindung dennoch ' + + 'nicht als bestätigt ein. Das deutet auf eine teilweise gefilterte ' + + 'Verbindung hin (Jugendschutz, Firewall, Portsperre).', + }; + } + + // Eine Stufe, die nicht messbar war (z.B. kein Gateway gemeldet), ist kein + // Erfolg — sie darf aber auch nicht als Störung ausgewiesen werden. + const stille = stufen.filter((s) => s.ok === false && s.stillErlaubt); + const unbekannt = stufen.filter((s) => s.ok === null && s.stillErlaubt); + const hinweise: string[] = []; + if (stille.length) { + hinweise.push( + `${stille.map((s) => s.name).join(' und ')} antwortet nicht auf Ping, ` + + 'leitet den Verkehr aber weiter — das ist bei vielen Routern die ' + + 'Werkseinstellung und keine Störung.', + ); + } + if (unbekannt.length) { + hinweise.push( + `${unbekannt.map((s) => s.name).join(' und ')} konnte nicht geprüft werden ` + + '(vom System nicht gemeldet).', + ); + } + + return { + status: 0, + label: 'Internet erreichbar — Gateway, Namensauflösung und HTTPS in Ordnung', + fehlerglied: '', + hinweis: hinweise.length ? hinweise.join(' ') : undefined, + }; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 75de64f..a3e5a2c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -134,6 +134,15 @@ export interface LinkInfo { bssid?: string; /** Empfangsstärke in dBm (nur wenn type='wifi') */ rssi?: number; + /** + * Androids eigenes Urteil: das System hat über diese Verbindung erfolgreich + * einen Erreichbarkeitsdienst abgerufen (NET_CAPABILITY_VALIDATED). Kostet + * uns keine Messung und ist die verlässlichste Auskunft über ein Gäste-WLAN + * mit Anmeldeseite, die ohne Root zu bekommen ist. + */ + validated?: boolean; + /** System hat eine Anmeldeseite erkannt (NET_CAPABILITY_CAPTIVE_PORTAL) */ + captivePortal?: boolean; } /** Einzelne RSSI-Probe des WLAN-Empfangstrackers */ diff --git a/src/routes/debug/+page.svelte b/src/routes/debug/+page.svelte index 5cdde98..5e7409e 100644 --- a/src/routes/debug/+page.svelte +++ b/src/routes/debug/+page.svelte @@ -1,6 +1,60 @@