Compare commits
No commits in common. "6330c78d1b54cdcc48f989f7416c0c51a8e35bda" and "780115c67e77534f49c73a0764aab5a07d2e58d3" have entirely different histories.
6330c78d1b
...
780115c67e
19 changed files with 1986 additions and 985 deletions
|
|
@ -24,7 +24,7 @@ Vite-Proxy an den Dolibarr-Testserver (`192.168.155.11`, siehe `vite.config.ts`)
|
|||
```bash
|
||||
npm run build
|
||||
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 open android
|
||||
```
|
||||
|
|
@ -45,13 +45,12 @@ src/lib/
|
|||
updater.ts APK-Auto-Update-Prüfung (KB #363)
|
||||
tools/ erweiterbare Tool-Plattform
|
||||
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
|
||||
telefonie/ (folgt: SIP, FreePBX, RTP)
|
||||
src/routes/
|
||||
login/ auftraege/ kunden/ protokoll/[id]/ einstellungen/
|
||||
android/ natives Android-Projekt inkl. Kotlin-Plugin (einzige Quelle)
|
||||
native-plugin/ nur noch Anleitung/Hinweise zum Plugin
|
||||
native-plugin/ Kotlin-Plugin NetDiagScanner (+ Integrationsanleitung)
|
||||
```
|
||||
|
||||
## Neues Tool hinzufügen
|
||||
|
|
|
|||
|
|
@ -51,14 +51,6 @@
|
|||
<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.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 -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
|
|
|||
|
|
@ -30,10 +30,8 @@ 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
|
||||
|
|
@ -74,86 +72,6 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
private val io = CoroutineScope(Dispatchers.IO)
|
||||
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 */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
|
@ -192,12 +110,7 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
}
|
||||
|
||||
// 2. Fallback: WLAN-DHCP-Info (ältere Geräte / LinkProperties leer)
|
||||
// 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()) {
|
||||
if (ip.isEmpty() || prefix == 0) {
|
||||
val wifi = context.applicationContext
|
||||
.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
|
||||
|
|
@ -208,31 +121,18 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
}
|
||||
}
|
||||
|
||||
// 3. Letzter Fallback: irgendein aktives Interface
|
||||
var prefixQuelle = if (prefix in 1..32) "adapter" else ""
|
||||
// 3. Letzter Fallback
|
||||
if (ip.isEmpty()) ip = firstLocalIpv4()
|
||||
if (prefix !in 1..32) prefix = 24
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
// Netzadresse aus IP + Präfix berechnen
|
||||
val ipInt = ipv4ToInt(ip) ?: 0
|
||||
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}")
|
||||
|
|
@ -252,9 +152,6 @@ 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 …).
|
||||
|
|
@ -299,17 +196,9 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
if (type.isNotEmpty()) dev.put("deviceType", type)
|
||||
devices.put(dev)
|
||||
}
|
||||
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()))
|
||||
resolve(call, JSObject().put("devices", devices))
|
||||
} catch (e: Exception) {
|
||||
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
|
||||
* 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> {
|
||||
val parts = cidr.trim().split('/')
|
||||
val ipInt = ipv4ToInt(parts[0].trim()) ?: return emptyList()
|
||||
val prefix = if (parts.size > 1) {
|
||||
// Nicht interpretierbar -> ablehnen statt raten
|
||||
parts[1].trim().toIntOrNull() ?: return emptyList()
|
||||
} else {
|
||||
24
|
||||
}
|
||||
val prefix = if (parts.size > 1) (parts[1].trim().toIntOrNull() ?: 24) 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
|
||||
|
|
@ -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 {
|
||||
val locks = acquireRadioLocks("ping", (count * 1500L) + 10_000L)
|
||||
try {
|
||||
val times = ArrayList<Double>()
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
if (addr.isReachable(1000)) {
|
||||
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 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
|
||||
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.
|
||||
// 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", 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)
|
||||
}
|
||||
.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 {
|
||||
try {
|
||||
val values = JSObject()
|
||||
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)
|
||||
for (oid in oids) {
|
||||
values.put(oid, Snmp.get(host, community, oid) ?: "-")
|
||||
}
|
||||
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)
|
||||
.put("answered", answered)
|
||||
.put("error", firstProblem))
|
||||
resolve(call, JSObject().put("values", values))
|
||||
} catch (e: Exception) {
|
||||
call.reject("snmpGet: ${e.message}")
|
||||
}
|
||||
|
|
@ -915,41 +718,18 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
try {
|
||||
val hops = JSArray()
|
||||
var deadStreak = 0
|
||||
var reachedTarget = false
|
||||
|
||||
for (ttl in 1..30) {
|
||||
// Drei Proben je Hop wie bei traceroute/mtr: eine einzelne Probe
|
||||
// ist bei ratenbegrenzten Routern reine Glückssache.
|
||||
var ip = "*"
|
||||
val times = ArrayList<Double>()
|
||||
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
|
||||
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
|
||||
if (deadStreak >= 5) break
|
||||
}
|
||||
|
||||
resolve(call, JSObject()
|
||||
.put("hops", hops)
|
||||
.put("reachedTarget", reachedTarget))
|
||||
resolve(call, JSObject().put("hops", hops))
|
||||
} catch (e: Exception) {
|
||||
call.reject("traceroute: ${e.message}")
|
||||
}
|
||||
|
|
@ -967,87 +747,42 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
val durationSec = call.getInt("durationSec") ?: 10
|
||||
io.launch {
|
||||
try {
|
||||
// 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)
|
||||
}
|
||||
// Einfacher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle:
|
||||
// Download = empfangene Bytes, Upload = gesendete Bytes je Sekunde.
|
||||
val res = 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 t0 = System.nanoTime()
|
||||
val end = System.currentTimeMillis() + phaseMs
|
||||
val end = System.currentTimeMillis() + durationSec * 500L
|
||||
val out = s.getOutputStream()
|
||||
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 t0 = System.nanoTime()
|
||||
val end = System.currentTimeMillis() + phaseMs
|
||||
val end = System.currentTimeMillis() + durationSec * 500L
|
||||
val inp = s.getInputStream()
|
||||
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 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
|
||||
|
||||
val secs = durationSec / 2.0
|
||||
return JSObject()
|
||||
.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)
|
||||
.put("downMbps", round1(downBytes * 8.0 / 1_000_000.0 / secs))
|
||||
.put("upMbps", round1(upBytes * 8.0 / 1_000_000.0 / secs))
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
|
@ -1064,24 +799,14 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
// Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service
|
||||
// gestartet werden, sonst kann Android den Prozess beenden.
|
||||
io.launch {
|
||||
// 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.lossSum += q.getInteger("lossPct", 0) ?: 0
|
||||
run.avgSum += q.getDouble("avgMs")
|
||||
run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs"))
|
||||
}
|
||||
} finally {
|
||||
releaseRadioLocks(locks)
|
||||
}
|
||||
}
|
||||
resolve(call, JSObject().put("runId", runId))
|
||||
}
|
||||
|
|
@ -1100,9 +825,9 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
}
|
||||
|
||||
private class StressRun(val host: String, val durationSec: Int) {
|
||||
@Volatile var active = true
|
||||
var active = true
|
||||
var samples = 0
|
||||
var lossSum = 0.0
|
||||
var lossSum = 0
|
||||
var avgSum = 0.0
|
||||
var maxMs = 0.0
|
||||
}
|
||||
|
|
@ -1673,36 +1398,15 @@ 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 {
|
||||
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
|
||||
) {
|
||||
if (!addr.isLoopbackAddress && 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) */
|
||||
|
|
@ -1724,87 +1428,23 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 -> (antwortende IP, Latenz in ms) */
|
||||
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 t0 = System.nanoTime()
|
||||
proc = ProcessBuilder(
|
||||
"/system/bin/ping", "-n", "-c", "1", "-W", "2", "-t", ttl.toString(), host,
|
||||
).redirectErrorStream(true).start()
|
||||
|
||||
val proc = ProcessBuilder("/system/bin/ping", "-c", "1", "-W", "2", "-t", ttl.toString(), host)
|
||||
.redirectErrorStream(true).start()
|
||||
val out = proc.inputStream.bufferedReader().readText()
|
||||
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)
|
||||
}
|
||||
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)
|
||||
} catch (e: Exception) {
|
||||
try { proc?.destroyForcibly() } catch (_: Exception) { }
|
||||
Hop("*", null, false)
|
||||
Hop("*", 0.0, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1831,14 +1471,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -4,77 +4,34 @@ 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.
|
||||
*
|
||||
* @param timeoutMs Wartezeit je Versuch
|
||||
* @param attempts Anzahl Versuche (UDP darf ruhig einmal verloren gehen)
|
||||
* @return Wert als String oder null bei Fehler/Timeout
|
||||
*/
|
||||
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()
|
||||
fun get(host: String, community: String, oid: String): String? {
|
||||
return try {
|
||||
val request = buildGetRequest(community, oid, requestId)
|
||||
DatagramSocket().use { sock ->
|
||||
sock.soTimeout = timeoutMs
|
||||
sock.send(DatagramPacket(request, request.size, InetAddress.getByName(host), 161))
|
||||
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)
|
||||
sock.receive(resp)
|
||||
parseResponse(buf, resp.length, requestId)
|
||||
it.receive(resp)
|
||||
parseFirstValue(buf, resp.length)
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Result.Timeout
|
||||
} catch (e: Exception) {
|
||||
Result.Failure(e.message ?: "SNMP-Fehler")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,143 +80,59 @@ object Snmp {
|
|||
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 varbindList = tlv(0x30, varbind)
|
||||
val requestId = (System.currentTimeMillis() and 0x7FFF).toInt()
|
||||
val pdu = tlv(
|
||||
0xA0, // GetRequest-PDU
|
||||
integer(requestId) + integer(0) + integer(0) + varbindList,
|
||||
)
|
||||
return tlv(
|
||||
val message = tlv(
|
||||
0x30,
|
||||
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 class Reader(val buf: ByteArray, val len: Int) {
|
||||
private fun parseFirstValue(buf: ByteArray, len: Int): String? {
|
||||
var i = 0
|
||||
|
||||
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
|
||||
// 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
|
||||
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)
|
||||
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" }
|
||||
return l
|
||||
}
|
||||
|
||||
fun intValue(l: Int): Long {
|
||||
// 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)
|
||||
i += l
|
||||
return v
|
||||
v.toString()
|
||||
}
|
||||
|
||||
fun text(l: Int): String {
|
||||
val s = String(buf, i, l)
|
||||
i += l
|
||||
return s
|
||||
}
|
||||
|
||||
fun hex(l: Int): String {
|
||||
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 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"
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
|
|
|||
81
native-plugin/MonitorService.kt
Normal file
81
native-plugin/MonitorService.kt
Normal 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
1556
native-plugin/NetDiagScannerPlugin.kt
Normal file
1556
native-plugin/NetDiagScannerPlugin.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,45 +1,36 @@
|
|||
# Natives Scan-Plugin `NetDiagScanner`
|
||||
|
||||
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`)
|
||||
|
||||
1. **Android-Projekt erzeugen** (falls noch nicht vorhanden):
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
npx cap add android
|
||||
```
|
||||
|
||||
2. **Plugin-Dateien kopieren** nach:
|
||||
```
|
||||
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
|
||||
├── NetDiagScannerPlugin.kt
|
||||
└── Snmp.kt
|
||||
```
|
||||
|
||||
> **Bis 08/2026 lagen dieselben Dateien zusätzlich hier im Ordner `native-plugin/`.**
|
||||
> Gebaut wurde aber immer nur die Kopie unter `android/` — eine Änderung an der
|
||||
> falschen Datei sah deshalb aus wie „der Fix wirkt nicht". Die Doppelablage ist
|
||||
> aufgelöst; dieser Ordner enthält nur noch diese Anleitung.
|
||||
|
||||
Das Verzeichnis `android/` liegt vollständig im Repo (Kotlin, Manifest, Gradle,
|
||||
Signing-Config sind dort fest eingerichtet). `npx cap sync android` kopiert nur die
|
||||
Web-Assets hinein und aktualisiert die Plugin-Liste — es überschreibt **keinen**
|
||||
nativen Code.
|
||||
|
||||
## Ein neues Plugin-Kommando ergänzen
|
||||
|
||||
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:
|
||||
3. **Plugin registrieren** in `android/app/src/main/java/de/data_it_solution/netdiag/MainActivity.kt`:
|
||||
```kotlin
|
||||
package de.data_it_solution.netdiag
|
||||
|
||||
import android.os.Bundle
|
||||
import com.getcapacitor.BridgeActivity
|
||||
|
||||
class MainActivity : BridgeActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
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
|
||||
(„plugin is not implemented on android", siehe KB #660).
|
||||
5. Berechtigungen im `AndroidManifest.xml` (Stand heute):
|
||||
|
||||
4. **Berechtigungen** in `android/app/src/main/AndroidManifest.xml` (innerhalb `<manifest>`):
|
||||
```xml
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_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_MULTICAST_STATE" />
|
||||
<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_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
```
|
||||
`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
|
||||
|
||||
- **ARP/MAC**: `/proc/net/arp` ist ab Android 10 in der Regel nicht mehr lesbar —
|
||||
dann bleiben MAC und Hersteller leer. Das ist der Grund, warum in den bisherigen
|
||||
Kunden-PDFs die Spalten MAC/Hersteller leer sind.
|
||||
- **Hersteller-Lookup**: nur kleiner OUI-Auszug eingebaut. Für volle Abdeckung die
|
||||
IEEE-OUI-Datei als Asset einbinden und in `ouiVendor()` nutzen.
|
||||
- **Durchsatz-Test**: spricht **kein** iperf3-Protokoll, sondern schreibt/liest roh
|
||||
auf einen TCP-Socket. Gegen einen echten iperf3-Server schlägt er fehl; er braucht
|
||||
eine schlichte TCP-Sink/Source-Gegenstelle. Umbau steht in
|
||||
`../../ROADMAP_UMSETZUNG.md` (Phase 1).
|
||||
- **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.
|
||||
- **ARP/MAC**: `/proc/net/arp` ist ab Android 10 teils nicht mehr lesbar — dann
|
||||
bleiben MAC/Hersteller leer; Geräte werden trotzdem über Ping/Hostname erkannt.
|
||||
- **Hersteller-Lookup**: nur kleiner OUI-Auszug eingebaut. Für volle Abdeckung
|
||||
die 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**: benötigt eine TCP-Sink/Source-Gegenstelle (2. Gerät bzw.
|
||||
iperf3-kompatibler Server). Misst sonst nur Fehlversuche.
|
||||
- **Stresstest**: für lange Läufe sollte ein Foreground-Service ergänzt werden,
|
||||
sonst kann Android den Prozess im Hintergrund beenden.
|
||||
- **PoE-/Strommessung**: bewusst nicht enthalten (Hardware-Grenze) — späteres Modul.
|
||||
|
|
|
|||
138
native-plugin/Snmp.kt
Normal file
138
native-plugin/Snmp.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -36,10 +36,3 @@ 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,9 +29,8 @@
|
|||
onrename?: () => void;
|
||||
} = $props();
|
||||
|
||||
// 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'];
|
||||
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
|
||||
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
|
||||
|
||||
/** Anzeigename: eigener Name vor mDNS-/Host-/NetBIOS-Name, sonst IP */
|
||||
const title = $derived(
|
||||
|
|
|
|||
|
|
@ -65,18 +65,11 @@ 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;
|
||||
|
|
@ -97,44 +90,20 @@ export interface DhcpLease {
|
|||
}
|
||||
export interface TracerouteHop {
|
||||
ttl: number;
|
||||
/** antwortende IP oder '*' wenn niemand geantwortet hat */
|
||||
ip: string;
|
||||
/** 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;
|
||||
ms: 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;
|
||||
prefix?: number;
|
||||
/** 'adapter' = vom System gemeldet, 'angenommen' = /24 als Rueckfallwert */
|
||||
prefixQuelle?: string;
|
||||
}>;
|
||||
getLocalSubnet(): Promise<{ subnet: string; ip: string; gateway: string }>;
|
||||
/** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */
|
||||
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;
|
||||
}>;
|
||||
ipScan(opts: { subnet: string }): Promise<{ devices: ScannedDevice[] }>;
|
||||
/** 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 */
|
||||
|
|
@ -154,20 +123,12 @@ export interface NetDiagScannerPlugin {
|
|||
/** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */
|
||||
snmpGet(opts: { host: string; community: string; oids: string[] }): Promise<{
|
||||
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(opts: { host: string }): Promise<{
|
||||
hops: TracerouteHop[];
|
||||
/** hat das Ziel selbst geantwortet? */
|
||||
reachedTarget: boolean;
|
||||
}>;
|
||||
/** Durchsatztest gegen eine TCP-Sink/Source-Gegenstelle (KEIN iperf3-Protokoll) */
|
||||
traceroute(opts: { host: string }): Promise<{ hops: TracerouteHop[] }>;
|
||||
/** Durchsatztest gegen eine Gegenstelle (iperf-kompatibel) */
|
||||
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 }>;
|
||||
/** Laufenden Stresstest beenden und Ergebnis holen */
|
||||
stopStressTest(opts: { runId: string }): Promise<{
|
||||
|
|
@ -236,13 +197,7 @@ 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',
|
||||
prefix: 24,
|
||||
prefixQuelle: 'adapter',
|
||||
};
|
||||
return { subnet: '192.168.1.0/24', ip: '192.168.1.50', gateway: '192.168.1.1' };
|
||||
},
|
||||
async ipScan() {
|
||||
return {
|
||||
|
|
@ -319,19 +274,14 @@ 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) * 1000) / 10,
|
||||
minMs: min,
|
||||
avgMs: avg,
|
||||
medianMs: rnd(min, avg),
|
||||
lossPct: Math.round(((sent - received) / sent) * 100),
|
||||
minMs: rnd(1, 4),
|
||||
avgMs: rnd(4, 12),
|
||||
maxMs: rnd(12, 40),
|
||||
jitterMs: rnd(0.5, 5),
|
||||
messaufschlagMs: Math.round((avg - min) * 10) / 10,
|
||||
verfahren: 'Mock (Browser-Entwicklung)',
|
||||
};
|
||||
},
|
||||
async wifiScan() {
|
||||
|
|
@ -354,26 +304,19 @@ const mock: NetDiagScannerPlugin = {
|
|||
async snmpGet(opts) {
|
||||
const values: Record<string, string> = {};
|
||||
for (const oid of opts.oids) values[oid] = String(Math.floor(Math.random() * 1000));
|
||||
return { values, answered: true, error: '' };
|
||||
return { values };
|
||||
},
|
||||
async traceroute() {
|
||||
return {
|
||||
hops: [
|
||||
{ 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 },
|
||||
{ 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) },
|
||||
],
|
||||
reachedTarget: true,
|
||||
};
|
||||
},
|
||||
async throughput() {
|
||||
return {
|
||||
downMbps: rnd(80, 940),
|
||||
upMbps: rnd(40, 500),
|
||||
downBytes: 12_000_000,
|
||||
upBytes: 6_000_000,
|
||||
measured: true,
|
||||
};
|
||||
return { downMbps: rnd(80, 940), upMbps: rnd(40, 500) };
|
||||
},
|
||||
async startStressTest() {
|
||||
return { runId: 'mock-run' };
|
||||
|
|
|
|||
|
|
@ -1,12 +1,7 @@
|
|||
/**
|
||||
* Tool: Durchsatz-Test — misst die Bandbreite gegen eine Gegenstelle.
|
||||
*
|
||||
* ACHTUNG: Der Test spricht **kein** iperf3-Protokoll. Nativ wird schlicht auf
|
||||
* 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.
|
||||
* Benötigt eine iperf-kompatible Gegenstelle (2. Gerät oder iperf3-Server).
|
||||
*/
|
||||
|
||||
import { scanner } from '../../scanner';
|
||||
|
|
@ -42,23 +37,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -31,11 +31,7 @@ export const ipScanTool: Tool = {
|
|||
category: 'netzwerk',
|
||||
name: 'IP-Scanner',
|
||||
icon: 'radar',
|
||||
// Ehrliche Beschreibung: gefunden wird ein Gerät nur, wenn es auf Ping
|
||||
// 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.',
|
||||
description: 'Sucht alle Geräte im Netzbereich (ARP + Ping + Namen).',
|
||||
scope: 'protocol',
|
||||
params: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -23,46 +23,22 @@ export const pingTool: Tool = {
|
|||
|
||||
const q = await scanner.pingQuality({ host, count });
|
||||
|
||||
// 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;
|
||||
// Bewertung: Verlust und Jitter
|
||||
let status: MeasureStatus = 0;
|
||||
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 {
|
||||
label: `${host}: ${leitwert} ms (Median), ${q.lossPct}% Verlust`,
|
||||
label: `${host}: ${q.avgMs} ms ø, ${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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -32,81 +32,26 @@ export const snmpTool: Tool = {
|
|||
if (!host) throw new Error('Keine Switch-IP angegeben');
|
||||
const community = String(ctx.params.community || 'public');
|
||||
|
||||
const { values, answered, error } = await scanner.snmpGet({
|
||||
host,
|
||||
community,
|
||||
oids: Object.values(OIDS),
|
||||
});
|
||||
const { values } = await scanner.snmpGet({ host, community, oids: Object.values(OIDS) });
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
// OID-Werte den lesbaren Namen zuordnen
|
||||
const named: Record<string, string> = {};
|
||||
for (const [name, oid] of Object.entries(OIDS)) named[name] = values[oid] ?? '-';
|
||||
|
||||
// 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);
|
||||
const inErr = parseInt(named['ifInErrors'], 10) || 0;
|
||||
const outErr = parseInt(named['ifOutErrors'], 10) || 0;
|
||||
let status: MeasureStatus = 0;
|
||||
if (sum > 0) status = 1;
|
||||
if (sum > 100) status = 2;
|
||||
if (inErr + outErr > 0) status = 1;
|
||||
if (inErr + outErr > 100) status = 2;
|
||||
|
||||
const speedBits = num('ifSpeed');
|
||||
return {
|
||||
label: `${host}: ${sum} Fehler`,
|
||||
label: `${host}: ${inErr + outErr} Fehler`,
|
||||
result: {
|
||||
host,
|
||||
port: text('ifDescr'),
|
||||
linkSpeed: speedBits !== null ? `${Math.round(speedBits / 1_000_000)} Mbit/s` : null,
|
||||
port: named['ifDescr'],
|
||||
linkSpeed: named['ifSpeed'],
|
||||
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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,7 @@
|
|||
/**
|
||||
* Tool: Dauer-/Stresstest — Langzeitmessung von Verlust und Latenz.
|
||||
*
|
||||
* ACHTUNG, bekannte Schwächen (Umbau in ROADMAP_UMSETZUNG.md Phase 2):
|
||||
* - 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.
|
||||
* Läuft nativ als Foreground-Service, damit Android den Lauf nicht beendet.
|
||||
* 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.',
|
||||
scope: 'protocol',
|
||||
params: [
|
||||
// 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: 'host', label: 'Ziel', type: 'text', default: '192.168.1.1' },
|
||||
{
|
||||
key: 'duration',
|
||||
label: 'Dauer',
|
||||
|
|
@ -47,51 +31,11 @@ export const stressTestTool: Tool = {
|
|||
},
|
||||
],
|
||||
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);
|
||||
|
||||
// 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
|
||||
// kann dadurch später kommen als die angegebene Dauer.
|
||||
// Auf das Ende des Laufs warten (Foreground-Service misst weiter)
|
||||
await new Promise((r) => setTimeout(r, durationSec * 1000));
|
||||
const res = await scanner.stopStressTest({ runId });
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
|
||||
import { scanner } from '../../scanner';
|
||||
import type { MeasureStatus, Tool } from '../types';
|
||||
import type { Tool } from '../types';
|
||||
|
||||
export const tracerouteTool: Tool = {
|
||||
id: 'traceroute',
|
||||
|
|
@ -15,36 +15,14 @@ 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, 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
|
||||
|
||||
const { hops } = await scanner.traceroute({ host });
|
||||
return {
|
||||
label: reachedTarget
|
||||
? `${hops.length} Hops bis ${host}`
|
||||
: antwortend === 0
|
||||
? `${host}: keine Antwort auf dem Weg`
|
||||
: `${host} nicht erreicht (${antwortend} Hops geantwortet)`,
|
||||
label: `${hops.length} Hops bis ${host}`,
|
||||
result: {
|
||||
ziel: host,
|
||||
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.',
|
||||
hops: hops.map((h) => `${h.ttl}. ${h.ip} (${h.ms} ms)`),
|
||||
},
|
||||
measureStatus: status,
|
||||
measureStatus: hops.length > 0 ? 0 : 2,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -163,17 +163,7 @@ export interface WifiTrackSession {
|
|||
}
|
||||
|
||||
/** Ampel-Bewertung einer Messung */
|
||||
/**
|
||||
* 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
|
||||
export type MeasureStatus = 0 | 1 | 2; // 0=ok, 1=warn, 2=fail
|
||||
|
||||
/** Ergebnis eines Tool-Laufs */
|
||||
export interface Measurement {
|
||||
|
|
|
|||
|
|
@ -49,9 +49,8 @@
|
|||
),
|
||||
);
|
||||
|
||||
// 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'];
|
||||
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail'];
|
||||
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500'];
|
||||
|
||||
onMount(async () => {
|
||||
const uuid = $page.params.id ?? '';
|
||||
|
|
|
|||
Loading…
Reference in a new issue