Compare commits

..

No commits in common. "6330c78d1b54cdcc48f989f7416c0c51a8e35bda" and "780115c67e77534f49c73a0764aab5a07d2e58d3" have entirely different histories.

19 changed files with 1986 additions and 985 deletions

View file

@ -24,7 +24,7 @@ Vite-Proxy an den Dolibarr-Testserver (`192.168.155.11`, siehe `vite.config.ts`)
```bash ```bash
npm run build npm run build
npx cap add android # einmalig npx cap add android # einmalig
# android/ liegt komplett im Repo — cap sync kopiert nur die Web-Assets # native-plugin/ einbinden -> siehe native-plugin/README.md
npx cap sync android npx cap sync android
npx cap open android npx cap open android
``` ```
@ -45,13 +45,12 @@ src/lib/
updater.ts APK-Auto-Update-Prüfung (KB #363) updater.ts APK-Auto-Update-Prüfung (KB #363)
tools/ erweiterbare Tool-Plattform tools/ erweiterbare Tool-Plattform
index.ts Registry — neues Tool hier eintragen index.ts Registry — neues Tool hier eintragen
netzwerk/ IP-Scan, Port, Ping, IP-Konflikt, SNMP, Traceroute, Stress netzwerk/ IP-Scan, Port, Ping, WLAN, DHCP, SNMP, Traceroute, Stress
internet/ Durchsatz-Test internet/ Durchsatz-Test
telefonie/ (folgt: SIP, FreePBX, RTP) telefonie/ (folgt: SIP, FreePBX, RTP)
src/routes/ src/routes/
login/ auftraege/ kunden/ protokoll/[id]/ einstellungen/ login/ auftraege/ kunden/ protokoll/[id]/ einstellungen/
android/ natives Android-Projekt inkl. Kotlin-Plugin (einzige Quelle) native-plugin/ Kotlin-Plugin NetDiagScanner (+ Integrationsanleitung)
native-plugin/ nur noch Anleitung/Hinweise zum Plugin
``` ```
## Neues Tool hinzufügen ## Neues Tool hinzufügen

View file

@ -51,14 +51,6 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<!--
WAKE_LOCK ist Pflicht fuer WifiManager.WifiLock.acquire() und den
PARTIAL_WAKE_LOCK waehrend einer Messung. Ohne dieses Recht wirft
acquire() eine SecurityException — die Sperren waren damit wirkungslos,
das WLAN ging zwischen den Proben schlafen und die gemessenen Latenzen
enthielten die Aufwachzeit statt der Netzlaufzeit.
-->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Geräte-Monitor: Vordergrund-Dienst + Ausfall-Benachrichtigungen --> <!-- Geräte-Monitor: Vordergrund-Dienst + Ausfall-Benachrichtigungen -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

View file

@ -30,10 +30,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.io.BufferedReader import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
@ -74,86 +72,6 @@ class NetDiagScannerPlugin : Plugin() {
private val io = CoroutineScope(Dispatchers.IO) private val io = CoroutineScope(Dispatchers.IO)
private val stressRuns = ConcurrentHashMap<String, StressRun>() private val stressRuns = ConcurrentHashMap<String, StressRun>()
/* --------------------------------------------------------------------- */
/* 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 */ /* Subnetz / lokale Netzwerkinfo */
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
@ -192,12 +110,7 @@ class NetDiagScannerPlugin : Plugin() {
} }
// 2. Fallback: WLAN-DHCP-Info (ältere Geräte / LinkProperties leer) // 2. Fallback: WLAN-DHCP-Info (ältere Geräte / LinkProperties leer)
// NUR wenn das aktive Netz auch wirklich WLAN ist. Sonst wuerde if (ip.isEmpty() || prefix == 0) {
// 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 val wifi = context.applicationContext
.getSystemService(Context.WIFI_SERVICE) as WifiManager .getSystemService(Context.WIFI_SERVICE) as WifiManager
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
@ -208,31 +121,18 @@ class NetDiagScannerPlugin : Plugin() {
} }
} }
// 3. Letzter Fallback: irgendein aktives Interface // 3. Letzter Fallback
var prefixQuelle = if (prefix in 1..32) "adapter" else ""
if (ip.isEmpty()) ip = firstLocalIpv4() if (ip.isEmpty()) ip = firstLocalIpv4()
if (prefix !in 1..32) prefix = 24
// Kein Netz = Fehler. Frueher wurde hier "192.168.1.1" erfunden und // Netzadresse aus IP + Präfix berechnen
// mit /24 zu einem Netz zusammengebaut, das es nirgends gab. val ipInt = ipv4ToInt(ip) ?: 0
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 mask = if (prefix == 0) 0 else (-1 shl (32 - prefix))
val network = intToIpv4(ipInt and mask) val network = intToIpv4(ipInt and mask)
resolve(call, JSObject() resolve(call, JSObject()
.put("subnet", "$network/$prefix") .put("subnet", "$network/$prefix")
.put("ip", ip) .put("ip", ip)
.put("prefix", prefix)
.put("prefixQuelle", prefixQuelle)
.put("gateway", gateway)) .put("gateway", gateway))
} catch (e: Exception) { } catch (e: Exception) {
call.reject("getLocalSubnet: ${e.message}") call.reject("getLocalSubnet: ${e.message}")
@ -252,9 +152,6 @@ class NetDiagScannerPlugin : Plugin() {
return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet") return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet")
} }
io.launch { 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 { try {
// Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau, // Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau,
// also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …). // also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …).
@ -299,17 +196,9 @@ class NetDiagScannerPlugin : Plugin() {
if (type.isNotEmpty()) dev.put("deviceType", type) if (type.isNotEmpty()) dev.put("deviceType", type)
devices.put(dev) devices.put(dev)
} }
resolve(call, JSObject() resolve(call, JSObject().put("devices", devices))
.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) { } catch (e: Exception) {
call.reject("ipScan: ${e.message}") call.reject("ipScan: ${e.message}")
} finally {
releaseRadioLocks(locks)
} }
} }
} }
@ -529,25 +418,10 @@ class NetDiagScannerPlugin : Plugin() {
* Ohne Praefix wird /24 angenommen. Netz- und Broadcast-Adresse sind * Ohne Praefix wird /24 angenommen. Netz- und Broadcast-Adresse sind
* ausgenommen (ausser /31, /32). Leer bei ungueltig oder > /16. * 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<Int> { private fun hostsInSubnet(cidr: String): List<Int> {
val parts = cidr.trim().split('/') val parts = cidr.trim().split('/')
val ipInt = ipv4ToInt(parts[0].trim()) ?: return emptyList() val ipInt = ipv4ToInt(parts[0].trim()) ?: return emptyList()
val prefix = if (parts.size > 1) { val prefix = if (parts.size > 1) (parts[1].trim().toIntOrNull() ?: 24) else 24
// Nicht interpretierbar -> ablehnen statt raten
parts[1].trim().toIntOrNull() ?: return emptyList()
} else {
24
}
if (prefix < 0 || prefix > 32) return emptyList() if (prefix < 0 || prefix > 32) return emptyList()
val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix)) val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix))
val network = ipInt and mask val network = ipInt and mask
@ -701,74 +575,29 @@ 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 { private fun measurePing(host: String, count: Int): JSObject {
val locks = acquireRadioLocks("ping", (count * 1500L) + 10_000L) val times = ArrayList<Double>()
try { val addr = InetAddress.getByName(host)
val times = ArrayList<Double>() repeat(count) {
val addr = InetAddress.getByName(host) val t0 = System.nanoTime()
if (addr.isReachable(1000)) {
// Aufwaermproben: Ergebnis wird verworfen, sie wecken nur das Funkmodul times.add((System.nanoTime() - t0) / 1_000_000.0)
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))
} }
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
@ -868,36 +697,10 @@ class NetDiagScannerPlugin : Plugin() {
io.launch { io.launch {
try { try {
val values = JSObject() val values = JSObject()
var answered = false for (oid in oids) {
var firstProblem = "" values.put(oid, Snmp.get(host, community, oid) ?: "-")
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) { } catch (e: Exception) {
call.reject("snmpGet: ${e.message}") call.reject("snmpGet: ${e.message}")
} }
@ -915,41 +718,18 @@ class NetDiagScannerPlugin : Plugin() {
try { try {
val hops = JSArray() val hops = JSArray()
var deadStreak = 0 var deadStreak = 0
var reachedTarget = false
for (ttl in 1..30) { for (ttl in 1..30) {
// Drei Proben je Hop wie bei traceroute/mtr: eine einzelne Probe val hop = pingWithTtl(host, ttl)
// ist bei ratenbegrenzten Routern reine Glückssache. hops.put(JSObject()
var ip = "*" .put("ttl", ttl)
val times = ArrayList<Double>() .put("ip", hop.ip)
var reached = false .put("ms", hop.ms))
repeat(3) { if (hop.ip == host || hop.reachedTarget) break
val h = pingWithTtl(host, ttl) // Nach 5 toten Hops in Folge abbrechen statt stur bis TTL 30
if (h.ip != "*" && ip == "*") ip = h.ip deadStreak = if (hop.ip == "*") deadStreak + 1 else 0
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 if (deadStreak >= 5) break
} }
resolve(call, JSObject().put("hops", hops))
resolve(call, JSObject()
.put("hops", hops)
.put("reachedTarget", reachedTarget))
} catch (e: Exception) { } catch (e: Exception) {
call.reject("traceroute: ${e.message}") call.reject("traceroute: ${e.message}")
} }
@ -967,87 +747,42 @@ class NetDiagScannerPlugin : Plugin() {
val durationSec = call.getInt("durationSec") ?: 10 val durationSec = call.getInt("durationSec") ?: 10
io.launch { io.launch {
try { try {
// Harte Obergrenze: selbst wenn unten etwas klemmt, loest der Call auf. // Einfacher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle:
// Vorher konnte read() unbegrenzt blockieren und das Werkzeug-Fenster // Download = empfangene Bytes, Upload = gesendete Bytes je Sekunde.
// blieb dauerhaft auf "Messung laeuft ...". val res = measureThroughput(host, port, durationSec)
val res = withTimeout((durationSec * 1000L) + 15_000L) {
measureThroughput(host, port, durationSec)
}
resolve(call, res) resolve(call, res)
} catch (e: TimeoutCancellationException) {
call.reject("Durchsatztest abgebrochen — Gegenstelle antwortet nicht")
} catch (e: Exception) { } catch (e: Exception) {
call.reject("throughput: ${e.message}") 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 { private fun measureThroughput(host: String, port: Int, durationSec: Int): JSObject {
val buf = ByteArray(64 * 1024) val buf = ByteArray(64 * 1024)
var downBytes = 0L var downBytes = 0L
var upBytes = 0L var upBytes = 0L
var upSecs = 0.0
var downSecs = 0.0
val phaseMs = durationSec * 500L // je Haelfte der Gesamtdauer
// Upload-Phase // Upload-Phase
Socket().use { s -> Socket().use { s ->
s.soTimeout = SOCKET_IO_TIMEOUT_MS
s.connect(InetSocketAddress(host, port), 3000) s.connect(InetSocketAddress(host, port), 3000)
val t0 = System.nanoTime() val end = System.currentTimeMillis() + durationSec * 500L
val end = System.currentTimeMillis() + phaseMs
val out = s.getOutputStream() val out = s.getOutputStream()
try { while (System.currentTimeMillis() < end) {
while (System.currentTimeMillis() < end) { out.write(buf); upBytes += buf.size
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 // Download-Phase
Socket().use { s -> Socket().use { s ->
s.soTimeout = SOCKET_IO_TIMEOUT_MS
s.connect(InetSocketAddress(host, port), 3000) s.connect(InetSocketAddress(host, port), 3000)
val t0 = System.nanoTime() val end = System.currentTimeMillis() + durationSec * 500L
val end = System.currentTimeMillis() + phaseMs
val inp = s.getInputStream() val inp = s.getInputStream()
try { while (System.currentTimeMillis() < end) {
while (System.currentTimeMillis() < end) { val n = inp.read(buf); if (n < 0) break; downBytes += n
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() return JSObject()
.put("downMbps", round1(down)) .put("downMbps", round1(downBytes * 8.0 / 1_000_000.0 / secs))
.put("upMbps", round1(up)) .put("upMbps", round1(upBytes * 8.0 / 1_000_000.0 / secs))
.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)
} }
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
@ -1064,23 +799,13 @@ class NetDiagScannerPlugin : Plugin() {
// Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service // Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service
// gestartet werden, sonst kann Android den Prozess beenden. // gestartet werden, sonst kann Android den Prozess beenden.
io.launch { io.launch {
// Funk/CPU wachhalten. Ersetzt den Foreground-Service NICHT — bei val end = System.currentTimeMillis() + durationSec * 1000L
// ausgeschaltetem Display kann Android den Lauf weiterhin einfrieren. while (System.currentTimeMillis() < end && run.active) {
// Der richtige Umbau steht in ROADMAP_UMSETZUNG.md Phase 2. val q = measurePing(host, 5)
val locks = acquireRadioLocks("stress", (durationSec * 1000L) + 60_000L) run.samples++
try { run.lossSum += q.getInteger("lossPct", 0) ?: 0
val end = System.currentTimeMillis() + durationSec * 1000L run.avgSum += q.getDouble("avgMs")
while (System.currentTimeMillis() < end && run.active) { run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs"))
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)) resolve(call, JSObject().put("runId", runId))
@ -1100,9 +825,9 @@ class NetDiagScannerPlugin : Plugin() {
} }
private class StressRun(val host: String, val durationSec: Int) { private class StressRun(val host: String, val durationSec: Int) {
@Volatile var active = true var active = true
var samples = 0 var samples = 0
var lossSum = 0.0 var lossSum = 0
var avgSum = 0.0 var avgSum = 0.0
var maxMs = 0.0 var maxMs = 0.0
} }
@ -1673,36 +1398,15 @@ class NetDiagScannerPlugin : Plugin() {
private fun intToIp(i: Int): String = private fun intToIp(i: Int): String =
"${i and 0xFF}.${i shr 8 and 0xFF}.${i shr 16 and 0xFF}.${i shr 24 and 0xFF}" "${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 { private fun firstLocalIpv4(): String {
try { java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni ->
java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni -> ni.inetAddresses.toList().forEach { addr ->
if (!ni.isUp || ni.isLoopback) return@forEach if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) {
ni.inetAddresses.toList().forEach { addr -> return addr.hostAddress ?: ""
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 "" return "192.168.1.1"
} }
/** /proc/net/arp lesen -> Map IP -> MAC (kann auf neuen Android-Versionen leer sein) */ /** /proc/net/arp lesen -> Map IP -> MAC (kann auf neuen Android-Versionen leer sein) */
@ -1724,87 +1428,23 @@ class NetDiagScannerPlugin : Plugin() {
return map return map
} }
/** /** ping mit fester TTL -> (antwortende IP, Latenz in ms) */
* Ergebnis einer TTL-Sonde. private data class Hop(val ip: String, val ms: Double, val reachedTarget: Boolean)
*
* @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 { private fun pingWithTtl(host: String, ttl: Int): Hop {
var proc: Process? = null
return try { return try {
val t0 = System.nanoTime() val proc = ProcessBuilder("/system/bin/ping", "-c", "1", "-W", "2", "-t", ttl.toString(), host)
proc = ProcessBuilder( .redirectErrorStream(true).start()
"/system/bin/ping", "-n", "-c", "1", "-W", "2", "-t", ttl.toString(), host,
).redirectErrorStream(true).start()
val out = proc.inputStream.bufferedReader().readText() val out = proc.inputStream.bufferedReader().readText()
if (!proc.waitFor(3, TimeUnit.SECONDS)) { proc.waitFor()
proc.destroyForcibly() val ip = Regex("""From ([\d.]+)""").find(out)?.groupValues?.get(1)
return Hop("*", null, false) ?: Regex("""\((\d+\.\d+\.\d+\.\d+)\)""").find(out)?.groupValues?.get(1)
} ?: "*"
val elapsedMs = (System.nanoTime() - t0) / 1_000_000.0 val ms = Regex("""time=([\d.]+)""").find(out)?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0
val reached = out.contains("bytes from")
var ip = "" Hop(if (reached) host else ip, ms, reached)
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) { } catch (e: Exception) {
try { proc?.destroyForcibly() } catch (_: Exception) { } Hop("*", 0.0, false)
Hop("*", null, false)
} }
} }
@ -1831,14 +1471,6 @@ class NetDiagScannerPlugin : Plugin() {
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0 private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
companion object { 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 * Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld
* (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf * (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf

View file

@ -4,77 +4,34 @@ import java.io.ByteArrayOutputStream
import java.net.DatagramPacket import java.net.DatagramPacket
import java.net.DatagramSocket import java.net.DatagramSocket
import java.net.InetAddress import java.net.InetAddress
import java.net.SocketTimeoutException
/** /**
* Minimaler SNMP-v2c-GET-Client. * Minimaler SNMP-v2c-GET-Client.
* *
* Reicht für das Auslesen einzelner OIDs (Link-Speed, Fehlerzähler) von * Reicht für das Auslesen einzelner OIDs (Link-Speed, Fehlerzähler) von
* gemanagten Switches. Implementiert nur so viel BER-Kodierung wie nötig. * 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 { 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. * Eine OID per SNMP v2c GET abfragen.
* *
* @param timeoutMs Wartezeit je Versuch * @return Wert als String oder null bei Fehler/Timeout
* @param attempts Anzahl Versuche (UDP darf ruhig einmal verloren gehen)
*/ */
fun get( fun get(host: String, community: String, oid: String): String? {
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 { return try {
val request = buildGetRequest(community, oid, requestId) val request = buildGetRequest(community, oid)
DatagramSocket().use { sock -> val socket = DatagramSocket()
sock.soTimeout = timeoutMs socket.soTimeout = 2500
sock.send(DatagramPacket(request, request.size, InetAddress.getByName(host), 161)) socket.use {
it.send(DatagramPacket(request, request.size, InetAddress.getByName(host), 161))
val buf = ByteArray(2048) val buf = ByteArray(2048)
val resp = DatagramPacket(buf, buf.size) val resp = DatagramPacket(buf, buf.size)
sock.receive(resp) it.receive(resp)
parseResponse(buf, resp.length, requestId) parseFirstValue(buf, resp.length)
} }
} catch (e: SocketTimeoutException) {
Result.Timeout
} catch (e: Exception) { } catch (e: Exception) {
Result.Failure(e.message ?: "SNMP-Fehler") null
} }
} }
@ -123,143 +80,59 @@ object Snmp {
return tlv(0x06, out.toByteArray()) return tlv(0x06, out.toByteArray())
} }
private fun buildGetRequest(community: String, oidStr: String, requestId: Int): ByteArray { private fun buildGetRequest(community: String, oidStr: String): ByteArray {
val varbind = tlv(0x30, oid(oidStr) + tlv(0x05, ByteArray(0))) // OID + NULL val varbind = tlv(0x30, oid(oidStr) + tlv(0x05, ByteArray(0))) // OID + NULL
val varbindList = tlv(0x30, varbind) val varbindList = tlv(0x30, varbind)
val requestId = (System.currentTimeMillis() and 0x7FFF).toInt()
val pdu = tlv( val pdu = tlv(
0xA0, // GetRequest-PDU 0xA0, // GetRequest-PDU
integer(requestId) + integer(0) + integer(0) + varbindList, integer(requestId) + integer(0) + integer(0) + varbindList,
) )
return tlv( val message = tlv(
0x30, 0x30,
integer(1) + octetString(community) + pdu, // version 1 = SNMPv2c integer(1) + octetString(community) + pdu, // version 1 = SNMPv2c
) )
return message
} }
/* ---- Antwort parsen ---- */ /* ---- Antwort parsen: ersten Variablen-Wert herausziehen ---- */
/** Kleiner BER-Leser mit Bereichsprüfung. */ private fun parseFirstValue(buf: ByteArray, len: Int): String? {
private class Reader(val buf: ByteArray, val len: Int) {
var i = 0 var i = 0
// Durch die Struktur navigieren bis zum ersten primitiven Wert nach einer OID
fun tag(): Int { var lastWasOid = false
require(i < len) { "unerwartetes Paketende" } while (i < len) {
return (buf[i++].toInt() and 0xFF) val tag = buf[i].toInt() and 0xFF
} i++
if (i >= len) break
fun length(): Int { var l = buf[i].toInt() and 0xFF
require(i < len) { "unerwartetes Paketende" } i++
var l = buf[i++].toInt() and 0xFF
if (l and 0x80 != 0) { if (l and 0x80 != 0) {
val n = l and 0x7F val n = l and 0x7F
require(n in 1..4 && i + n <= len) { "ungueltige Laenge" }
l = 0 l = 0
for (k in 0 until n) l = (l shl 8) or (buf[i++].toInt() and 0xFF) for (k in 0 until n) { l = (l shl 8) or (buf[i].toInt() and 0xFF); i++ }
} }
require(l >= 0 && i + l <= len) { "Laenge zeigt ueber das Paket hinaus" } // Konstruierte Typen (SEQUENCE, PDU) aufsteigen
return l if (tag == 0x30 || tag == 0xA2 || tag == 0xA0) continue
} if (tag == 0x06) { lastWasOid = true; i += l; continue }
if (lastWasOid) {
fun intValue(l: Int): Long { return when (tag) {
var v = 0L 0x02, 0x41, 0x42, 0x43, 0x44, 0x46 -> { // INTEGER, Counter, Gauge, TimeTicks ...
for (k in 0 until l) v = (v shl 8) or (buf[i + k].toLong() and 0xFF) var v = 0L
i += l for (k in 0 until l) v = (v shl 8) or (buf[i + k].toLong() and 0xFF)
return v v.toString()
} }
0x04 -> String(buf, i, l) // OCTET STRING
fun text(l: Int): String { 0x05 -> "" // NULL -> kein Wert
val s = String(buf, i, l) else -> {
i += l val sb = StringBuilder()
return s for (k in 0 until l) sb.append("%02X".format(buf[i + k]))
} sb.toString()
}
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")
} }
i += l
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}")
} }
} return null
/** 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"
} }
} }

View file

@ -0,0 +1,81 @@
package de.data_it_solution.netdiag
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
/**
* Schlanker Vordergrund-Dienst für den Geräte-Monitor.
*
* Er hält den App-Prozess am Leben, solange die Überwachung läuft damit
* Android die Mess-Schleife bei ausgeschaltetem Display oder App-Wechsel nicht
* beendet. Die eigentliche Ping-Logik läuft im NetDiagScannerPlugin; dieser
* Dienst zeigt nur die dauerhafte Benachrichtigung.
*/
class MonitorService : Service() {
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val text = intent?.getStringExtra(EXTRA_TEXT) ?: "Geräte-Überwachung läuft"
val notification = buildNotification(text)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else {
startForeground(NOTIF_ID, notification)
}
return START_STICKY
}
private fun buildNotification(text: String): Notification {
ensureChannel(this)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("NetDiag — Geräte-Monitor")
.setContentText(text)
.setSmallIcon(android.R.drawable.ic_menu_compass)
.setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW)
.build()
}
companion object {
const val CHANNEL_ID = "netdiag-monitor"
const val NOTIF_ID = 4711
const val EXTRA_TEXT = "text"
/** Benachrichtigungskanal anlegen (idempotent) */
fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = ctx.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
"Geräte-Monitor",
NotificationManager.IMPORTANCE_LOW,
),
)
}
}
fun start(ctx: Context, text: String) {
val i = Intent(ctx, MonitorService::class.java).putExtra(EXTRA_TEXT, text)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
ctx.startForegroundService(i)
} else {
ctx.startService(i)
}
}
fun stop(ctx: Context) {
ctx.stopService(Intent(ctx, MonitorService::class.java))
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,45 +1,36 @@
# Natives Scan-Plugin `NetDiagScanner` # Natives Scan-Plugin `NetDiagScanner`
Der WebView kann keine Raw-Sockets/ICMP/ARP — die eigentliche Netzwerk-Messung Der WebView kann keine Raw-Sockets/ICMP/ARP — die eigentliche Netzwerk-Messung
läuft in einem nativen Android-Plugin (Kotlin). läuft in diesem nativen Android-Plugin (Kotlin).
## Wo der Code liegt ## Dateien
**Einzige Quelle:** - `NetDiagScannerPlugin.kt` — Capacitor-Plugin mit allen Scan-Methoden
- `Snmp.kt` — minimaler SNMP-v2c-GET-Client
``` ## Integration (einmalig, nach `npx cap add android`)
android/app/src/main/java/de/data_it_solution/netdiag/
├── NetDiagScannerPlugin.kt Capacitor-Plugin mit allen Scan-Methoden
├── MonitorService.kt Vordergrund-Dienst des Geräte-Monitors
├── Snmp.kt minimaler SNMP-v2c-GET-Client
└── MainActivity.kt registriert das Plugin
```
> **Bis 08/2026 lagen dieselben Dateien zusätzlich hier im Ordner `native-plugin/`.** 1. **Android-Projekt erzeugen** (falls noch nicht vorhanden):
> Gebaut wurde aber immer nur die Kopie unter `android/` — eine Änderung an der ```bash
> falschen Datei sah deshalb aus wie „der Fix wirkt nicht". Die Doppelablage ist npm install
> aufgelöst; dieser Ordner enthält nur noch diese Anleitung. npm run build
npx cap add android
```
Das Verzeichnis `android/` liegt vollständig im Repo (Kotlin, Manifest, Gradle, 2. **Plugin-Dateien kopieren** nach:
Signing-Config sind dort fest eingerichtet). `npx cap sync android` kopiert nur die ```
Web-Assets hinein und aktualisiert die Plugin-Liste — es überschreibt **keinen** android/app/src/main/java/de/data_it_solution/netdiag/
nativen Code. ├── NetDiagScannerPlugin.kt
└── Snmp.kt
```
## Ein neues Plugin-Kommando ergänzen 3. **Plugin registrieren** in `android/app/src/main/java/de/data_it_solution/netdiag/MainActivity.kt`:
1. Methode in `NetDiagScannerPlugin.kt` mit `@PluginMethod` anlegen.
2. Im TS-Wrapper `src/lib/scanner.ts` die Signatur ergänzen (inkl. Browser-Mock,
sonst lässt sich die Seite im Dev-Server nicht mehr bedienen).
3. Werkzeug unter `src/lib/tools/<kategorie>/` anlegen und in `tools/index.ts` eintragen.
## Erstmalige Einrichtung (nur relevant, wenn `android/` neu erzeugt wird)
Normalerweise **nicht nötig**`android/` ist eingecheckt. Falls doch:
1. `npm install && npx vite build && npx cap add android`
2. Kotlin-Dateien nach `android/app/src/main/java/de/data_it_solution/netdiag/` legen
3. Plugin in `MainActivity.kt` registrieren:
```kotlin ```kotlin
package de.data_it_solution.netdiag
import android.os.Bundle
import com.getcapacitor.BridgeActivity
class MainActivity : BridgeActivity() { class MainActivity : BridgeActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
registerPlugin(NetDiagScannerPlugin::class.java) registerPlugin(NetDiagScannerPlugin::class.java)
@ -47,37 +38,35 @@ Normalerweise **nicht nötig** — `android/` ist eingecheckt. Falls doch:
} }
} }
``` ```
4. **`kotlin-android` im Gradle-Build aktivieren** — sonst werden die `.kt`-Dateien
stillschweigend ignoriert, der Build ist grün und das Plugin fehlt im APK 4. **Berechtigungen** in `android/app/src/main/AndroidManifest.xml` (innerhalb `<manifest>`):
(„plugin is not implemented on android", siehe KB #660).
5. Berechtigungen im `AndroidManifest.xml` (Stand heute):
```xml ```xml
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" /> <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" /> <uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" /> <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
``` ```
`ACCESS_FINE_LOCATION` ist Pflicht, damit Android WLAN-Scan-Ergebnisse liefert. `ACCESS_FINE_LOCATION` ist Pflicht, damit Android WLAN-Scan-Ergebnisse liefert.
5. **Kotlin-Coroutines** sicherstellen — in `android/app/build.gradle`:
```gradle
dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0"
}
```
## Bekannte Einschränkungen ## Bekannte Einschränkungen
- **ARP/MAC**: `/proc/net/arp` ist ab Android 10 in der Regel nicht mehr lesbar — - **ARP/MAC**: `/proc/net/arp` ist ab Android 10 teils nicht mehr lesbar — dann
dann bleiben MAC und Hersteller leer. Das ist der Grund, warum in den bisherigen bleiben MAC/Hersteller leer; Geräte werden trotzdem über Ping/Hostname erkannt.
Kunden-PDFs die Spalten MAC/Hersteller leer sind. - **Hersteller-Lookup**: nur kleiner OUI-Auszug eingebaut. Für volle Abdeckung
- **Hersteller-Lookup**: nur kleiner OUI-Auszug eingebaut. Für volle Abdeckung die die IEEE-OUI-Datei als Asset einbinden und in `ouiVendor()` nutzen.
IEEE-OUI-Datei als Asset einbinden und in `ouiVendor()` nutzen. - **DHCP-Discover**: Best-effort — Port 68 kann vom System-DHCP-Client belegt sein.
- **Durchsatz-Test**: spricht **kein** iperf3-Protokoll, sondern schreibt/liest roh - **Durchsatz-Test**: benötigt eine TCP-Sink/Source-Gegenstelle (2. Gerät bzw.
auf einen TCP-Socket. Gegen einen echten iperf3-Server schlägt er fehl; er braucht iperf3-kompatibler Server). Misst sonst nur Fehlversuche.
eine schlichte TCP-Sink/Source-Gegenstelle. Umbau steht in - **Stresstest**: für lange Läufe sollte ein Foreground-Service ergänzt werden,
`../../ROADMAP_UMSETZUNG.md` (Phase 1). sonst kann Android den Prozess im Hintergrund beenden.
- **Stresstest**: läuft **nicht** in einem Foreground-Service — bei ausgeschaltetem
Display friert Android den Lauf ein. Umbau in Phase 2.
- **IP-Konflikt-Prüfung**: hängt an der ARP-Tabelle und ist damit auf aktuellen
Android-Versionen praktisch nicht durchführbar.
- **PoE-/Strommessung**: bewusst nicht enthalten (Hardware-Grenze) — späteres Modul. - **PoE-/Strommessung**: bewusst nicht enthalten (Hardware-Grenze) — späteres Modul.

138
native-plugin/Snmp.kt Normal file
View file

@ -0,0 +1,138 @@
package de.data_it_solution.netdiag
import java.io.ByteArrayOutputStream
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
/**
* 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.
*/
object Snmp {
/**
* Eine OID per SNMP v2c GET abfragen.
*
* @return Wert als String oder null bei Fehler/Timeout
*/
fun get(host: String, community: String, oid: String): String? {
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 buf = ByteArray(2048)
val resp = DatagramPacket(buf, buf.size)
it.receive(resp)
parseFirstValue(buf, resp.length)
}
} catch (e: Exception) {
null
}
}
/* ---- BER-Kodierung ---- */
private fun tlv(tag: Int, value: ByteArray): ByteArray {
val out = ByteArrayOutputStream()
out.write(tag)
when {
value.size < 0x80 -> out.write(value.size)
value.size < 0x100 -> { out.write(0x81); out.write(value.size) }
else -> { out.write(0x82); out.write(value.size shr 8); out.write(value.size and 0xFF) }
}
out.write(value)
return out.toByteArray()
}
private fun integer(v: Int): ByteArray {
val bytes = when {
v == 0 -> byteArrayOf(0)
v < 0x80 -> byteArrayOf(v.toByte())
v < 0x8000 -> byteArrayOf((v shr 8).toByte(), v.toByte())
else -> byteArrayOf((v shr 24).toByte(), (v shr 16).toByte(), (v shr 8).toByte(), v.toByte())
}
return tlv(0x02, bytes)
}
private fun octetString(s: String): ByteArray = tlv(0x04, s.toByteArray())
private fun oid(o: String): ByteArray {
val parts = o.trim().trimStart('.').split('.').map { it.toInt() }
val out = ByteArrayOutputStream()
out.write(parts[0] * 40 + parts[1]) // erste zwei Subidentifier zusammengefasst
for (i in 2 until parts.size) {
var v = parts[i]
if (v < 0x80) {
out.write(v)
} else {
val stack = ArrayDeque<Int>()
stack.addFirst(v and 0x7F)
v = v shr 7
while (v > 0) { stack.addFirst((v and 0x7F) or 0x80); v = v shr 7 }
stack.forEach { out.write(it) }
}
}
return tlv(0x06, out.toByteArray())
}
private fun buildGetRequest(community: String, oidStr: String): 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(
0x30,
integer(1) + octetString(community) + pdu, // version 1 = SNMPv2c
)
return message
}
/* ---- Antwort parsen: ersten Variablen-Wert herausziehen ---- */
private fun parseFirstValue(buf: ByteArray, len: Int): String? {
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++
if (l and 0x80 != 0) {
val n = l and 0x7F
l = 0
for (k in 0 until n) { l = (l shl 8) or (buf[i].toInt() and 0xFF); i++ }
}
// 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
}
return null
}
}

View file

@ -36,10 +36,3 @@ body {
.ampel-fail { .ampel-fail {
color: #f85149; 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;
}

View file

@ -29,9 +29,8 @@
onrename?: () => void; onrename?: () => void;
} = $props(); } = $props();
// Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar) const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess']; const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
/** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */ /** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */
const title = $derived( const title = $derived(

View file

@ -65,18 +65,11 @@ export interface OpenPort {
export interface PingQuality { export interface PingQuality {
sent: number; sent: number;
received: number; received: number;
/** Verlust in Prozent (Kommazahl — 1 von 20 Paketen = 5,0) */
lossPct: number; lossPct: number;
minMs: number; minMs: number;
avgMs: number; avgMs: number;
/** Median der Laufzeiten — robuster gegen einzelne Ausreisser als avgMs */
medianMs: number;
maxMs: number; maxMs: number;
jitterMs: 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 { export interface WifiNetwork {
ssid: string; ssid: string;
@ -97,44 +90,20 @@ export interface DhcpLease {
} }
export interface TracerouteHop { export interface TracerouteHop {
ttl: number; ttl: number;
/** antwortende IP oder '*' wenn niemand geantwortet hat */
ip: string; ip: string;
/** Mittelwert der Proben in ms, null wenn keine Antwort kam (NICHT 0) */ ms: number;
ms: number | null;
minMs?: number;
/** wie viele der 3 Proben geantwortet haben */
antworten?: number;
} }
export interface ThroughputResult { export interface ThroughputResult {
downMbps: number; downMbps: number;
upMbps: number; upMbps: number;
downBytes?: number;
upBytes?: number;
/** false = es wurde kein einziges Byte uebertragen, also keine Messung */
measured?: boolean;
} }
/** Schnittstelle des nativen Plugins */ /** Schnittstelle des nativen Plugins */
export interface NetDiagScannerPlugin { export interface NetDiagScannerPlugin {
/** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */ /** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */
getLocalSubnet(): Promise<{ getLocalSubnet(): Promise<{ subnet: string; ip: string; gateway: string }>;
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) */ /** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */
ipScan(opts: { subnet: string }): Promise<{ ipScan(opts: { subnet: string }): Promise<{ devices: ScannedDevice[] }>;
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 … */ /** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */
mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>; mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>;
/** IP-Konflikt-Prüfung: findet IP-Adressen, die zwei Geräte gleichzeitig benutzen */ /** IP-Konflikt-Prüfung: findet IP-Adressen, die zwei Geräte gleichzeitig benutzen */
@ -154,20 +123,12 @@ export interface NetDiagScannerPlugin {
/** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */ /** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */
snmpGet(opts: { host: string; community: string; oids: string[] }): Promise<{ snmpGet(opts: { host: string; community: string; oids: string[] }): Promise<{
values: Record<string, string>; values: Record<string, string>;
/** false = der Switch hat ueberhaupt nicht geantwortet */
answered: boolean;
/** Klartextgrund, wenn etwas nicht gelesen werden konnte */
error: string;
}>; }>;
/** Traceroute zu einem Host */ /** Traceroute zu einem Host */
traceroute(opts: { host: string }): Promise<{ traceroute(opts: { host: string }): Promise<{ hops: TracerouteHop[] }>;
hops: TracerouteHop[]; /** Durchsatztest gegen eine Gegenstelle (iperf-kompatibel) */
/** 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<ThroughputResult>; throughput(opts: { host: string; port: number; durationSec: number }): Promise<ThroughputResult>;
/** Dauer-/Stresstest starten (läuft NICHT im Foreground-Service, s. stresstest.ts) */ /** Dauer-/Stresstest starten (läuft als Foreground-Service) */
startStressTest(opts: { host: string; durationSec: number }): Promise<{ runId: string }>; startStressTest(opts: { host: string; durationSec: number }): Promise<{ runId: string }>;
/** Laufenden Stresstest beenden und Ergebnis holen */ /** Laufenden Stresstest beenden und Ergebnis holen */
stopStressTest(opts: { runId: string }): Promise<{ stopStressTest(opts: { runId: string }): Promise<{
@ -236,13 +197,7 @@ let mockWifiTrackRssi = -55;
const mock: NetDiagScannerPlugin = { const mock: NetDiagScannerPlugin = {
async getLocalSubnet() { async getLocalSubnet() {
return { return { subnet: '192.168.1.0/24', ip: '192.168.1.50', gateway: '192.168.1.1' };
subnet: '192.168.1.0/24',
ip: '192.168.1.50',
gateway: '192.168.1.1',
prefix: 24,
prefixQuelle: 'adapter',
};
}, },
async ipScan() { async ipScan() {
return { return {
@ -319,19 +274,14 @@ const mock: NetDiagScannerPlugin = {
async pingQuality(opts) { async pingQuality(opts) {
const sent = opts.count; const sent = opts.count;
const received = sent - (Math.random() < 0.3 ? 1 : 0); const received = sent - (Math.random() < 0.3 ? 1 : 0);
const min = rnd(1, 4);
const avg = rnd(4, 12);
return { return {
sent, sent,
received, received,
lossPct: Math.round(((sent - received) / sent) * 1000) / 10, lossPct: Math.round(((sent - received) / sent) * 100),
minMs: min, minMs: rnd(1, 4),
avgMs: avg, avgMs: rnd(4, 12),
medianMs: rnd(min, avg),
maxMs: rnd(12, 40), maxMs: rnd(12, 40),
jitterMs: rnd(0.5, 5), jitterMs: rnd(0.5, 5),
messaufschlagMs: Math.round((avg - min) * 10) / 10,
verfahren: 'Mock (Browser-Entwicklung)',
}; };
}, },
async wifiScan() { async wifiScan() {
@ -354,26 +304,19 @@ const mock: NetDiagScannerPlugin = {
async snmpGet(opts) { async snmpGet(opts) {
const values: Record<string, string> = {}; const values: Record<string, string> = {};
for (const oid of opts.oids) values[oid] = String(Math.floor(Math.random() * 1000)); for (const oid of opts.oids) values[oid] = String(Math.floor(Math.random() * 1000));
return { values, answered: true, error: '' }; return { values };
}, },
async traceroute() { async traceroute() {
return { return {
hops: [ hops: [
{ ttl: 1, ip: '192.168.1.1', ms: rnd(1, 3), antworten: 3 }, { ttl: 1, ip: '192.168.1.1', ms: rnd(1, 3) },
{ ttl: 2, ip: '*', ms: null, antworten: 0 }, { ttl: 2, ip: '10.0.0.1', ms: rnd(8, 15) },
{ ttl: 3, ip: '8.8.8.8', ms: rnd(15, 30), antworten: 3 }, { ttl: 3, ip: '8.8.8.8', ms: rnd(15, 30) },
], ],
reachedTarget: true,
}; };
}, },
async throughput() { async throughput() {
return { return { downMbps: rnd(80, 940), upMbps: rnd(40, 500) };
downMbps: rnd(80, 940),
upMbps: rnd(40, 500),
downBytes: 12_000_000,
upBytes: 6_000_000,
measured: true,
};
}, },
async startStressTest() { async startStressTest() {
return { runId: 'mock-run' }; return { runId: 'mock-run' };

View file

@ -1,12 +1,7 @@
/** /**
* Tool: Durchsatz-Test misst die Bandbreite gegen eine Gegenstelle. * Tool: Durchsatz-Test misst die Bandbreite gegen eine Gegenstelle.
* *
* ACHTUNG: Der Test spricht **kein** iperf3-Protokoll. Nativ wird schlicht auf * Benötigt eine iperf-kompatible Gegenstelle (2. Gerät oder iperf3-Server).
* einen TCP-Socket geschrieben bzw. von ihm gelesen ohne Cookie, ohne
* Parameterblock, ohne Zustandsautomat. Gegen einen echten iperf3-Server bricht
* die Verbindung deshalb ab; er braucht eine einfache TCP-Sink/Source-Gegenstelle.
* Umbau (eigenes Protokoll oder mitgeliefertes iperf3-Binary) steht in
* ROADMAP_UMSETZUNG.md Phase 1.
*/ */
import { scanner } from '../../scanner'; import { scanner } from '../../scanner';
@ -42,23 +37,6 @@ export const iperfTool: Tool = {
const res = await scanner.throughput({ host, port, durationSec }); 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; let status: MeasureStatus = 0;
if (res.downMbps < 100) status = 1; if (res.downMbps < 100) status = 1;
if (res.downMbps < 10) status = 2; if (res.downMbps < 10) status = 2;

View file

@ -31,11 +31,7 @@ export const ipScanTool: Tool = {
category: 'netzwerk', category: 'netzwerk',
name: 'IP-Scanner', name: 'IP-Scanner',
icon: 'radar', icon: 'radar',
// Ehrliche Beschreibung: gefunden wird ein Gerät nur, wenn es auf Ping description: 'Sucht alle Geräte im Netzbereich (ARP + Ping + Namen).',
// antwortet — oder wenn es sich per mDNS meldet. Die ARP-Tabelle wird
// lediglich nachgeschlagen, um MAC/Hersteller zu ergänzen, und ist ab
// Android 10 meist gar nicht lesbar. Mehrgleisige Suche: Phase 3.
description: 'Sucht Geräte, die auf Ping antworten oder sich per mDNS melden.',
scope: 'protocol', scope: 'protocol',
params: [ params: [
{ {

View file

@ -23,46 +23,22 @@ export const pingTool: Tool = {
const q = await scanner.pingQuality({ host, count }); const q = await scanner.pingQuality({ host, count });
// Gar keine Antwort: das Ziel ist nicht erreichbar. Latenzwerte gibt es // Bewertung: Verlust und Jitter
// 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; let status: MeasureStatus = 0;
if (q.lossPct > 0 || q.jitterMs > 10) status = 1; if (q.lossPct > 0 || q.jitterMs > 10) status = 1;
if (q.lossPct >= 10 || leitwert > 100) status = 2; if (q.lossPct >= 10 || q.avgMs > 100) status = 2;
return { return {
label: `${host}: ${leitwert} ms (Median), ${q.lossPct}% Verlust`, label: `${host}: ${q.avgMs} ms ø, ${q.lossPct}% Verlust`,
result: { result: {
host, host,
gesendet: q.sent, gesendet: q.sent,
empfangen: q.received, empfangen: q.received,
verlustProzent: q.lossPct, verlustProzent: q.lossPct,
minMs: q.minMs, minMs: q.minMs,
medianMs: q.medianMs,
avgMs: q.avgMs, avgMs: q.avgMs,
maxMs: q.maxMs, maxMs: q.maxMs,
jitterMs: q.jitterMs, 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, measureStatus: status,
}; };

View file

@ -32,81 +32,26 @@ export const snmpTool: Tool = {
if (!host) throw new Error('Keine Switch-IP angegeben'); if (!host) throw new Error('Keine Switch-IP angegeben');
const community = String(ctx.params.community || 'public'); const community = String(ctx.params.community || 'public');
const { values, answered, error } = await scanner.snmpGet({ const { values } = await scanner.snmpGet({ host, community, oids: Object.values(OIDS) });
host,
community,
oids: Object.values(OIDS),
});
// Der Switch hat überhaupt nicht geantwortet -> keine Messung. // OID-Werte den lesbaren Namen zuordnen
// Früher wurde daraus über parseInt('-') || 0 ein grünes „0 Fehler" const named: Record<string, string> = {};
// (siehe Prod-Messungen #95/#96) — ein Totalausfall sah damit aus wie ein for (const [name, oid] of Object.entries(OIDS)) named[name] = values[oid] ?? '-';
// 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
};
}
// Zahlenwerte nur übernehmen, wenn sie wirklich geliefert wurden const inErr = parseInt(named['ifInErrors'], 10) || 0;
const num = (key: string): number | null => { const outErr = parseInt(named['ifOutErrors'], 10) || 0;
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; let status: MeasureStatus = 0;
if (sum > 0) status = 1; if (inErr + outErr > 0) status = 1;
if (sum > 100) status = 2; if (inErr + outErr > 100) status = 2;
const speedBits = num('ifSpeed');
return { return {
label: `${host}: ${sum} Fehler`, label: `${host}: ${inErr + outErr} Fehler`,
result: { result: {
host, host,
port: text('ifDescr'), port: named['ifDescr'],
linkSpeed: speedBits !== null ? `${Math.round(speedBits / 1_000_000)} Mbit/s` : null, linkSpeed: named['ifSpeed'],
eingangsFehler: inErr, eingangsFehler: inErr,
ausgangsFehler: outErr, 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, measureStatus: status,
}; };

View file

@ -1,14 +1,7 @@
/** /**
* Tool: Dauer-/Stresstest Langzeitmessung von Verlust und Latenz. * Tool: Dauer-/Stresstest Langzeitmessung von Verlust und Latenz.
* *
* ACHTUNG, bekannte Schwächen (Umbau in ROADMAP_UMSETZUNG.md Phase 2): * Läuft nativ als Foreground-Service, damit Android den Lauf nicht beendet.
* - Der Lauf hängt an einer nackten Coroutine im Plugin, NICHT an einem
* Foreground-Service. Bei ausgeschaltetem Display friert Android ihn ein.
* - Die native Schleife hat kein festes Intervall die Zahl der Messpunkte
* hängt davon ab, wie schnell das Ziel antwortet (guter Link ~250 Punkte in
* 5 min, Totalausfall ~38). Sie ist damit keine Messgröße.
* - Es wird keine Zeitreihe gespeichert, nur Summen. Wann war die Störung?"
* lässt sich aus dem Ergebnis nicht beantworten.
* Die Dauer wird als Parameter vorgegeben; run() wartet auf das Ergebnis. * Die Dauer wird als Parameter vorgegeben; run() wartet auf das Ergebnis.
*/ */
@ -23,16 +16,7 @@ export const stressTestTool: Tool = {
description: 'Langzeitmessung: Paketverlust und Latenz über einen Zeitraum.', description: 'Langzeitmessung: Paketverlust und Latenz über einen Zeitraum.',
scope: 'protocol', scope: 'protocol',
params: [ params: [
// KEIN fester Vorgabewert mehr. Die alte Vorgabe 192.168.1.1 hat im { key: 'host', label: 'Ziel', type: 'text', default: '192.168.1.1' },
// 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', key: 'duration',
label: 'Dauer', label: 'Dauer',
@ -47,51 +31,11 @@ export const stressTestTool: Tool = {
}, },
], ],
async run(ctx) { async run(ctx) {
let host = String(ctx.params.host ?? '').trim(); const host = String(ctx.params.host || '192.168.1.1');
const durationSec = Number(ctx.params.duration || 300); 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 }); const { runId } = await scanner.startStressTest({ host, durationSec });
// Auf das Ende des Laufs warten. Der Timer läuft in der WebView und wird von // Auf das Ende des Laufs warten (Foreground-Service misst weiter)
// Chromium gedrosselt, sobald die Seite in den Hintergrund gerät — das Ergebnis
// kann dadurch später kommen als die angegebene Dauer.
await new Promise((r) => setTimeout(r, durationSec * 1000)); await new Promise((r) => setTimeout(r, durationSec * 1000));
const res = await scanner.stopStressTest({ runId }); const res = await scanner.stopStressTest({ runId });

View file

@ -3,7 +3,7 @@
*/ */
import { scanner } from '../../scanner'; import { scanner } from '../../scanner';
import type { MeasureStatus, Tool } from '../types'; import type { Tool } from '../types';
export const tracerouteTool: Tool = { export const tracerouteTool: Tool = {
id: 'traceroute', id: 'traceroute',
@ -15,36 +15,14 @@ export const tracerouteTool: Tool = {
params: [{ key: 'host', label: 'Ziel', type: 'text', default: '8.8.8.8' }], params: [{ key: 'host', label: 'Ziel', type: 'text', default: '8.8.8.8' }],
async run(ctx) { async run(ctx) {
const host = String(ctx.params.host || '8.8.8.8'); const host = String(ctx.params.host || '8.8.8.8');
const { hops, reachedTarget } = await scanner.traceroute({ host }); const { hops } = 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 { return {
label: reachedTarget label: `${hops.length} Hops bis ${host}`,
? `${hops.length} Hops bis ${host}`
: antwortend === 0
? `${host}: keine Antwort auf dem Weg`
: `${host} nicht erreicht (${antwortend} Hops geantwortet)`,
result: { result: {
ziel: host, ziel: host,
zielErreicht: reachedTarget, hops: hops.map((h) => `${h.ttl}. ${h.ip} (${h.ms} ms)`),
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: status, measureStatus: hops.length > 0 ? 0 : 2,
}; };
}, },
}; };

View file

@ -163,17 +163,7 @@ export interface WifiTrackSession {
} }
/** Ampel-Bewertung einer Messung */ /** 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 */ /** Ergebnis eines Tool-Laufs */
export interface Measurement { export interface Measurement {

View file

@ -49,9 +49,8 @@
), ),
); );
// Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar) const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess']; const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
onMount(async () => { onMount(async () => {
const uuid = $page.params.id ?? ''; const uuid = $page.params.id ?? '';