From 240d65d5345296e13b992e77f970a529039030f6 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Fri, 14 Aug 2026 19:16:17 +0200 Subject: [PATCH] Phase 1: Messverfahren liefern keine falschen Aussagen mehr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Vier Werkzeuge haben nachweislich Unsinn ins Kundenprotokoll geschrieben. Belege sind echte Prod-Messungen; jeder Fix wurde im Emulator gegen den passenden Fehlerfall nachgestellt. SNMP (Snmp.kt, snmp.ts) Snmp.get lieferte bei JEDEM Problem null -> "-" -> parseInt("-")||0 -> 0. Ein Switch, der gar nicht antwortet, stand damit als "0 Fehler" in Gruen im Kunden-PDF (Prod #95/#96). Neu: Ergebnistyp Value/Timeout/Failure statt String?, Request-ID- und error-status-Pruefung, v2c-Ausnahmen (0x80/0x81/0x82) erkannt, ein Wiederholversuch bei Timeout. Das Werkzeug meldet jetzt "nicht messbar" statt Zahlen zu erfinden. Test: SNMP gegen einen Host ohne Agent -> status 3 "keine SNMP-Antwort". Traceroute (pingWithTtl, traceroute.ts) Der Fallback-Regex \((\d+\.\d+\.\d+\.\d+)\) traf IMMER zuerst die ping-Kopfzeile "PING 8.8.8.8 (8.8.8.8) ...". Ein stummer Hop wurde dadurch als Ziel ausgegeben und der Trace beendet — daher "2 Hops bis 8.8.8.8, beide 0 ms" (Prod #13). Zwischen-Hops liefern zudem nie ein time=, die Zeit war strukturell 0. Neu: Kopfzeile verworfen, zeilenweise geparst, Reverse-DNS-Antworten erkannt, RTT selbst per nanoTime gestoppt, 3 Proben je TTL, waitFor(3s)+destroyForcibly, "keine Antwort" ist null und nicht 0, Bewertung haengt an "Ziel erreicht". Test: 8.8.8.8 -> 1 Hop mit echten 13,3 ms (statt 0 ms); 192.0.2.1 -> status 3, alle Hops "* (keine Antwort)" statt gruen. Ping (measurePing, ping.ts) Kein WifiLock, keine Aufwaermproben: zwischen den Proben schlief das Funkmodul ein, die Aufwachzeit landete in der Messung — 296 ms zum LAN-Gateway bei 0 % Verlust und rotem Status (Prod #4). WAKE_LOCK fehlte im Manifest, weshalb WifiLock.acquire() eine SecurityException warf, die ein leerer catch schluckte. Neu: WAKE_LOCK im Manifest, WifiLock (LOW_LATENCY) + PARTIAL_WAKE_LOCK um jede Messung, 2 verworfene Aufwaermproben, Median und Messaufschlag (avg-min) zusaetzlich ausgewiesen, Verlust als Kommazahl statt Ganzzahl-Division, Bewertung auf den Median statt den Mittelwert, "keine Antwort" != "0 ms". Test: 20 Proben -> Median 0,3 ms, Messaufschlag 0,1 ms, Verfahren dokumentiert. Durchsatz (measureThroughput, iperf.ts) Kein soTimeout: nahm die Gegenstelle an, ohne zu senden, blockierte read() unbegrenzt und das Werkzeug-Fenster hing dauerhaft auf "Messung laeuft ...". Neu: soTimeout 4 s je Phase, harter withTimeout-Guard, Ist-Zeit statt angenommener halber Dauer, measured-Flag — ohne uebertragene Bytes gibt es kein rotes "0 Mbit/s" mehr, sondern "nicht messbar". Test: Gegenstelle nimmt an und schweigt -> Ergebnis nach 30 s statt Dauerhaenger. Netzerkennung (firstLocalIpv4, getLocalSubnet, hostsInSubnet) firstLocalIpv4() gab bei fehlendem Netz hart "192.168.1.1" zurueck; zusammen mit dem Praefix-Fallback /24 entstand das Phantom-Netz 192.168.1.0/24, das auch noch ins Protokoll geschrieben wurde. Der Fehlerzweig in ipscan.ts konnte deshalb nie greifen. Neu: "" statt Phantom-IP, Link-Local uebersprungen, getLocalSubnet meldet einen Fehler, DHCP-Fallback nur bei WLAN-Transport (sonst wurde bei gestecktem USB-RJ45 die Maske aus dem WLAN genommen), unlesbares CIDR-Praefix wird abgelehnt statt still durch /24 ersetzt. Test: Flugmodus -> "Kein Netzbereich — WLAN/LAN nicht aktiv?" statt Phantom-Netz. Stresstest-Ziel (stresstest.ts) Vorgabe 192.168.1.1 im Kundennetz 192.168.178.0/24 -> 5 Minuten ins Leere gemessen, Ergebnis "100 % Verlust" rot im Protokoll (Prod #126). Neu: kein fester Vorgabewert, leer = Gateway des aktiven Adapters, und eine Vorlaufprobe bricht ab, bevor minutenlang gegen ein totes Ziel gemessen wird. Test: Ziel 192.168.1.1 -> nach 7 s "antwortet nicht — Test nicht gestartet" (vorher: 60 s Messung mit rotem 100-%-Ergebnis). Neue Bewertung "nicht messbar" (MeasureStatus 3) Bisher gab es nur ok/warn/fail. Ein Test, der gar nicht durchgefuehrt werden konnte, musste sich als eines davon ausgeben — meist als Gruen. Status 3 wird neutral grau dargestellt (Messkarte und Geraetekarte) und ist keine Aussage ueber das Kundennetz. Geprueft: gradle compileDebugKotlin ok, svelte-check 0 Fehler (2 vorbestehende Warnungen), alle sieben Faelle im Emulator (Pixel 6 / Android 14) durchgespielt und die Ergebnisse in der Datenbank gegengelesen statt am Bildschirm geraten. Co-Authored-By: Claude Opus 5 (1M context) --- android/app/src/main/AndroidManifest.xml | 8 + .../netdiag/NetDiagScannerPlugin.kt | 522 +++++++++++++++--- .../java/de/data_it_solution/netdiag/Snmp.kt | 219 ++++++-- src/app.css | 7 + src/lib/components/DeviceCard.svelte | 5 +- src/lib/scanner.ts | 83 ++- src/lib/tools/internet/iperf.ts | 17 + src/lib/tools/netzwerk/ping.ts | 30 +- src/lib/tools/netzwerk/snmp.ts | 77 ++- src/lib/tools/netzwerk/stresstest.ts | 51 +- src/lib/tools/netzwerk/traceroute.ts | 32 +- src/lib/types.ts | 12 +- src/routes/protokoll/[id]/+page.svelte | 5 +- 13 files changed, 906 insertions(+), 162 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6378887..2fc9f39 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -51,6 +51,14 @@ + + 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 d9fa0c5..ec61424 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 @@ -30,8 +30,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import java.io.BufferedReader import java.io.File import java.io.FileOutputStream @@ -72,6 +74,86 @@ class NetDiagScannerPlugin : Plugin() { private val io = CoroutineScope(Dispatchers.IO) private val stressRuns = ConcurrentHashMap() + /* --------------------------------------------------------------------- */ + /* Funk/CPU wachhalten waehrend einer Messung */ + /* --------------------------------------------------------------------- */ + + /** + * Haelt WLAN-Funkmodul und CPU waehrend einer Messung wach. + * + * Warum das eine Messgroesse ist und keine Nebensache: Zwischen zwei Proben + * liegen Pausen von 200 ms und mehr. Ohne Lock schaltet das WLAN in den + * Energiesparmodus und muss zum naechsten Beacon aufwachen — die gemessene + * Zeit enthaelt dann die Aufwachzeit statt der Netzlaufzeit. Genau so kommt + * Prod-Messung #4 zustande: 296 ms Mittel zum LAN-Gateway bei 0 % Verlust, + * und selbst der Minimalwert lag bei 207 ms. + * + * Stolperfalle: `WifiLock.acquire()` braucht `android.permission.WAKE_LOCK`. + * Fehlt das Recht, fliegt eine SecurityException — die frueher ein leerer + * catch-Block verschluckt hat, der Lock war also wirkungslos ohne jeden + * Hinweis. Deshalb wird hier protokolliert. + */ + private class RadioLocks( + val wifi: WifiManager.WifiLock?, + val wake: android.os.PowerManager.WakeLock?, + ) + + private fun acquireRadioLocks(tag: String, timeoutMs: Long): RadioLocks { + var wifi: WifiManager.WifiLock? = null + var wake: android.os.PowerManager.WakeLock? = null + try { + val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + WifiManager.WIFI_MODE_FULL_LOW_LATENCY + } else { + @Suppress("DEPRECATION") WifiManager.WIFI_MODE_FULL_HIGH_PERF + } + wifi = wm.createWifiLock(mode, "netdiag-$tag") + wifi.acquire() + } catch (e: Exception) { + android.util.Log.w(TAG, "WifiLock ($tag) nicht moeglich: ${e.message}") + wifi = null + } + try { + val pm = context.applicationContext + .getSystemService(Context.POWER_SERVICE) as android.os.PowerManager + wake = pm.newWakeLock(android.os.PowerManager.PARTIAL_WAKE_LOCK, "netdiag:$tag") + // Immer mit Zeitgrenze anfordern — ein vergessener WakeLock wuerde + // sonst den Akku leersaugen. + wake.acquire(timeoutMs) + } catch (e: Exception) { + android.util.Log.w(TAG, "WakeLock ($tag) nicht moeglich: ${e.message}") + wake = null + } + return RadioLocks(wifi, wake) + } + + /** + * Laeuft das aktive Netz ueber WLAN? Entscheidet, ob die WLAN-DHCP-Info als + * Fallback fuer Maske/Gateway ueberhaupt zum aktiven Interface passt. + */ + private fun isActiveTransportWifi(): Boolean { + return try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true + val cm = context.applicationContext + .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + val caps = cm.activeNetwork?.let { cm.getNetworkCapabilities(it) } ?: return false + caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } catch (e: Exception) { + android.util.Log.w(TAG, "isActiveTransportWifi: ${e.message}") + false + } + } + + private fun releaseRadioLocks(locks: RadioLocks) { + try { if (locks.wifi?.isHeld == true) locks.wifi.release() } catch (e: Exception) { + android.util.Log.w(TAG, "WifiLock-Freigabe: ${e.message}") + } + try { if (locks.wake?.isHeld == true) locks.wake.release() } catch (e: Exception) { + android.util.Log.w(TAG, "WakeLock-Freigabe: ${e.message}") + } + } + /* --------------------------------------------------------------------- */ /* Subnetz / lokale Netzwerkinfo */ /* --------------------------------------------------------------------- */ @@ -110,7 +192,12 @@ class NetDiagScannerPlugin : Plugin() { } // 2. Fallback: WLAN-DHCP-Info (ältere Geräte / LinkProperties leer) - if (ip.isEmpty() || prefix == 0) { + // NUR wenn das aktive Netz auch wirklich WLAN ist. Sonst wuerde + // bei gestecktem USB-RJ45-Adapter (dessen LinkProperties oft + // prefixLength 0 melden) die Maske und das Gateway aus dem + // parallel verbundenen WLAN uebernommen — die App scannt dann + // ein ganz anderes Netz als das Kabel, in dem sie steckt. + if ((ip.isEmpty() || prefix == 0) && isActiveTransportWifi()) { val wifi = context.applicationContext .getSystemService(Context.WIFI_SERVICE) as WifiManager @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo @@ -121,18 +208,31 @@ class NetDiagScannerPlugin : Plugin() { } } - // 3. Letzter Fallback + // 3. Letzter Fallback: irgendein aktives Interface + var prefixQuelle = if (prefix in 1..32) "adapter" else "" if (ip.isEmpty()) ip = firstLocalIpv4() - if (prefix !in 1..32) prefix = 24 - // Netzadresse aus IP + Präfix berechnen - val ipInt = ipv4ToInt(ip) ?: 0 + // Kein Netz = Fehler. Frueher wurde hier "192.168.1.1" erfunden und + // mit /24 zu einem Netz zusammengebaut, das es nirgends gab. + val ipInt = ipv4ToInt(ip) + if (ip.isEmpty() || ipInt == null) { + call.reject("Kein aktives IPv4-Netz — WLAN/LAN verbunden?") + return@launch + } + if (prefix !in 1..32) { + // Nur noch als benannte Annahme, nicht mehr stillschweigend + prefix = 24 + prefixQuelle = "angenommen" + } + val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix)) val network = intToIpv4(ipInt and mask) resolve(call, JSObject() .put("subnet", "$network/$prefix") .put("ip", ip) + .put("prefix", prefix) + .put("prefixQuelle", prefixQuelle) .put("gateway", gateway)) } catch (e: Exception) { call.reject("getLocalSubnet: ${e.message}") @@ -152,6 +252,9 @@ class NetDiagScannerPlugin : Plugin() { return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet") } io.launch { + // Ohne wachgehaltenes Funkmodul faellt bei einem laengeren Sweep die + // Trefferquote, weil das WLAN zwischendurch in den Sparmodus geht. + val locks = acquireRadioLocks("ipscan", (hosts.size * 60L).coerceIn(30_000L, 600_000L)) try { // Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau, // also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …). @@ -196,9 +299,17 @@ class NetDiagScannerPlugin : Plugin() { if (type.isNotEmpty()) dev.put("deviceType", type) devices.put(dev) } - resolve(call, JSObject().put("devices", devices)) + resolve(call, JSObject() + .put("devices", devices) + // Diagnosefelder: ein leeres Ergebnis ist etwas anderes als ein + // gescheiterter Scan — der Aufrufer kann das jetzt unterscheiden. + .put("probed", hosts.size) + .put("answered", alive.size) + .put("arpAvailable", arp.isNotEmpty())) } catch (e: Exception) { call.reject("ipScan: ${e.message}") + } finally { + releaseRadioLocks(locks) } } } @@ -418,10 +529,25 @@ class NetDiagScannerPlugin : Plugin() { * Ohne Praefix wird /24 angenommen. Netz- und Broadcast-Adresse sind * ausgenommen (ausser /31, /32). Leer bei ungueltig oder > /16. */ + /** + * Alle Host-Adressen eines CIDR-Bereichs. + * + * Zum Praefix: Frueher wurde ein fehlendes ODER unlesbares Praefix + * kommentarlos durch 24 ersetzt — "192.168.144.0/zwanzig" ergab dann 254 + * statt 4094 Adressen, und die Eingabe "192.168.153.0" ohne Praefix (siehe + * Prod-Messung #2) scannte still nur ein Viertelprozent des gemeinten Netzes. + * Ein fehlendes Praefix ist jetzt "nimm /24" (dokumentierte Annahme, der + * Aufrufer weist darauf hin), ein KAPUTTES Praefix dagegen ein Fehler. + */ private fun hostsInSubnet(cidr: String): List { val parts = cidr.trim().split('/') val ipInt = ipv4ToInt(parts[0].trim()) ?: return emptyList() - val prefix = if (parts.size > 1) (parts[1].trim().toIntOrNull() ?: 24) else 24 + val prefix = if (parts.size > 1) { + // Nicht interpretierbar -> ablehnen statt raten + parts[1].trim().toIntOrNull() ?: return emptyList() + } else { + 24 + } if (prefix < 0 || prefix > 32) return emptyList() val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix)) val network = ipInt and mask @@ -575,29 +701,74 @@ class NetDiagScannerPlugin : Plugin() { } } + /** + * Ping-Messung. + * + * Bekannte Grenze des Verfahrens: `InetAddress.isReachable()` schickt auf + * Android in aller Regel KEIN ICMP (dafuer fehlen die Rechte), sondern baut + * eine TCP-Verbindung auf. Die gemessene Zeit enthaelt daher immer etwas + * Verbindungsaufbau. Was sich sauber beheben laesst, ist hier gemacht: + * - Funk/CPU bleiben waehrend der Messung wach (sonst dominiert die + * WLAN-Aufwachzeit die Werte, siehe acquireRadioLocks) + * - die ersten Proben werden verworfen (Verbindungsaufbau, ARP, DNS-Cache) + * - zusaetzlich zu avg werden min und Median ausgewiesen; der Median ist + * gegen einzelne Ausreisser robust, min zeigt was das Netz wirklich kann + * - `messaufschlagMs` (avg - min) macht sichtbar, wie viel Messverfahren + * im Wert steckt + */ private fun measurePing(host: String, count: Int): JSObject { - val times = ArrayList() - val addr = InetAddress.getByName(host) - repeat(count) { - val t0 = System.nanoTime() - if (addr.isReachable(1000)) { - times.add((System.nanoTime() - t0) / 1_000_000.0) + val locks = acquireRadioLocks("ping", (count * 1500L) + 10_000L) + try { + val times = ArrayList() + val addr = InetAddress.getByName(host) + + // Aufwaermproben: Ergebnis wird verworfen, sie wecken nur das Funkmodul + repeat(PING_WARMUP_PROBES) { + try { addr.isReachable(1000) } catch (_: Exception) { } + Thread.sleep(100) } - Thread.sleep(200) + + repeat(count) { + val t0 = System.nanoTime() + val ok = try { addr.isReachable(1000) } catch (_: Exception) { false } + if (ok) times.add((System.nanoTime() - t0) / 1_000_000.0) + Thread.sleep(200) + } + + val received = times.size + // Kommazahl statt Ganzzahl-Division: 1 von 20 verlorenen Paketen sind + // 5,0 % — vorher wurde alles unter 1 % auf 0 abgeschnitten. + val loss = if (count > 0) (count - received) * 100.0 / count else 0.0 + val sorted = times.sorted() + val min = sorted.firstOrNull() ?: 0.0 + val max = sorted.lastOrNull() ?: 0.0 + val avg = if (times.isNotEmpty()) times.average() else 0.0 + val median = when { + sorted.isEmpty() -> 0.0 + sorted.size % 2 == 1 -> sorted[sorted.size / 2] + else -> (sorted[sorted.size / 2 - 1] + sorted[sorted.size / 2]) / 2.0 + } + // Jitter: mittlere absolute Abweichung aufeinanderfolgender EMPFANGENER + // Proben. Verlorene Proben landen gar nicht erst in der Liste — das ist + // kein RFC-3550-Jitter, deshalb wird das Verfahren mit ausgegeben. + var jitter = 0.0 + for (i in 1 until times.size) jitter += Math.abs(times[i] - times[i - 1]) + if (times.size > 1) jitter /= (times.size - 1) + + return JSObject() + .put("sent", count) + .put("received", received) + .put("lossPct", round1(loss)) + .put("minMs", round1(min)) + .put("avgMs", round1(avg)) + .put("medianMs", round1(median)) + .put("maxMs", round1(max)) + .put("jitterMs", round1(jitter)) + .put("messaufschlagMs", round1(if (times.isNotEmpty()) avg - min else 0.0)) + .put("verfahren", "TCP-Erreichbarkeit (isReachable), kein ICMP") + } finally { + releaseRadioLocks(locks) } - val received = times.size - val loss = ((count - received) * 100) / count - val min = times.minOrNull() ?: 0.0 - val max = times.maxOrNull() ?: 0.0 - val avg = if (times.isNotEmpty()) times.average() else 0.0 - // Jitter = mittlere absolute Abweichung aufeinanderfolgender Werte - var jitter = 0.0 - for (i in 1 until times.size) jitter += Math.abs(times[i] - times[i - 1]) - if (times.size > 1) jitter /= (times.size - 1) - return JSObject() - .put("sent", count).put("received", received).put("lossPct", loss) - .put("minMs", round1(min)).put("avgMs", round1(avg)) - .put("maxMs", round1(max)).put("jitterMs", round1(jitter)) } /* --------------------------------------------------------------------- */ @@ -697,10 +868,36 @@ class NetDiagScannerPlugin : Plugin() { io.launch { try { val values = JSObject() - for (oid in oids) { - values.put(oid, Snmp.get(host, community, oid) ?: "-") + var answered = false + var firstProblem = "" + + for ((idx, oid) in oids.withIndex()) { + when (val res = Snmp.get(host, community, oid)) { + is Snmp.Result.Value -> { + answered = true + values.put(oid, res.text) + } + is Snmp.Result.Failure -> { + // Der Agent hat geantwortet, nur diese OID gibt es nicht. + // Das Geraet ist also erreichbar. + answered = true + if (firstProblem.isEmpty()) firstProblem = res.reason + } + Snmp.Result.Timeout -> { + if (firstProblem.isEmpty()) { + firstProblem = "Keine Antwort auf SNMP (Port 161)" + } + // Antwortet schon die erste OID nicht, antwortet auch der + // Rest nicht — dann nicht noch 3x ins Timeout laufen. + if (idx == 0) break + } + } } - resolve(call, JSObject().put("values", values)) + + resolve(call, JSObject() + .put("values", values) + .put("answered", answered) + .put("error", firstProblem)) } catch (e: Exception) { call.reject("snmpGet: ${e.message}") } @@ -718,18 +915,41 @@ class NetDiagScannerPlugin : Plugin() { try { val hops = JSArray() var deadStreak = 0 + var reachedTarget = false + for (ttl in 1..30) { - val hop = pingWithTtl(host, ttl) - hops.put(JSObject() - .put("ttl", ttl) - .put("ip", hop.ip) - .put("ms", hop.ms)) - if (hop.ip == host || hop.reachedTarget) break - // Nach 5 toten Hops in Folge abbrechen statt stur bis TTL 30 - deadStreak = if (hop.ip == "*") deadStreak + 1 else 0 + // Drei Proben je Hop wie bei traceroute/mtr: eine einzelne Probe + // ist bei ratenbegrenzten Routern reine Glückssache. + var ip = "*" + val times = ArrayList() + var reached = false + repeat(3) { + val h = pingWithTtl(host, ttl) + if (h.ip != "*" && ip == "*") ip = h.ip + if (h.ms != null) times.add(h.ms) + if (h.reachedTarget) reached = true + } + + val hop = JSObject().put("ttl", ttl).put("ip", ip) + if (times.isNotEmpty()) { + hop.put("ms", round1(times.average())) + hop.put("minMs", round1(times.minOrNull() ?: 0.0)) + } else { + // Kein Wert ist etwas anderes als 0 ms — sonst steht im + // Protokoll "0 ms" für einen Hop, der nie geantwortet hat. + hop.put("ms", null as Any?) + } + hop.put("antworten", times.size) + hops.put(hop) + + if (reached) { reachedTarget = true; break } + deadStreak = if (ip == "*") deadStreak + 1 else 0 if (deadStreak >= 5) break } - resolve(call, JSObject().put("hops", hops)) + + resolve(call, JSObject() + .put("hops", hops) + .put("reachedTarget", reachedTarget)) } catch (e: Exception) { call.reject("traceroute: ${e.message}") } @@ -747,42 +967,87 @@ class NetDiagScannerPlugin : Plugin() { val durationSec = call.getInt("durationSec") ?: 10 io.launch { try { - // Einfacher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle: - // Download = empfangene Bytes, Upload = gesendete Bytes je Sekunde. - val res = measureThroughput(host, port, durationSec) + // Harte Obergrenze: selbst wenn unten etwas klemmt, loest der Call auf. + // Vorher konnte read() unbegrenzt blockieren und das Werkzeug-Fenster + // blieb dauerhaft auf "Messung laeuft ...". + val res = withTimeout((durationSec * 1000L) + 15_000L) { + measureThroughput(host, port, durationSec) + } resolve(call, res) + } catch (e: TimeoutCancellationException) { + call.reject("Durchsatztest abgebrochen — Gegenstelle antwortet nicht") } catch (e: Exception) { call.reject("throughput: ${e.message}") } } } + /** + * Roher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle. + * + * ACHTUNG: Das ist KEIN iperf3. iperf3 erwartet zuerst ein 37-Byte-Cookie und + * einen laengenpraefigierten JSON-Parameterblock; hier werden schlicht Bytes + * geschrieben bzw. gelesen. Gegen einen echten iperf3-Server bricht die + * Verbindung deshalb ab. Umbau steht in ROADMAP_UMSETZUNG.md Phase 1. + * + * Jede Phase bekommt einen soTimeout — ohne den blockiert read()/write() + * unbegrenzt, wenn die Gegenstelle zwar annimmt, aber nichts tut. + */ private fun measureThroughput(host: String, port: Int, durationSec: Int): JSObject { val buf = ByteArray(64 * 1024) var downBytes = 0L var upBytes = 0L + var upSecs = 0.0 + var downSecs = 0.0 + val phaseMs = durationSec * 500L // je Haelfte der Gesamtdauer + // Upload-Phase Socket().use { s -> + s.soTimeout = SOCKET_IO_TIMEOUT_MS s.connect(InetSocketAddress(host, port), 3000) - val end = System.currentTimeMillis() + durationSec * 500L + val t0 = System.nanoTime() + val end = System.currentTimeMillis() + phaseMs val out = s.getOutputStream() - while (System.currentTimeMillis() < end) { - out.write(buf); upBytes += buf.size + try { + while (System.currentTimeMillis() < end) { + out.write(buf); upBytes += buf.size + } + } catch (_: Exception) { + // Gegenstelle bricht ab: gemessen wird, was bis dahin durchging } + // Ist-Zeit statt angenommener Haelfte — ein vorzeitiger Abbruch wuerde + // sonst als niedriger Durchsatz erscheinen statt als kurze Messung. + upSecs = (System.nanoTime() - t0) / 1_000_000_000.0 } + // Download-Phase Socket().use { s -> + s.soTimeout = SOCKET_IO_TIMEOUT_MS s.connect(InetSocketAddress(host, port), 3000) - val end = System.currentTimeMillis() + durationSec * 500L + val t0 = System.nanoTime() + val end = System.currentTimeMillis() + phaseMs val inp = s.getInputStream() - while (System.currentTimeMillis() < end) { - val n = inp.read(buf); if (n < 0) break; downBytes += n + try { + while (System.currentTimeMillis() < end) { + val n = inp.read(buf); if (n < 0) break; downBytes += n + } + } catch (_: Exception) { + // s.o. } + downSecs = (System.nanoTime() - t0) / 1_000_000_000.0 } - val secs = durationSec / 2.0 + + val down = if (downSecs > 0.1 && downBytes > 0) downBytes * 8.0 / 1_000_000.0 / downSecs else 0.0 + val up = if (upSecs > 0.1 && upBytes > 0) upBytes * 8.0 / 1_000_000.0 / upSecs else 0.0 + return JSObject() - .put("downMbps", round1(downBytes * 8.0 / 1_000_000.0 / secs)) - .put("upMbps", round1(upBytes * 8.0 / 1_000_000.0 / secs)) + .put("downMbps", round1(down)) + .put("upMbps", round1(up)) + .put("downBytes", downBytes) + .put("upBytes", upBytes) + // Ohne uebertragene Bytes gab es keine Messung — der Aufrufer darf + // daraus keine "0 Mbit/s"-Bewertung machen. + .put("measured", downBytes > 0 || upBytes > 0) } /* --------------------------------------------------------------------- */ @@ -799,13 +1064,23 @@ class NetDiagScannerPlugin : Plugin() { // Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service // gestartet werden, sonst kann Android den Prozess beenden. io.launch { - val end = System.currentTimeMillis() + durationSec * 1000L - while (System.currentTimeMillis() < end && run.active) { - val q = measurePing(host, 5) - run.samples++ - run.lossSum += q.getInteger("lossPct", 0) ?: 0 - run.avgSum += q.getDouble("avgMs") - run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs")) + // Funk/CPU wachhalten. Ersetzt den Foreground-Service NICHT — bei + // ausgeschaltetem Display kann Android den Lauf weiterhin einfrieren. + // Der richtige Umbau steht in ROADMAP_UMSETZUNG.md Phase 2. + val locks = acquireRadioLocks("stress", (durationSec * 1000L) + 60_000L) + try { + val end = System.currentTimeMillis() + durationSec * 1000L + while (System.currentTimeMillis() < end && run.active) { + val q = measurePing(host, 5) + run.samples++ + // lossPct ist seit der Ping-Korrektur eine Kommazahl — + // getInteger() haette hier still 0 geliefert. + run.lossSum += q.getDouble("lossPct") + run.avgSum += q.getDouble("avgMs") + run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs")) + } + } finally { + releaseRadioLocks(locks) } } resolve(call, JSObject().put("runId", runId)) @@ -825,9 +1100,9 @@ class NetDiagScannerPlugin : Plugin() { } private class StressRun(val host: String, val durationSec: Int) { - var active = true + @Volatile var active = true var samples = 0 - var lossSum = 0 + var lossSum = 0.0 var avgSum = 0.0 var maxMs = 0.0 } @@ -1398,15 +1673,36 @@ class NetDiagScannerPlugin : Plugin() { private fun intToIp(i: Int): String = "${i and 0xFF}.${i shr 8 and 0xFF}.${i shr 16 and 0xFF}.${i shr 24 and 0xFF}" + /** + * Erste brauchbare lokale IPv4-Adresse — oder "" wenn es keine gibt. + * + * Frueher stand hier `return "192.168.1.1"` als Fallback. Zusammen mit dem + * Praefix-Fallback /24 entstand daraus das Phantom-Netz 192.168.1.0/24: die + * App hat munter ein Netz gescannt, das gar nicht existierte, und den Wert + * anschliessend ins Protokoll geschrieben. Der dafuer vorgesehene Fehlerzweig + * in ipscan.ts ("Kein Netzbereich — WLAN/LAN nicht aktiv?") konnte deshalb nie + * greifen. Kein Netz zu haben ist ein Fehler und muss auch so gemeldet werden. + * + * Link-Local (169.254.x.x) wird uebersprungen: das ist eine Selbstvergabe + * mangels DHCP und taugt nicht als Scan-Grundlage. + */ private fun firstLocalIpv4(): String { - java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni -> - ni.inetAddresses.toList().forEach { addr -> - if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) { - return addr.hostAddress ?: "" + try { + java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni -> + if (!ni.isUp || ni.isLoopback) return@forEach + ni.inetAddresses.toList().forEach { addr -> + if (!addr.isLoopbackAddress && + !addr.isLinkLocalAddress && + addr is java.net.Inet4Address + ) { + return addr.hostAddress ?: "" + } } } + } catch (e: Exception) { + android.util.Log.w(TAG, "firstLocalIpv4: ${e.message}") } - return "192.168.1.1" + return "" } /** /proc/net/arp lesen -> Map IP -> MAC (kann auf neuen Android-Versionen leer sein) */ @@ -1428,23 +1724,87 @@ class NetDiagScannerPlugin : Plugin() { return map } - /** ping mit fester TTL -> (antwortende IP, Latenz in ms) */ - private data class Hop(val ip: String, val ms: Double, val reachedTarget: Boolean) + /** + * Ergebnis einer TTL-Sonde. + * + * @param ip antwortende IP oder "*" wenn niemand geantwortet hat + * @param ms Laufzeit in ms, oder null wenn nicht messbar + * @param reachedTarget true wenn das Ziel selbst geantwortet hat (Echo Reply) + */ + private data class Hop(val ip: String, val ms: Double?, val reachedTarget: Boolean) + /** + * ping mit fester TTL -> antwortender Hop. + * + * Frühere Fassung hatte zwei Fehler, die zusammen ganze Routen erfunden haben: + * + * 1. Fällt der Regex `From ([\d.]+)` durch (stummer Hop, oder ein Router, der + * mit Reverse-DNS antwortet: "From fritz.box (192.168.1.1)"), griff der + * Fallback `\((\d+\.\d+\.\d+\.\d+)\)`. Der trifft aber IMMER zuerst die + * Kopfzeile `PING 8.8.8.8 (8.8.8.8) 56 data bytes` — ein Hop, der gar nicht + * geantwortet hat, wurde damit als Ziel ausgegeben und der Trace beendet. + * Das ist die Ursache für Prod-Messung #13 ("2 Hops bis 8.8.8.8"). + * 2. Zwischen-Hops liefern grundsätzlich kein `time=` (das gibt es nur beim + * Echo Reply des Ziels) -> `ms` war strukturell 0.0. + * + * Jetzt: Kopfzeile wird verworfen, zeilenweise ausgewertet, die Laufzeit selbst + * gestoppt, und der Prozess bekommt eine harte Zeitgrenze (vorher konnte + * `waitFor()` unbegrenzt hängen). + */ private fun pingWithTtl(host: String, ttl: Int): Hop { + var proc: Process? = null return try { - val proc = ProcessBuilder("/system/bin/ping", "-c", "1", "-W", "2", "-t", ttl.toString(), host) - .redirectErrorStream(true).start() + val t0 = System.nanoTime() + proc = ProcessBuilder( + "/system/bin/ping", "-n", "-c", "1", "-W", "2", "-t", ttl.toString(), host, + ).redirectErrorStream(true).start() + val out = proc.inputStream.bufferedReader().readText() - proc.waitFor() - val ip = Regex("""From ([\d.]+)""").find(out)?.groupValues?.get(1) - ?: Regex("""\((\d+\.\d+\.\d+\.\d+)\)""").find(out)?.groupValues?.get(1) - ?: "*" - val ms = Regex("""time=([\d.]+)""").find(out)?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0 - val reached = out.contains("bytes from") - Hop(if (reached) host else ip, ms, reached) + if (!proc.waitFor(3, TimeUnit.SECONDS)) { + proc.destroyForcibly() + return Hop("*", null, false) + } + val elapsedMs = (System.nanoTime() - t0) / 1_000_000.0 + + var ip = "" + var reached = false + var reportedMs: Double? = null + + for (raw in out.lineSequence()) { + val line = raw.trim() + // Kopfzeile "PING 8.8.8.8 (8.8.8.8) 56(84) bytes of data." NIE auswerten + if (line.startsWith("PING ")) continue + + // Ziel selbst: "64 bytes from 8.8.8.8: icmp_seq=1 ttl=115 time=12.3 ms" + if (line.contains("bytes from")) { + reached = true + if (ip.isEmpty()) { + ip = Regex("""bytes from ([\d.]+)""").find(line)?.groupValues?.get(1) ?: "" + } + reportedMs = Regex("""time[=<]([\d.]+)""").find(line) + ?.groupValues?.get(1)?.toDoubleOrNull() + break + } + + // Zwischen-Hop: "From 192.168.1.1 icmp_seq=1 Time to live exceeded" + // oder mit Namen: "From fritz.box (192.168.1.1) icmp_seq=1 ..." + if (line.startsWith("From ")) { + val m = Regex("""From (?:\S+ )?\(?(\d+\.\d+\.\d+\.\d+)\)?""").find(line) + if (m != null && ip.isEmpty()) ip = m.groupValues[1] + } + } + + when { + // Echo Reply: bevorzugt die vom ping gemeldete Zeit, sonst die selbst gestoppte + reached -> Hop(if (ip.isNotEmpty()) ip else host, reportedMs ?: round1(elapsedMs), true) + // Antwortender Zwischen-Hop: ping meldet keine Zeit -> selbst gestoppte nutzen + ip.isNotEmpty() -> Hop(ip, round1(elapsedMs), false) + // Niemand hat geantwortet + else -> Hop("*", null, false) + } } catch (e: Exception) { - Hop("*", 0.0, false) + try { proc?.destroyForcibly() } catch (_: Exception) { } + Hop("*", null, false) } } @@ -1471,6 +1831,14 @@ class NetDiagScannerPlugin : Plugin() { private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0 companion object { + private const val TAG = "NetDiagScanner" + + /** Lese-/Schreib-Zeitgrenze auf Mess-Sockets. Ohne sie blockiert read() unbegrenzt. */ + private const val SOCKET_IO_TIMEOUT_MS = 4000 + + /** Proben, die vor einer Ping-Messung verworfen werden (Funkmodul aufwecken). */ + private const val PING_WARMUP_PROBES = 2 + /** * Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld * (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/Snmp.kt b/android/app/src/main/java/de/data_it_solution/netdiag/Snmp.kt index 3154dd4..61e66c2 100644 --- a/android/app/src/main/java/de/data_it_solution/netdiag/Snmp.kt +++ b/android/app/src/main/java/de/data_it_solution/netdiag/Snmp.kt @@ -4,34 +4,77 @@ import java.io.ByteArrayOutputStream import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress +import java.net.SocketTimeoutException /** * Minimaler SNMP-v2c-GET-Client. * * Reicht für das Auslesen einzelner OIDs (Link-Speed, Fehlerzähler) von * gemanagten Switches. Implementiert nur so viel BER-Kodierung wie nötig. + * + * WICHTIG — warum das Ergebnis ein eigener Typ ist und kein `String?`: + * Früher lieferte diese Klasse bei JEDEM Problem `null`; der Aufrufer machte + * daraus `"-"`, `parseInt("-") || 0` ergab `0` und das Werkzeug meldete + * „0 Fehler" in Grün — obwohl der Switch überhaupt nicht geantwortet hatte. + * Genau so steht es in den Prod-Messungen #95/#96. Ein nicht erreichbarer Switch + * darf nie wie ein fehlerfreier Switch aussehen, deshalb wird der Grund jetzt + * mitgeliefert und der Aufrufer muss ihn behandeln. */ object Snmp { + /** Ergebnis einer OID-Abfrage — Wert, Zeitüberschreitung oder Fehler mit Grund. */ + sealed class Result { + /** Der Agent hat geantwortet und einen Wert geliefert. */ + data class Value(val text: String) : Result() + + /** Keine Antwort in der vorgegebenen Zeit (kein Agent, Firewall, Gerät aus). */ + object Timeout : Result() + + /** Agent hat geantwortet, aber keinen brauchbaren Wert geliefert. */ + data class Failure(val reason: String) : Result() + } + /** * Eine OID per SNMP v2c GET abfragen. * - * @return Wert als String oder null bei Fehler/Timeout + * @param timeoutMs Wartezeit je Versuch + * @param attempts Anzahl Versuche (UDP darf ruhig einmal verloren gehen) */ - fun get(host: String, community: String, oid: String): String? { + fun get( + host: String, + community: String, + oid: String, + timeoutMs: Int = 1500, + attempts: Int = 2, + ): Result { + var last: Result = Result.Timeout + repeat(attempts) { + last = getOnce(host, community, oid, timeoutMs) + // Nur bei Zeitüberschreitung erneut versuchen — eine inhaltliche + // Absage (falsche OID, error-status) wiederholt sich sowieso. + if (last !is Result.Timeout) return last + } + return last + } + + private fun getOnce(host: String, community: String, oid: String, timeoutMs: Int): Result { + // Request-ID zufällig, damit eine verspätete Antwort auf eine frühere + // Abfrage nicht als Antwort auf diese hier durchgeht. + val requestId = (1..0x7FFFFFFE).random() return try { - val request = buildGetRequest(community, oid) - val socket = DatagramSocket() - socket.soTimeout = 2500 - socket.use { - it.send(DatagramPacket(request, request.size, InetAddress.getByName(host), 161)) + val request = buildGetRequest(community, oid, requestId) + DatagramSocket().use { sock -> + sock.soTimeout = timeoutMs + sock.send(DatagramPacket(request, request.size, InetAddress.getByName(host), 161)) val buf = ByteArray(2048) val resp = DatagramPacket(buf, buf.size) - it.receive(resp) - parseFirstValue(buf, resp.length) + sock.receive(resp) + parseResponse(buf, resp.length, requestId) } + } catch (e: SocketTimeoutException) { + Result.Timeout } catch (e: Exception) { - null + Result.Failure(e.message ?: "SNMP-Fehler") } } @@ -80,59 +123,143 @@ object Snmp { return tlv(0x06, out.toByteArray()) } - private fun buildGetRequest(community: String, oidStr: String): ByteArray { + private fun buildGetRequest(community: String, oidStr: String, requestId: Int): ByteArray { val varbind = tlv(0x30, oid(oidStr) + tlv(0x05, ByteArray(0))) // OID + NULL val varbindList = tlv(0x30, varbind) - val requestId = (System.currentTimeMillis() and 0x7FFF).toInt() val pdu = tlv( 0xA0, // GetRequest-PDU integer(requestId) + integer(0) + integer(0) + varbindList, ) - val message = tlv( + return tlv( 0x30, integer(1) + octetString(community) + pdu, // version 1 = SNMPv2c ) - return message } - /* ---- Antwort parsen: ersten Variablen-Wert herausziehen ---- */ + /* ---- Antwort parsen ---- */ - private fun parseFirstValue(buf: ByteArray, len: Int): String? { + /** Kleiner BER-Leser mit Bereichsprüfung. */ + private class Reader(val buf: ByteArray, val len: Int) { var i = 0 - // Durch die Struktur navigieren bis zum ersten primitiven Wert nach einer OID - var lastWasOid = false - while (i < len) { - val tag = buf[i].toInt() and 0xFF - i++ - if (i >= len) break - var l = buf[i].toInt() and 0xFF - i++ + + fun tag(): Int { + require(i < len) { "unerwartetes Paketende" } + return (buf[i++].toInt() and 0xFF) + } + + fun length(): Int { + require(i < len) { "unerwartetes Paketende" } + var l = buf[i++].toInt() and 0xFF if (l and 0x80 != 0) { val n = l and 0x7F + require(n in 1..4 && i + n <= len) { "ungueltige Laenge" } l = 0 - for (k in 0 until n) { l = (l shl 8) or (buf[i].toInt() and 0xFF); i++ } + for (k in 0 until n) l = (l shl 8) or (buf[i++].toInt() and 0xFF) } - // Konstruierte Typen (SEQUENCE, PDU) aufsteigen - if (tag == 0x30 || tag == 0xA2 || tag == 0xA0) continue - if (tag == 0x06) { lastWasOid = true; i += l; continue } - if (lastWasOid) { - return when (tag) { - 0x02, 0x41, 0x42, 0x43, 0x44, 0x46 -> { // INTEGER, Counter, Gauge, TimeTicks ... - var v = 0L - for (k in 0 until l) v = (v shl 8) or (buf[i + k].toLong() and 0xFF) - v.toString() - } - 0x04 -> String(buf, i, l) // OCTET STRING - 0x05 -> "" // NULL -> kein Wert - else -> { - val sb = StringBuilder() - for (k in 0 until l) sb.append("%02X".format(buf[i + k])) - sb.toString() - } - } - } - i += l + require(l >= 0 && i + l <= len) { "Laenge zeigt ueber das Paket hinaus" } + return l } - return null + + fun intValue(l: Int): Long { + var v = 0L + for (k in 0 until l) v = (v shl 8) or (buf[i + k].toLong() and 0xFF) + i += l + return v + } + + fun text(l: Int): String { + val s = String(buf, i, l) + i += l + return s + } + + fun hex(l: Int): String { + val sb = StringBuilder() + for (k in 0 until l) sb.append("%02X".format(buf[i + k])) + i += l + return sb.toString() + } + + fun ipv4(l: Int): String { + val s = (0 until l).joinToString(".") { (buf[i + it].toInt() and 0xFF).toString() } + i += l + return s + } + + fun skip(l: Int) { i += l } + } + + /** + * Die Antwortstruktur wird gezielt durchlaufen statt heuristisch nach dem + * ersten primitiven Wert gesucht — nur so lassen sich Request-ID und + * error-status überhaupt prüfen. + * + * SEQUENCE { version, community, GetResponse[0xA2] { req-id, err-status, + * err-index, SEQUENCE { SEQUENCE { OID, Wert } } } } + */ + private fun parseResponse(buf: ByteArray, len: Int, expectedRequestId: Int): Result { + return try { + val r = Reader(buf, len) + if (r.tag() != 0x30) return Result.Failure("kein SNMP-Paket") + r.length() + + if (r.tag() != 0x02) return Result.Failure("Version fehlt") + r.skip(r.length()) + if (r.tag() != 0x04) return Result.Failure("Community fehlt") + r.skip(r.length()) + + val pduTag = r.tag() + if (pduTag != 0xA2) return Result.Failure("keine GetResponse (Tag 0x%02X)".format(pduTag)) + r.length() + + if (r.tag() != 0x02) return Result.Failure("Request-ID fehlt") + val reqId = r.intValue(r.length()) + if (reqId != expectedRequestId.toLong()) { + return Result.Failure("Antwort gehoert zu einer anderen Abfrage") + } + + if (r.tag() != 0x02) return Result.Failure("error-status fehlt") + val errStatus = r.intValue(r.length()) + if (errStatus != 0L) return Result.Failure(errorStatusText(errStatus.toInt())) + + if (r.tag() != 0x02) return Result.Failure("error-index fehlt") + r.skip(r.length()) + + if (r.tag() != 0x30) return Result.Failure("Variablenliste fehlt") + r.length() + if (r.tag() != 0x30) return Result.Failure("Variable fehlt") + r.length() + if (r.tag() != 0x06) return Result.Failure("OID fehlt") + r.skip(r.length()) + + val valueTag = r.tag() + val valueLen = r.length() + when (valueTag) { + // INTEGER, Counter32, Gauge32, TimeTicks, Counter64 + 0x02, 0x41, 0x42, 0x43, 0x46 -> Result.Value(r.intValue(valueLen).toString()) + 0x04 -> Result.Value(r.text(valueLen)) // OCTET STRING + 0x40 -> Result.Value(r.ipv4(valueLen)) // IpAddress + 0x05 -> Result.Failure("Agent liefert keinen Wert (NULL)") + // v2c-Ausnahmen: der Agent kennt die OID nicht. Fruehler kam hier + // ein Leerstring heraus, der als 0 weiterverarbeitet wurde. + 0x80 -> Result.Failure("OID auf diesem Geraet nicht vorhanden") + 0x81 -> Result.Failure("OID-Instanz nicht vorhanden (Port-Index falsch?)") + 0x82 -> Result.Failure("Ende der MIB erreicht") + else -> Result.Value(r.hex(valueLen)) + } + } catch (e: Exception) { + Result.Failure("Antwort nicht lesbar: ${e.message}") + } + } + + /** error-status aus RFC 3416 in Klartext. */ + private fun errorStatusText(code: Int): String = when (code) { + 1 -> "Antwort zu gross (tooBig)" + 2 -> "OID unbekannt (noSuchName)" + 3 -> "Wert passt nicht (badValue)" + 4 -> "nur lesbar (readOnly)" + 5 -> "allgemeiner Agent-Fehler (genErr)" + 6 -> "Zugriff verweigert — Community pruefen (noAccess)" + else -> "Agent meldet Fehler $code" } } diff --git a/src/app.css b/src/app.css index d24a712..72f7d78 100644 --- a/src/app.css +++ b/src/app.css @@ -36,3 +36,10 @@ body { .ampel-fail { color: #f85149; } +/* + * „Nicht messbar" — bewusst neutral-grau statt rot. Der Test konnte nicht + * durchgeführt werden; das ist keine Aussage über das Kundennetz. + */ +.ampel-unmess { + color: #8b949e; +} diff --git a/src/lib/components/DeviceCard.svelte b/src/lib/components/DeviceCard.svelte index 2d44c5f..a1c64b2 100644 --- a/src/lib/components/DeviceCard.svelte +++ b/src/lib/components/DeviceCard.svelte @@ -29,8 +29,9 @@ onrename?: () => void; } = $props(); - const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail']; - const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500']; + // Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar) + const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess']; + const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500']; /** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */ const title = $derived( diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 1782b13..7a1d1e2 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -65,11 +65,18 @@ export interface OpenPort { export interface PingQuality { sent: number; received: number; + /** Verlust in Prozent (Kommazahl — 1 von 20 Paketen = 5,0) */ lossPct: number; minMs: number; avgMs: number; + /** Median der Laufzeiten — robuster gegen einzelne Ausreisser als avgMs */ + medianMs: number; maxMs: number; jitterMs: number; + /** avg - min: Anteil, der vom Messverfahren stammt und nicht vom Netz */ + messaufschlagMs: number; + /** Klartext des benutzten Messverfahrens (fuer das Protokoll) */ + verfahren: string; } export interface WifiNetwork { ssid: string; @@ -90,20 +97,44 @@ export interface DhcpLease { } export interface TracerouteHop { ttl: number; + /** antwortende IP oder '*' wenn niemand geantwortet hat */ ip: string; - ms: number; + /** Mittelwert der Proben in ms, null wenn keine Antwort kam (NICHT 0) */ + ms: number | null; + minMs?: number; + /** wie viele der 3 Proben geantwortet haben */ + antworten?: number; } export interface ThroughputResult { downMbps: number; upMbps: number; + downBytes?: number; + upBytes?: number; + /** false = es wurde kein einziges Byte uebertragen, also keine Messung */ + measured?: boolean; } /** Schnittstelle des nativen Plugins */ export interface NetDiagScannerPlugin { /** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */ - getLocalSubnet(): Promise<{ subnet: string; ip: string; gateway: string }>; + getLocalSubnet(): Promise<{ + subnet: string; + ip: string; + gateway: string; + prefix?: number; + /** 'adapter' = vom System gemeldet, 'angenommen' = /24 als Rueckfallwert */ + prefixQuelle?: string; + }>; /** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */ - ipScan(opts: { subnet: string }): Promise<{ devices: ScannedDevice[] }>; + ipScan(opts: { subnet: string }): Promise<{ + devices: ScannedDevice[]; + /** wie viele Adressen geprueft wurden */ + probed?: number; + /** wie viele davon geantwortet haben */ + answered?: number; + /** war die ARP-Tabelle lesbar? (ab Android 10 meist nicht) */ + arpAvailable?: boolean; + }>; /** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */ mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>; /** IP-Konflikt-Prüfung: findet IP-Adressen, die zwei Geräte gleichzeitig benutzen */ @@ -123,9 +154,17 @@ export interface NetDiagScannerPlugin { /** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */ snmpGet(opts: { host: string; community: string; oids: string[] }): Promise<{ values: Record; + /** false = der Switch hat ueberhaupt nicht geantwortet */ + answered: boolean; + /** Klartextgrund, wenn etwas nicht gelesen werden konnte */ + error: string; }>; /** Traceroute zu einem Host */ - traceroute(opts: { host: string }): Promise<{ hops: TracerouteHop[] }>; + traceroute(opts: { host: string }): Promise<{ + hops: TracerouteHop[]; + /** hat das Ziel selbst geantwortet? */ + reachedTarget: boolean; + }>; /** Durchsatztest gegen eine TCP-Sink/Source-Gegenstelle (KEIN iperf3-Protokoll) */ throughput(opts: { host: string; port: number; durationSec: number }): Promise; /** Dauer-/Stresstest starten (läuft NICHT im Foreground-Service, s. stresstest.ts) */ @@ -197,7 +236,13 @@ let mockWifiTrackRssi = -55; const mock: NetDiagScannerPlugin = { async getLocalSubnet() { - return { subnet: '192.168.1.0/24', ip: '192.168.1.50', gateway: '192.168.1.1' }; + return { + subnet: '192.168.1.0/24', + ip: '192.168.1.50', + gateway: '192.168.1.1', + prefix: 24, + prefixQuelle: 'adapter', + }; }, async ipScan() { return { @@ -274,14 +319,19 @@ const mock: NetDiagScannerPlugin = { async pingQuality(opts) { const sent = opts.count; const received = sent - (Math.random() < 0.3 ? 1 : 0); + const min = rnd(1, 4); + const avg = rnd(4, 12); return { sent, received, - lossPct: Math.round(((sent - received) / sent) * 100), - minMs: rnd(1, 4), - avgMs: rnd(4, 12), + lossPct: Math.round(((sent - received) / sent) * 1000) / 10, + minMs: min, + avgMs: avg, + medianMs: rnd(min, avg), maxMs: rnd(12, 40), jitterMs: rnd(0.5, 5), + messaufschlagMs: Math.round((avg - min) * 10) / 10, + verfahren: 'Mock (Browser-Entwicklung)', }; }, async wifiScan() { @@ -304,19 +354,26 @@ const mock: NetDiagScannerPlugin = { async snmpGet(opts) { const values: Record = {}; for (const oid of opts.oids) values[oid] = String(Math.floor(Math.random() * 1000)); - return { values }; + return { values, answered: true, error: '' }; }, async traceroute() { return { hops: [ - { ttl: 1, ip: '192.168.1.1', ms: rnd(1, 3) }, - { ttl: 2, ip: '10.0.0.1', ms: rnd(8, 15) }, - { ttl: 3, ip: '8.8.8.8', ms: rnd(15, 30) }, + { ttl: 1, ip: '192.168.1.1', ms: rnd(1, 3), antworten: 3 }, + { ttl: 2, ip: '*', ms: null, antworten: 0 }, + { ttl: 3, ip: '8.8.8.8', ms: rnd(15, 30), antworten: 3 }, ], + reachedTarget: true, }; }, async throughput() { - return { downMbps: rnd(80, 940), upMbps: rnd(40, 500) }; + return { + downMbps: rnd(80, 940), + upMbps: rnd(40, 500), + downBytes: 12_000_000, + upBytes: 6_000_000, + measured: true, + }; }, async startStressTest() { return { runId: 'mock-run' }; diff --git a/src/lib/tools/internet/iperf.ts b/src/lib/tools/internet/iperf.ts index 614829c..aaf47bd 100644 --- a/src/lib/tools/internet/iperf.ts +++ b/src/lib/tools/internet/iperf.ts @@ -42,6 +42,23 @@ export const iperfTool: Tool = { const res = await scanner.throughput({ host, port, durationSec }); + // Kein einziges Byte übertragen = es gab keine Messung. Daraus „0 Mbit/s" + // in Rot zu machen wäre eine Aussage über das Kundennetz, die die Messung + // gar nicht hergibt — meist fehlt schlicht die Gegenstelle. + if (res.measured === false) { + return { + label: `${host}:${port} — keine Gegenstelle`, + result: { + gegenstelle: `${host}:${port}`, + hinweis: + 'Es wurden keine Daten übertragen. Der Test braucht eine Gegenstelle, ' + + 'die auf diesem Port Daten annimmt und sendet. Ein echter iperf3-Server ' + + 'funktioniert hier NICHT (siehe Modul-Doku).', + }, + measureStatus: 3, // nicht messbar + }; + } + let status: MeasureStatus = 0; if (res.downMbps < 100) status = 1; if (res.downMbps < 10) status = 2; diff --git a/src/lib/tools/netzwerk/ping.ts b/src/lib/tools/netzwerk/ping.ts index e448cff..f6c0863 100644 --- a/src/lib/tools/netzwerk/ping.ts +++ b/src/lib/tools/netzwerk/ping.ts @@ -23,22 +23,46 @@ export const pingTool: Tool = { const q = await scanner.pingQuality({ host, count }); - // Bewertung: Verlust und Jitter + // Gar keine Antwort: das Ziel ist nicht erreichbar. Latenzwerte gibt es + // dann nicht — 0 ms auszugeben wäre irreführend. + if (q.received === 0) { + return { + label: `${host}: keine Antwort`, + result: { + host, + gesendet: q.sent, + empfangen: 0, + verlustProzent: 100, + hinweis: 'Ziel antwortet nicht. Falsche Adresse, Gerät aus oder Firewall?', + }, + measureStatus: 2, + }; + } + + // Bewertung auf den Median statt den Mittelwert: ein einzelner Ausreißer + // (WLAN-Aufwachen, kurzer Funkstörer) verschiebt den Mittelwert stark, der + // Median bleibt bei dem, was das Netz üblicherweise liefert. + const leitwert = q.medianMs ?? q.avgMs; let status: MeasureStatus = 0; if (q.lossPct > 0 || q.jitterMs > 10) status = 1; - if (q.lossPct >= 10 || q.avgMs > 100) status = 2; + if (q.lossPct >= 10 || leitwert > 100) status = 2; return { - label: `${host}: ${q.avgMs} ms ø, ${q.lossPct}% Verlust`, + label: `${host}: ${leitwert} ms (Median), ${q.lossPct}% Verlust`, result: { host, gesendet: q.sent, empfangen: q.received, verlustProzent: q.lossPct, minMs: q.minMs, + medianMs: q.medianMs, avgMs: q.avgMs, maxMs: q.maxMs, jitterMs: q.jitterMs, + // Macht sichtbar, wie viel Messverfahren im Wert steckt: ein großer + // Abstand zwischen Mittelwert und Minimum kommt vom Messweg, nicht vom Netz. + messaufschlagMs: q.messaufschlagMs, + verfahren: q.verfahren, }, measureStatus: status, }; diff --git a/src/lib/tools/netzwerk/snmp.ts b/src/lib/tools/netzwerk/snmp.ts index 0965306..ab48e0d 100644 --- a/src/lib/tools/netzwerk/snmp.ts +++ b/src/lib/tools/netzwerk/snmp.ts @@ -32,26 +32,81 @@ export const snmpTool: Tool = { if (!host) throw new Error('Keine Switch-IP angegeben'); const community = String(ctx.params.community || 'public'); - const { values } = await scanner.snmpGet({ host, community, oids: Object.values(OIDS) }); + const { values, answered, error } = await scanner.snmpGet({ + host, + community, + oids: Object.values(OIDS), + }); - // OID-Werte den lesbaren Namen zuordnen - const named: Record = {}; - for (const [name, oid] of Object.entries(OIDS)) named[name] = values[oid] ?? '-'; + // Der Switch hat überhaupt nicht geantwortet -> keine Messung. + // Früher wurde daraus über parseInt('-') || 0 ein grünes „0 Fehler" + // (siehe Prod-Messungen #95/#96) — ein Totalausfall sah damit aus wie ein + // fehlerfreier Switch. + if (!answered) { + return { + label: `${host}: keine SNMP-Antwort`, + result: { + host, + hinweis: + error || + 'Keine Antwort auf Port 161. Mögliche Ursachen: SNMP am Gerät nicht ' + + 'aktiviert, andere Community, SNMPv3 statt v2c, oder Firewall.', + }, + measureStatus: 3, // nicht messbar — keine Aussage über das Netz + }; + } - const inErr = parseInt(named['ifInErrors'], 10) || 0; - const outErr = parseInt(named['ifOutErrors'], 10) || 0; + // Zahlenwerte nur übernehmen, wenn sie wirklich geliefert wurden + const num = (key: string): number | null => { + const raw = values[OIDS[key]]; + if (raw === undefined || raw === null || raw === '') return null; + const n = Number.parseInt(raw, 10); + return Number.isNaN(n) ? null : n; + }; + const text = (key: string): string | null => { + const raw = values[OIDS[key]]; + return raw === undefined || raw === '' ? null : raw; + }; + + const inErr = num('ifInErrors'); + const outErr = num('ifOutErrors'); + + // Agent erreichbar, aber die Fehlerzähler gibt es nicht (falscher Port-Index, + // eingeschränkte MIB-Sicht). Auch das ist „nicht messbar", nicht „0 Fehler". + if (inErr === null && outErr === null) { + return { + label: `${host}: Fehlerzähler nicht lesbar`, + result: { + host, + port: text('ifDescr'), + linkSpeed: text('ifSpeed'), + hinweis: + error || + 'Der Switch antwortet, liefert für diesen Port-Index aber keine ' + + 'Fehlerzähler. Standardmäßig wird Interface-Index 1 abgefragt — das ist ' + + 'oft nicht der gesuchte Port.', + }, + measureStatus: 3, + }; + } + + const sum = (inErr ?? 0) + (outErr ?? 0); let status: MeasureStatus = 0; - if (inErr + outErr > 0) status = 1; - if (inErr + outErr > 100) status = 2; + if (sum > 0) status = 1; + if (sum > 100) status = 2; + const speedBits = num('ifSpeed'); return { - label: `${host}: ${inErr + outErr} Fehler`, + label: `${host}: ${sum} Fehler`, result: { host, - port: named['ifDescr'], - linkSpeed: named['ifSpeed'], + port: text('ifDescr'), + linkSpeed: speedBits !== null ? `${Math.round(speedBits / 1_000_000)} Mbit/s` : null, eingangsFehler: inErr, ausgangsFehler: outErr, + hinweis: + 'Absolute Zähler seit dem letzten Neustart des Switches — sie sagen nichts ' + + 'darüber, ob die Fehler heute oder vor zwei Jahren aufgetreten sind.', }, measureStatus: status, }; diff --git a/src/lib/tools/netzwerk/stresstest.ts b/src/lib/tools/netzwerk/stresstest.ts index 5351197..8cbbff0 100644 --- a/src/lib/tools/netzwerk/stresstest.ts +++ b/src/lib/tools/netzwerk/stresstest.ts @@ -23,7 +23,16 @@ export const stressTestTool: Tool = { description: 'Langzeitmessung: Paketverlust und Latenz über einen Zeitraum.', scope: 'protocol', params: [ - { key: 'host', label: 'Ziel', type: 'text', default: '192.168.1.1' }, + // KEIN fester Vorgabewert mehr. Die alte Vorgabe 192.168.1.1 hat im + // Kundennetz 192.168.178.0/24 fünf Minuten lang ins Leere gemessen und das + // Ergebnis als „100 % Verlust" rot ins Protokoll geschrieben (Prod #126). + // Leer lassen -> es wird das Gateway des aktiven Adapters genommen. + { + key: 'host', + label: 'Ziel — leer = Gateway des aktiven Netzes', + type: 'text', + placeholder: 'leer lassen → Gateway', + }, { key: 'duration', label: 'Dauer', @@ -38,9 +47,47 @@ export const stressTestTool: Tool = { }, ], async run(ctx) { - const host = String(ctx.params.host || '192.168.1.1'); + let host = String(ctx.params.host ?? '').trim(); const durationSec = Number(ctx.params.duration || 300); + // Kein Ziel angegeben -> Gateway des aktiven Adapters + if (!host) { + try { + host = String((await scanner.getLocalSubnet()).gateway ?? '').trim(); + } catch { + /* unten abgefangen */ + } + } + if (!host) { + return { + label: 'Kein Ziel — WLAN/LAN nicht aktiv?', + result: { + hinweis: + 'Es konnte kein Gateway ermittelt werden. Bitte ein Ziel angeben ' + + '(z.B. den Router des Kundennetzes).', + }, + measureStatus: 3, + }; + } + + // Vorlaufprobe: antwortet das Ziel überhaupt? Sonst misst man minuten- bis + // stundenlang gegen etwas, das gar nicht da ist, und bekommt am Ende + // „100 % Verlust" — was wie ein kaputtes Kundennetz aussieht. + const probe = await scanner.pingQuality({ host, count: 3 }); + if (probe.received === 0) { + return { + label: `${host} antwortet nicht — Test nicht gestartet`, + result: { + host, + hinweis: + `${host} hat auf 3 Vorabproben nicht geantwortet. Ein Dauertest gegen ` + + 'ein nicht erreichbares Ziel liefert nur 100 % Verlust und sagt nichts ' + + 'über das Netz aus. Ziel prüfen und erneut starten.', + }, + measureStatus: 3, + }; + } + const { runId } = await scanner.startStressTest({ host, durationSec }); // Auf das Ende des Laufs warten. Der Timer läuft in der WebView und wird von // Chromium gedrosselt, sobald die Seite in den Hintergrund gerät — das Ergebnis diff --git a/src/lib/tools/netzwerk/traceroute.ts b/src/lib/tools/netzwerk/traceroute.ts index b7c0708..5d264ed 100644 --- a/src/lib/tools/netzwerk/traceroute.ts +++ b/src/lib/tools/netzwerk/traceroute.ts @@ -3,7 +3,7 @@ */ import { scanner } from '../../scanner'; -import type { Tool } from '../types'; +import type { MeasureStatus, Tool } from '../types'; export const tracerouteTool: Tool = { id: 'traceroute', @@ -15,14 +15,36 @@ export const tracerouteTool: Tool = { params: [{ key: 'host', label: 'Ziel', type: 'text', default: '8.8.8.8' }], async run(ctx) { const host = String(ctx.params.host || '8.8.8.8'); - const { hops } = await scanner.traceroute({ host }); + const { hops, reachedTarget } = await scanner.traceroute({ host }); + + const antwortend = hops.filter((h) => h.ip !== '*').length; + + // Bewertung an „Ziel erreicht" hängen, nicht an „Liste nicht leer". + // Vorher war jede Ausgabe grün — auch eine Liste aus lauter Sternchen. + let status: MeasureStatus = 0; + if (!reachedTarget) status = 2; + if (antwortend === 0) status = 3; // gar keine Antwort -> nicht messbar + return { - label: `${hops.length} Hops bis ${host}`, + label: reachedTarget + ? `${hops.length} Hops bis ${host}` + : antwortend === 0 + ? `${host}: keine Antwort auf dem Weg` + : `${host} nicht erreicht (${antwortend} Hops geantwortet)`, result: { ziel: host, - hops: hops.map((h) => `${h.ttl}. ${h.ip} (${h.ms} ms)`), + zielErreicht: reachedTarget, + hops: hops.map((h) => + h.ip === '*' + ? `${h.ttl}. * (keine Antwort)` + : `${h.ttl}. ${h.ip} (${h.ms === null || h.ms === undefined ? '—' : h.ms + ' ms'})`, + ), + hinweis: reachedTarget + ? undefined + : 'Viele Router antworten auf abgelaufene TTL grundsätzlich nicht — ' + + 'Sternchen bedeuten nicht zwingend eine Störung.', }, - measureStatus: hops.length > 0 ? 0 : 2, + measureStatus: status, }; }, }; diff --git a/src/lib/types.ts b/src/lib/types.ts index ecb1eb8..fc48705 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -163,7 +163,17 @@ export interface WifiTrackSession { } /** Ampel-Bewertung einer Messung */ -export type MeasureStatus = 0 | 1 | 2; // 0=ok, 1=warn, 2=fail +/** + * Bewertung einer Messung. + * + * 3 ist bewusst kein „schlechter als rot", sondern eine eigene Aussage: + * die Messung konnte gar nicht durchgeführt werden (Switch antwortet nicht + * auf SNMP, kein Netz, Gegenstelle fehlt). Ohne diesen Zustand wird ein + * fehlgeschlagener Test entweder als „alles gut" (grün, weil 0 Fehler + * gezählt wurden) oder als Netzfehler (rot) protokolliert — beides ist + * gegenüber dem Kunden falsch. + */ +export type MeasureStatus = 0 | 1 | 2 | 3; // 0=ok, 1=warn, 2=fail, 3=nicht messbar /** Ergebnis eines Tool-Laufs */ export interface Measurement { diff --git a/src/routes/protokoll/[id]/+page.svelte b/src/routes/protokoll/[id]/+page.svelte index eea0e88..48e0695 100644 --- a/src/routes/protokoll/[id]/+page.svelte +++ b/src/routes/protokoll/[id]/+page.svelte @@ -49,8 +49,9 @@ ), ); - const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail']; - const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500']; + // Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar) + const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess']; + const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500']; onMount(async () => { const uuid = $page.params.id ?? '';