Bugfixes: IP-Scanner, DHCP, Aufträge, Messergebnisse [apk]
All checks were successful
Build APK / build-apk (push) Successful in 1m41s

- IP-Scanner: ConnectivityManager.getLinkProperties statt hartcodiertem /24 –
  erkennt jetzt das echte Subnetz inkl. Prefix-Länge und Gateway
- DHCP: dhcpDiscover durch dhcpInfo ersetzt (liest WifiManager.dhcpInfo,
  kein Root nötig) – zeigt Server, Gateway, Lease-Zeit, DNS
- Aufträge: tms-Feld ergänzt, Order by tms DESC – "zuletzt bearbeitet" zuerst;
  Checkbox-Logik invertiert (Standard: alle Aufträge, Haken = nur aktive)
- MeasurementResult-Komponente: Arrays (WLAN-Netze, Traceroute-Hops) als
  echte Liste statt Komma-String; Skalare kompakt in einer Zeile
- Traceroute: 5 aufeinanderfolgende Timeouts → Abbruch statt endlos warten
- tools/types.ts: MeasureStatus exportiert (behebt 5 Svelte-Check-Fehler)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-19 22:02:46 +02:00
parent 34356f25ef
commit 53d91d1526
9 changed files with 339 additions and 211 deletions

View file

