From 881ca7956f17801b41858643b2c8ef53a2059520 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Fri, 14 Aug 2026 18:39:40 +0200 Subject: [PATCH] Phase 0: doppelte Plugin-Quelle aufgeloest, falsche Doku-Zusagen korrigiert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aufraeumen vor den eigentlichen Korrekturen — ohne Verhaltensaenderung am Code. Doppelte Plugin-Quelle: Die drei Kotlin-Dateien lagen byte-identisch in native-plugin/ UND in android/app/src/main/java/de/data_it_solution/netdiag/. Gebaut wurde immer nur die Kopie unter android/ — eine Aenderung an der falschen Datei sah deshalb aus wie "der Fix wirkt nicht". native-plugin/ enthaelt jetzt nur noch die Anleitung, die auf den echten Ort verweist. Doku sagte drei Dinge, die nicht stimmen: - stresstest.ts behauptete "laeuft nativ als Foreground-Service". Tut es nicht — im Plugin steht an der Stelle nur ein Kommentar, dass man einen ergaenzen sollte. Bei ausgeschaltetem Display friert Android den Lauf ein. - iperf.ts/scanner.ts/README nannten den Durchsatztest "iperf-kompatibel". Er spricht kein iperf3-Protokoll (kein Cookie, kein Parameterblock) und scheitert gegen einen echten iperf3-Server. - ipscan.ts beschrieb sich als "ARP + Ping + Namen". Gefunden wird nur, wer auf Ping antwortet oder sich per mDNS meldet; die ARP-Tabelle wird lediglich nachgeschlagen und ist ab Android 10 meist gar nicht lesbar. Statt der falschen Zusagen stehen dort jetzt die tatsaechlichen Grenzen plus Verweis auf die Phase, die sie behebt. README: DHCP-Check und WLAN-Scan als Werkzeuge entfernt (sind nicht mehr in tools/index.ts registriert, tauchen aber in alten Protokollen noch auf). Geprueft: vite build ok, svelte-check 0 Fehler (2 vorbestehende Warnungen). Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 7 +- native-plugin/MonitorService.kt | 81 -- native-plugin/NetDiagScannerPlugin.kt | 1556 ------------------------- native-plugin/README.md | 93 +- native-plugin/Snmp.kt | 138 --- src/lib/scanner.ts | 4 +- src/lib/tools/internet/iperf.ts | 7 +- src/lib/tools/netzwerk/ipscan.ts | 6 +- src/lib/tools/netzwerk/stresstest.ts | 13 +- 9 files changed, 80 insertions(+), 1825 deletions(-) delete mode 100644 native-plugin/MonitorService.kt delete mode 100644 native-plugin/NetDiagScannerPlugin.kt delete mode 100644 native-plugin/Snmp.kt 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 });