package de.data_it_solution.netdiag import android.Manifest import android.content.Context import android.net.wifi.WifiManager import com.getcapacitor.JSArray import com.getcapacitor.JSObject import com.getcapacitor.Plugin import com.getcapacitor.PluginCall import com.getcapacitor.PluginMethod import com.getcapacitor.annotation.CapacitorPlugin import com.getcapacitor.annotation.Permission import com.getcapacitor.annotation.PermissionCallback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.File import java.io.FileReader import java.net.DatagramPacket import java.net.DatagramSocket import java.net.InetAddress import java.net.InetSocketAddress import java.net.Socket import java.util.concurrent.ConcurrentHashMap /** * NetDiagScanner — natives Scan-Plugin der NetDiag-App. * * Der WebView kann keine Raw-Sockets/ICMP/ARP. Diese Klasse führt die * eigentlichen Netzwerk-Messungen durch und wird vom TS-Wrapper * (src/lib/scanner.ts) über `registerPlugin('NetDiagScanner')` angesprochen. * * Integration: Datei nach * android/app/src/main/java/de/data_it_solution/netdiag/ * kopieren und in MainActivity registrieren: * registerPlugin(NetDiagScannerPlugin::class.java) */ @CapacitorPlugin( name = "NetDiagScanner", permissions = [ Permission(alias = "location", strings = [Manifest.permission.ACCESS_FINE_LOCATION]) ] ) class NetDiagScannerPlugin : Plugin() { private val io = CoroutineScope(Dispatchers.IO) private val stressRuns = ConcurrentHashMap() /* --------------------------------------------------------------------- */ /* Subnetz / lokale Netzwerkinfo */ /* --------------------------------------------------------------------- */ @PluginMethod fun getLocalSubnet(call: PluginCall) { io.launch { try { val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo val ipInt = dhcp?.ipAddress ?: 0 val gwInt = dhcp?.gateway ?: 0 val ip = if (ipInt != 0) intToIp(ipInt) else firstLocalIpv4() val gateway = if (gwInt != 0) intToIp(gwInt) else "" val base = ip.substringBeforeLast('.', "192.168.1") resolve(call, JSObject() .put("subnet", "$base.0/24") .put("ip", ip) .put("gateway", gateway)) } catch (e: Exception) { call.reject("getLocalSubnet: ${e.message}") } } } /* --------------------------------------------------------------------- */ /* IP-Scan: Geräte im Subnetz finden */ /* --------------------------------------------------------------------- */ @PluginMethod fun ipScan(call: PluginCall) { val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt") val base = subnet.substringBeforeLast('.', "192.168.1") io.launch { try { // Parallel-Ping über das gesamte /24 val alive = withContext(Dispatchers.IO) { (1..254).map { host -> async { val ip = "$base.$host" if (InetAddress.getByName(ip).isReachable(350)) ip else null } }.awaitAll().filterNotNull() } val arp = readArpTable() val devices = JSArray() for (ip in alive) { val dev = JSObject().put("ip", ip) arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) } try { val name = InetAddress.getByName(ip).canonicalHostName if (name != ip) dev.put("hostname", name) } catch (_: Exception) { } devices.put(dev) } resolve(call, JSObject().put("devices", devices)) } catch (e: Exception) { call.reject("ipScan: ${e.message}") } } } /* --------------------------------------------------------------------- */ /* Port-Scan */ /* --------------------------------------------------------------------- */ @PluginMethod fun portScan(call: PluginCall) { val ip = call.getString("ip") ?: return call.reject("ip fehlt") val portsArg = call.getArray("ports") ?: JSArray() val ports = (0 until portsArg.length()).map { portsArg.getInt(it) } io.launch { try { val open = withContext(Dispatchers.IO) { ports.map { port -> async { try { Socket().use { s -> s.connect(InetSocketAddress(ip, port), 700) } port } catch (_: Exception) { null } } }.awaitAll().filterNotNull() } val arr = JSArray() for (p in open) arr.put(JSObject().put("port", p).put("service", serviceName(p))) resolve(call, JSObject().put("open", arr)) } catch (e: Exception) { call.reject("portScan: ${e.message}") } } } /* --------------------------------------------------------------------- */ /* Ping-Qualität */ /* --------------------------------------------------------------------- */ @PluginMethod fun pingQuality(call: PluginCall) { val host = call.getString("host") ?: return call.reject("host fehlt") val count = call.getInt("count") ?: 20 io.launch { try { resolve(call, measurePing(host, count)) } catch (e: Exception) { call.reject("pingQuality: ${e.message}") } } } private fun measurePing(host: String, count: Int): JSObject { val times = ArrayList() val addr = InetAddress.getByName(host) repeat(count) { val t0 = System.nanoTime() if (addr.isReachable(1000)) { times.add((System.nanoTime() - t0) / 1_000_000.0) } Thread.sleep(200) } 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)) } /* --------------------------------------------------------------------- */ /* WLAN-Scan */ /* --------------------------------------------------------------------- */ @PluginMethod fun wifiScan(call: PluginCall) { if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) { requestPermissionForAlias("location", call, "wifiScanPermCallback") return } doWifiScan(call) } @PermissionCallback private fun wifiScanPermCallback(call: PluginCall) { if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) { doWifiScan(call) } else { call.reject("Standortberechtigung für WLAN-Scan abgelehnt") } } private fun doWifiScan(call: PluginCall) { try { val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager val arr = JSArray() for (r in wifi.scanResults) { val freq = r.frequency arr.put(JSObject() .put("ssid", if (r.SSID.isNullOrEmpty()) "(versteckt)" else r.SSID) .put("bssid", r.BSSID ?: "") .put("channel", freqToChannel(freq)) .put("rssi", r.level) .put("band", if (freq > 4000) "5 GHz" else "2.4 GHz")) } resolve(call, JSObject().put("networks", arr)) } catch (e: Exception) { call.reject("wifiScan: ${e.message}") } } /* --------------------------------------------------------------------- */ /* DHCP-Discover (Rogue-DHCP-Erkennung) */ /* --------------------------------------------------------------------- */ @PluginMethod fun dhcpDiscover(call: PluginCall) { io.launch { try { val servers = discoverDhcpServers() val arr = JSArray() val arp = readArpTable() for (ip in servers) { arr.put(JSObject().put("ip", ip).put("mac", arp[ip] ?: "")) } resolve(call, JSObject().put("servers", arr)) } catch (e: Exception) { call.reject("dhcpDiscover: ${e.message}") } } } /** * Sendet einen DHCPDISCOVER-Broadcast und sammelt antwortende Server. * Best-effort: scheitert auf manchen Geräten an Port-68-Belegung. */ private fun discoverDhcpServers(): List { val found = LinkedHashSet() val socket = DatagramSocket() socket.broadcast = true socket.soTimeout = 3000 try { val xid = byteArrayOf(0x39, 0x03, 0x42.toByte(), 0x12) val packet = buildDhcpDiscover(xid) socket.send(DatagramPacket(packet, packet.size, InetAddress.getByName("255.255.255.255"), 67)) val buf = ByteArray(1500) val deadline = System.currentTimeMillis() + 3000 while (System.currentTimeMillis() < deadline) { try { val resp = DatagramPacket(buf, buf.size) socket.receive(resp) // Server-Identifier (Option 54) aus dem Paket lesen, sonst Absender val srv = parseDhcpServerId(buf, resp.length) ?: resp.address.hostAddress if (srv != null) found.add(srv) } catch (_: Exception) { break } } } finally { socket.close() } return found.toList() } /* --------------------------------------------------------------------- */ /* SNMP v2c GET (Switch: Link-Speed, Fehlerzähler) */ /* --------------------------------------------------------------------- */ @PluginMethod fun snmpGet(call: PluginCall) { val host = call.getString("host") ?: return call.reject("host fehlt") val community = call.getString("community") ?: "public" val oidsArg = call.getArray("oids") ?: JSArray() val oids = (0 until oidsArg.length()).map { oidsArg.getString(it) } io.launch { try { val values = JSObject() for (oid in oids) { values.put(oid, Snmp.get(host, community, oid) ?: "-") } resolve(call, JSObject().put("values", values)) } catch (e: Exception) { call.reject("snmpGet: ${e.message}") } } } /* --------------------------------------------------------------------- */ /* Traceroute (über das System-ping-Binary, kein Root nötig) */ /* --------------------------------------------------------------------- */ @PluginMethod fun traceroute(call: PluginCall) { val host = call.getString("host") ?: return call.reject("host fehlt") io.launch { try { val hops = JSArray() for (ttl in 1..20) { 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 } resolve(call, JSObject().put("hops", hops)) } catch (e: Exception) { call.reject("traceroute: ${e.message}") } } } /* --------------------------------------------------------------------- */ /* Durchsatz-Test */ /* --------------------------------------------------------------------- */ @PluginMethod fun throughput(call: PluginCall) { val host = call.getString("host") ?: return call.reject("host fehlt") val port = call.getInt("port") ?: 5201 val durationSec = call.getInt("durationSec") ?: 10 io.launch { try { // Einfacher TCP-Durchsatz gegen eine Sink/Source-Gegenstelle: // Download = empfangene Bytes, Upload = gesendete Bytes je Sekunde. val res = measureThroughput(host, port, durationSec) resolve(call, res) } catch (e: Exception) { call.reject("throughput: ${e.message}") } } } private fun measureThroughput(host: String, port: Int, durationSec: Int): JSObject { val buf = ByteArray(64 * 1024) var downBytes = 0L var upBytes = 0L // Upload-Phase Socket().use { s -> s.connect(InetSocketAddress(host, port), 3000) val end = System.currentTimeMillis() + durationSec * 500L val out = s.getOutputStream() while (System.currentTimeMillis() < end) { out.write(buf); upBytes += buf.size } } // Download-Phase Socket().use { s -> s.connect(InetSocketAddress(host, port), 3000) val end = System.currentTimeMillis() + durationSec * 500L val inp = s.getInputStream() while (System.currentTimeMillis() < end) { val n = inp.read(buf); if (n < 0) break; downBytes += n } } val secs = durationSec / 2.0 return JSObject() .put("downMbps", round1(downBytes * 8.0 / 1_000_000.0 / secs)) .put("upMbps", round1(upBytes * 8.0 / 1_000_000.0 / secs)) } /* --------------------------------------------------------------------- */ /* Dauer-/Stresstest */ /* --------------------------------------------------------------------- */ @PluginMethod fun startStressTest(call: PluginCall) { val host = call.getString("host") ?: return call.reject("host fehlt") val durationSec = call.getInt("durationSec") ?: 300 val runId = "run-${System.currentTimeMillis()}" val run = StressRun(host, durationSec) stressRuns[runId] = run // Hinweis: für Läufe > einige Minuten sollte ein Foreground-Service // gestartet werden, sonst kann Android den Prozess beenden. io.launch { val end = System.currentTimeMillis() + durationSec * 1000L while (System.currentTimeMillis() < end && run.active) { val q = measurePing(host, 5) run.samples++ run.lossSum += q.getInteger("lossPct", 0) ?: 0 run.avgSum += q.getDouble("avgMs") run.maxMs = Math.max(run.maxMs, q.getDouble("maxMs")) } } resolve(call, JSObject().put("runId", runId)) } @PluginMethod fun stopStressTest(call: PluginCall) { val runId = call.getString("runId") ?: return call.reject("runId fehlt") val run = stressRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden") run.active = false val n = Math.max(1, run.samples) resolve(call, JSObject() .put("samples", run.samples) .put("lossPct", run.lossSum / n) .put("avgMs", round1(run.avgSum / n)) .put("maxMs", round1(run.maxMs))) } private class StressRun(val host: String, val durationSec: Int) { var active = true var samples = 0 var lossSum = 0 var avgSum = 0.0 var maxMs = 0.0 } /* --------------------------------------------------------------------- */ /* Hilfsfunktionen */ /* --------------------------------------------------------------------- */ private fun resolve(call: PluginCall, data: JSObject) { // Capacitor erwartet die Auflösung auf dem Main-Thread activity.runOnUiThread { call.resolve(data) } } private fun intToIp(i: Int): String = "${i and 0xFF}.${i shr 8 and 0xFF}.${i shr 16 and 0xFF}.${i shr 24 and 0xFF}" private fun firstLocalIpv4(): String { java.net.NetworkInterface.getNetworkInterfaces().toList().forEach { ni -> ni.inetAddresses.toList().forEach { addr -> if (!addr.isLoopbackAddress && addr is java.net.Inet4Address) { return addr.hostAddress ?: "" } } } return "192.168.1.1" } /** /proc/net/arp lesen -> Map IP -> MAC (kann auf neuen Android-Versionen leer sein) */ private fun readArpTable(): Map { val map = HashMap() try { BufferedReader(FileReader(File("/proc/net/arp"))).use { br -> br.readLine() // Kopfzeile var line = br.readLine() while (line != null) { val parts = line.split(Regex("\\s+")) if (parts.size >= 4 && parts[3] != "00:00:00:00:00:00") { map[parts[0]] = parts[3].uppercase() } line = br.readLine() } } } catch (_: Exception) { } return map } /** ping mit fester TTL -> (antwortende IP, Latenz in ms) */ private data class Hop(val ip: String, val ms: Double, val reachedTarget: Boolean) private fun pingWithTtl(host: String, ttl: Int): Hop { return try { val proc = ProcessBuilder("/system/bin/ping", "-c", "1", "-W", "2", "-t", ttl.toString(), host) .redirectErrorStream(true).start() val out = proc.inputStream.bufferedReader().readText() proc.waitFor() val ip = Regex("""From ([\d.]+)""").find(out)?.groupValues?.get(1) ?: Regex("""\((\d+\.\d+\.\d+\.\d+)\)""").find(out)?.groupValues?.get(1) ?: "*" val ms = Regex("""time=([\d.]+)""").find(out)?.groupValues?.get(1)?.toDoubleOrNull() ?: 0.0 val reached = out.contains("bytes from") Hop(if (reached) host else ip, ms, reached) } catch (e: Exception) { Hop("*", 0.0, false) } } private fun freqToChannel(freq: Int): Int = when { freq == 2484 -> 14 freq in 2412..2472 -> (freq - 2412) / 5 + 1 freq in 5170..5825 -> (freq - 5170) / 5 + 34 else -> 0 } private fun serviceName(port: Int): String = when (port) { 21 -> "ftp"; 22 -> "ssh"; 23 -> "telnet"; 53 -> "dns"; 80 -> "http" 139 -> "netbios"; 443 -> "https"; 445 -> "smb"; 502 -> "modbus" 1883 -> "mqtt"; 3389 -> "rdp"; 8080 -> "http-alt"; 8443 -> "https-alt" else -> "" } /** Minimaler OUI-Hersteller-Lookup. Für vollständige Abdeckung OUI-DB einbinden. */ private fun ouiVendor(mac: String): String { val oui = mac.replace(":", "").take(6).uppercase() return OUI[oui] ?: "" } private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0 private fun buildDhcpDiscover(xid: ByteArray): ByteArray { val p = ByteArray(300) p[0] = 1 // op = BOOTREQUEST p[1] = 1 // htype = Ethernet p[2] = 6 // hlen System.arraycopy(xid, 0, p, 4, 4) p[10] = 0x80.toByte() // Broadcast-Flag // Magic Cookie p[236] = 99; p[237] = 130.toByte(); p[238] = 83; p[239] = 99 // Option 53: DHCP Message Type = DISCOVER p[240] = 53; p[241] = 1; p[242] = 1 p[243] = 255.toByte() // Ende return p } private fun parseDhcpServerId(buf: ByteArray, len: Int): String? { var i = 240 while (i + 1 < len) { val opt = buf[i].toInt() and 0xFF if (opt == 255) break if (opt == 0) { i++; continue } val l = buf[i + 1].toInt() and 0xFF if (opt == 54 && l == 4) { return "${buf[i+2].toInt() and 0xFF}.${buf[i+3].toInt() and 0xFF}." + "${buf[i+4].toInt() and 0xFF}.${buf[i+5].toInt() and 0xFF}" } i += 2 + l } return null } companion object { /** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */ private val OUI = mapOf( "3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi", "001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link" ) } }