diff --git a/README.md b/README.md index 4f8612c..20de2de 100644 --- a/README.md +++ b/README.md @@ -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 -# native-plugin/ einbinden -> siehe native-plugin/README.md +# android/ liegt komplett im Repo — cap sync kopiert nur die Web-Assets npx cap sync android npx cap open android ``` @@ -45,12 +45,13 @@ 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, WLAN, DHCP, SNMP, Traceroute, Stress + netzwerk/ IP-Scan, Port, Ping, IP-Konflikt, SNMP, Traceroute, Stress internet/ Durchsatz-Test telefonie/ (folgt: SIP, FreePBX, RTP) src/routes/ login/ auftraege/ kunden/ protokoll/[id]/ einstellungen/ -native-plugin/ Kotlin-Plugin NetDiagScanner (+ Integrationsanleitung) +android/ natives Android-Projekt inkl. Kotlin-Plugin (einzige Quelle) +native-plugin/ nur noch Anleitung/Hinweise zum Plugin ``` ## Neues Tool hinzufügen diff --git a/native-plugin/MonitorService.kt b/native-plugin/MonitorService.kt deleted file mode 100644 index 9bd98bf..0000000 --- a/native-plugin/MonitorService.kt +++ /dev/null @@ -1,81 +0,0 @@ -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)) - } - } -} diff --git a/native-plugin/NetDiagScannerPlugin.kt b/native-plugin/NetDiagScannerPlugin.kt deleted file mode 100644 index d9fa0c5..0000000 --- a/native-plugin/NetDiagScannerPlugin.kt +++ /dev/null @@ -1,1556 +0,0 @@ -package de.data_it_solution.netdiag - -import android.Manifest -import android.app.NotificationManager -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.IntentFilter -import android.net.ConnectivityManager -import android.net.LinkProperties -import android.net.Network -import android.net.NetworkCapabilities -import android.net.Uri -import android.net.nsd.NsdManager -import android.net.nsd.NsdServiceInfo -import android.net.wifi.WifiManager -import android.os.Build -import android.provider.Settings -import androidx.core.app.NotificationCompat -import androidx.core.content.FileProvider -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.FileOutputStream -import java.io.FileReader -import java.net.DatagramPacket -import java.net.DatagramSocket -import java.net.HttpURLConnection -import java.net.Inet4Address -import java.net.InetAddress -import java.net.InetSocketAddress -import java.net.Socket -import java.net.URL -import java.util.concurrent.ConcurrentHashMap -import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.CountDownLatch -import java.util.concurrent.TimeUnit - -/** - * 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 { - var ip = "" - var prefix = 0 - var gateway = "" - - // 1. Aktives Netz (WLAN ODER Ethernet) über LinkProperties — - // liefert das ECHTE Präfix, nicht pauschal /24. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val cm = context.applicationContext - .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val lp = cm.activeNetwork?.let { cm.getLinkProperties(it) } - if (lp != null) { - for (la in lp.linkAddresses) { - val a = la.address - if (a is Inet4Address && !a.isLoopbackAddress && !a.isLinkLocalAddress) { - ip = a.hostAddress ?: "" - prefix = la.prefixLength - break - } - } - for (route in lp.routes) { - val gw = route.gateway - if (route.isDefaultRoute && gw is Inet4Address && !gw.isAnyLocalAddress) { - gateway = gw.hostAddress ?: "" - break - } - } - } - } - - // 2. Fallback: WLAN-DHCP-Info (ältere Geräte / LinkProperties leer) - if (ip.isEmpty() || prefix == 0) { - val wifi = context.applicationContext - .getSystemService(Context.WIFI_SERVICE) as WifiManager - @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo - if (dhcp != null) { - if (ip.isEmpty() && dhcp.ipAddress != 0) ip = intToIp(dhcp.ipAddress) - if (prefix == 0 && dhcp.netmask != 0) prefix = Integer.bitCount(dhcp.netmask) - if (gateway.isEmpty() && dhcp.gateway != 0) gateway = intToIp(dhcp.gateway) - } - } - - // 3. Letzter Fallback - if (ip.isEmpty()) ip = firstLocalIpv4() - if (prefix !in 1..32) prefix = 24 - - // 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("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 hosts = hostsInSubnet(subnet) - if (hosts.isEmpty()) { - return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet") - } - io.launch { - try { - // Parallel-Ping über ALLE Host-Adressen des Subnetzes — CIDR-genau, - // also exakt der Bereich, den die Netzmaske aufspannt (/24, /23, /22 …). - val alive = withContext(Dispatchers.IO) { - hosts.map { ipInt -> - async { - val ip = intToIpv4(ipInt) - if (InetAddress.getByName(ip).isReachable(350)) ip else null - } - }.awaitAll().filterNotNull() - } - val arp = readArpTable() - // Pro lebendem Host parallel anreichern: Reverse-DNS, NetBIOS-Name, - // Quick-Port-Probe (für die Geräteart-Heuristik). - val enriched = withContext(Dispatchers.IO) { - alive.map { ip -> - async { - val hostname = try { - val n = InetAddress.getByName(ip).canonicalHostName - if (n != ip) n else "" - } catch (_: Exception) { "" } - EnrichedHost(ip, hostname, netbiosName(ip), quickPortProbe(ip)) - } - }.awaitAll() - } - val devices = JSArray() - for (h in enriched) { - val dev = JSObject().put("ip", h.ip) - val mac = arp[h.ip] - val vendor = mac?.let { ouiVendor(it) } ?: "" - if (mac != null) dev.put("mac", mac) - if (vendor.isNotEmpty()) dev.put("vendor", vendor) - if (h.hostname.isNotEmpty()) dev.put("hostname", h.hostname) - if (!h.netbios.isNullOrEmpty()) dev.put("netbiosName", h.netbios) - if (h.openPorts.isNotEmpty()) { - val pa = JSArray() - h.openPorts.sorted().forEach { pa.put(it) } - dev.put("openPorts", pa) - } - val nameHint = h.hostname.ifEmpty { h.netbios ?: "" } - val type = guessDeviceType(vendor, nameHint, h.openPorts) - if (type.isNotEmpty()) dev.put("deviceType", type) - devices.put(dev) - } - resolve(call, JSObject().put("devices", devices)) - } catch (e: Exception) { - call.reject("ipScan: ${e.message}") - } - } - } - - /** Zwischenergebnis der parallelen Geräte-Anreicherung im IP-Scan */ - private data class EnrichedHost( - val ip: String, - val hostname: String, - val netbios: String?, - val openPorts: List, - ) - - /** - * Schneller TCP-Connect-Test auf einige Schlüsselports — speist die - * Geräteart-Heuristik (z.B. 554 → Kamera, 9100 → Drucker). 500 ms Timeout. - */ - private suspend fun quickPortProbe(ip: String): List { - val probe = listOf(22, 23, 80, 443, 445, 554, 1883, 3389, 8000, 9100) - return withContext(Dispatchers.IO) { - probe.map { port -> - async { - try { - Socket().use { it.connect(InetSocketAddress(ip, port), 500) } - port - } catch (_: Exception) { - null - } - } - }.awaitAll().filterNotNull() - } - } - - /** - * NetBIOS-Namensabfrage (NBSTAT, UDP 137) — liefert den Workstation-Namen - * vieler Windows-Rechner, NAS-Geräte und Kameras. Kein Root nötig. - */ - private fun netbiosName(ip: String): String? { - // NBSTAT-„Node Status Request" für den Wildcard-Namen "*" (50 Byte). - val query = byteArrayOf( - 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x20, - 0x43, 0x4B, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, - 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, - 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, - 0x00, 0x00, 0x21, 0x00, 0x01, - ) - return try { - DatagramSocket().use { sock -> - sock.soTimeout = 600 - sock.send(DatagramPacket(query, query.size, InetAddress.getByName(ip), 137)) - val buf = ByteArray(512) - val resp = DatagramPacket(buf, buf.size) - sock.receive(resp) - val data = resp.data - if (resp.length < 57) return null - val numNames = data[56].toInt() and 0xFF - for (i in 0 until numNames) { - val base = 57 + i * 18 - if (base + 18 > resp.length) break - val suffix = data[base + 15].toInt() and 0xFF - val isGroup = (data[base + 16].toInt() and 0x80) != 0 - // Suffix 0x00 + kein Gruppen-Flag = der eigentliche Gerätename - if (suffix == 0x00 && !isGroup) { - val name = String(data, base, 15, Charsets.US_ASCII).trim() - if (name.isNotEmpty()) return name - } - } - null - } - } catch (_: Exception) { - null - } - } - - /** - * Geräteart aus Hersteller, Name und offenen Ports schätzen. - * Best-Effort-Heuristik — leerer String, wenn nichts Eindeutiges erkennbar. - */ - private fun guessDeviceType(vendor: String, name: String, ports: List): String { - val v = vendor.lowercase() - val n = name.lowercase() - // 1. Eindeutige Hersteller - if (v.contains("axis") || v.contains("hikvision") || v.contains("dahua")) return "Kamera" - if (v.contains("avm")) return "Router" - if (v.contains("sonos")) return "Lautsprecher" - if (v.contains("synology") || v.contains("qnap")) return "NAS" - if (v.contains("raspberry")) return "Raspberry Pi" - if (v.contains("espressif")) return "IoT-Gerät" - // 2. Namensmuster - if (n.contains("camera") || n.contains("kamera") || n.contains("ipcam") || - n.contains("nvr") || n.contains("axis") || n.contains("hikvision")) return "Kamera" - if (n.contains("printer") || n.contains("drucker")) return "Drucker" - if (n.contains("fritz") || n.contains("router") || n.contains("gateway")) return "Router" - if (n.contains("switch")) return "Switch" - if (n.contains("wallbox") || n.contains("keba") || n.contains("charger")) return "Wallbox" - if (n.contains("nas") || n.contains("synology") || n.contains("diskstation")) return "NAS" - // 3. Portmuster - return when { - 554 in ports -> "Kamera" - 9100 in ports || 515 in ports -> "Drucker" - 3389 in ports || (445 in ports && 1883 !in ports) -> "Windows-PC" - 1883 in ports || 502 in ports -> "IoT/SPS" - 22 in ports && (80 in ports || 443 in ports) -> "Server" - 22 in ports -> "Linux-Gerät" - else -> "" - } - } - - /* --------------------------------------------------------------------- */ - /* mDNS / Bonjour — Drucker, Kameras, Chromecast, AirPlay … */ - /* --------------------------------------------------------------------- */ - - /** - * mDNS-Dienstsuche über NsdManager. Liefert pro IP den Bonjour-Namen und - * die angebotenen Diensttypen. Kein Root, keine Berechtigung nötig — nur - * ein Multicast-Lock fürs WLAN. Ergebnis ist Best-Effort (Timeout-begrenzt). - */ - @PluginMethod - fun mdnsScan(call: PluginCall) { - val timeoutMs = (call.getInt("timeoutMs") ?: 4000).toLong() - io.launch { - try { - val found = discoverMdns(timeoutMs) - val arr = JSArray() - for ((ip, info) in found) { - val services = JSArray() - info.services.forEach { services.put(it) } - arr.put(JSObject() - .put("ip", ip) - .put("name", info.name) - .put("services", services)) - } - resolve(call, JSObject().put("devices", arr)) - } catch (e: Exception) { - call.reject("mdnsScan: ${e.message}") - } - } - } - - private class MdnsInfo { - var name: String = "" - val services: MutableSet = ConcurrentHashMap.newKeySet() - } - - @Suppress("DEPRECATION") - private fun discoverMdns(timeoutMs: Long): Map { - val nsd = context.applicationContext - .getSystemService(Context.NSD_SERVICE) as NsdManager - val wifi = context.applicationContext - .getSystemService(Context.WIFI_SERVICE) as WifiManager - val types = listOf( - "_printer._tcp.", "_ipp._tcp.", "_pdl-datastream._tcp.", "_googlecast._tcp.", - "_airplay._tcp.", "_raop._tcp.", "_http._tcp.", "_workstation._tcp.", - "_smb._tcp.", "_rtsp._tcp.", "_axis-video._tcp.", "_hap._tcp.", "_ssh._tcp.", - ) - val result = ConcurrentHashMap() - val pending = ConcurrentLinkedQueue() - val listeners = ArrayList() - val mlock = wifi.createMulticastLock("netdiag-mdns").apply { - setReferenceCounted(true) - try { acquire() } catch (_: Exception) { } - } - try { - for (type in types) { - val l = object : NsdManager.DiscoveryListener { - override fun onStartDiscoveryFailed(s: String?, e: Int) {} - override fun onStopDiscoveryFailed(s: String?, e: Int) {} - override fun onDiscoveryStarted(s: String?) {} - override fun onDiscoveryStopped(s: String?) {} - override fun onServiceFound(info: NsdServiceInfo) { pending.add(info) } - override fun onServiceLost(info: NsdServiceInfo) {} - } - try { - nsd.discoverServices(type, NsdManager.PROTOCOL_DNS_SD, l) - listeners.add(l) - } catch (_: Exception) { } - } - // Gefundene Dienste seriell auflösen — NsdManager.resolveService - // verträgt keine parallelen Aufrufe. - val deadline = System.currentTimeMillis() + timeoutMs - while (System.currentTimeMillis() < deadline) { - val info = pending.poll() - if (info == null) { - Thread.sleep(100) - continue - } - val lock = CountDownLatch(1) - try { - nsd.resolveService(info, object : NsdManager.ResolveListener { - override fun onResolveFailed(s: NsdServiceInfo?, e: Int) { lock.countDown() } - override fun onServiceResolved(s: NsdServiceInfo) { - val host = s.host?.hostAddress - if (host != null) { - val mi = result.getOrPut(host) { MdnsInfo() } - if (mi.name.isEmpty()) mi.name = s.serviceName ?: "" - val t = (s.serviceType ?: "").trim('.', ' ') - if (t.isNotEmpty()) mi.services.add(t) - } - lock.countDown() - } - }) - lock.await(1500, TimeUnit.MILLISECONDS) - } catch (_: Exception) { } - } - } finally { - for (l in listeners) { - try { nsd.stopServiceDiscovery(l) } catch (_: Exception) { } - } - try { if (mlock.isHeld) mlock.release() } catch (_: Exception) { } - } - return result - } - - /** - * Alle Host-IPs (als Int) eines CIDR-Subnetzes. - * "192.168.1.0/24" -> .1 bis .254, "10.0.0.0/22" -> 1022 Hosts usw. - * Ohne Praefix wird /24 angenommen. Netz- und Broadcast-Adresse sind - * ausgenommen (ausser /31, /32). Leer bei ungueltig oder > /16. - */ - private fun hostsInSubnet(cidr: String): List { - val parts = cidr.trim().split('/') - val ipInt = ipv4ToInt(parts[0].trim()) ?: return emptyList() - val prefix = if (parts.size > 1) (parts[1].trim().toIntOrNull() ?: 24) else 24 - if (prefix < 0 || prefix > 32) return emptyList() - val mask = if (prefix == 0) 0 else (-1 shl (32 - prefix)) - val network = ipInt and mask - val broadcast = network or mask.inv() - val out = ArrayList() - if (prefix >= 31) { - var i = network - while (true) { out.add(i); if (i == broadcast) break; i++ } - return out - } - val count = (broadcast.toLong() and 0xFFFFFFFFL) - (network.toLong() and 0xFFFFFFFFL) - 1L - if (count < 1L || count > 65534L) return emptyList() - var i = network + 1 - val last = broadcast - 1 - while (true) { out.add(i); if (i == last) break; i++ } - return out - } - - /** "192.168.1.50" -> 32-Bit-Int (big-endian), null bei ungueltig */ - private fun ipv4ToInt(s: String): Int? { - val o = s.split('.') - if (o.size != 4) return null - var v = 0 - for (part in o) { - val n = part.toIntOrNull() ?: return null - if (n < 0 || n > 255) return null - v = (v shl 8) or n - } - return v - } - - /** 32-Bit-Int (big-endian) -> "192.168.1.50" */ - private fun intToIpv4(i: Int): String = - "${(i shr 24) and 0xFF}.${(i shr 16) and 0xFF}.${(i shr 8) and 0xFF}.${i and 0xFF}" - - /* --------------------------------------------------------------------- */ - /* IP-Konflikt-Prüfung — eine IP, von zwei Geräten gleichzeitig benutzt */ - /* --------------------------------------------------------------------- */ - - /** - * Sucht IP-Adressen, die im Netz von mehr als einem Gerät benutzt werden. - * - * Ohne Root lässt sich kein Roh-ARP mitschneiden — der praktikable Weg: - * über mehrere Runden das Subnetz anpingen (das erzwingt jeweils eine - * ARP-Auflösung) und nach jeder Runde /proc/net/arp auslesen. Erscheint für - * dieselbe IP über die Runden hinweg mehr als eine MAC, nutzen zwei Geräte - * diese Adresse → Konflikt. - * - * Risiko: /proc/net/arp kann auf neueren Android-Versionen leer sein — - * dann meldet das Ergebnis `arpAvailable = false`. - */ - @PluginMethod - fun arpConflictScan(call: PluginCall) { - val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt") - val rounds = (call.getInt("rounds") ?: 4).coerceIn(2, 10) - val delayMs = (call.getInt("delayMs") ?: 600).coerceIn(0, 5000).toLong() - val hosts = hostsInSubnet(subnet) - if (hosts.isEmpty()) { - return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet") - } - io.launch { - try { - val seen = HashMap>() - var arpEverFilled = false - for (r in 0 until rounds) { - withContext(Dispatchers.IO) { - hosts.map { ipInt -> - async { - try { - InetAddress.getByName(intToIpv4(ipInt)).isReachable(300) - } catch (_: Exception) { - false - } - } - }.awaitAll() - } - val arp = readArpTable() - if (arp.isNotEmpty()) arpEverFilled = true - for ((ip, mac) in arp) { - seen.getOrPut(ip) { HashSet() }.add(mac) - } - if (r < rounds - 1 && delayMs > 0) Thread.sleep(delayMs) - } - val conflicts = JSArray() - for ((ip, macs) in seen) { - if (macs.size > 1) { - val macArr = JSArray() - macs.forEach { macArr.put(it) } - conflicts.put(JSObject().put("ip", ip).put("macs", macArr)) - } - } - resolve(call, JSObject() - .put("conflicts", conflicts) - .put("checked", seen.size) - .put("rounds", rounds) - .put("arpAvailable", arpEverFilled)) - } catch (e: Exception) { - call.reject("arpConflictScan: ${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-Info — DHCP-Server, von dem das Gerät seine Adresse bezieht */ - /* --------------------------------------------------------------------- */ - - /** - * Liefert den DHCP-Server, von dem das Gerät selbst seine IP bezogen hat, - * samt Lease-Dauer, Gateway und DNS. - * - * Eine aktive Rogue-DHCP-Suche (DHCPDISCOVER senden, OFFER empfangen) ist - * auf nicht gerootetem Android NICHT möglich: der Server antwortet auf - * UDP-Port 68, der ist privilegiert (< 1024) und vom System-DHCP-Client - * belegt. Darum hier nur die verlässliche, vom OS bezogene Lease-Info. - */ - @PluginMethod - fun dhcpInfo(call: PluginCall) { - io.launch { - try { - val wifi = context.applicationContext - .getSystemService(Context.WIFI_SERVICE) as WifiManager - @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo - - val out = JSObject() - val dns = JSArray() - if (dhcp != null && dhcp.serverAddress != 0) { - out.put("server", intToIp(dhcp.serverAddress)) - out.put("lease", dhcp.leaseDuration) - out.put("gateway", if (dhcp.gateway != 0) intToIp(dhcp.gateway) else "") - if (dhcp.dns1 != 0) dns.put(intToIp(dhcp.dns1)) - if (dhcp.dns2 != 0) dns.put(intToIp(dhcp.dns2)) - } else { - // Kein WLAN-DHCP greifbar (z. B. Ethernet) — Server nicht ermittelbar - out.put("server", "") - out.put("lease", 0) - out.put("gateway", "") - } - out.put("dns", dns) - resolve(call, out) - } catch (e: Exception) { - call.reject("dhcpInfo: ${e.message}") - } - } - } - - /* --------------------------------------------------------------------- */ - /* 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() - var deadStreak = 0 - for (ttl in 1..30) { - val hop = pingWithTtl(host, ttl) - hops.put(JSObject() - .put("ttl", ttl) - .put("ip", hop.ip) - .put("ms", hop.ms)) - if (hop.ip == host || hop.reachedTarget) break - // Nach 5 toten Hops in Folge abbrechen statt stur bis TTL 30 - deadStreak = if (hop.ip == "*") deadStreak + 1 else 0 - if (deadStreak >= 5) 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 - } - - /* --------------------------------------------------------------------- */ - /* Geräte-Monitor — Dauerüberwachung mehrerer Geräte (Kamera-Problem) */ - /* --------------------------------------------------------------------- */ - - private val monitorRuns = ConcurrentHashMap() - - private class MonitorRun( - val targets: List>, - val intervalSec: Int, - ) { - @Volatile var active = true - /** je IP: true = erreichbar */ - val state = ConcurrentHashMap() - /** je IP: Beginn des aktuellen Ausfalls (für die Ausfalldauer) */ - val downSince = ConcurrentHashMap() - /** alle bisher erzeugten Ereignisse — für die UI-Wiederaufnahme */ - val events = java.util.concurrent.CopyOnWriteArrayList() - } - - /** - * Überwacht die Erreichbarkeit mehrerer Geräte im festen Intervall. - * Jeder Wechsel erreichbar↔nicht erreichbar erzeugt ein `monitorEvent`. - * Ein Vordergrund-Dienst hält den Prozess am Leben (Display aus / App-Wechsel), - * ein WifiLock verhindert, dass das WLAN zwischen den Messungen schläft. - */ - @PluginMethod - fun startMonitor(call: PluginCall) { - val arr = call.getArray("hosts") ?: return call.reject("hosts fehlt") - val intervalSec = (call.getInt("intervalSec") ?: 30).coerceIn(5, 600) - val targets = ArrayList>() - for (i in 0 until arr.length()) { - val o = arr.optJSONObject(i) ?: continue - val ip = o.optString("ip") - if (ip.isNotEmpty()) targets.add(ip to o.optString("label", ip)) - } - if (targets.isEmpty()) return call.reject("keine Geräte gewählt") - - val runId = "mon-${System.currentTimeMillis()}" - val run = MonitorRun(targets, intervalSec) - monitorRuns[runId] = run - MonitorService.start(context, "${targets.size} Geräte · alle ${intervalSec}s") - - @Suppress("DEPRECATION") - val wifiLock = (context.applicationContext - .getSystemService(Context.WIFI_SERVICE) as WifiManager) - .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "netdiag-monitor") - try { wifiLock.acquire() } catch (_: Exception) { } - - io.launch { - try { - // Ausgangslage erfassen — erzeugt noch kein Ereignis - for ((ip, _) in targets) run.state[ip] = isReachable(ip) - while (run.active) { - Thread.sleep(intervalSec * 1000L) - if (!run.active) break - for ((ip, label) in targets) { - if (!run.active) break - val up = isReachable(ip) - val prev = run.state[ip] ?: up - if (up == prev) continue - run.state[ip] = up - val now = System.currentTimeMillis() - val ev = JSObject() - .put("runId", runId) - .put("ip", ip) - .put("label", label) - .put("ts", now) - if (up) { - val since = run.downSince.remove(ip) - ev.put("type", "up") - ev.put( - "durationSec", - if (since != null) ((now - since) / 1000L).toInt() else 0, - ) - } else { - run.downSince[ip] = now - ev.put("type", "down") - notifyDown(label, ip) - } - run.events.add(ev) - notifyListeners("monitorEvent", ev) - } - } - } finally { - try { if (wifiLock.isHeld) wifiLock.release() } catch (_: Exception) { } - } - } - resolve(call, JSObject().put("runId", runId)) - } - - @PluginMethod - fun stopMonitor(call: PluginCall) { - val runId = call.getString("runId") ?: return call.reject("runId fehlt") - val run = monitorRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden") - run.active = false - if (monitorRuns.isEmpty()) MonitorService.stop(context) - val events = JSArray() - run.events.forEach { events.put(it) } - resolve(call, JSObject().put("stopped", true).put("events", events)) - } - - /** Status eines Monitor-Laufs abfragen (UI-Wiederaufnahme nach Seitenwechsel) */ - @PluginMethod - fun getMonitorStatus(call: PluginCall) { - val runId = call.getString("runId") ?: return call.reject("runId fehlt") - val run = monitorRuns[runId] - val events = JSArray() - run?.events?.forEach { events.put(it) } - resolve(call, JSObject() - .put("running", run != null && run.active) - .put("events", events)) - } - - private fun isReachable(ip: String): Boolean = try { - InetAddress.getByName(ip).isReachable(1500) - } catch (_: Exception) { - false - } - - /** Heads-up-Benachrichtigung, wenn ein überwachtes Gerät ausfällt */ - private fun notifyDown(label: String, ip: String) { - try { - MonitorService.ensureChannel(context) - val mgr = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val n = NotificationCompat.Builder(context, MonitorService.CHANNEL_ID) - .setContentTitle("Gerät nicht erreichbar") - .setContentText("$label ($ip) antwortet nicht mehr") - .setSmallIcon(android.R.drawable.stat_notify_error) - .setAutoCancel(true) - .build() - mgr.notify(ip.hashCode(), n) - } catch (_: Exception) { } - } - - /* --------------------------------------------------------------------- */ - /* IP-Test — alle aktiven Netzwerk-Interfaces (WLAN, Ethernet, USB-RJ45) */ - /* --------------------------------------------------------------------- */ - - /** - * Listet alle aktiven Netzwerk-Interfaces des Geräts. Für jedes Interface - * werden IP, Präfix, Gateway, DNS, DHCP-Server (nur WLAN) und Link-Speed - * geliefert. Bei USB-RJ45-Adaptern wird die Speed aus - * /sys/class/net//speed gelesen — funktioniert ohne Root. - * - * Anwendungsfall: Adapter in eine Netzwerkdose stecken und sofort sehen, - * ob eine IP kommt und ob der Link 10/100/1000 Mbit ausspuckt. - */ - @PluginMethod - fun linkInfo(call: PluginCall) { - io.launch { - try { - val cm = context.applicationContext - .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - val wifi = context.applicationContext - .getSystemService(Context.WIFI_SERVICE) as WifiManager - val defaultIface = cm.activeNetwork?.let { cm.getLinkProperties(it)?.interfaceName } - - val arr = JSArray() - val networks: Array = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) cm.allNetworks - else emptyArray() - for (net in networks) { - val caps = cm.getNetworkCapabilities(net) ?: continue - val lp = cm.getLinkProperties(net) ?: continue - val iface = lp.interfaceName ?: continue - - val type = when { - caps.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> "wifi" - caps.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> "ethernet" - caps.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> "cellular" - else -> "other" - } - // IPv4 + Präfix aus den LinkAddresses - var ipv4: String? = null - var prefix = 0 - for (la in lp.linkAddresses) { - val a = la.address - if (a is Inet4Address && !a.isLoopbackAddress && !a.isLinkLocalAddress) { - ipv4 = a.hostAddress - prefix = la.prefixLength - break - } - } - var gateway: String? = null - for (route in lp.routes) { - val gw = route.gateway - if (route.isDefaultRoute && gw is Inet4Address && !gw.isAnyLocalAddress) { - gateway = gw.hostAddress - break - } - } - val dnsArr = JSArray() - for (d in lp.dnsServers) { - if (d is Inet4Address) d.hostAddress?.let { dnsArr.put(it) } - } - - val obj = JSObject() - .put("iface", iface) - .put("type", type) - .put("isDefault", iface == defaultIface) - if (ipv4 != null) obj.put("ipv4", ipv4) - if (prefix > 0) obj.put("prefixLength", prefix) - if (gateway != null) obj.put("gateway", gateway) - obj.put("dns", dnsArr) - - // USB-RJ45-Heuristik - val ifLower = iface.lowercase() - val isUsb = ifLower.startsWith("rndis") || - ifLower.startsWith("usb") || - ifLower.startsWith("ecm") || - (type == "ethernet" && ifLower.matches(Regex("eth[1-9].*"))) - if (isUsb) obj.put("isUsbEthernet", true) - - // Link-Speed-Quellen - if (type == "wifi") { - try { - @Suppress("DEPRECATION") val ci = wifi.connectionInfo - if (ci != null) { - if (ci.linkSpeed > 0) obj.put("linkSpeedMbps", ci.linkSpeed) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - ci.rxLinkSpeedMbps > 0) { - obj.put("rxLinkSpeedMbps", ci.rxLinkSpeedMbps) - } - val ssid = ci.ssid?.trim('"') - if (!ssid.isNullOrEmpty() && ssid != "") { - obj.put("ssid", ssid) - } - if (!ci.bssid.isNullOrEmpty()) obj.put("bssid", ci.bssid) - if (ci.rssi != -127) obj.put("rssi", ci.rssi) - } - @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo - if (dhcp != null && dhcp.serverAddress != 0) { - obj.put("dhcpServer", intToIp(dhcp.serverAddress)) - if (dhcp.leaseDuration > 0) obj.put("leaseSec", dhcp.leaseDuration) - } - } catch (_: Exception) { /* WLAN-Info nicht greifbar */ } - } else if (type == "ethernet") { - val mbps = readEthernetSpeed(iface) - if (mbps != null) obj.put("linkSpeedMbps", mbps) - } - arr.put(obj) - } - resolve(call, JSObject().put("links", arr)) - } catch (e: Exception) { - call.reject("linkInfo: ${e.message}") - } - } - } - - /** - * Link-Speed eines Ethernet-Interfaces aus /sys/class/net//speed lesen. - * Liefert Mbit/s als Int (z.B. 1000 für Gigabit), oder null wenn nicht lesbar - * (auf manchen Android-12+-Geräten ist /sys/class/net/ selektiv gesperrt). - */ - private fun readEthernetSpeed(iface: String): Int? { - return try { - val f = File("/sys/class/net/$iface/speed") - if (!f.exists() || !f.canRead()) return null - val raw = f.readText().trim() - val n = raw.toIntOrNull() ?: return null - if (n <= 0) null else n - } catch (_: Exception) { - null - } - } - - /* --------------------------------------------------------------------- */ - /* WLAN-Empfangstracker — RSSI live beim Durchgehen durchs Gebäude */ - /* --------------------------------------------------------------------- */ - - private val wifiTrackRuns = ConcurrentHashMap() - - private class WifiTrackRun( - val bssid: String, - val mode: String, // "connected" | "scan" - val intervalMs: Int, - ) { - @Volatile var active = true - val samples = java.util.concurrent.CopyOnWriteArrayList() - var scanReceiver: BroadcastReceiver? = null - } - - /** - * Aktiven WLAN-Scan triggern (System legt frische Ergebnisse in den Cache). - * Liefert `triggered=false` bei Android-Throttling (ab API 28: max. 4 - * Foreground-Scans / 2 Min). - */ - @PluginMethod - fun startWifiScan(call: PluginCall) { - if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) { - requestPermissionForAlias("location", call, "startWifiScanPermCallback") - return - } - doStartWifiScan(call) - } - - @PermissionCallback - private fun startWifiScanPermCallback(call: PluginCall) { - if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) { - doStartWifiScan(call) - } else { - call.reject("Standortberechtigung für WLAN-Scan abgelehnt") - } - } - - private fun doStartWifiScan(call: PluginCall) { - try { - val wifi = context.applicationContext - .getSystemService(Context.WIFI_SERVICE) as WifiManager - @Suppress("DEPRECATION") val triggered = wifi.startScan() - resolve(call, JSObject().put("triggered", triggered)) - } catch (e: Exception) { - call.reject("startWifiScan: ${e.message}") - } - } - - /** - * Empfangs-Tracker für ein BSSID starten. Wenn das BSSID das aktuell - * verbundene Netz ist: Live-RSSI aus `WifiManager.connectionInfo` alle - * `intervalMs` (kein Scan-Throttling). Sonst: periodischer `startScan` - * alle ~30 s, BroadcastReceiver lauscht auf `SCAN_RESULTS_AVAILABLE_ACTION` - * und liefert den Wert des passenden BSSIDs. - */ - @PluginMethod - fun startWifiTrack(call: PluginCall) { - val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt") - val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000) - if (getPermissionState("location") != com.getcapacitor.PermissionState.GRANTED) { - requestPermissionForAlias("location", call, "wifiTrackPermCallback") - return - } - doStartWifiTrack(call, bssid, intervalMs) - } - - @PermissionCallback - private fun wifiTrackPermCallback(call: PluginCall) { - if (getPermissionState("location") == com.getcapacitor.PermissionState.GRANTED) { - val bssid = call.getString("bssid") ?: return call.reject("bssid fehlt") - val intervalMs = (call.getInt("intervalMs") ?: 500).coerceIn(200, 10_000) - doStartWifiTrack(call, bssid, intervalMs) - } else { - call.reject("Standortberechtigung für WLAN-Tracker abgelehnt") - } - } - - private fun doStartWifiTrack(call: PluginCall, bssid: String, intervalMs: Int) { - val wifi = context.applicationContext - .getSystemService(Context.WIFI_SERVICE) as WifiManager - @Suppress("DEPRECATION") val connectedBssid = wifi.connectionInfo?.bssid - val mode = if (bssid.equals(connectedBssid, ignoreCase = true)) "connected" else "scan" - val runId = "wifi-${System.currentTimeMillis()}" - val run = WifiTrackRun(bssid, mode, intervalMs) - wifiTrackRuns[runId] = run - - if (mode == "connected") { - // Live-Polling vom verbundenen AP — kein Scan-Throttling - io.launch { - try { - while (run.active) { - @Suppress("DEPRECATION") val ci = wifi.connectionInfo - if (ci != null && ci.rssi != -127 && - bssid.equals(ci.bssid, ignoreCase = true)) { - val now = System.currentTimeMillis() - val ev = JSObject() - .put("runId", runId) - .put("ts", now) - .put("rssi", ci.rssi) - .put("source", "connected") - run.samples.add(ev) - notifyListeners("wifiSignal", ev) - } - Thread.sleep(intervalMs.toLong()) - } - } catch (_: Exception) { /* Tracker endet */ } - } - } else { - // Scan-Modus: BroadcastReceiver + periodisches startScan - val receiver = object : BroadcastReceiver() { - override fun onReceive(ctx: Context?, intent: Intent?) { - if (intent?.action != WifiManager.SCAN_RESULTS_AVAILABLE_ACTION) return - try { - val match = wifi.scanResults.firstOrNull { - it.BSSID.equals(bssid, ignoreCase = true) - } ?: return - val now = System.currentTimeMillis() - val ev = JSObject() - .put("runId", runId) - .put("ts", now) - .put("rssi", match.level) - .put("source", "scan") - run.samples.add(ev) - notifyListeners("wifiSignal", ev) - } catch (_: Exception) { /* ignorieren */ } - } - } - run.scanReceiver = receiver - context.applicationContext.registerReceiver( - receiver, - IntentFilter(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION), - ) - // Erster Scan sofort, dann alle 30 s neu triggern - io.launch { - try { - @Suppress("DEPRECATION") wifi.startScan() - while (run.active) { - Thread.sleep(30_000L) - if (!run.active) break - @Suppress("DEPRECATION") wifi.startScan() - } - } catch (_: Exception) { /* Tracker endet */ } - } - } - resolve(call, JSObject().put("runId", runId).put("mode", mode)) - } - - @PluginMethod - fun stopWifiTrack(call: PluginCall) { - val runId = call.getString("runId") ?: return call.reject("runId fehlt") - val run = wifiTrackRuns.remove(runId) ?: return call.reject("Lauf nicht gefunden") - run.active = false - run.scanReceiver?.let { - try { - context.applicationContext.unregisterReceiver(it) - } catch (_: Exception) { /* schon abgemeldet */ } - } - val samples = JSArray() - var min = Int.MAX_VALUE - var max = Int.MIN_VALUE - var sum = 0L - var n = 0 - run.samples.forEach { s -> - samples.put(JSObject() - .put("ts", s.optLong("ts")) - .put("rssi", s.optInt("rssi")) - .put("source", s.optString("source"))) - val r = s.optInt("rssi") - if (r < min) min = r - if (r > max) max = r - sum += r - n++ - } - val avg = if (n > 0) (sum / n).toInt() else 0 - resolve(call, JSObject() - .put("samples", samples) - .put("min", if (n > 0) min else 0) - .put("max", if (n > 0) max else 0) - .put("avg", avg)) - } - - /** Status eines Tracker-Laufs abfragen (Wiederaufnahme nach Seitenwechsel) */ - @PluginMethod - fun getWifiTrackStatus(call: PluginCall) { - val runId = call.getString("runId") ?: return call.reject("runId fehlt") - val run = wifiTrackRuns[runId] - val samples = JSArray() - run?.samples?.forEach { s -> - samples.put(JSObject() - .put("ts", s.optLong("ts")) - .put("rssi", s.optInt("rssi")) - .put("source", s.optString("source"))) - } - resolve(call, JSObject() - .put("running", run != null && run.active) - .put("samples", samples) - .put("mode", run?.mode ?: "connected")) - } - - /* --------------------------------------------------------------------- */ - /* App-Update: APK herunterladen und Paketinstaller öffnen */ - /* --------------------------------------------------------------------- */ - - /** - * Lädt die neue APK vom (authentifizierten) Update-Proxy des netdiag-Moduls - * herunter und öffnet den Android-Paketinstaller. Der Download-Fortschritt - * wird laufend als `updateProgress`-Event (0–100 %) gemeldet. - * - * Vor Android 8 genügt die globale Einstellung „Unbekannte Quellen". Ab - * Android 8 muss die App einzeln berechtigt sein — fehlt das Recht, wird - * der passende Einstellungs-Dialog geöffnet und der Aufruf abgewiesen. - */ - @PluginMethod - fun installUpdate(call: PluginCall) { - val url = call.getString("url") ?: return call.reject("url fehlt") - io.launch { - try { - // Ab Android 8: App braucht das Recht, Pakete zu installieren - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && - !context.packageManager.canRequestPackageInstalls() - ) { - val perm = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES) - .setData(Uri.parse("package:${context.packageName}")) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(perm) - call.reject("Bitte erlauben, dass NetDiag Apps installieren darf, dann erneut tippen") - return@launch - } - - val apk = File(context.cacheDir, "NetDiag-update.apk") - downloadApk(url, apk) - - val uri = FileProvider.getUriForFile( - context, "${context.packageName}.fileprovider", apk - ) - val install = Intent(Intent.ACTION_VIEW) - .setDataAndType(uri, "application/vnd.android.package-archive") - .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - context.startActivity(install) - resolve(call, JSObject().put("started", true)) - } catch (e: Exception) { - call.reject("installUpdate: ${e.message}") - } - } - } - - /** APK streamend in `target` laden und dabei `updateProgress`-Events senden. */ - private fun downloadApk(url: String, target: File) { - val conn = (URL(url).openConnection() as HttpURLConnection).apply { - connectTimeout = 15_000 - readTimeout = 120_000 - instanceFollowRedirects = true - } - try { - val code = conn.responseCode - if (code != 200) throw Exception("Download fehlgeschlagen (HTTP $code)") - val total = conn.contentLength.toLong() // -1 wenn unbekannt - var read = 0L - var lastPct = -1 - conn.inputStream.use { input -> - FileOutputStream(target).use { out -> - val buf = ByteArray(64 * 1024) - while (true) { - val n = input.read(buf) - if (n < 0) break - out.write(buf, 0, n) - read += n - if (total > 0) { - val pct = (read * 100 / total).toInt() - if (pct != lastPct) { - lastPct = pct - notifyListeners( - "updateProgress", JSObject().put("percent", pct) - ) - } - } - } - } - } - if (target.length() < 1024) throw Exception("APK unvollständig empfangen") - } finally { - conn.disconnect() - } - } - - /* --------------------------------------------------------------------- */ - /* 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 - - companion object { - /** - * Kuratierter OUI-Auszug der gängigsten Hersteller im Handwerksumfeld - * (Router, Switches, Kameras, Drucker, IoT, NAS). Kein Anspruch auf - * Vollständigkeit — unbekannte MACs liefern einfach einen leeren Vendor. - */ - private val OUI: Map = buildMap { - // AVM / FRITZ!Box - listOf("00040E", "3810D5", "5C4979", "C80E14", "E0286D", "3410F4") - .forEach { put(it, "AVM") } - // TP-Link - listOf("003192", "50C7BF", "D8EB97", "EC086B", "A42BB0", "1CFA68", - "14CC20", "B0487A", "6032B1", "5091E3").forEach { put(it, "TP-Link") } - // Netgear - listOf("000FB5", "20E52A", "00146C", "001B2F", "001E2A", "00223F", - "0024B2", "28C68E", "A040A0", "3C3786", "6CB0CE").forEach { put(it, "Netgear") } - // Ubiquiti - listOf("00156D", "0418D6", "24A43C", "44D9E7", "687251", "788A20", - "802AA8", "B4FB0E", "DC9FDB", "F09FC2", "FCECDA", "74ACB9", - "18E829", "944A0C", "E063DA").forEach { put(it, "Ubiquiti") } - // Cisco - listOf("00000C", "001AA1", "0023AC", "F09E63").forEach { put(it, "Cisco") } - // MikroTik - listOf("000C42", "4C5E0C", "6C3B6B", "CC2DE0", "E48D8C", "64D154", - "B869F4", "18FD74", "2CC81B", "DC2C6E", "744D28", "488F5A") - .forEach { put(it, "MikroTik") } - // D-Link - listOf("001195", "1CBDB9", "001B11", "001CF0", "14D64D", "28107B", - "78542E", "B8A386", "C8BE19").forEach { put(it, "D-Link") } - // Hikvision (Kameras) - listOf("4419B6", "BCAD28", "C05627", "2857BE", "4CBD8F", "54C415", - "A41437", "B4A382", "18680F").forEach { put(it, "Hikvision") } - // Dahua (Kameras) - listOf("3CEF8C", "9002A9", "14A78B", "E0508B", "08EDED", "24526A", - "6C1C71").forEach { put(it, "Dahua") } - // Axis (Kameras) - listOf("00408C", "ACCC8E", "B8A44F", "E82725").forEach { put(it, "Axis") } - // Drucker - listOf("001E0B", "3CD92B", "9457A5", "001321", "A0481C", "308D99", - "380025", "00215A", "9C8E99", "EC8EB5", "705A0F", "B499BA") - .forEach { put(it, "HP") } - listOf("008077", "30055C", "001BA9").forEach { put(it, "Brother") } - listOf("002673", "88873D", "F48139", "2C9EFC", "001E8F", "B08E1A") - .forEach { put(it, "Canon") } - listOf("000048", "0026AB", "A4EE57", "64EB8C", "44D244", "381A52") - .forEach { put(it, "Epson") } - // Apple - listOf("F0B479", "3C0754", "A4B197", "DC2B2A", "040CCE", "7CD1C3", - "F0DBF8", "88665A", "28CFE9", "001EC2", "002500", "D8A25E") - .forEach { put(it, "Apple") } - // Samsung - listOf("002566", "8425DB", "5CF6DC", "0017C9", "001A8A", "3423BA", - "781FDB", "8C7712", "BC1485", "5C0A5B").forEach { put(it, "Samsung") } - // Espressif (ESP32/ESP8266 — Shelly, Tasmota, viele IoT-Geräte) - listOf("240AC4", "30AEA4", "246F28", "84CCA8", "A020A6", "7C9EBD", - "8CAAB5", "3C6105", "24B2DE", "DC4F22", "84F3EB", "BCDDC2", - "A4CF12", "CC50E3", "2462AB", "18FE34", "5CCF7F", "600194", - "2C3AE8", "ECFABC", "B4E62D", "9038C9").forEach { put(it, "Espressif") } - // Raspberry Pi - listOf("B827EB", "DCA632", "E45F01", "28CDC1", "D83ADD", "2CCF67") - .forEach { put(it, "Raspberry Pi") } - // Intel - listOf("001CC0", "3CA9F4", "A0A8CD", "8C1645", "7CB27D", "9C305B", - "0013E8", "5C514F", "94659C").forEach { put(it, "Intel") } - // Sonos - listOf("000E58", "5CAAFD", "949F3E", "B8E937", "347E5C", "48A6B8", - "542A1B").forEach { put(it, "Sonos") } - // NAS - listOf("001132", "9009D0").forEach { put(it, "Synology") } - listOf("00089B", "245EBE").forEach { put(it, "QNAP") } - // Amazon (Echo / Fire) - listOf("8871E5", "FCA183", "44650D", "F0272D", "68DBF5", "50DCE7", - "AC63BE", "40B4CD", "0C47C9", "74C246").forEach { put(it, "Amazon") } - // Google / Nest / Chromecast - listOf("F4F5D8", "F4F5E8", "30FD38", "6CADF8", "546009", "A47733", - "1CF29A", "3C5AB4", "D86C63", "48D6D5").forEach { put(it, "Google") } - // Industrie / Gebäudetechnik - listOf("000E8C", "001B1B", "286336", "001C06", "8CF319") - .forEach { put(it, "Siemens") } - put("00A057", "Lancom") - put("001A22", "eQ-3 / Homematic") - } - } -} diff --git a/native-plugin/README.md b/native-plugin/README.md index 6b09afa..b78e519 100644 --- a/native-plugin/README.md +++ b/native-plugin/README.md @@ -1,36 +1,45 @@ # Natives Scan-Plugin `NetDiagScanner` Der WebView kann keine Raw-Sockets/ICMP/ARP — die eigentliche Netzwerk-Messung -läuft in diesem nativen Android-Plugin (Kotlin). +läuft in einem nativen Android-Plugin (Kotlin). -## Dateien +## Wo der Code liegt -- `NetDiagScannerPlugin.kt` — Capacitor-Plugin mit allen Scan-Methoden -- `Snmp.kt` — minimaler SNMP-v2c-GET-Client +**Einzige Quelle:** -## Integration (einmalig, nach `npx cap add android`) +``` +android/app/src/main/java/de/data_it_solution/netdiag/ +├── NetDiagScannerPlugin.kt Capacitor-Plugin mit allen Scan-Methoden +├── MonitorService.kt Vordergrund-Dienst des Geräte-Monitors +├── Snmp.kt minimaler SNMP-v2c-GET-Client +└── MainActivity.kt registriert das Plugin +``` -1. **Android-Projekt erzeugen** (falls noch nicht vorhanden): - ```bash - npm install - npm run build - npx cap add android - ``` +> **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. -2. **Plugin-Dateien kopieren** nach: - ``` - android/app/src/main/java/de/data_it_solution/netdiag/ - ├── NetDiagScannerPlugin.kt - └── Snmp.kt - ``` +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. -3. **Plugin registrieren** in `android/app/src/main/java/de/data_it_solution/netdiag/MainActivity.kt`: +## 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//` anlegen und in `tools/index.ts` eintragen. + +## Erstmalige Einrichtung (nur relevant, wenn `android/` neu erzeugt wird) + +Normalerweise **nicht nötig** — `android/` ist eingecheckt. Falls doch: + +1. `npm install && npx vite build && npx cap add android` +2. Kotlin-Dateien nach `android/app/src/main/java/de/data_it_solution/netdiag/` legen +3. Plugin in `MainActivity.kt` registrieren: ```kotlin - 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) @@ -38,35 +47,37 @@ läuft in diesem nativen Android-Plugin (Kotlin). } } ``` - -4. **Berechtigungen** in `android/app/src/main/AndroidManifest.xml` (innerhalb ``): +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): ```xml + + + ``` `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 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. +- **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. - **PoE-/Strommessung**: bewusst nicht enthalten (Hardware-Grenze) — späteres Modul. diff --git a/native-plugin/Snmp.kt b/native-plugin/Snmp.kt deleted file mode 100644 index 3154dd4..0000000 --- a/native-plugin/Snmp.kt +++ /dev/null @@ -1,138 +0,0 @@ -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() - 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 - } -} diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 1dd3dab..1782b13 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -126,9 +126,9 @@ export interface NetDiagScannerPlugin { }>; /** Traceroute zu einem Host */ traceroute(opts: { host: string }): Promise<{ hops: TracerouteHop[] }>; - /** Durchsatztest gegen eine Gegenstelle (iperf-kompatibel) */ + /** Durchsatztest gegen eine TCP-Sink/Source-Gegenstelle (KEIN iperf3-Protokoll) */ throughput(opts: { host: string; port: number; durationSec: number }): Promise; - /** Dauer-/Stresstest starten (läuft als Foreground-Service) */ + /** Dauer-/Stresstest starten (läuft NICHT im Foreground-Service, s. stresstest.ts) */ startStressTest(opts: { host: string; durationSec: number }): Promise<{ runId: string }>; /** Laufenden Stresstest beenden und Ergebnis holen */ stopStressTest(opts: { runId: string }): Promise<{ diff --git a/src/lib/tools/internet/iperf.ts b/src/lib/tools/internet/iperf.ts index 29d94ba..614829c 100644 --- a/src/lib/tools/internet/iperf.ts +++ b/src/lib/tools/internet/iperf.ts @@ -1,7 +1,12 @@ /** * Tool: Durchsatz-Test — misst die Bandbreite gegen eine Gegenstelle. * - * Benötigt eine iperf-kompatible Gegenstelle (2. Gerät oder iperf3-Server). + * 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. */ import { scanner } from '../../scanner'; diff --git a/src/lib/tools/netzwerk/ipscan.ts b/src/lib/tools/netzwerk/ipscan.ts index d88549a..18c6bb9 100644 --- a/src/lib/tools/netzwerk/ipscan.ts +++ b/src/lib/tools/netzwerk/ipscan.ts @@ -31,7 +31,11 @@ export const ipScanTool: Tool = { category: 'netzwerk', name: 'IP-Scanner', icon: 'radar', - description: 'Sucht alle Geräte im Netzbereich (ARP + Ping + Namen).', + // 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.', scope: 'protocol', params: [ { diff --git a/src/lib/tools/netzwerk/stresstest.ts b/src/lib/tools/netzwerk/stresstest.ts index 125a5d6..5351197 100644 --- a/src/lib/tools/netzwerk/stresstest.ts +++ b/src/lib/tools/netzwerk/stresstest.ts @@ -1,7 +1,14 @@ /** * Tool: Dauer-/Stresstest — Langzeitmessung von Verlust und Latenz. * - * Läuft nativ als Foreground-Service, damit Android den Lauf nicht beendet. + * 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. * Die Dauer wird als Parameter vorgegeben; run() wartet auf das Ergebnis. */ @@ -35,7 +42,9 @@ export const stressTestTool: Tool = { const durationSec = Number(ctx.params.duration || 300); const { runId } = await scanner.startStressTest({ host, durationSec }); - // Auf das Ende des Laufs warten (Foreground-Service misst weiter) + // 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. await new Promise((r) => setTimeout(r, durationSec * 1000)); const res = await scanner.stopStressTest({ runId });