diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 65f10a9..2e317b0 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -41,6 +41,8 @@ + + diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt index 413e296..518a63c 100644 --- a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt +++ b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt @@ -5,6 +5,8 @@ import android.content.Context import android.content.Intent import android.net.ConnectivityManager 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 @@ -27,6 +29,8 @@ 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 @@ -34,6 +38,9 @@ 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. @@ -150,14 +157,36 @@ class NetDiagScannerPlugin : Plugin() { }.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 (ip in alive) { - val dev = JSObject().put("ip", ip) - arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) } - try { - val name = InetAddress.getByName(ip).canonicalHostName - if (name != ip) dev.put("hostname", name) - } catch (_: Exception) { } + 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)) @@ -167,6 +196,215 @@ class NetDiagScannerPlugin : Plugin() { } } + /** 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. @@ -692,10 +930,86 @@ class NetDiagScannerPlugin : Plugin() { private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0 companion object { - /** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */ - private val OUI = mapOf( - "3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi", - "001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link" - ) + /** + * 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/NetDiagScannerPlugin.kt b/native-plugin/NetDiagScannerPlugin.kt index 413e296..518a63c 100644 --- a/native-plugin/NetDiagScannerPlugin.kt +++ b/native-plugin/NetDiagScannerPlugin.kt @@ -5,6 +5,8 @@ import android.content.Context import android.content.Intent import android.net.ConnectivityManager 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 @@ -27,6 +29,8 @@ 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 @@ -34,6 +38,9 @@ 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. @@ -150,14 +157,36 @@ class NetDiagScannerPlugin : Plugin() { }.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 (ip in alive) { - val dev = JSObject().put("ip", ip) - arp[ip]?.let { dev.put("mac", it).put("vendor", ouiVendor(it)) } - try { - val name = InetAddress.getByName(ip).canonicalHostName - if (name != ip) dev.put("hostname", name) - } catch (_: Exception) { } + 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)) @@ -167,6 +196,215 @@ class NetDiagScannerPlugin : Plugin() { } } + /** 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. @@ -692,10 +930,86 @@ class NetDiagScannerPlugin : Plugin() { private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0 companion object { - /** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */ - private val OUI = mapOf( - "3810D5" to "AVM", "DCA632" to "Raspberry Pi", "B827EB" to "Raspberry Pi", - "001CC0" to "Intel", "F0B479" to "Apple", "D8EB97" to "TP-Link" - ) + /** + * 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/src/lib/components/DeviceCard.svelte b/src/lib/components/DeviceCard.svelte new file mode 100644 index 0000000..2d44c5f --- /dev/null +++ b/src/lib/components/DeviceCard.svelte @@ -0,0 +1,128 @@ + + +
+
+
+
+ {title} + {#if onrename} + + {/if} +
+ {#if detail}
{detail}
{/if} +
+
+ {#if device.vendor}{device.vendor}{/if} + {#if onfavorite} + + {/if} +
+
+ + {#if device.deviceType || device.openPorts?.length || device.mdnsServices?.length} +
+ {#if device.deviceType} + + {device.deviceType} + + {/if} + {#each device.openPorts ?? [] as port (port)} + :{port} + {/each} + {#each device.mdnsServices ?? [] as svc (svc)} + {svc} + {/each} +
+ {/if} + + {#if device.note} +

{device.note}

+ {/if} + + {#each measurements as m (m.clientId)} +
+ +
+

{m.label}

+ +
+
+ {/each} + + {#if tools.length && onrun} +
+ {#each tools as tool (tool.id)} + + {/each} +
+ {/if} +
diff --git a/src/lib/protocols.ts b/src/lib/protocols.ts index 605cd55..40828d1 100644 --- a/src/lib/protocols.ts +++ b/src/lib/protocols.ts @@ -44,11 +44,16 @@ export async function createProtocol(init: { /** Gerät zum Protokoll hinzufügen oder per IP aktualisieren */ export function upsertDevice( protocol: Protocol, - dev: Omit & { clientId?: string }, + dev: Partial & { ip: string }, ): Device { const existing = protocol.devices.find((d) => d.ip === dev.ip); if (existing) { - Object.assign(existing, { ...dev, clientId: existing.clientId }); + // Nur gesetzte Felder übernehmen — ein magerer Re-Scan darf zuvor + // gefundene Daten (mDNS-Name, Favorit, eigener Name) nicht überschreiben. + const target = existing as unknown as Record; + for (const [k, v] of Object.entries(dev)) { + if (k !== 'clientId' && v !== undefined) target[k] = v; + } return existing; } const created: Device = { ...dev, clientId: dev.clientId ?? uid() }; diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index 6ccedf7..47437ed 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -15,6 +15,20 @@ export interface ScannedDevice { mac?: string; hostname?: string; vendor?: string; + /** geschätzte Geräteart (Kamera, Drucker, Router …) */ + deviceType?: string; + /** NetBIOS-Name (UDP-137-Abfrage) */ + netbiosName?: string; + /** offene Ports aus der Quick-Port-Probe */ + openPorts?: number[]; +} +/** Ein per mDNS/Bonjour gefundenes Gerät */ +export interface MdnsDevice { + ip: string; + /** Bonjour-Anzeigename */ + name: string; + /** angebotene Diensttypen, z.B. ['_googlecast._tcp', '_printer._tcp'] */ + services: string[]; } export interface OpenPort { port: number; @@ -62,6 +76,8 @@ export interface NetDiagScannerPlugin { getLocalSubnet(): Promise<{ subnet: string; ip: string; gateway: string }>; /** IP-Scan: Geräte im Subnetz finden (ARP + Ping-Sweep + Namensauflösung) */ ipScan(opts: { subnet: string }): Promise<{ devices: ScannedDevice[] }>; + /** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */ + mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>; /** Port-Scan eines Geräts */ portScan(opts: { ip: string; ports: number[] }): Promise<{ open: OpenPort[] }>; /** Ping-Qualität (Latenz, Jitter, Paketverlust) */ @@ -106,10 +122,56 @@ const mock: NetDiagScannerPlugin = { async ipScan() { return { devices: [ - { ip: '192.168.1.1', mac: 'AA:BB:CC:00:00:01', hostname: 'fritzbox', vendor: 'AVM' }, - { ip: '192.168.1.10', mac: 'AA:BB:CC:00:00:0A', hostname: 'switch-keller', vendor: 'TP-Link' }, - { ip: '192.168.1.50', mac: 'AA:BB:CC:00:00:32', hostname: 'handy', vendor: 'Samsung' }, - { ip: '192.168.1.77', mac: 'AA:BB:CC:00:00:4D', hostname: 'wallbox', vendor: 'Keba' }, + { + ip: '192.168.1.1', + mac: 'AA:BB:CC:00:00:01', + hostname: 'fritzbox', + vendor: 'AVM', + deviceType: 'Router', + openPorts: [53, 80, 443], + }, + { + ip: '192.168.1.10', + mac: 'AA:BB:CC:00:00:0A', + hostname: 'switch-keller', + vendor: 'TP-Link', + deviceType: 'Switch', + openPorts: [80], + }, + { + ip: '192.168.1.40', + mac: 'AA:BB:CC:00:00:28', + hostname: 'ipcam-hof', + vendor: 'Hikvision', + deviceType: 'Kamera', + openPorts: [80, 554], + }, + { + ip: '192.168.1.50', + mac: 'AA:BB:CC:00:00:32', + hostname: 'handy', + vendor: 'Samsung', + deviceType: '', + openPorts: [], + }, + { + ip: '192.168.1.77', + mac: 'AA:BB:CC:00:00:4D', + hostname: 'wallbox', + vendor: '', + netbiosName: 'WALLBOX', + deviceType: 'Wallbox', + openPorts: [80, 502], + }, + ], + }; + }, + async mdnsScan() { + return { + devices: [ + { ip: '192.168.1.20', name: 'Brother HL-L2350DW', services: ['_printer._tcp', '_ipp._tcp'] }, + { ip: '192.168.1.30', name: 'Wohnzimmer-TV', services: ['_googlecast._tcp'] }, + { ip: '192.168.1.40', name: 'IP-Kamera Hof', services: ['_rtsp._tcp'] }, ], }; }, diff --git a/src/lib/tools/netzwerk/ipscan.ts b/src/lib/tools/netzwerk/ipscan.ts index d76db8d..d88549a 100644 --- a/src/lib/tools/netzwerk/ipscan.ts +++ b/src/lib/tools/netzwerk/ipscan.ts @@ -8,10 +8,24 @@ * dessen Subnetz direkt. */ -import { scanner } from '../../scanner'; +import { scanner, type MdnsDevice } from '../../scanner'; import { debugLog } from '../../debuglog.svelte'; +import type { Device } from '../../types'; import type { Tool } from '../types'; +/** Geräteart aus den angebotenen mDNS-Diensten ableiten */ +function typeFromMdns(services: string[]): string { + const s = services.join(' '); + if (s.includes('_printer') || s.includes('_ipp') || s.includes('_pdl-datastream')) + return 'Drucker'; + if (s.includes('_googlecast')) return 'Chromecast/TV'; + if (s.includes('_airplay') || s.includes('_raop')) return 'AirPlay-Gerät'; + if (s.includes('_rtsp') || s.includes('_axis-video')) return 'Kamera'; + if (s.includes('_hap')) return 'HomeKit-Gerät'; + if (s.includes('_smb')) return 'NAS'; + return ''; +} + export const ipScanTool: Tool = { id: 'ipscan', category: 'netzwerk', @@ -66,18 +80,51 @@ export const ipScanTool: Tool = { `gescannt wird "${subnet}" (Quelle: ${source})`, ); const { devices } = await scanner.ipScan({ subnet }); - debugLog.add('info', `IP-Scan Ergebnis: ${devices.length} Geräte in ${subnet}`); + + // mDNS/Bonjour zusätzlich abfragen — liefert sprechende Namen und findet + // Geräte, die nicht auf Ping antworten (manche Kameras/Drucker). Best-Effort. + let mdns: MdnsDevice[] = []; + try { + mdns = (await scanner.mdnsScan({ timeoutMs: 4000 })).devices; + } catch { + /* mDNS fehlgeschlagen — IP-Scan bleibt trotzdem gültig */ + } + const mdnsByIp = new Map(mdns.map((m) => [m.ip, m])); + + // Beide Quellen per IP zusammenführen + const merged: (Partial & { ip: string })[] = devices.map((d) => { + const m = mdnsByIp.get(d.ip); + if (!m) return d; + return { + ...d, + mdnsName: m.name, + mdnsServices: m.services, + deviceType: d.deviceType || typeFromMdns(m.services), + }; + }); + // Geräte, die nur per mDNS auftauchten, ergänzen + for (const m of mdns) { + if (merged.some((d) => d.ip === m.ip)) continue; + merged.push({ + ip: m.ip, + hostname: m.name, + mdnsName: m.name, + mdnsServices: m.services, + deviceType: typeFromMdns(m.services), + }); + } + + debugLog.add( + 'info', + `IP-Scan Ergebnis: ${merged.length} Geräte in ${subnet} ` + + `(${devices.length} per Ping/ARP, ${mdns.length} per mDNS)`, + ); const via = source === 'adapter' ? ' (Adapter erkannt)' : ''; return { - label: `${devices.length} Geräte im Netz ${subnet}${via}`, - result: { subnet, count: devices.length, quelle: source }, - measureStatus: devices.length > 0 ? 0 : 1, - devices: devices.map((d) => ({ - ip: d.ip, - mac: d.mac, - hostname: d.hostname, - vendor: d.vendor, - })), + label: `${merged.length} Geräte im Netz ${subnet}${via}`, + result: { subnet, count: merged.length, quelle: source }, + measureStatus: merged.length > 0 ? 0 : 1, + devices: merged, }; }, }; diff --git a/src/lib/tools/types.ts b/src/lib/tools/types.ts index fd922c0..2e9c208 100644 --- a/src/lib/tools/types.ts +++ b/src/lib/tools/types.ts @@ -43,15 +43,9 @@ export interface ToolRunResult { measureStatus: MeasureStatus; /** * Optional: im Netzwerk gefundene Geräte. Werden vom Protokoll - * übernommen (z.B. beim IP-Scan). + * übernommen (z.B. beim IP-Scan). `ip` ist Pflicht, alles Weitere optional. */ - devices?: Array<{ - ip: string; - mac?: string; - hostname?: string; - vendor?: string; - deviceType?: string; - }>; + devices?: Array & { ip: string }>; } /** Ein Diagnose-Werkzeug */ diff --git a/src/routes/protokoll/[id]/+page.svelte b/src/routes/protokoll/[id]/+page.svelte index 8e16c91..a3ef46b 100644 --- a/src/routes/protokoll/[id]/+page.svelte +++ b/src/routes/protokoll/[id]/+page.svelte @@ -5,6 +5,7 @@ import AppHeader from '$lib/components/AppHeader.svelte'; import ToolDialog from '$lib/components/ToolDialog.svelte'; import MeasurementResult from '$lib/components/MeasurementResult.svelte'; + import DeviceCard from '$lib/components/DeviceCard.svelte'; import { getProtocol, saveProtocol, deleteProtocol } from '$lib/db'; import { addMeasurement, upsertDevice } from '$lib/protocols'; import { sync } from '$lib/sync.svelte'; @@ -65,16 +66,11 @@ const tool = activeTool; const result = await tool.run({ params, protocol, device: activeDevice }); - // Neu gefundene Geräte übernehmen (z.B. IP-Scan) + // Neu gefundene Geräte übernehmen (z.B. IP-Scan) — alle gelieferten + // Felder durchreichen (mac, hostname, vendor, deviceType, mDNS, Ports …) if (result.devices) { for (const d of result.devices) { - upsertDevice(protocol, { - ip: d.ip, - mac: d.mac, - hostname: d.hostname, - vendor: d.vendor, - deviceType: d.deviceType, - }); + upsertDevice(protocol, { ...d, lastSeen: Date.now() }); } } @@ -207,36 +203,12 @@

{/if} {#each protocol.devices as device (device.clientId)} -
-
- {device.ip} - {device.vendor ?? ''} -
-
- {device.hostname ?? ''}{device.mac ? ' · ' + device.mac : ''} -
- - {#each measurementsFor(device.clientId) as m (m.clientId)} -
- -
-

{m.label}

- -
-
- {/each} - -
- {#each deviceTools as tool (tool.id)} - - {/each} -
-
+ openTool(tool, device)} + /> {/each}