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

View file

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

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

View file

@ -1,6 +1,10 @@
/**
* Tool: DHCP-Check erkennt antwortende DHCP-Server.
* Mehr als ein Server deutet auf einen Rogue-DHCP hin (Warnung).
* Tool: DHCP-Info zeigt den DHCP-Server, von dem das Gerät seine Adresse hat.
*
* 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';
@ -9,25 +13,25 @@ import type { MeasureStatus, Tool } from '../types';
export const dhcpCheckTool: Tool = {
id: 'dhcpcheck',
category: 'netzwerk',
name: 'DHCP-Check',
name: 'DHCP-Info',
icon: 'server',
description: 'Findet DHCP-Server — erkennt unerwünschte Zweit-Server.',
description: 'Zeigt DHCP-Server, Lease-Dauer und DNS des Geräts.',
scope: 'protocol',
params: [],
async run() {
const { servers } = await scanner.dhcpDiscover();
let status: MeasureStatus = 0;
if (servers.length === 0) status = 2; // kein DHCP-Server
if (servers.length > 1) status = 2; // Rogue-DHCP
const info = await scanner.dhcpInfo();
const hasServer = info.server !== '';
const status: MeasureStatus = hasServer ? 0 : 1;
return {
label:
servers.length === 1
? `1 DHCP-Server: ${servers[0].ip}`
: `${servers.length} DHCP-Server (!)`,
label: hasServer ? `DHCP-Server: ${info.server}` : 'Kein DHCP-Server ermittelbar',
result: {
count: servers.length,
server: servers.map((s) => `${s.ip}${s.mac ? ' / ' + s.mac : ''}`),
hinweis: servers.length > 1 ? 'Mehrere DHCP-Server — Rogue-DHCP prüfen!' : '',
server: info.server || '—',
lease: info.lease ? `${info.lease} s` : '—',
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,
};

View file

@ -9,6 +9,10 @@
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';
/** Eingabefeld eines Tools (für das Parameter-Formular) */

View file

@ -30,6 +30,8 @@ export interface Order {
ref: string;
refClient?: string;
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 */
status: number;
/** true wenn Status 0/1/2 (aktiver Auftrag) */

View file

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

View file

@ -4,6 +4,7 @@
import { page } from '$app/stores';
import AppHeader from '$lib/components/AppHeader.svelte';
import ToolDialog from '$lib/components/ToolDialog.svelte';
import MeasurementResult from '$lib/components/MeasurementResult.svelte';
import { getProtocol, saveProtocol, deleteProtocol } from '$lib/db';
import { addMeasurement, upsertDevice } from '$lib/protocols';
import { sync } from '$lib/sync.svelte';
@ -119,11 +120,6 @@
function protocolMeasurements() {
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>
{#if protocol}
@ -194,7 +190,7 @@
<span class="text-sm font-medium">{getTool(m.tool)?.name ?? m.tool}</span>
</div>
<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>
{/each}
</section>
@ -225,7 +221,7 @@
<span class="mt-1 h-2 w-2 shrink-0 rounded-full {ampelDot[m.measureStatus]}"></span>
<div class="min-w-0">
<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>
{/each}