@ -3,6 +3,7 @@ package de.data_it_solution.netdiag
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Build import android.os.Build
@ -26,9 +27,8 @@ import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.FileReader import java.io.FileReader
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.Inet4Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
@ -66,15 +66,58 @@ class NetDiagScannerPlugin : Plugin() {
fun getLocalSubnet(call: PluginCall) { fun getLocalSubnet(call: PluginCall) {
io.launch { io.launch {
try { try {
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager 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 @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
val ipInt = dhcp?.ipAddress ?: 0 if (dhcp != null) {
val gwInt = dhcp?.gateway ?: 0 if (ip.isEmpty() && dhcp.ipAddress != 0) ip = intToIp(dhcp.ipAddress)
val ip = if (ipInt != 0) intToIp(ipInt) else firstLocalIpv4() if (prefix == 0 && dhcp.netmask != 0) prefix = Integer.bitCount(dhcp.netmask)
val gateway = if (gwInt != 0) intToIp(gwInt) else "" if (gateway.isEmpty() && dhcp.gateway != 0) gateway = intToIp(dhcp.gateway)
val base = ip.substringBeforeLast('.', "192.168.1") }
}
// 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() resolve(call, JSObject()
.put("subnet", "$base.0/24") .put("subnet", "$network/$prefix")
.put("ip", ip) .put("ip", ip)
.put("gateway", gateway)) .put("gateway", gateway))
} catch (e: Exception) { } catch (e: Exception) {
@ -287,56 +330,46 @@ class NetDiagScannerPlugin : Plugin() {
} }
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
/* DHCP-Discover (Rogue-DHCP-Erkennung) */ /* 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 @PluginMethod
fun dhcpDiscover(call: PluginCall) { fun dhcpInfo(call: PluginCall) {
io.launch { io.launch {
try { try {
val servers = discoverDhcpServers() val wifi = context.applicationContext
val arr = JSArray() .getSystemService(Context.WIFI_SERVICE) as WifiManager
val arp = readArpTable() @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
for (ip in servers) {
arr.put(JSObject().put("ip", ip).put("mac", arp[ip] ?: ""))
}
resolve(call, JSObject().put("servers", arr))
} catch (e: Exception) {
call.reject("dhcpDiscover: ${e.message}")
}
}
}
/** val out = JSObject()
* Sendet einen DHCPDISCOVER-Broadcast und sammelt antwortende Server. val dns = JSArray()
* Best-effort: scheitert auf manchen Geräten an Port-68-Belegung. if (dhcp != null && dhcp.serverAddress != 0) {
*/ out.put("server", intToIp(dhcp.serverAddress))
private fun discoverDhcpServers(): List<String> { out.put("lease", dhcp.leaseDuration)
val found = LinkedHashSet<String>() out.put("gateway", if (dhcp.gateway != 0) intToIp(dhcp.gateway) else "")
val socket = DatagramSocket() if (dhcp.dns1 != 0) dns.put(intToIp(dhcp.dns1))
socket.broadcast = true if (dhcp.dns2 != 0) dns.put(intToIp(dhcp.dns2))
socket.soTimeout = 3000 } else {
try { // Kein WLAN-DHCP greifbar (z. B. Ethernet) — Server nicht ermittelbar
val xid = byteArrayOf(0x39, 0x03, 0x42.toByte(), 0x12) out.put("server", "")
val packet = buildDhcpDiscover(xid) out.put("lease", 0)
socket.send(DatagramPacket(packet, packet.size, InetAddress.getByName("255.255.255.255"), 67)) out.put("gateway", "")
val buf = ByteArray(1500) }
val deadline = System.currentTimeMillis() + 3000 out.put("dns", dns)
while (System.currentTimeMillis() < deadline) { resolve(call, out)
try { } catch (e: Exception) {
val resp = DatagramPacket(buf, buf.size) call.reject("dhcpInfo: ${e.message}")
socket.receive(resp)
// Server-Identifier (Option 54) aus dem Paket lesen, sonst Absender
val srv = parseDhcpServerId(buf, resp.length) ?: resp.address.hostAddress
if (srv != null) found.add(srv)
} catch (_: Exception) {
break
} }
} }
} finally {
socket.close()
}
return found.toList()
} }
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
@ -372,13 +405,17 @@ class NetDiagScannerPlugin : Plugin() {
io.launch { io.launch {
try { try {
val hops = JSArray() val hops = JSArray()
for (ttl in 1..20) { var deadStreak = 0
for (ttl in 1..30) {
val hop = pingWithTtl(host, ttl) val hop = pingWithTtl(host, ttl)
hops.put(JSObject() hops.put(JSObject()
.put("ttl", ttl) .put("ttl", ttl)
.put("ip", hop.ip) .put("ip", hop.ip)
.put("ms", hop.ms)) .put("ms", hop.ms))
if (hop.ip == host || hop.reachedTarget) break 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)) resolve(call, JSObject().put("hops", hops))
} catch (e: Exception) { } catch (e: Exception) {
@ -654,37 +691,6 @@ class NetDiagScannerPlugin : Plugin() {
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0 private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
private fun buildDhcpDiscover(xid: ByteArray): ByteArray {
val p = ByteArray(300)
p[0] = 1 // op = BOOTREQUEST
p[1] = 1 // htype = Ethernet
p[2] = 6 // hlen
System.arraycopy(xid, 0, p, 4, 4)
p[10] = 0x80.toByte() // Broadcast-Flag
// Magic Cookie
p[236] = 99; p[237] = 130.toByte(); p[238] = 83; p[239] = 99
// Option 53: DHCP Message Type = DISCOVER
p[240] = 53; p[241] = 1; p[242] = 1
p[243] = 255.toByte() // Ende
return p
}
private fun parseDhcpServerId(buf: ByteArray, len: Int): String? {
var i = 240
while (i + 1 < len) {
val opt = buf[i].toInt() and 0xFF
if (opt == 255) break
if (opt == 0) { i++; continue }
val l = buf[i + 1].toInt() and 0xFF
if (opt == 54 && l == 4) {
return "${buf[i+2].toInt() and 0xFF}.${buf[i+3].toInt() and 0xFF}." +
"${buf[i+4].toInt() and 0xFF}.${buf[i+5].toInt() and 0xFF}"
}
i += 2 + l
}
return null
}
companion object { companion object {
/** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */ /** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */
private val OUI = mapOf( private val OUI = mapOf(

View file

@ -3,6 +3,7 @@ package de.data_it_solution.netdiag
import android.Manifest import android.Manifest
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.ConnectivityManager
import android.net.Uri import android.net.Uri
import android.net.wifi.WifiManager import android.net.wifi.WifiManager
import android.os.Build import android.os.Build
@ -26,9 +27,8 @@ import java.io.BufferedReader
import java.io.File import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.FileReader import java.io.FileReader
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.Inet4Address
import java.net.InetAddress import java.net.InetAddress
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
@ -66,15 +66,58 @@ class NetDiagScannerPlugin : Plugin() {
fun getLocalSubnet(call: PluginCall) { fun getLocalSubnet(call: PluginCall) {
io.launch { io.launch {
try { try {
val wifi = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager 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 @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
val ipInt = dhcp?.ipAddress ?: 0 if (dhcp != null) {
val gwInt = dhcp?.gateway ?: 0 if (ip.isEmpty() && dhcp.ipAddress != 0) ip = intToIp(dhcp.ipAddress)
val ip = if (ipInt != 0) intToIp(ipInt) else firstLocalIpv4() if (prefix == 0 && dhcp.netmask != 0) prefix = Integer.bitCount(dhcp.netmask)
val gateway = if (gwInt != 0) intToIp(gwInt) else "" if (gateway.isEmpty() && dhcp.gateway != 0) gateway = intToIp(dhcp.gateway)
val base = ip.substringBeforeLast('.', "192.168.1") }
}
// 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() resolve(call, JSObject()
.put("subnet", "$base.0/24") .put("subnet", "$network/$prefix")
.put("ip", ip) .put("ip", ip)
.put("gateway", gateway)) .put("gateway", gateway))
} catch (e: Exception) { } catch (e: Exception) {
@ -90,14 +133,18 @@ class NetDiagScannerPlugin : Plugin() {
@PluginMethod @PluginMethod
fun ipScan(call: PluginCall) { fun ipScan(call: PluginCall) {
val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt") val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt")
val base = subnet.substringBeforeLast('.', "192.168.1") val hosts = hostsInSubnet(subnet)
if (hosts.isEmpty()) {
return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet")
}
io.launch { io.launch {
try { try {
// Parallel-Ping über das gesamte /24 // 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) { val alive = withContext(Dispatchers.IO) {
(1..254).map { host -> hosts.map { ipInt ->
async { async {
val ip = "$base.$host" val ip = intToIpv4(ipInt)
if (InetAddress.getByName(ip).isReachable(350)) ip else null if (InetAddress.getByName(ip).isReachable(350)) ip else null
} }
}.awaitAll().filterNotNull() }.awaitAll().filterNotNull()
@ -120,6 +167,51 @@ class NetDiagScannerPlugin : Plugin() {
} }
} }
/**
* 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<Int> {
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<Int>()
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}"
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
/* Port-Scan */ /* Port-Scan */
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
@ -238,56 +330,46 @@ class NetDiagScannerPlugin : Plugin() {
} }
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
/* DHCP-Discover (Rogue-DHCP-Erkennung) */ /* 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 @PluginMethod
fun dhcpDiscover(call: PluginCall) { fun dhcpInfo(call: PluginCall) {
io.launch { io.launch {
try { try {
val servers = discoverDhcpServers() val wifi = context.applicationContext
val arr = JSArray() .getSystemService(Context.WIFI_SERVICE) as WifiManager
val arp = readArpTable() @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
for (ip in servers) {
arr.put(JSObject().put("ip", ip).put("mac", arp[ip] ?: ""))
}
resolve(call, JSObject().put("servers", arr))
} catch (e: Exception) {
call.reject("dhcpDiscover: ${e.message}")
}
}
}
/** val out = JSObject()
* Sendet einen DHCPDISCOVER-Broadcast und sammelt antwortende Server. val dns = JSArray()
* Best-effort: scheitert auf manchen Geräten an Port-68-Belegung. if (dhcp != null && dhcp.serverAddress != 0) {
*/ out.put("server", intToIp(dhcp.serverAddress))
private fun discoverDhcpServers(): List<String> { out.put("lease", dhcp.leaseDuration)
val found = LinkedHashSet<String>() out.put("gateway", if (dhcp.gateway != 0) intToIp(dhcp.gateway) else "")
val socket = DatagramSocket() if (dhcp.dns1 != 0) dns.put(intToIp(dhcp.dns1))
socket.broadcast = true if (dhcp.dns2 != 0) dns.put(intToIp(dhcp.dns2))
socket.soTimeout = 3000 } else {
try { // Kein WLAN-DHCP greifbar (z. B. Ethernet) — Server nicht ermittelbar
val xid = byteArrayOf(0x39, 0x03, 0x42.toByte(), 0x12) out.put("server", "")
val packet = buildDhcpDiscover(xid) out.put("lease", 0)
socket.send(DatagramPacket(packet, packet.size, InetAddress.getByName("255.255.255.255"), 67)) out.put("gateway", "")
val buf = ByteArray(1500) }
val deadline = System.currentTimeMillis() + 3000 out.put("dns", dns)
while (System.currentTimeMillis() < deadline) { resolve(call, out)
try { } catch (e: Exception) {
val resp = DatagramPacket(buf, buf.size) call.reject("dhcpInfo: ${e.message}")
socket.receive(resp)
// Server-Identifier (Option 54) aus dem Paket lesen, sonst Absender
val srv = parseDhcpServerId(buf, resp.length) ?: resp.address.hostAddress
if (srv != null) found.add(srv)
} catch (_: Exception) {
break
} }
} }
} finally {
socket.close()
}
return found.toList()
} }
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */
@ -323,13 +405,17 @@ class NetDiagScannerPlugin : Plugin() {
io.launch { io.launch {
try { try {
val hops = JSArray() val hops = JSArray()
for (ttl in 1..20) { var deadStreak = 0
for (ttl in 1..30) {
val hop = pingWithTtl(host, ttl) val hop = pingWithTtl(host, ttl)
hops.put(JSObject() hops.put(JSObject()
.put("ttl", ttl) .put("ttl", ttl)
.put("ip", hop.ip) .put("ip", hop.ip)
.put("ms", hop.ms)) .put("ms", hop.ms))
if (hop.ip == host || hop.reachedTarget) break 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)) resolve(call, JSObject().put("hops", hops))
} catch (e: Exception) { } catch (e: Exception) {
@ -605,37 +691,6 @@ class NetDiagScannerPlugin : Plugin() {
private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0 private fun round1(v: Double): Double = Math.round(v * 10.0) / 10.0
private fun buildDhcpDiscover(xid: ByteArray): ByteArray {
val p = ByteArray(300)
p[0] = 1 // op = BOOTREQUEST
p[1] = 1 // htype = Ethernet
p[2] = 6 // hlen
System.arraycopy(xid, 0, p, 4, 4)
p[10] = 0x80.toByte() // Broadcast-Flag
// Magic Cookie
p[236] = 99; p[237] = 130.toByte(); p[238] = 83; p[239] = 99
// Option 53: DHCP Message Type = DISCOVER
p[240] = 53; p[241] = 1; p[242] = 1
p[243] = 255.toByte() // Ende
return p
}
private fun parseDhcpServerId(buf: ByteArray, len: Int): String? {
var i = 240
while (i + 1 < len) {
val opt = buf[i].toInt() and 0xFF
if (opt == 255) break
if (opt == 0) { i++; continue }
val l = buf[i + 1].toInt() and 0xFF
if (opt == 54 && l == 4) {
return "${buf[i+2].toInt() and 0xFF}.${buf[i+3].toInt() and 0xFF}." +
"${buf[i+4].toInt() and 0xFF}.${buf[i+5].toInt() and 0xFF}"
}
i += 2 + l
}
return null
}
companion object { companion object {
/** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */ /** Kleiner OUI-Auszug — bei Bedarf vollständige IEEE-OUI-Datei einbinden. */
private val OUI = mapOf( private val OUI = mapOf(

View file

@ -0,0 +1,46 @@
<script lang="ts">
/**
* Stellt das `result`-Objekt einer Messung lesbar dar.
*
* Skalare Werte (Latenz, Anzahl, …) kommen kompakt in eine Zeile. Arrays
* — WLAN-Netze, Traceroute-Hops — werden als echte, zeilenweise Liste
* dargestellt statt in eine endlose Komma-Zeile gequetscht.
*/
let { result }: { result: Record<string, unknown> } = $props();
/** Leere Werte ('', null, undefined, leeres Array) ausblenden */
function isEmpty(v: unknown): boolean {
if (v == null || v === '') return true;
if (Array.isArray(v)) return v.length === 0;
return false;
}
const entries = $derived(Object.entries(result).filter(([, v]) => !isEmpty(v)));
/** Skalare als eine kompakte „schlüssel: wert · schlüssel: wert"-Zeile */
const scalarText = $derived(
entries
.filter(([, v]) => !Array.isArray(v))
.map(([k, v]) => `${k}: ${String(v)}`)
.join(' · '),
);
/** Array-Werte je als eigene Liste */
const lists = $derived(entries.filter(([, v]) => Array.isArray(v)) as [string, unknown[]][]);
</script>
{#if scalarText}
<p class="break-words text-[11px] text-zinc-500">{scalarText}</p>
{/if}
{#each lists as [key, items] (key)}
<div class="mt-0.5">
<span class="text-[11px] text-zinc-600">{key}:</span>
<ul class="mt-0.5 flex flex-col gap-0.5">
{#each items as item, i (i)}
<li class="break-words pl-2 text-[11px] text-zinc-400">{String(item)}</li>
{/each}
</ul>
</div>
{/each}

View file

@ -36,9 +36,15 @@ export interface WifiNetwork {
rssi: number; rssi: number;
band: string; band: string;
} }
export interface DhcpServer { export interface DhcpLease {
ip: string; /** DHCP-Server, von dem das Gerät seine IP bezieht ('' wenn unbekannt) */
mac?: string; server: string;
/** Lease-Dauer in Sekunden (0 wenn unbekannt) */
lease: number;
/** Standard-Gateway */
gateway: string;
/** zugewiesene DNS-Server */
dns: string[];
} }
export interface TracerouteHop { export interface TracerouteHop {
ttl: number; ttl: number;
@ -62,8 +68,8 @@ export interface NetDiagScannerPlugin {
pingQuality(opts: { host: string; count: number }): Promise<PingQuality>; pingQuality(opts: { host: string; count: number }): Promise<PingQuality>;
/** WLAN-Scan: umliegende Netze, Kanäle, Signalstärke */ /** WLAN-Scan: umliegende Netze, Kanäle, Signalstärke */
wifiScan(): Promise<{ networks: WifiNetwork[] }>; wifiScan(): Promise<{ networks: WifiNetwork[] }>;
/** DHCP-Server im Netz erkennen (Rogue-DHCP-Erkennung) */ /** DHCP-Lease-Info des Geräts (Server, Lease-Dauer, Gateway, DNS) */
dhcpDiscover(): Promise<{ servers: DhcpServer[] }>; dhcpInfo(): Promise<DhcpLease>;
/** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */ /** SNMP v2c Abfrage (Switch: Link-Speed, Fehlerzähler) */
snmpGet(opts: { host: string; community: string; oids: string[] }): Promise<{ snmpGet(opts: { host: string; community: string; oids: string[] }): Promise<{
values: Record<string, string>; values: Record<string, string>;
@ -137,8 +143,13 @@ const mock: NetDiagScannerPlugin = {
], ],
}; };
}, },
async dhcpDiscover() { async dhcpInfo() {
return { servers: [{ ip: '192.168.1.1', mac: 'AA:BB:CC:00:00:01' }] }; return {
server: '192.168.1.1',
lease: 86400,
gateway: '192.168.1.1',
dns: ['192.168.1.1', '8.8.8.8'],
};
}, },
async snmpGet(opts) { async snmpGet(opts) {
const values: Record<string, string> = {}; const values: Record<string, string> = {};

View file

@ -1,6 +1,10 @@
/** /**
* Tool: DHCP-Check erkennt antwortende DHCP-Server. * Tool: DHCP-Info zeigt den DHCP-Server, von dem das Gerät seine Adresse hat.
* Mehr als ein Server deutet auf einen Rogue-DHCP hin (Warnung). *
* Eine aktive Rogue-DHCP-Suche ist auf nicht gerootetem Android nicht möglich:
* der DHCP-Server antwortet auf den privilegierten UDP-Port 68, den eine
* normale App nicht binden kann. Darum hier nur die verlässliche Lease-Info,
* die das Betriebssystem selbst bezogen hat.
*/ */
import { scanner } from '../../scanner'; import { scanner } from '../../scanner';
@ -9,25 +13,25 @@ import type { MeasureStatus, Tool } from '../types';
export const dhcpCheckTool: Tool = { export const dhcpCheckTool: Tool = {
id: 'dhcpcheck', id: 'dhcpcheck',
category: 'netzwerk', category: 'netzwerk',
name: 'DHCP-Check', name: 'DHCP-Info',
icon: 'server', icon: 'server',
description: 'Findet DHCP-Server — erkennt unerwünschte Zweit-Server.', description: 'Zeigt DHCP-Server, Lease-Dauer und DNS des Geräts.',
scope: 'protocol', scope: 'protocol',
params: [], params: [],
async run() { async run() {
const { servers } = await scanner.dhcpDiscover(); const info = await scanner.dhcpInfo();
let status: MeasureStatus = 0; const hasServer = info.server !== '';
if (servers.length === 0) status = 2; // kein DHCP-Server const status: MeasureStatus = hasServer ? 0 : 1;
if (servers.length > 1) status = 2; // Rogue-DHCP
return { return {
label: label: hasServer ? `DHCP-Server: ${info.server}` : 'Kein DHCP-Server ermittelbar',
servers.length === 1
? `1 DHCP-Server: ${servers[0].ip}`
: `${servers.length} DHCP-Server (!)`,
result: { result: {
count: servers.length, server: info.server || '—',
server: servers.map((s) => `${s.ip}${s.mac ? ' / ' + s.mac : ''}`), lease: info.lease ? `${info.lease} s` : '—',
hinweis: servers.length > 1 ? 'Mehrere DHCP-Server — Rogue-DHCP prüfen!' : '', gateway: info.gateway || '—',
dns: info.dns.length ? info.dns : ['—'],
hinweis: hasServer
? 'Nur der DHCP-Server des Geräts — Rogue-DHCP-Erkennung braucht Root.'
: 'Auf Ethernet / ohne WLAN nicht ermittelbar.',
}, },
measureStatus: status, measureStatus: status,
}; };

View file

@ -9,6 +9,10 @@
import type { Device, MeasureStatus, Protocol } from '../types'; import type { Device, MeasureStatus, Protocol } from '../types';
// Für Tool-Dateien unter tools/<kategorie>/ mit-exportieren, damit sie ihre
// Ampel-Bewertung typisieren können, ohne quer in src/lib/types zu greifen.
export type { MeasureStatus };
export type ToolCategory = 'netzwerk' | 'internet' | 'telefonie'; export type ToolCategory = 'netzwerk' | 'internet' | 'telefonie';
/** Eingabefeld eines Tools (für das Parameter-Formular) */ /** Eingabefeld eines Tools (für das Parameter-Formular) */

View file

@ -30,6 +30,8 @@ export interface Order {
ref: string; ref: string;
refClient?: string; refClient?: string;
date?: number; date?: number;
/** Unix-Zeit der letzten Änderung (Dolibarr commande.tms) — Sortierschlüssel der Liste */
tms?: number;
/** Dolibarr-Status: -1 storniert, 0 Entwurf, 1 validiert, 2 in Bearbeitung, 3 abgeschlossen */ /** Dolibarr-Status: -1 storniert, 0 Entwurf, 1 validiert, 2 in Bearbeitung, 3 abgeschlossen */
status: number; status: number;
/** true wenn Status 0/1/2 (aktiver Auftrag) */ /** true wenn Status 0/1/2 (aktiver Auftrag) */

View file

@ -12,7 +12,9 @@
let orders = $state<Order[]>([]); let orders = $state<Order[]>([]);
let search = $state(''); let search = $state('');
let showAll = $state(false); // Standard: alle Aufträge, zuletzt bearbeitete zuerst (Server sortiert nach tms).
// Haken setzen blendet abgeschlossene aus und zeigt nur aktive Aufträge.
let onlyActive = $state(false);
let loading = $state(false); let loading = $state(false);
let loadError = $state(''); let loadError = $state('');
@ -40,7 +42,7 @@
loading = true; loading = true;
loadError = ''; loadError = '';
try { try {
const res = await listOrders({ open: !showAll, q: search.trim() || undefined }); const res = await listOrders({ open: onlyActive, q: search.trim() || undefined });
orders = res.orders; orders = res.orders;
} catch (e) { } catch (e) {
loadError = e instanceof ApiError ? e.message : 'Laden fehlgeschlagen'; loadError = e instanceof ApiError ? e.message : 'Laden fehlgeschlagen';
@ -54,9 +56,9 @@
searchTimer = setTimeout(load, 300); searchTimer = setTimeout(load, 300);
} }
async function toggleShowAll() { async function toggleOnlyActive() {
showAll = !showAll; onlyActive = !onlyActive;
await Preferences.set({ key: 'nd_show_all', value: showAll ? '1' : '0' }); await Preferences.set({ key: 'nd_only_active', value: onlyActive ? '1' : '0' });
load(); load();
} }
@ -82,7 +84,7 @@
} }
onMount(async () => { onMount(async () => {
showAll = (await Preferences.get({ key: 'nd_show_all' })).value === '1'; onlyActive = (await Preferences.get({ key: 'nd_only_active' })).value === '1';
load(); load();
}); });
</script> </script>
@ -103,8 +105,8 @@
</div> </div>
<label class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-400"> <label class="flex items-center gap-2 px-3 py-2 text-sm text-zinc-400">
<input type="checkbox" checked={showAll} onchange={toggleShowAll} /> <input type="checkbox" checked={onlyActive} onchange={toggleOnlyActive} />
Auch abgeschlossene Aufträge anzeigen Nur aktive Aufträge anzeigen
</label> </label>
<div class="flex-1 overflow-y-auto"> <div class="flex-1 overflow-y-auto">
@ -141,9 +143,11 @@
{#if order.note} {#if order.note}
<div class="truncate text-xs text-zinc-400">{order.note}</div> <div class="truncate text-xs text-zinc-400">{order.note}</div>
{/if} {/if}
<!-- Auftragsnummer + Datum: nur Kleingedrucktes --> <!-- Auftragsnummer + Bearbeitungsdatum: nur Kleingedrucktes -->
<div class="truncate text-[11px] text-zinc-500"> <div class="truncate text-[11px] text-zinc-500">
{order.ref}{order.refClient ? ' · ' + order.refClient : ''}{order.date {order.ref}{order.refClient ? ' · ' + order.refClient : ''}{order.tms
? ' · bearb. ' + fmtDate(order.tms)
: order.date
? ' · ' + fmtDate(order.date) ? ' · ' + fmtDate(order.date)
: ''} : ''}
</div> </div>

View file

@ -4,6 +4,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import AppHeader from '$lib/components/AppHeader.svelte'; import AppHeader from '$lib/components/AppHeader.svelte';
import ToolDialog from '$lib/components/ToolDialog.svelte'; import ToolDialog from '$lib/components/ToolDialog.svelte';
import MeasurementResult from '$lib/components/MeasurementResult.svelte';
import { getProtocol, saveProtocol, deleteProtocol } from '$lib/db'; import { getProtocol, saveProtocol, deleteProtocol } from '$lib/db';
import { addMeasurement, upsertDevice } from '$lib/protocols'; import { addMeasurement, upsertDevice } from '$lib/protocols';
import { sync } from '$lib/sync.svelte'; import { sync } from '$lib/sync.svelte';
@ -119,11 +120,6 @@
function protocolMeasurements() { function protocolMeasurements() {
return protocol?.measurements.filter((m) => !m.deviceClientId) ?? []; return protocol?.measurements.filter((m) => !m.deviceClientId) ?? [];
} }
function resultText(r: Record<string, unknown>): string {
return Object.entries(r)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
.join(' · ');
}
</script> </script>
{#if protocol} {#if protocol}
@ -194,7 +190,7 @@
<span class="text-sm font-medium">{getTool(m.tool)?.name ?? m.tool}</span> <span class="text-sm font-medium">{getTool(m.tool)?.name ?? m.tool}</span>
</div> </div>
<p class="mt-1 text-xs {ampel[m.measureStatus]}">{m.label}</p> <p class="mt-1 text-xs {ampel[m.measureStatus]}">{m.label}</p>
<p class="mt-0.5 break-words text-[11px] text-zinc-500">{resultText(m.result)}</p> <div class="mt-0.5"><MeasurementResult result={m.result} /></div>
</div> </div>
{/each} {/each}
</section> </section>
@ -225,7 +221,7 @@
<span class="mt-1 h-2 w-2 shrink-0 rounded-full {ampelDot[m.measureStatus]}"></span> <span class="mt-1 h-2 w-2 shrink-0 rounded-full {ampelDot[m.measureStatus]}"></span>
<div class="min-w-0"> <div class="min-w-0">
<p class="text-xs {ampel[m.measureStatus]}">{m.label}</p> <p class="text-xs {ampel[m.measureStatus]}">{m.label}</p>
<p class="break-words text-[11px] text-zinc-500">{resultText(m.result)}</p> <MeasurementResult result={m.result} />
</div> </div>
</div> </div>
{/each} {/each}