Compare commits
5 commits
bbf24c06b8
...
7236017e87
| Author | SHA1 | Date | |
|---|---|---|---|
| 7236017e87 | |||
| 2f7b1c91a4 | |||
| 7d79358c02 | |||
| c954afc889 | |||
| 460b85d708 |
20 changed files with 1842 additions and 199 deletions
196
android/app/src/main/java/de/data_it_solution/netdiag/Dns.kt
Normal file
196
android/app/src/main/java/de/data_it_solution/netdiag/Dns.kt
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
package de.data_it_solution.netdiag
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.InetAddress
|
||||
import java.net.SocketTimeoutException
|
||||
|
||||
/**
|
||||
* Minimaler DNS-Client über UDP/53 — fragt einen BESTIMMTEN Server.
|
||||
*
|
||||
* Warum überhaupt: `InetAddress.getByName()` (die Grundlage von `dnsResolve`
|
||||
* im Plugin) benutzt den System-Resolver. Welcher Server antwortet, ist dort
|
||||
* nicht wählbar, und der Systemcache liegt dazwischen — eine zweite Abfrage
|
||||
* misst den Cache, nicht das Netz. Für die Frage, die beim Kunden zählt
|
||||
* („antwortet die Fritzbox / der Domaincontroller überhaupt, und antwortet sie
|
||||
* richtig?"), braucht es genau das: einen Server, ein Paket, eine Antwort.
|
||||
*
|
||||
* Aufbau bewusst wie `Snmp.kt`: eigener UDP-Client, eigener Ergebnistyp statt
|
||||
* `null`. Der Grund steht dort ausführlich — kurz: ein nicht erreichbarer
|
||||
* Server darf nie wie ein fehlerfreier aussehen. Ein DNS-Server, der „keine
|
||||
* Antwort" gibt, und einer, der „diesen Namen kenne ich nicht" sagt, sind
|
||||
* zwei völlig verschiedene Befunde: der eine ist ein Ausfall, der andere ein
|
||||
* korrekt arbeitender Server.
|
||||
*
|
||||
* Implementiert nur so viel vom Protokoll (RFC 1035) wie nötig: eine Frage
|
||||
* je Paket, A-Records aus dem Antwortteil, Namenskompression beim Überspringen.
|
||||
*/
|
||||
object Dns {
|
||||
|
||||
/** Ergebnis einer Abfrage bei EINEM bestimmten Server. */
|
||||
sealed class Result {
|
||||
/** Server hat geantwortet und Adressen geliefert. */
|
||||
data class Addresses(val ips: List<String>, val ms: Double, val autoritativ: Boolean) : Result()
|
||||
|
||||
/** Server hat geantwortet, kennt den Namen aber nicht (RCODE 3, NXDOMAIN). */
|
||||
data class Unbekannt(val ms: Double) : Result()
|
||||
|
||||
/**
|
||||
* Server hat geantwortet, aber ohne Adressen und ohne NXDOMAIN —
|
||||
* z.B. RCODE 2 (SERVFAIL) oder eine Antwort ganz ohne A-Record.
|
||||
*/
|
||||
data class OhneAntwort(val rcode: Int, val ms: Double) : Result()
|
||||
|
||||
/** Keine Antwort in der vorgegebenen Zeit (Server aus, Firewall, falsche Adresse). */
|
||||
object Timeout : Result()
|
||||
|
||||
/** Paket kam an, war aber nicht verwertbar. */
|
||||
data class Failure(val reason: String) : Result()
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Namen bei einem bestimmten Server abfragen.
|
||||
*
|
||||
* @param server IP des zu befragenden DNS-Servers
|
||||
* @param name abzufragender Name (A-Record)
|
||||
* @param timeoutMs Wartezeit je Versuch
|
||||
* @param attempts Versuche — UDP darf einmal verloren gehen
|
||||
*/
|
||||
fun query(
|
||||
server: String,
|
||||
name: String,
|
||||
timeoutMs: Int = 2000,
|
||||
attempts: Int = 2,
|
||||
): Result {
|
||||
var last: Result = Result.Timeout
|
||||
repeat(attempts) {
|
||||
last = queryOnce(server, name, timeoutMs)
|
||||
// Nur bei Zeitüberschreitung erneut fragen — eine inhaltliche
|
||||
// Antwort (NXDOMAIN, SERVFAIL) wiederholt sich sowieso.
|
||||
if (last !is Result.Timeout) return last
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
private fun queryOnce(server: String, name: String, timeoutMs: Int): Result {
|
||||
// Zufällige ID, damit eine verspätete Antwort auf eine frühere Frage
|
||||
// nicht als Antwort auf diese durchgeht (dasselbe Motiv wie in Snmp.kt).
|
||||
val id = (1..0xFFFE).random()
|
||||
val t0 = System.nanoTime()
|
||||
return try {
|
||||
val request = buildQuery(id, name)
|
||||
DatagramSocket().use { sock ->
|
||||
sock.soTimeout = timeoutMs
|
||||
sock.send(DatagramPacket(request, request.size, InetAddress.getByName(server), 53))
|
||||
val buf = ByteArray(1500)
|
||||
val resp = DatagramPacket(buf, buf.size)
|
||||
sock.receive(resp)
|
||||
val ms = (System.nanoTime() - t0) / 1e6
|
||||
parseResponse(buf, resp.length, id, ms)
|
||||
}
|
||||
} catch (e: SocketTimeoutException) {
|
||||
Result.Timeout
|
||||
} catch (e: Exception) {
|
||||
Result.Failure(e.message ?: "DNS-Fehler")
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- Anfrage bauen (RFC 1035, Abschnitt 4) ---- */
|
||||
|
||||
private fun buildQuery(id: Int, name: String): ByteArray {
|
||||
val out = ByteArrayOutputStream()
|
||||
// Kopf: ID, Flags (RD=1 „bitte rekursiv auflösen"), 1 Frage, kein Rest
|
||||
out.write(id shr 8 and 0xFF); out.write(id and 0xFF)
|
||||
out.write(0x01); out.write(0x00)
|
||||
out.write(0x00); out.write(0x01)
|
||||
out.write(0x00); out.write(0x00)
|
||||
out.write(0x00); out.write(0x00)
|
||||
out.write(0x00); out.write(0x00)
|
||||
// Frage: Name als Folge von Längen-Präfix-Labels, dann Nullbyte
|
||||
for (label in name.trim().trim('.').split('.')) {
|
||||
val bytes = label.toByteArray(Charsets.US_ASCII)
|
||||
// Ein Label darf höchstens 63 Zeichen haben; längere würden das
|
||||
// Längenbyte in die Flags hineinlaufen lassen.
|
||||
if (bytes.size > 63) throw IllegalArgumentException("Namensteil zu lang: $label")
|
||||
out.write(bytes.size)
|
||||
out.write(bytes)
|
||||
}
|
||||
out.write(0x00)
|
||||
out.write(0x00); out.write(0x01) // QTYPE A
|
||||
out.write(0x00); out.write(0x01) // QCLASS IN
|
||||
return out.toByteArray()
|
||||
}
|
||||
|
||||
/* ---- Antwort lesen ---- */
|
||||
|
||||
private fun parseResponse(buf: ByteArray, len: Int, erwarteteId: Int, ms: Double): Result {
|
||||
if (len < 12) return Result.Failure("Antwort zu kurz ($len Byte)")
|
||||
val id = (buf[0].toInt() and 0xFF shl 8) or (buf[1].toInt() and 0xFF)
|
||||
if (id != erwarteteId) {
|
||||
return Result.Failure("Antwort gehört zu einer anderen Anfrage")
|
||||
}
|
||||
val flags2 = buf[3].toInt() and 0xFF
|
||||
val rcode = flags2 and 0x0F
|
||||
val autoritativ = (buf[2].toInt() and 0x04) != 0
|
||||
val qdCount = (buf[4].toInt() and 0xFF shl 8) or (buf[5].toInt() and 0xFF)
|
||||
val anCount = (buf[6].toInt() and 0xFF shl 8) or (buf[7].toInt() and 0xFF)
|
||||
|
||||
if (rcode == 3) return Result.Unbekannt(ms) // NXDOMAIN: Server kennt den Namen nicht
|
||||
|
||||
var pos = 12
|
||||
// Fragen überspringen
|
||||
repeat(qdCount) {
|
||||
pos = skipName(buf, len, pos)
|
||||
pos += 4 // QTYPE + QCLASS
|
||||
}
|
||||
val ips = ArrayList<String>()
|
||||
repeat(anCount) {
|
||||
if (pos >= len) return@repeat
|
||||
pos = skipName(buf, len, pos)
|
||||
if (pos + 10 > len) return@repeat
|
||||
val type = (buf[pos].toInt() and 0xFF shl 8) or (buf[pos + 1].toInt() and 0xFF)
|
||||
val rdLen = (buf[pos + 8].toInt() and 0xFF shl 8) or (buf[pos + 9].toInt() and 0xFF)
|
||||
val rdPos = pos + 10
|
||||
if (type == 1 && rdLen == 4 && rdPos + 4 <= len) {
|
||||
ips.add(
|
||||
"${buf[rdPos].toInt() and 0xFF}.${buf[rdPos + 1].toInt() and 0xFF}." +
|
||||
"${buf[rdPos + 2].toInt() and 0xFF}.${buf[rdPos + 3].toInt() and 0xFF}",
|
||||
)
|
||||
}
|
||||
pos = rdPos + rdLen
|
||||
}
|
||||
if (ips.isNotEmpty()) return Result.Addresses(ips, ms, autoritativ)
|
||||
return Result.OhneAntwort(rcode, ms)
|
||||
}
|
||||
|
||||
/**
|
||||
* Über einen Namen hinweglesen und die Position dahinter liefern.
|
||||
*
|
||||
* Namen können komprimiert sein: zwei Bits gesetzt (0xC0) bedeutet „hier
|
||||
* folgt ein Zeiger auf eine frühere Stelle im Paket". Der Zeiger ist zwei
|
||||
* Byte lang und beendet den Namen — ihm zu folgen ist hier unnötig, weil
|
||||
* wir nur die Adressen aus dem Antwortteil brauchen.
|
||||
*/
|
||||
private fun skipName(buf: ByteArray, len: Int, start: Int): Int {
|
||||
var pos = start
|
||||
while (pos < len) {
|
||||
val l = buf[pos].toInt() and 0xFF
|
||||
if (l == 0) return pos + 1
|
||||
if (l and 0xC0 == 0xC0) return pos + 2
|
||||
pos += 1 + l
|
||||
}
|
||||
return pos
|
||||
}
|
||||
|
||||
/** Klartext des Antwortcodes fürs Protokoll (RFC 1035, Abschnitt 4.1.1) */
|
||||
fun rcodeText(rcode: Int): String = when (rcode) {
|
||||
0 -> "kein Fehler"
|
||||
1 -> "Anfrage vom Server nicht verstanden (FORMERR)"
|
||||
2 -> "Serverfehler (SERVFAIL)"
|
||||
3 -> "Name unbekannt (NXDOMAIN)"
|
||||
4 -> "Anfrageart wird nicht unterstützt (NOTIMP)"
|
||||
5 -> "Anfrage abgelehnt (REFUSED)"
|
||||
else -> "Antwortcode $rcode"
|
||||
}
|
||||
}
|
||||
|
|
@ -1923,6 +1923,16 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
.put("iface", iface)
|
||||
.put("type", type)
|
||||
.put("isDefault", iface == defaultIface)
|
||||
// Androids eigenes Urteil über die Verbindung. Das System
|
||||
// prüft nach jedem Verbindungsaufbau selbst gegen einen
|
||||
// Erreichbarkeitsdienst — dieses Ergebnis kostet uns hier
|
||||
// keine einzige Messung und ist die verlässlichste Auskunft
|
||||
// über ein Gäste-WLAN mit Anmeldeseite, die ohne Root zu
|
||||
// bekommen ist.
|
||||
obj.put("validated", caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED))
|
||||
if (caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)) {
|
||||
obj.put("captivePortal", true)
|
||||
}
|
||||
if (ipv4 != null) obj.put("ipv4", ipv4)
|
||||
if (prefix > 0) obj.put("prefixLength", prefix)
|
||||
if (gateway != null) obj.put("gateway", gateway)
|
||||
|
|
@ -1936,6 +1946,40 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
(type == "ethernet" && ifLower.matches(Regex("eth[1-9].*")))
|
||||
if (isUsb) obj.put("isUsbEthernet", true)
|
||||
|
||||
/*
|
||||
* DHCP-Server: moderne API zuerst, für JEDEN Anschlusstyp.
|
||||
*
|
||||
* `WifiManager.dhcpInfo` (weiter unten) gibt es nur für WLAN und
|
||||
* ist deprecated — an einer Netzwerkdose blieb die Angabe deshalb
|
||||
* leer, obwohl das Gerät seine Adresse sichtbar per DHCP bezogen
|
||||
* hat. Für ein Abnahmeprotokoll ist „von welchem Server kommt die
|
||||
* Adresse" aber gerade an der Dose die interessante Frage (zweiter
|
||||
* Router im Netz, vergessener Testserver).
|
||||
*
|
||||
* `getDhcpServerAddress()` gibt es ab API 30. Bewusst VOR dem
|
||||
* WLAN-Pfad: so läuft der Normalfall über die unterstützte API,
|
||||
* und der alte Pfad füllt nur noch, was diese nicht liefert.
|
||||
*
|
||||
* Die Lease-DAUER gibt es hier nicht — sie steckt nur im
|
||||
* deprecated DhcpInfo und nur für WLAN. Sie zu erfinden oder als
|
||||
* 0 zu schreiben wäre eine Falschaussage im Protokoll; „0" hat
|
||||
* genau so schon einmal als „0 DHCP-Server (!)" in Rot in einem
|
||||
* ausgelieferten Kundendokument gestanden.
|
||||
*/
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val srvIp = lp.dhcpServerAddress?.hostAddress
|
||||
if (!srvIp.isNullOrEmpty() && srvIp != "0.0.0.0") {
|
||||
obj.put("dhcpServer", srvIp)
|
||||
// Quelle mitliefern wie bei `prefixQuelle` in
|
||||
// getLocalSubnet(): sonst ist später nicht mehr
|
||||
// nachvollziehbar, ob die Angabe von der
|
||||
// unterstützten API oder aus dem alten WLAN-Pfad
|
||||
// stammt — und damit auch nicht, ob sie an einer
|
||||
// Kabeldose überhaupt zu erwarten war.
|
||||
obj.put("dhcpQuelle", "system")
|
||||
}
|
||||
}
|
||||
|
||||
// Link-Speed-Quellen
|
||||
if (type == "wifi") {
|
||||
try {
|
||||
|
|
@ -1955,7 +1999,11 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
}
|
||||
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
|
||||
if (dhcp != null && dhcp.serverAddress != 0) {
|
||||
obj.put("dhcpServer", intToIp(dhcp.serverAddress))
|
||||
// Nur füllen, was die moderne API oben nicht liefert
|
||||
if (!obj.has("dhcpServer")) {
|
||||
obj.put("dhcpServer", intToIp(dhcp.serverAddress))
|
||||
obj.put("dhcpQuelle", "wlan")
|
||||
}
|
||||
if (dhcp.leaseDuration > 0) obj.put("leaseSec", dhcp.leaseDuration)
|
||||
}
|
||||
} catch (_: Exception) { /* WLAN-Info nicht greifbar */ }
|
||||
|
|
@ -1963,6 +2011,7 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
val mbps = readEthernetSpeed(iface)
|
||||
if (mbps != null) obj.put("linkSpeedMbps", mbps)
|
||||
}
|
||||
|
||||
arr.put(obj)
|
||||
}
|
||||
resolve(call, JSObject().put("links", arr))
|
||||
|
|
@ -1989,6 +2038,171 @@ class NetDiagScannerPlugin : Plugin() {
|
|||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
/* Internet-Kette — Namensauflösung und HTTPS-Abruf */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Namen auflösen über den System-Resolver.
|
||||
*
|
||||
* GRENZE, die im Ergebnis stehen muss: welcher DNS-Server befragt wird, ist
|
||||
* hier NICHT wählbar, und zwischen Aufruf und Server liegt der Cache des
|
||||
* Systems. Eine zweite Abfrage desselben Namens misst deshalb den Cache,
|
||||
* nicht das Netz. Für „antwortet dieser bestimmte Server" braucht es einen
|
||||
* eigenen UDP-Client auf Port 53 (eigener Roadmap-Punkt) — für die Frage
|
||||
* „funktioniert Namensauflösung überhaupt" reicht dieser Weg.
|
||||
*/
|
||||
@PluginMethod
|
||||
fun dnsResolve(call: PluginCall) {
|
||||
val host = call.getString("host") ?: return call.reject("host fehlt")
|
||||
val timeoutMs = (call.getInt("timeoutMs") ?: 5000).coerceIn(500, 30_000)
|
||||
io.launch {
|
||||
val t0 = System.nanoTime()
|
||||
try {
|
||||
val addrs = withTimeout(timeoutMs.toLong()) {
|
||||
withContext(Dispatchers.IO) { InetAddress.getAllByName(host) }
|
||||
}
|
||||
val ms = (System.nanoTime() - t0) / 1e6
|
||||
val arr = JSArray()
|
||||
for (a in addrs) a.hostAddress?.let { arr.put(it) }
|
||||
resolve(call, JSObject()
|
||||
.put("ok", arr.length() > 0)
|
||||
.put("ips", arr)
|
||||
.put("latenzMs", round1(ms)))
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
resolve(call, JSObject()
|
||||
.put("ok", false)
|
||||
.put("ips", JSArray())
|
||||
.put("fehler", "Keine Antwort innerhalb von $timeoutMs ms"))
|
||||
} catch (e: Exception) {
|
||||
// UnknownHostException ist der Normalfall bei kaputtem DNS und
|
||||
// keine Ausnahmesituation — deshalb aufgelöst statt reject().
|
||||
resolve(call, JSObject()
|
||||
.put("ok", false)
|
||||
.put("ips", JSArray())
|
||||
.put("fehler", e.message ?: e.javaClass.simpleName))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Einen Namen bei EINEM bestimmten DNS-Server abfragen (eigener UDP-Client).
|
||||
*
|
||||
* Unterschied zu `dnsResolve`: dort antwortet, wer das System gerade für
|
||||
* richtig hält, und der Systemcache liegt dazwischen. Hier geht genau ein
|
||||
* Paket an genau einen Server — nur so ist die Frage „antwortet die
|
||||
* Fritzbox / der Domaincontroller überhaupt" zu beantworten.
|
||||
*
|
||||
* Die Antwort unterscheidet ausdrücklich zwischen „keine Antwort" (Ausfall)
|
||||
* und „Name unbekannt" (Server arbeitet korrekt). Das sind zwei völlig
|
||||
* verschiedene Befunde beim Kunden.
|
||||
*/
|
||||
@PluginMethod
|
||||
fun dnsQuery(call: PluginCall) {
|
||||
val server = call.getString("server") ?: return call.reject("server fehlt")
|
||||
val name = call.getString("name") ?: return call.reject("name fehlt")
|
||||
val timeoutMs = (call.getInt("timeoutMs") ?: 2000).coerceIn(300, 20_000)
|
||||
io.launch {
|
||||
try {
|
||||
val r = withContext(Dispatchers.IO) { Dns.query(server, name, timeoutMs) }
|
||||
val obj = JSObject().put("server", server).put("name", name)
|
||||
when (r) {
|
||||
is Dns.Result.Addresses -> {
|
||||
val arr = JSArray()
|
||||
for (ip in r.ips) arr.put(ip)
|
||||
obj.put("status", "ok")
|
||||
obj.put("ips", arr)
|
||||
obj.put("latenzMs", round1(r.ms))
|
||||
obj.put("autoritativ", r.autoritativ)
|
||||
}
|
||||
is Dns.Result.Unbekannt -> {
|
||||
obj.put("status", "unbekannt")
|
||||
obj.put("ips", JSArray())
|
||||
obj.put("latenzMs", round1(r.ms))
|
||||
obj.put("meldung", Dns.rcodeText(3))
|
||||
}
|
||||
is Dns.Result.OhneAntwort -> {
|
||||
obj.put("status", "ohneAdresse")
|
||||
obj.put("ips", JSArray())
|
||||
obj.put("latenzMs", round1(r.ms))
|
||||
obj.put("meldung", Dns.rcodeText(r.rcode))
|
||||
}
|
||||
is Dns.Result.Timeout -> {
|
||||
obj.put("status", "keineAntwort")
|
||||
obj.put("ips", JSArray())
|
||||
obj.put("meldung", "Keine Antwort innerhalb von $timeoutMs ms")
|
||||
}
|
||||
is Dns.Result.Failure -> {
|
||||
obj.put("status", "fehler")
|
||||
obj.put("ips", JSArray())
|
||||
obj.put("meldung", r.reason)
|
||||
}
|
||||
}
|
||||
resolve(call, obj)
|
||||
} catch (e: Exception) {
|
||||
call.reject("dnsQuery: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine URL abrufen und Statuszeile plus Laufzeit melden.
|
||||
*
|
||||
* `instanceFollowRedirects = false` ist hier keine Feinheit, sondern der
|
||||
* Kern der Aussage: ein Gäste-WLAN mit Anmeldeseite beantwortet JEDE
|
||||
* Anfrage mit einer Umleitung auf sein Portal. Folgt man ihr, kommt am Ende
|
||||
* ein sauberes 200 zurück und die Messung meldet „Internet in Ordnung",
|
||||
* obwohl ohne Anmeldung nichts geht. Ohne Weiterleitung steht die 302 mit
|
||||
* ihrem Ziel im Protokoll und ist als Portal erkennbar.
|
||||
*/
|
||||
@PluginMethod
|
||||
fun httpsCheck(call: PluginCall) {
|
||||
val url = call.getString("url") ?: return call.reject("url fehlt")
|
||||
val timeoutMs = (call.getInt("timeoutMs") ?: 8000).coerceIn(500, 60_000)
|
||||
io.launch {
|
||||
var conn: HttpURLConnection? = null
|
||||
val t0 = System.nanoTime()
|
||||
try {
|
||||
val res = withTimeout(timeoutMs.toLong() + 2000L) {
|
||||
withContext(Dispatchers.IO) {
|
||||
val c = URL(url).openConnection() as HttpURLConnection
|
||||
conn = c
|
||||
c.instanceFollowRedirects = false
|
||||
c.connectTimeout = timeoutMs
|
||||
c.readTimeout = timeoutMs
|
||||
c.requestMethod = "GET"
|
||||
c.setRequestProperty("User-Agent", "NetDiag")
|
||||
// Kein Zwischenspeicher: sonst misst der zweite Aufruf
|
||||
// beim selben Kunden die eigene Festplatte.
|
||||
c.useCaches = false
|
||||
c.setRequestProperty("Cache-Control", "no-cache")
|
||||
val code = c.responseCode
|
||||
val ort = c.getHeaderField("Location")
|
||||
Triple(code, ort, c.responseMessage ?: "")
|
||||
}
|
||||
}
|
||||
val ms = (System.nanoTime() - t0) / 1e6
|
||||
val obj = JSObject()
|
||||
.put("erreichbar", true)
|
||||
.put("statusCode", res.first)
|
||||
.put("statusText", res.third)
|
||||
.put("latenzMs", round1(ms))
|
||||
if (!res.second.isNullOrEmpty()) obj.put("weiterleitungAuf", res.second)
|
||||
resolve(call, obj)
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
resolve(call, JSObject()
|
||||
.put("erreichbar", false)
|
||||
.put("fehler", "Keine Antwort innerhalb von $timeoutMs ms"))
|
||||
} catch (e: Exception) {
|
||||
resolve(call, JSObject()
|
||||
.put("erreichbar", false)
|
||||
.put("fehler", e.message ?: e.javaClass.simpleName))
|
||||
} finally {
|
||||
try { conn?.disconnect() } catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------------------- */
|
||||
/* WLAN-Empfangstracker — RSSI live beim Durchgehen durchs Gebäude */
|
||||
/* --------------------------------------------------------------------- */
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
import AmpelBadge from './AmpelBadge.svelte';
|
||||
import type { Device, Measurement } from '$lib/types';
|
||||
import type { Tool } from '$lib/tools/types';
|
||||
import { teileNachGeraetetyp } from '$lib/tools/geraeteaktionen';
|
||||
|
||||
let {
|
||||
device,
|
||||
|
|
@ -30,6 +31,9 @@
|
|||
onrename?: () => void;
|
||||
} = $props();
|
||||
|
||||
// Werkzeuge nach Gerätetyp sortieren (passend vorn, Rest nachrangig)
|
||||
const aufgeteilt = $derived(teileNachGeraetetyp(tools, device.deviceType));
|
||||
|
||||
// Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar)
|
||||
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
|
||||
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
|
||||
|
|
@ -132,8 +136,14 @@
|
|||
{/each}
|
||||
|
||||
{#if tools.length && onrun}
|
||||
<!--
|
||||
Nach Gerätetyp getrennt: passende Werkzeuge vorn, unpassende blasser
|
||||
dahinter. Bewusst NICHT ausgeblendet — `deviceType` ist geraten (MAC-
|
||||
Hersteller, mDNS, offene Ports), und ein als „Linux-Gerät" erkannter
|
||||
Switch verlöre sonst ausgerechnet SNMP. Siehe tools/geraeteaktionen.ts.
|
||||
-->
|
||||
<div class="mt-2 flex flex-wrap gap-1.5">
|
||||
{#each tools as tool (tool.id)}
|
||||
{#each aufgeteilt.passend as tool (tool.id)}
|
||||
<button
|
||||
class="rounded bg-zinc-800 px-2 py-1 text-xs text-sky-300 active:bg-zinc-700"
|
||||
onclick={() => onrun?.(tool)}
|
||||
|
|
@ -141,6 +151,15 @@
|
|||
{tool.name}
|
||||
</button>
|
||||
{/each}
|
||||
{#each aufgeteilt.nachrangig as tool (tool.id)}
|
||||
<button
|
||||
class="rounded bg-zinc-900 px-2 py-1 text-xs text-zinc-500 active:bg-zinc-700"
|
||||
title="Passt selten zu diesem Gerätetyp — trotzdem ausführbar"
|
||||
onclick={() => onrun?.(tool)}
|
||||
>
|
||||
{tool.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<script lang="ts">
|
||||
import type { Tool, ToolPreview } from '$lib/tools/types';
|
||||
import type { Tool, ToolParamField, ToolPreview } from '$lib/tools/types';
|
||||
import type { Device, Protocol } from '$lib/types';
|
||||
import { debugLog } from '$lib/debuglog.svelte';
|
||||
import { scanner } from '$lib/scanner';
|
||||
|
|
@ -41,6 +41,22 @@
|
|||
...Object.fromEntries(tool.params.map((p) => [p.key, p.default ?? ''])),
|
||||
...(startParams ?? {}),
|
||||
});
|
||||
/**
|
||||
* Passende Bildschirmtastatur je Eingabefeld.
|
||||
*
|
||||
* `decimal` statt `numeric`, wo Punkte vorkommen: die numerische Tastatur
|
||||
* hat keinen Punkt, und ohne den lässt sich weder eine IP-Adresse noch ein
|
||||
* Netzbereich eingeben. Reine Anzahlen (Pakete, Sekunden, Runden) bekommen
|
||||
* `numeric`, Namen und URLs bleiben bei der Volltastatur — `url` wäre hier
|
||||
* falsch, weil dieselben Felder auch reine Hostnamen aufnehmen.
|
||||
*/
|
||||
function tastaturModus(field: ToolParamField): 'text' | 'numeric' | 'decimal' {
|
||||
if (field.type === 'number') return 'numeric';
|
||||
const k = field.key.toLowerCase();
|
||||
if (/(host|ziel|ip|subnet|netz|gateway|server|adresse)/.test(k)) return 'decimal';
|
||||
return 'text';
|
||||
}
|
||||
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
|
|
@ -190,9 +206,22 @@
|
|||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<!--
|
||||
Tastatur passend zum Feld. Auf dem Handy vor Ort ist das kein
|
||||
Feinschliff: für „192.168.30.1" oder eine Portnummer die
|
||||
Buchstabentastatur zu bekommen, kostet jedes Mal zwei
|
||||
Fehleingaben. `inputmode="decimal"` bringt Ziffern UND Punkt —
|
||||
bei „numeric" fehlt der Punkt und damit jede IP-Eingabe.
|
||||
Zusätzlich Autokorrektur und Großschreibung aus: sonst wird aus
|
||||
„fritz.box" beim Tippen „Fritz.Box".
|
||||
-->
|
||||
<input
|
||||
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
|
||||
type={field.type === 'number' ? 'number' : 'text'}
|
||||
inputmode={tastaturModus(field)}
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
placeholder={field.placeholder ?? ''}
|
||||
bind:value={params[field.key]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -54,9 +54,23 @@ const FELDER: Record<string, Feld> = {
|
|||
geraete: { label: 'Je Gerät' },
|
||||
ausfaelle: { label: 'Einzelne Ausfälle' },
|
||||
anzahlGeraete: { label: 'Überwachte Geräte' },
|
||||
// Internet-Kette. `fehlerglied` ist die eigentliche Aussage des Werkzeugs —
|
||||
// fehlt der Schlüssel hier oder in netdiagKundenfelder(), steht im
|
||||
// Kundendokument nur noch die Ampel.
|
||||
fehlerglied: { label: 'Fehlerglied' },
|
||||
stufen: { label: 'Geprüfte Stufen' },
|
||||
systemUrteil: { label: 'Urteil des Betriebssystems' },
|
||||
validated: { label: 'Internetverbindung bestätigt' },
|
||||
captivePortal: { label: 'Anmeldeseite erkannt' },
|
||||
// DNS-Prüfung (je ein Paket an je einen Server)
|
||||
gefragteServer: { label: 'Befragte DNS-Server' },
|
||||
antworten: { label: 'Antworten der Server' },
|
||||
// Netz / DHCP / Dose
|
||||
server: { label: 'DHCP-Server' },
|
||||
dhcpServer: { label: 'DHCP-Server' },
|
||||
// 'system' = vom Betriebssystem gemeldet (jeder Anschlusstyp), 'wlan' = alter
|
||||
// WLAN-Weg. Steht im Protokoll, damit ein fehlender Wert einordenbar bleibt.
|
||||
dhcpQuelle: { label: 'Quelle der DHCP-Angabe' },
|
||||
lease: { label: 'Lease-Dauer', einheit: 's' },
|
||||
leaseSec: { label: 'Lease-Dauer', einheit: 's' },
|
||||
gateway: { label: 'Gateway' },
|
||||
|
|
|
|||
199
src/lib/monitor-auswertung.ts
Normal file
199
src/lib/monitor-auswertung.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
/**
|
||||
* Rechnung des Geräte-Monitors — bewusst OHNE jeden Laufzeit-Import.
|
||||
*
|
||||
* Ausgelagert aus `monitor.ts`, damit `tools/pruefe-monitor.mjs` die Funktion
|
||||
* direkt laden kann. Vorher importierte der Prüfer eine von Hand erzeugte
|
||||
* .mjs-Kopie aus einem Sitzungsverzeichnis unter /tmp; nach dessen Wegfall
|
||||
* lief er nicht mehr — im Repo stand trotzdem „alle Fälle korrekt". Eine
|
||||
* Zusicherung, die man nicht nachrechnen kann, ist keine.
|
||||
*
|
||||
* Alles hier ist reine Rechnung ohne Netz- oder Datenbankzugriff. Was die
|
||||
* Zahlen bedeuten und welche drei Fehler sie vorher systematisch zugunsten
|
||||
* des Dokuments verfälscht haben, steht im Kopf von `monitor.ts`.
|
||||
*/
|
||||
|
||||
import type { DeviceMonitorSession, MeasureStatus, MonitorEvent } from './types';
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDuration(sec: number): string {
|
||||
if (sec < 60) return Math.round(sec) + ' s';
|
||||
const m = Math.floor(sec / 60);
|
||||
if (m < 60) {
|
||||
const s = Math.round(sec % 60);
|
||||
return s ? `${m} min ${s} s` : `${m} min`;
|
||||
}
|
||||
const h = Math.floor(m / 60);
|
||||
const restM = m % 60;
|
||||
return restM ? `${h} Std ${restM} min` : `${h} Std`;
|
||||
}
|
||||
|
||||
/** Prozent mit einer Nachkommastelle, deutsches Komma */
|
||||
function fmtProzent(p: number): string {
|
||||
return p.toFixed(1).replace('.', ',') + ' %';
|
||||
}
|
||||
|
||||
export interface GeraetAuswertung {
|
||||
ip: string;
|
||||
label: string;
|
||||
ausfallzeitSek: number;
|
||||
aussetzer: number;
|
||||
laengsterAusfallSek: number;
|
||||
verfuegbarkeitProzent: number;
|
||||
/** einzelne Ausfälle als lesbare Zeilen */
|
||||
ausfaelle: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ausfallsegmente eines Geräts aus seinen Ereignissen bilden.
|
||||
*
|
||||
* Ein Ausfall beginnt mit 'down' und endet mit dem nächsten 'up'. Ist am Ende
|
||||
* noch einer offen, wird er bis `bis` zugerechnet — er ist ja nicht dadurch
|
||||
* verschwunden, dass die Messung aufhörte.
|
||||
*
|
||||
* `durationSec` aus dem up-Ereignis wird bewusst NICHT verwendet, sondern die
|
||||
* Differenz der Zeitstempel: die native Dauer war in einem Fall nachweislich
|
||||
* 0 (nämlich wenn der Ausfall schon vor dem Start bestand), und zwei Quellen
|
||||
* für dieselbe Zahl gehen irgendwann auseinander.
|
||||
*/
|
||||
function segmenteFuer(events: MonitorEvent[], von: number, bis: number): number[][] {
|
||||
const segmente: number[][] = [];
|
||||
let offen: number | null = null;
|
||||
for (const e of [...events].sort((a, b) => a.ts - b.ts)) {
|
||||
if (e.type === 'down') {
|
||||
if (offen === null) offen = e.ts;
|
||||
} else if (offen !== null) {
|
||||
segmente.push([offen, e.ts]);
|
||||
offen = null;
|
||||
}
|
||||
}
|
||||
if (offen !== null) segmente.push([offen, bis]);
|
||||
// Auf den Messzeitraum begrenzen — ein Ereignis vor dem Start (Wiederauf-
|
||||
// nahme einer älteren Sitzung) darf die Ausfallzeit nicht aufblähen.
|
||||
return segmente
|
||||
.map(([a, b]) => [Math.max(a, von), Math.min(b, bis)])
|
||||
.filter(([a, b]) => b > a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aus einer Überwachungssitzung die Felder für die gespeicherte Messung bauen.
|
||||
*
|
||||
* @param sitzung die Sitzung mit ihren Ereignissen
|
||||
* @param endeTs Ende des Messzeitraums (aus dem Plugin, sonst jetzt)
|
||||
* @param abgerissen true = der Lauf wurde nicht sauber beendet (Prozess tot,
|
||||
* nativer Lauf verschwunden) — dann steht das im Hinweis,
|
||||
* statt eine Verfügbarkeit über eine Zeitspanne auszuweisen,
|
||||
* in der gar nicht gemessen wurde.
|
||||
*/
|
||||
export function buildMonitorMeasurement(
|
||||
sitzung: DeviceMonitorSession,
|
||||
endeTs: number,
|
||||
abgerissen = false,
|
||||
): { label: string; result: Record<string, unknown>; measureStatus: MeasureStatus } {
|
||||
const von = sitzung.startedAt;
|
||||
const bis = Math.max(von, endeTs);
|
||||
const dauerSek = Math.max(1, Math.round((bis - von) / 1000));
|
||||
|
||||
const auswertung: GeraetAuswertung[] = sitzung.targets.map((t) => {
|
||||
const eigene = sitzung.events.filter((e) => e.ip === t.ip);
|
||||
const segmente = segmenteFuer(eigene, von, bis);
|
||||
const ausfallzeitSek = Math.round(
|
||||
segmente.reduce((s, [a, b]) => s + (b - a), 0) / 1000,
|
||||
);
|
||||
const laengster = segmente.length
|
||||
? Math.round(Math.max(...segmente.map(([a, b]) => b - a)) / 1000)
|
||||
: 0;
|
||||
return {
|
||||
ip: t.ip,
|
||||
label: t.label || t.ip,
|
||||
ausfallzeitSek,
|
||||
aussetzer: segmente.length,
|
||||
laengsterAusfallSek: laengster,
|
||||
verfuegbarkeitProzent: Math.max(
|
||||
0,
|
||||
Math.min(100, (1 - ausfallzeitSek / dauerSek) * 100),
|
||||
),
|
||||
ausfaelle: segmente.map(
|
||||
([a, b]) =>
|
||||
`${t.label || t.ip} ${fmtTime(a)} – ${fmtTime(b)} (${fmtDuration((b - a) / 1000)})`,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Schlechtestes Gerät bestimmt Label und Ampel (siehe Kopfkommentar)
|
||||
const schlechtestes = auswertung.reduce<GeraetAuswertung | null>(
|
||||
(min, g) => (min === null || g.verfuegbarkeitProzent < min.verfuegbarkeitProzent ? g : min),
|
||||
null,
|
||||
);
|
||||
const gesamtAussetzer = auswertung.reduce((s, g) => s + g.aussetzer, 0);
|
||||
|
||||
/*
|
||||
* Bewertung. Die Schwellen sind bewusst streng: Für eine Kamera oder eine
|
||||
* Wallbox ist ein Aussetzer pro Termin bereits das gemeldete Problem, nicht
|
||||
* ein Schönheitsfehler. 100 % über den ganzen Zeitraum = grün, jeder
|
||||
* Aussetzer = gelb, unter 95 % oder ein Gerät durchgehend tot = rot.
|
||||
*/
|
||||
let status: MeasureStatus = 0;
|
||||
if (gesamtAussetzer > 0) status = 1;
|
||||
if ((schlechtestes?.verfuegbarkeitProzent ?? 100) < 95) status = 2;
|
||||
if (abgerissen) status = 3; // nicht messbar — die Grundlage stimmt nicht
|
||||
|
||||
const hinweise: string[] = [];
|
||||
if (abgerissen) {
|
||||
hinweise.push(
|
||||
'Die Überwachung wurde nicht regulär beendet (App oder Gerät zwischendurch ' +
|
||||
'gestoppt). Der ausgewiesene Zeitraum endet beim letzten aufgezeichneten ' +
|
||||
'Ereignis; danach wurde nicht mehr gemessen.',
|
||||
);
|
||||
}
|
||||
// Messverfahren offenlegen: das gehört zu einer belastbaren Aussage dazu.
|
||||
hinweise.push(
|
||||
'Gemessen durch regelmäßige Erreichbarkeitsprüfung alle ' +
|
||||
`${sitzung.intervalSec} Sekunden. Ein Gerät, das Ping-Anfragen ` +
|
||||
'grundsätzlich nicht beantwortet, erscheint dabei als nicht erreichbar.',
|
||||
);
|
||||
|
||||
const label = schlechtestes
|
||||
? gesamtAussetzer === 0
|
||||
? `${auswertung.length} Geräte über ${fmtDuration(dauerSek)} durchgehend erreichbar`
|
||||
: `${schlechtestes.label}: ${fmtProzent(schlechtestes.verfuegbarkeitProzent)} · ` +
|
||||
`${gesamtAussetzer} ${gesamtAussetzer === 1 ? 'Aussetzer' : 'Aussetzer'} in ${fmtDuration(dauerSek)}`
|
||||
: 'Überwachung ohne Geräte';
|
||||
|
||||
return {
|
||||
label,
|
||||
measureStatus: status,
|
||||
result: {
|
||||
dauerSekunden: dauerSek,
|
||||
intervallSek: sitzung.intervalSec,
|
||||
geraeteAnzahl: auswertung.length,
|
||||
verfuegbarkeitProzent: Number((schlechtestes?.verfuegbarkeitProzent ?? 100).toFixed(1)),
|
||||
ausfallzeitSek: schlechtestes?.ausfallzeitSek ?? 0,
|
||||
aussetzer: gesamtAussetzer,
|
||||
laengsterAusfallSek: Math.max(0, ...auswertung.map((g) => g.laengsterAusfallSek)),
|
||||
// Vorformatierte Zeilen statt Objekte: MeasurementResult rendert Arrays
|
||||
// zeilenweise, und dieselben Zeichenketten werden im PDF wiederverwendet.
|
||||
geraete: auswertung.map(
|
||||
(g) =>
|
||||
// Nur benannte Geräte bekommen die IP in Klammern dahinter — ohne
|
||||
// eigenen Namen stand sonst „10.0.2.2 (10.0.2.2)" im Kundendokument.
|
||||
`${g.label === g.ip ? g.ip : `${g.label} (${g.ip})`}: ` +
|
||||
`${fmtProzent(g.verfuegbarkeitProzent)}` +
|
||||
(g.aussetzer
|
||||
? ` · ${g.aussetzer} ${g.aussetzer === 1 ? 'Aussetzer' : 'Aussetzer'}` +
|
||||
` · ${fmtDuration(g.ausfallzeitSek)} Ausfall` +
|
||||
` · längster ${fmtDuration(g.laengsterAusfallSek)}`
|
||||
: ' · durchgehend erreichbar'),
|
||||
),
|
||||
ausfaelle: auswertung.flatMap((g) => g.ausfaelle),
|
||||
hinweis: hinweise.join(' '),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -24,196 +24,13 @@
|
|||
*/
|
||||
|
||||
import { addMeasurement } from './protocols';
|
||||
import type {
|
||||
DeviceMonitorSession,
|
||||
MeasureStatus,
|
||||
Measurement,
|
||||
MonitorEvent,
|
||||
Protocol,
|
||||
} from './types';
|
||||
import { buildMonitorMeasurement } from './monitor-auswertung';
|
||||
import type { DeviceMonitorSession, Measurement, Protocol } from './types';
|
||||
|
||||
function fmtTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString('de-DE', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDuration(sec: number): string {
|
||||
if (sec < 60) return Math.round(sec) + ' s';
|
||||
const m = Math.floor(sec / 60);
|
||||
if (m < 60) {
|
||||
const s = Math.round(sec % 60);
|
||||
return s ? `${m} min ${s} s` : `${m} min`;
|
||||
}
|
||||
const h = Math.floor(m / 60);
|
||||
const restM = m % 60;
|
||||
return restM ? `${h} Std ${restM} min` : `${h} Std`;
|
||||
}
|
||||
|
||||
/** Prozent mit einer Nachkommastelle, deutsches Komma */
|
||||
function fmtProzent(p: number): string {
|
||||
return p.toFixed(1).replace('.', ',') + ' %';
|
||||
}
|
||||
|
||||
interface GeraetAuswertung {
|
||||
ip: string;
|
||||
label: string;
|
||||
ausfallzeitSek: number;
|
||||
aussetzer: number;
|
||||
laengsterAusfallSek: number;
|
||||
verfuegbarkeitProzent: number;
|
||||
/** einzelne Ausfälle als lesbare Zeilen */
|
||||
ausfaelle: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ausfallsegmente eines Geräts aus seinen Ereignissen bilden.
|
||||
*
|
||||
* Ein Ausfall beginnt mit 'down' und endet mit dem nächsten 'up'. Ist am Ende
|
||||
* noch einer offen, wird er bis `bis` zugerechnet — er ist ja nicht dadurch
|
||||
* verschwunden, dass die Messung aufhörte.
|
||||
*
|
||||
* `durationSec` aus dem up-Ereignis wird bewusst NICHT verwendet, sondern die
|
||||
* Differenz der Zeitstempel: die native Dauer war in einem Fall nachweislich
|
||||
* 0 (nämlich wenn der Ausfall schon vor dem Start bestand), und zwei Quellen
|
||||
* für dieselbe Zahl gehen irgendwann auseinander.
|
||||
*/
|
||||
function segmenteFuer(events: MonitorEvent[], von: number, bis: number): number[][] {
|
||||
const segmente: number[][] = [];
|
||||
let offen: number | null = null;
|
||||
for (const e of [...events].sort((a, b) => a.ts - b.ts)) {
|
||||
if (e.type === 'down') {
|
||||
if (offen === null) offen = e.ts;
|
||||
} else if (offen !== null) {
|
||||
segmente.push([offen, e.ts]);
|
||||
offen = null;
|
||||
}
|
||||
}
|
||||
if (offen !== null) segmente.push([offen, bis]);
|
||||
// Auf den Messzeitraum begrenzen — ein Ereignis vor dem Start (Wiederauf-
|
||||
// nahme einer älteren Sitzung) darf die Ausfallzeit nicht aufblähen.
|
||||
return segmente
|
||||
.map(([a, b]) => [Math.max(a, von), Math.min(b, bis)])
|
||||
.filter(([a, b]) => b > a);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aus einer Überwachungssitzung die Felder für die gespeicherte Messung bauen.
|
||||
*
|
||||
* @param sitzung die Sitzung mit ihren Ereignissen
|
||||
* @param endeTs Ende des Messzeitraums (aus dem Plugin, sonst jetzt)
|
||||
* @param abgerissen true = der Lauf wurde nicht sauber beendet (Prozess tot,
|
||||
* nativer Lauf verschwunden) — dann steht das im Hinweis,
|
||||
* statt eine Verfügbarkeit über eine Zeitspanne auszuweisen,
|
||||
* in der gar nicht gemessen wurde.
|
||||
*/
|
||||
export function buildMonitorMeasurement(
|
||||
sitzung: DeviceMonitorSession,
|
||||
endeTs: number,
|
||||
abgerissen = false,
|
||||
): { label: string; result: Record<string, unknown>; measureStatus: MeasureStatus } {
|
||||
const von = sitzung.startedAt;
|
||||
const bis = Math.max(von, endeTs);
|
||||
const dauerSek = Math.max(1, Math.round((bis - von) / 1000));
|
||||
|
||||
const auswertung: GeraetAuswertung[] = sitzung.targets.map((t) => {
|
||||
const eigene = sitzung.events.filter((e) => e.ip === t.ip);
|
||||
const segmente = segmenteFuer(eigene, von, bis);
|
||||
const ausfallzeitSek = Math.round(
|
||||
segmente.reduce((s, [a, b]) => s + (b - a), 0) / 1000,
|
||||
);
|
||||
const laengster = segmente.length
|
||||
? Math.round(Math.max(...segmente.map(([a, b]) => b - a)) / 1000)
|
||||
: 0;
|
||||
return {
|
||||
ip: t.ip,
|
||||
label: t.label || t.ip,
|
||||
ausfallzeitSek,
|
||||
aussetzer: segmente.length,
|
||||
laengsterAusfallSek: laengster,
|
||||
verfuegbarkeitProzent: Math.max(
|
||||
0,
|
||||
Math.min(100, (1 - ausfallzeitSek / dauerSek) * 100),
|
||||
),
|
||||
ausfaelle: segmente.map(
|
||||
([a, b]) =>
|
||||
`${t.label || t.ip} ${fmtTime(a)} – ${fmtTime(b)} (${fmtDuration((b - a) / 1000)})`,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Schlechtestes Gerät bestimmt Label und Ampel (siehe Kopfkommentar)
|
||||
const schlechtestes = auswertung.reduce<GeraetAuswertung | null>(
|
||||
(min, g) => (min === null || g.verfuegbarkeitProzent < min.verfuegbarkeitProzent ? g : min),
|
||||
null,
|
||||
);
|
||||
const gesamtAussetzer = auswertung.reduce((s, g) => s + g.aussetzer, 0);
|
||||
|
||||
/*
|
||||
* Bewertung. Die Schwellen sind bewusst streng: Für eine Kamera oder eine
|
||||
* Wallbox ist ein Aussetzer pro Termin bereits das gemeldete Problem, nicht
|
||||
* ein Schönheitsfehler. 100 % über den ganzen Zeitraum = grün, jeder
|
||||
* Aussetzer = gelb, unter 95 % oder ein Gerät durchgehend tot = rot.
|
||||
*/
|
||||
let status: MeasureStatus = 0;
|
||||
if (gesamtAussetzer > 0) status = 1;
|
||||
if ((schlechtestes?.verfuegbarkeitProzent ?? 100) < 95) status = 2;
|
||||
if (abgerissen) status = 3; // nicht messbar — die Grundlage stimmt nicht
|
||||
|
||||
const hinweise: string[] = [];
|
||||
if (abgerissen) {
|
||||
hinweise.push(
|
||||
'Die Überwachung wurde nicht regulär beendet (App oder Gerät zwischendurch ' +
|
||||
'gestoppt). Der ausgewiesene Zeitraum endet beim letzten aufgezeichneten ' +
|
||||
'Ereignis; danach wurde nicht mehr gemessen.',
|
||||
);
|
||||
}
|
||||
// Messverfahren offenlegen: das gehört zu einer belastbaren Aussage dazu.
|
||||
hinweise.push(
|
||||
'Gemessen durch regelmäßige Erreichbarkeitsprüfung alle ' +
|
||||
`${sitzung.intervalSec} Sekunden. Ein Gerät, das Ping-Anfragen ` +
|
||||
'grundsätzlich nicht beantwortet, erscheint dabei als nicht erreichbar.',
|
||||
);
|
||||
|
||||
const label = schlechtestes
|
||||
? gesamtAussetzer === 0
|
||||
? `${auswertung.length} Geräte über ${fmtDuration(dauerSek)} durchgehend erreichbar`
|
||||
: `${schlechtestes.label}: ${fmtProzent(schlechtestes.verfuegbarkeitProzent)} · ` +
|
||||
`${gesamtAussetzer} ${gesamtAussetzer === 1 ? 'Aussetzer' : 'Aussetzer'} in ${fmtDuration(dauerSek)}`
|
||||
: 'Überwachung ohne Geräte';
|
||||
|
||||
return {
|
||||
label,
|
||||
measureStatus: status,
|
||||
result: {
|
||||
dauerSekunden: dauerSek,
|
||||
intervallSek: sitzung.intervalSec,
|
||||
geraeteAnzahl: auswertung.length,
|
||||
verfuegbarkeitProzent: Number((schlechtestes?.verfuegbarkeitProzent ?? 100).toFixed(1)),
|
||||
ausfallzeitSek: schlechtestes?.ausfallzeitSek ?? 0,
|
||||
aussetzer: gesamtAussetzer,
|
||||
laengsterAusfallSek: Math.max(0, ...auswertung.map((g) => g.laengsterAusfallSek)),
|
||||
// Vorformatierte Zeilen statt Objekte: MeasurementResult rendert Arrays
|
||||
// zeilenweise, und dieselben Zeichenketten werden im PDF wiederverwendet.
|
||||
geraete: auswertung.map(
|
||||
(g) =>
|
||||
// Nur benannte Geräte bekommen die IP in Klammern dahinter — ohne
|
||||
// eigenen Namen stand sonst „10.0.2.2 (10.0.2.2)" im Kundendokument.
|
||||
`${g.label === g.ip ? g.ip : `${g.label} (${g.ip})`}: ` +
|
||||
`${fmtProzent(g.verfuegbarkeitProzent)}` +
|
||||
(g.aussetzer
|
||||
? ` · ${g.aussetzer} ${g.aussetzer === 1 ? 'Aussetzer' : 'Aussetzer'}` +
|
||||
` · ${fmtDuration(g.ausfallzeitSek)} Ausfall` +
|
||||
` · längster ${fmtDuration(g.laengsterAusfallSek)}`
|
||||
: ' · durchgehend erreichbar'),
|
||||
),
|
||||
ausfaelle: auswertung.flatMap((g) => g.ausfaelle),
|
||||
hinweis: hinweise.join(' '),
|
||||
},
|
||||
};
|
||||
}
|
||||
// Die Rechnung selbst liegt in monitor-auswertung.ts, damit sie ohne
|
||||
// Capacitor-Abhängigkeiten geladen und geprüft werden kann (tools/pruefe-monitor.mjs).
|
||||
export { buildMonitorMeasurement } from './monitor-auswertung';
|
||||
export type { GeraetAuswertung } from './monitor-auswertung';
|
||||
|
||||
/**
|
||||
* Sitzung abschließen und als Messung ins Protokoll legen.
|
||||
|
|
|
|||
|
|
@ -206,6 +206,43 @@ export interface ThroughputResult {
|
|||
measured?: boolean;
|
||||
}
|
||||
|
||||
/** Ergebnis einer Namensauflösung */
|
||||
export interface DnsResult {
|
||||
ok: boolean;
|
||||
ips: string[];
|
||||
latenzMs?: number;
|
||||
fehler?: string;
|
||||
}
|
||||
|
||||
/** Ergebnis eines HTTP(S)-Abrufs ohne Weiterleitungsverfolgung */
|
||||
export interface HttpsResult {
|
||||
erreichbar: boolean;
|
||||
statusCode?: number;
|
||||
statusText?: string;
|
||||
latenzMs?: number;
|
||||
/** Ziel einer 30x-Antwort — bei einem Gäste-WLAN die Adresse der Anmeldeseite */
|
||||
weiterleitungAuf?: string;
|
||||
fehler?: string;
|
||||
}
|
||||
|
||||
/** Antwort eines einzelnen, namentlich befragten DNS-Servers */
|
||||
export interface DnsQueryResult {
|
||||
server: string;
|
||||
name: string;
|
||||
/**
|
||||
* 'ok' = Adressen geliefert · 'unbekannt' = Server antwortet, kennt den
|
||||
* Namen nicht (NXDOMAIN — ein FUNKTIONIERENDER Server) · 'ohneAdresse' =
|
||||
* antwortet ohne brauchbares Ergebnis (SERVFAIL/REFUSED) · 'keineAntwort' =
|
||||
* gar nichts kam zurück · 'fehler' = Paket nicht auswertbar.
|
||||
*/
|
||||
status: 'ok' | 'unbekannt' | 'ohneAdresse' | 'keineAntwort' | 'fehler';
|
||||
ips: string[];
|
||||
latenzMs?: number;
|
||||
/** Server ist für diese Zone zuständig (AA-Flag) */
|
||||
autoritativ?: boolean;
|
||||
meldung?: string;
|
||||
}
|
||||
|
||||
/** Schnittstelle des nativen Plugins */
|
||||
export interface NetDiagScannerPlugin {
|
||||
/** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */
|
||||
|
|
@ -332,6 +369,26 @@ export interface NetDiagScannerPlugin {
|
|||
}>;
|
||||
/** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */
|
||||
linkInfo(): Promise<{ links: LinkInfo[] }>;
|
||||
/**
|
||||
* Namen über den System-Resolver auflösen.
|
||||
*
|
||||
* GRENZE: welcher DNS-Server antwortet, ist nicht wählbar, und der
|
||||
* System-Cache liegt dazwischen — eine zweite Abfrage desselben Namens misst
|
||||
* den Cache. Für „antwortet dieser bestimmte Server" braucht es einen eigenen
|
||||
* UDP-Client (eigener Roadmap-Punkt).
|
||||
*/
|
||||
dnsResolve(opts: { host: string; timeoutMs?: number }): Promise<DnsResult>;
|
||||
/**
|
||||
* Eine URL abrufen, ohne Weiterleitungen zu folgen. Das Nichtfolgen ist der
|
||||
* Kern: ein Gäste-WLAN mit Anmeldeseite beantwortet jede Anfrage mit einer
|
||||
* Umleitung, und wer ihr folgt, bekommt ein sauberes 200 zurück.
|
||||
*/
|
||||
httpsCheck(opts: { url: string; timeoutMs?: number }): Promise<HttpsResult>;
|
||||
/**
|
||||
* Einen Namen bei EINEM bestimmten DNS-Server abfragen (eigener UDP-Client
|
||||
* auf Port 53, ohne den Zwischenspeicher des Systems).
|
||||
*/
|
||||
dnsQuery(opts: { server: string; name: string; timeoutMs?: number }): Promise<DnsQueryResult>;
|
||||
/** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */
|
||||
startWifiScan(): Promise<{ triggered: boolean }>;
|
||||
/** WLAN-Empfangstracker für ein BSSID starten — live RSSI per `wifiSignal`-Event */
|
||||
|
|
@ -835,6 +892,7 @@ const mock: NetDiagScannerPlugin = {
|
|||
gateway: '192.168.1.1',
|
||||
dns: ['192.168.1.1', '1.1.1.1'],
|
||||
linkSpeedMbps: 1000,
|
||||
validated: true,
|
||||
},
|
||||
{
|
||||
iface: 'wlan0',
|
||||
|
|
@ -851,10 +909,39 @@ const mock: NetDiagScannerPlugin = {
|
|||
ssid: 'AllesWattLaeuft',
|
||||
bssid: 'AA:BB:CC:11:22:33',
|
||||
rssi: -52,
|
||||
validated: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
/*
|
||||
* Der Mock liefert für DNS und HTTPS BEWUSST nur den guten Fall — im Browser
|
||||
* gibt es kein Kundennetz, gegen das sich ein Ausfall ehrlich nachstellen
|
||||
* ließe. Wer die Internet-Kette hier durchklickt, sieht deshalb immer Grün.
|
||||
* Die Fehlerpfade (kein Gateway, DNS tot, Anmeldeseite, HTTPS blockiert)
|
||||
* werden nicht über den Mock geprüft, sondern über `tools/pruefe-kette.mjs`
|
||||
* gegen konstruierte Stufen mit von Hand ermitteltem Sollwert. Dieselbe
|
||||
* Falle wie beim geplanten Geräte-Diff: der Mock sieht perfekt aus und
|
||||
* beweist nichts.
|
||||
*/
|
||||
async dnsResolve(opts) {
|
||||
return { ok: true, ips: ['142.250.185.67'], latenzMs: rnd(8, 40) };
|
||||
},
|
||||
async httpsCheck() {
|
||||
return { erreichbar: true, statusCode: 204, statusText: 'No Content', latenzMs: rnd(30, 120) };
|
||||
},
|
||||
async dnsQuery(opts) {
|
||||
// Auch hier nur der Gutfall — siehe Kommentar oben. Die Fehlerpfade der
|
||||
// DNS-Prüfung rechnet tools/pruefe-dns.mjs nach.
|
||||
return {
|
||||
server: opts.server,
|
||||
name: opts.name,
|
||||
status: 'ok' as const,
|
||||
ips: ['142.250.185.67'],
|
||||
latenzMs: rnd(3, 25),
|
||||
autoritativ: false,
|
||||
};
|
||||
},
|
||||
async startWifiScan() {
|
||||
return { triggered: true };
|
||||
},
|
||||
|
|
|
|||
86
src/lib/tools/geraeteaktionen.ts
Normal file
86
src/lib/tools/geraeteaktionen.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Welche Geräte-Werkzeuge passen zu welchem Gerätetyp — bewusst OHNE
|
||||
* Laufzeit-Import, damit `tools/pruefe-geraeteaktionen.mjs` die Regeln
|
||||
* nachrechnen kann.
|
||||
*
|
||||
* Anlass: „SNMP-Switch" stand an jeder IP-Kamera und erzeugte dort zuverlässig
|
||||
* eine Messung „nicht messbar" im Kundenprotokoll — eine Zeile, die niemandem
|
||||
* hilft und den Abnahmebeleg verwässert.
|
||||
*
|
||||
* WICHTIG — warum hier nichts verschwindet: `Device.deviceType` ist freier Text
|
||||
* aus zwei unabhängigen Quellen (Kotlin `guessDeviceType`, TS `typeFromMdns`)
|
||||
* und rät anhand von MAC-Hersteller, mDNS-Diensten und offenen Ports. Ein
|
||||
* managed Switch, der als „Linux-Gerät" durchgeht, verlöre bei hartem Filtern
|
||||
* ausgerechnet SNMP — das wäre schlimmer als die überflüssige Zeile, die wir
|
||||
* loswerden wollen. Unpassende Werkzeuge werden deshalb **nachrangig**
|
||||
* einsortiert (die Karte zeigt sie kleiner und blasser), nicht entfernt.
|
||||
* Unbekannter oder leerer Typ ⇒ alles gleichrangig, nie weniger.
|
||||
*/
|
||||
|
||||
/** Werkzeuge, die nur bei bestimmten Gerätetypen vorn stehen sollen */
|
||||
const NUR_BEI: Record<string, string[]> = {
|
||||
/*
|
||||
* SNMP beantwortet praktisch nur verwaltbare Netz- und Bürotechnik. Die Liste
|
||||
* ist absichtlich großzügig: lieber ein Werkzeug zu viel vorn als eines, das
|
||||
* der Techniker beim Kunden suchen muss. Drucker gehören dazu — sie sind in
|
||||
* der Praxis die häufigste SNMP-Quelle überhaupt (Tonerstand, Zählerstand).
|
||||
*/
|
||||
snmp: [
|
||||
'switch',
|
||||
'router',
|
||||
'gateway',
|
||||
'nas',
|
||||
'drucker',
|
||||
'printer',
|
||||
'server',
|
||||
'usv',
|
||||
'accesspoint',
|
||||
'access point',
|
||||
'firewall',
|
||||
],
|
||||
};
|
||||
|
||||
/** Gerätetyp auf einen Vergleichswert bringen (Kleinschreibung, ohne Beiwerk) */
|
||||
function normiere(typ: string | undefined | null): string {
|
||||
return (typ ?? '').toLowerCase().trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Passt ein Werkzeug zum Gerätetyp?
|
||||
*
|
||||
* @param toolId ID aus der Werkzeug-Registry
|
||||
* @param deviceType freier Text aus `Device.deviceType` (darf leer sein)
|
||||
* @returns true = gleichrangig anzeigen, false = nachrangig
|
||||
*/
|
||||
export function passtZuGeraet(toolId: string, deviceType: string | undefined | null): boolean {
|
||||
const nurBei = NUR_BEI[toolId];
|
||||
if (!nurBei) {
|
||||
return true; // keine Einschränkung hinterlegt
|
||||
}
|
||||
const typ = normiere(deviceType);
|
||||
if (typ === '') {
|
||||
return true; // unbekannt ⇒ nie weniger zeigen
|
||||
}
|
||||
// Teilstring-Vergleich: die Quellen liefern u.a. „SNMP-Switch", „IP-Kamera",
|
||||
// „Windows-PC" — ein Gleichheitsvergleich ginge an der Wirklichkeit vorbei.
|
||||
return nurBei.some((k) => typ.includes(k));
|
||||
}
|
||||
|
||||
/**
|
||||
* Werkzeugliste in „passend" und „nachrangig" teilen, Reihenfolge bleibt sonst
|
||||
* unverändert.
|
||||
*
|
||||
* @param tools Werkzeuge mit `scope: 'device'`
|
||||
* @param deviceType freier Text aus `Device.deviceType`
|
||||
*/
|
||||
export function teileNachGeraetetyp<T extends { id: string }>(
|
||||
tools: T[],
|
||||
deviceType: string | undefined | null,
|
||||
): { passend: T[]; nachrangig: T[] } {
|
||||
const passend: T[] = [];
|
||||
const nachrangig: T[] = [];
|
||||
for (const t of tools) {
|
||||
(passtZuGeraet(t.id, deviceType) ? passend : nachrangig).push(t);
|
||||
}
|
||||
return { passend, nachrangig };
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@
|
|||
|
||||
import type { Tool, ToolCategory } from './types';
|
||||
|
||||
import { dnsCheckTool } from './netzwerk/dnscheck';
|
||||
import { ipConflictTool } from './netzwerk/ipconflict';
|
||||
import { ipScanTool } from './netzwerk/ipscan';
|
||||
import { pingTool } from './netzwerk/ping';
|
||||
|
|
@ -17,6 +18,7 @@ import { snmpTool } from './netzwerk/snmp';
|
|||
import { stressTestTool } from './netzwerk/stresstest';
|
||||
import { tracerouteTool } from './netzwerk/traceroute';
|
||||
|
||||
import { internetKetteTool } from './internet/internetkette';
|
||||
import { iperfTool } from './internet/iperf';
|
||||
|
||||
/**
|
||||
|
|
@ -35,8 +37,11 @@ export const TOOLS: Tool[] = [
|
|||
ipConflictTool,
|
||||
snmpTool,
|
||||
tracerouteTool,
|
||||
dnsCheckTool,
|
||||
stressTestTool,
|
||||
// Internet
|
||||
// Internet — die Kette steht bewusst vorn: sie ist die Erstdiagnose,
|
||||
// wenn der Kunde „Internet geht nicht" sagt.
|
||||
internetKetteTool,
|
||||
iperfTool,
|
||||
// Telefonie: folgt (SIP-Registrierung, FreePBX-Check, RTP-Qualität)
|
||||
];
|
||||
|
|
|
|||
156
src/lib/tools/internet/internetkette.ts
Normal file
156
src/lib/tools/internet/internetkette.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Tool: Internet-Kette — Anschluss → Gateway → Namensauflösung → HTTPS.
|
||||
*
|
||||
* Die Standard-Erstdiagnose vor Ort. Die häufigste Aussage beim Kunden ist
|
||||
* „Internet geht nicht"; bisher musste der Techniker Ping, Traceroute und
|
||||
* Browser einzeln bedienen und selbst schlussfolgern, welches Glied hakt.
|
||||
* Dieses Werkzeug misst alle Stufen in einem Lauf und benennt das Fehlerglied.
|
||||
*
|
||||
* Zwei Entwurfsentscheidungen, die für die Ehrlichkeit der Aussage wesentlich
|
||||
* sind und deshalb hier stehen:
|
||||
*
|
||||
* 1. **Die Kette bricht nicht ab.** Auch nach einer fehlgeschlagenen Stufe
|
||||
* laufen die restlichen weiter. Sonst wäre der häufigste Fall überhaupt
|
||||
* nicht auswertbar: sehr viele Router beantworten Ping grundsätzlich nicht.
|
||||
* Ein Abbruch dort meldete „Gateway tot" bei völlig gesundem Internet.
|
||||
* 2. **Eine stille Stufe ist kein Fehler, wenn eine spätere Stufe antwortet.**
|
||||
* Wer eine Antwort aus dem Internet bekommt, hat sein Gateway benutzt —
|
||||
* dann ist dessen Schweigen eine Eigenheit, keine Störung, und wird als
|
||||
* solche formuliert.
|
||||
*/
|
||||
|
||||
import { scanner } from '../../scanner';
|
||||
import type { Tool } from '../types';
|
||||
import { bewerteKette, type KettenStufe } from './kette-bewertung';
|
||||
|
||||
export { bewerteKette } from './kette-bewertung';
|
||||
export type { KettenStufe, KettenUrteil } from './kette-bewertung';
|
||||
|
||||
export const internetKetteTool: Tool = {
|
||||
id: 'internetkette',
|
||||
category: 'internet',
|
||||
name: 'Internet-Kette',
|
||||
icon: 'link',
|
||||
description: 'Anschluss → Gateway → Namensauflösung → HTTPS in einem Lauf, mit benanntem Fehlerglied.',
|
||||
scope: 'protocol',
|
||||
quickRun: true,
|
||||
params: [
|
||||
{ key: 'dnsName', label: 'Name zum Auflösen', type: 'text', default: 'www.google.de' },
|
||||
{
|
||||
key: 'url',
|
||||
label: 'Adresse für den HTTPS-Abruf',
|
||||
type: 'text',
|
||||
default: 'https://connectivitycheck.gstatic.com/generate_204',
|
||||
},
|
||||
],
|
||||
async run(ctx) {
|
||||
const dnsName = String(ctx.params.dnsName || 'www.google.de').trim();
|
||||
const url = String(ctx.params.url || 'https://connectivitycheck.gstatic.com/generate_204').trim();
|
||||
|
||||
/* Stufe 1 — eigener Anschluss */
|
||||
const { links } = await scanner.linkInfo();
|
||||
const link =
|
||||
links.find((l) => l.isDefault && l.ipv4) ??
|
||||
links.find((l) => l.ipv4 && l.type !== 'cellular') ??
|
||||
links.find((l) => l.ipv4);
|
||||
|
||||
const anschlussText = link
|
||||
? `${anschlussName(link.type)} ${link.iface}, IP ${link.ipv4}/${link.prefixLength ?? '?'}`
|
||||
: 'keine IP-Adresse auf keinem Anschluss';
|
||||
|
||||
const stufen: KettenStufe[] = [
|
||||
{ name: 'Anschluss', ok: link ? true : null, detail: anschlussText },
|
||||
];
|
||||
|
||||
/* Stufe 2 — Gateway */
|
||||
const gateway = link?.gateway;
|
||||
if (!gateway) {
|
||||
stufen.push({
|
||||
name: 'Gateway',
|
||||
ok: null,
|
||||
detail: 'kein Standard-Gateway gemeldet',
|
||||
stillErlaubt: true,
|
||||
});
|
||||
} else {
|
||||
const q = await scanner.pingQuality({ host: gateway, count: 4 });
|
||||
stufen.push({
|
||||
name: 'Gateway',
|
||||
ok: q.received > 0,
|
||||
detail:
|
||||
q.received > 0
|
||||
? `${gateway} antwortet in ${q.medianMs ?? q.avgMs} ms (${q.received}/${q.sent} Pakete)`
|
||||
: `${gateway} antwortet auf keines von ${q.sent} Paketen`,
|
||||
stillErlaubt: true,
|
||||
});
|
||||
}
|
||||
|
||||
/* Stufe 3 — Namensauflösung */
|
||||
const dns = await scanner.dnsResolve({ host: dnsName, timeoutMs: 5000 });
|
||||
stufen.push({
|
||||
name: 'Namensauflösung',
|
||||
ok: dns.ok,
|
||||
detail: dns.ok
|
||||
? `${dnsName} → ${dns.ips.slice(0, 2).join(', ')}${dns.latenzMs !== undefined ? ` (${dns.latenzMs} ms)` : ''}`
|
||||
: `${dnsName} nicht auflösbar${dns.fehler ? ` (${dns.fehler})` : ''}`,
|
||||
});
|
||||
|
||||
/* Stufe 4 — HTTPS */
|
||||
const https = await scanner.httpsCheck({ url, timeoutMs: 8000 });
|
||||
// 2xx zählt als Erfolg, 204 ist die erwartete Antwort des Prüfdienstes.
|
||||
// Eine Weiterleitung ist KEIN Erfolg — genau dort sitzt die Anmeldeseite.
|
||||
const code = https.statusCode ?? 0;
|
||||
const httpsOk = https.erreichbar && code >= 200 && code < 300;
|
||||
const weiterleitung = https.erreichbar && code >= 300 && code < 400;
|
||||
stufen.push({
|
||||
name: 'HTTPS-Abruf',
|
||||
ok: https.erreichbar ? httpsOk : false,
|
||||
detail: https.erreichbar
|
||||
? `HTTP ${code}${https.statusText ? ' ' + https.statusText : ''}` +
|
||||
(https.latenzMs !== undefined ? ` in ${https.latenzMs} ms` : '') +
|
||||
(https.weiterleitungAuf ? ` → Umleitung auf ${https.weiterleitungAuf}` : '')
|
||||
: `kein Verbindungsaufbau${https.fehler ? ` (${https.fehler})` : ''}`,
|
||||
});
|
||||
|
||||
const portalErkannt = weiterleitung || link?.captivePortal === true;
|
||||
const urteil = bewerteKette(stufen, portalErkannt, link?.validated);
|
||||
|
||||
// Systemurteil als eigene, klar als solche gekennzeichnete Zeile: es ist
|
||||
// eine Fremdaussage, keine eigene Messung.
|
||||
const systemText =
|
||||
link?.validated === undefined
|
||||
? undefined
|
||||
: link.validated
|
||||
? 'Android stuft diese Verbindung als bestätigt ein.'
|
||||
: 'Android stuft diese Verbindung NICHT als bestätigt ein.';
|
||||
|
||||
return {
|
||||
label: urteil.label,
|
||||
measureStatus: urteil.status,
|
||||
result: {
|
||||
fehlerglied: urteil.fehlerglied || 'keines',
|
||||
stufen: stufen.map(
|
||||
(s, i) =>
|
||||
`${i + 1}. ${s.name}: ${s.detail}` +
|
||||
(s.ok === true ? '' : s.ok === false ? ' — keine Antwort' : ' — nicht messbar'),
|
||||
),
|
||||
gateway: gateway ?? undefined,
|
||||
systemUrteil: systemText,
|
||||
hinweis: urteil.hinweis,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/** Anschlussart auf Deutsch — dieselbe Sprache wie netdiagKundentext() im Modul */
|
||||
function anschlussName(type: string): string {
|
||||
switch (type) {
|
||||
case 'ethernet':
|
||||
return 'LAN (Kabel)';
|
||||
case 'wifi':
|
||||
return 'WLAN';
|
||||
case 'cellular':
|
||||
return 'Mobilfunk';
|
||||
default:
|
||||
return 'Anschluss';
|
||||
}
|
||||
}
|
||||
162
src/lib/tools/internet/kette-bewertung.ts
Normal file
162
src/lib/tools/internet/kette-bewertung.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
/**
|
||||
* Bewertung der Internet-Kette — bewusst OHNE jeden Laufzeit-Import.
|
||||
*
|
||||
* Warum eine eigene Datei: die Fehlerpfade dieses Werkzeugs (kein Gateway,
|
||||
* DNS tot, Anmeldeseite, HTTPS blockiert) lassen sich im Browser-Mock nicht
|
||||
* ehrlich nachstellen — dort ist immer alles grün. Geprüft wird deshalb mit
|
||||
* `tools/pruefe-kette.mjs` gegen konstruierte Stufen mit von Hand ermitteltem
|
||||
* Sollwert. Damit dieser Prüfer die Funktion laden kann, darf hier nichts
|
||||
* stehen, was `scanner.ts` (und damit Capacitor) nachzieht: nur `import type`,
|
||||
* das beim Ausführen mit Node restlos verschwindet.
|
||||
*
|
||||
* Der Ausweg „Datei von Hand nach .mjs kopieren" wurde beim Monitor gewählt
|
||||
* und hat sich als Sackgasse erwiesen: die Kopie lag in einem
|
||||
* Sitzungsverzeichnis, das Prüfwerkzeug lief nach dessen Wegfall nicht mehr,
|
||||
* und im Repo stand trotzdem „alle Fälle korrekt".
|
||||
*/
|
||||
|
||||
import type { MeasureStatus } from '../types';
|
||||
|
||||
/** Eine Stufe der Kette, so wie sie gemessen wurde */
|
||||
export interface KettenStufe {
|
||||
/** Kurzname für das Kundendokument („Gateway", „Namensauflösung" …) */
|
||||
name: string;
|
||||
/**
|
||||
* true = Stufe hat geantwortet
|
||||
* false = Stufe hat nicht geantwortet
|
||||
* null = nicht messbar (die Voraussetzung fehlte, z.B. kein Gateway bekannt)
|
||||
*/
|
||||
ok: boolean | null;
|
||||
/** Messwert/Antwort als Klartext für das Protokoll */
|
||||
detail: string;
|
||||
/**
|
||||
* true = diese Stufe darf allein kein Rot auslösen, solange eine spätere
|
||||
* Stufe geantwortet hat. Sehr viele Router beantworten Ping grundsätzlich
|
||||
* nicht; wer eine Antwort aus dem Internet bekommt, hat sein Gateway aber
|
||||
* nachweislich benutzt.
|
||||
*/
|
||||
stillErlaubt?: boolean;
|
||||
}
|
||||
|
||||
/** Ergebnis der Kettenbewertung */
|
||||
export interface KettenUrteil {
|
||||
status: MeasureStatus;
|
||||
label: string;
|
||||
/** benanntes Fehlerglied, leer wenn die Kette durchgeht */
|
||||
fehlerglied: string;
|
||||
hinweis?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aus den gemessenen Stufen das Urteil bilden.
|
||||
*
|
||||
* @param stufen die Stufen in Reihenfolge (Anschluss zuerst, HTTPS zuletzt)
|
||||
* @param portalErkannt Anmeldeseite erkannt (Weiterleitung oder Systemurteil)
|
||||
* @param systemUrteil Androids NET_CAPABILITY_VALIDATED, falls bekannt
|
||||
*/
|
||||
export function bewerteKette(
|
||||
stufen: KettenStufe[],
|
||||
portalErkannt: boolean,
|
||||
systemUrteil?: boolean,
|
||||
): KettenUrteil {
|
||||
// Gar nichts messbar (kein Anschluss) — Status 3 statt einer Falschaussage
|
||||
// über das Kundennetz. Ohne eigene IP haben wir schlicht nicht gemessen.
|
||||
const anschluss = stufen[0];
|
||||
if (!anschluss || anschluss.ok === null) {
|
||||
return {
|
||||
status: 3,
|
||||
label: 'Nicht messbar — kein aktiver Netzwerkanschluss',
|
||||
fehlerglied: 'Anschluss',
|
||||
hinweis:
|
||||
'Das Messgerät hatte keine IP-Adresse. Es wurde nichts über das Kundennetz gemessen.',
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Die Anmeldeseite wird VOR der Fehlerglied-Suche entschieden. Sie erklärt
|
||||
* sämtliche anderen Symptome: ein Gäste-WLAN-Portal beantwortet den
|
||||
* HTTPS-Abruf mit einer Umleitung (die Stufe gilt damit als nicht bestanden)
|
||||
* und biegt oft auch die Namensauflösung um. Ohne diesen Vorrang stand im
|
||||
* Kundendokument „Kein Internet — HTTPS-Abruf antwortet nicht", obwohl der
|
||||
* Server sauber geantwortet hat und der Techniker nur auf „Anmelden" tippen
|
||||
* muss. Das ist der Unterschied zwischen einem Anruf beim Provider und
|
||||
* einem erledigten Termin.
|
||||
*/
|
||||
if (portalErkannt) {
|
||||
return {
|
||||
status: 1,
|
||||
label: 'Anmeldeseite im Weg — Internet erst nach Anmeldung',
|
||||
fehlerglied: 'Anmeldeseite',
|
||||
hinweis:
|
||||
'Die Anfrage wurde auf eine Anmeldeseite umgeleitet (typisch für ein ' +
|
||||
'Gäste-WLAN). Ohne Anmeldung kommt kein Gerät ins Internet — Kameras, ' +
|
||||
'Wechselrichter und Wallboxen können sich dort grundsätzlich nicht melden.',
|
||||
};
|
||||
}
|
||||
|
||||
// Index der letzten Stufe, die geantwortet hat — entscheidet darüber, ob das
|
||||
// Schweigen einer früheren Stufe entschuldigt ist.
|
||||
const letzteAntwortIdx = stufen.reduce((idx, s, i) => (s.ok === true ? i : idx), -1);
|
||||
|
||||
// Fehlerglied: erste Stufe ohne Antwort, deren Schweigen nicht durch eine
|
||||
// spätere Antwort erklärt ist.
|
||||
let fehlerglied = '';
|
||||
for (let i = 0; i < stufen.length; i++) {
|
||||
const s = stufen[i];
|
||||
if (s.ok !== false) continue;
|
||||
if (s.stillErlaubt && letzteAntwortIdx > i) continue;
|
||||
fehlerglied = s.name;
|
||||
break;
|
||||
}
|
||||
|
||||
if (fehlerglied !== '') {
|
||||
return {
|
||||
status: 2,
|
||||
label: `Kein Internet — ${fehlerglied} antwortet nicht`,
|
||||
fehlerglied,
|
||||
};
|
||||
}
|
||||
|
||||
const httpsStufe = stufen[stufen.length - 1];
|
||||
const internetGeht = httpsStufe?.ok === true;
|
||||
|
||||
// Alles geantwortet, aber Android hält die Verbindung nicht für bestätigt:
|
||||
// „HTTPS geht, irgendetwas anderes klemmt trotzdem" — gelb, nicht grün.
|
||||
if (internetGeht && systemUrteil === false) {
|
||||
return {
|
||||
status: 1,
|
||||
label: 'Internet erreichbar, vom System aber nicht bestätigt',
|
||||
fehlerglied: '',
|
||||
hinweis:
|
||||
'Alle Stufen haben geantwortet, Android stuft die Verbindung dennoch ' +
|
||||
'nicht als bestätigt ein. Das deutet auf eine teilweise gefilterte ' +
|
||||
'Verbindung hin (Jugendschutz, Firewall, Portsperre).',
|
||||
};
|
||||
}
|
||||
|
||||
// Eine Stufe, die nicht messbar war (z.B. kein Gateway gemeldet), ist kein
|
||||
// Erfolg — sie darf aber auch nicht als Störung ausgewiesen werden.
|
||||
const stille = stufen.filter((s) => s.ok === false && s.stillErlaubt);
|
||||
const unbekannt = stufen.filter((s) => s.ok === null && s.stillErlaubt);
|
||||
const hinweise: string[] = [];
|
||||
if (stille.length) {
|
||||
hinweise.push(
|
||||
`${stille.map((s) => s.name).join(' und ')} antwortet nicht auf Ping, ` +
|
||||
'leitet den Verkehr aber weiter — das ist bei vielen Routern die ' +
|
||||
'Werkseinstellung und keine Störung.',
|
||||
);
|
||||
}
|
||||
if (unbekannt.length) {
|
||||
hinweise.push(
|
||||
`${unbekannt.map((s) => s.name).join(' und ')} konnte nicht geprüft werden ` +
|
||||
'(vom System nicht gemeldet).',
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
status: 0,
|
||||
label: 'Internet erreichbar — Gateway, Namensauflösung und HTTPS in Ordnung',
|
||||
fehlerglied: '',
|
||||
hinweis: hinweise.length ? hinweise.join(' ') : undefined,
|
||||
};
|
||||
}
|
||||
181
src/lib/tools/netzwerk/dns-bewertung.ts
Normal file
181
src/lib/tools/netzwerk/dns-bewertung.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
/**
|
||||
* Bewertung der DNS-Prüfung — ohne Laufzeit-Import, damit
|
||||
* `tools/pruefe-dns.mjs` sie nachrechnen kann.
|
||||
*
|
||||
* Kernaussage des Werkzeugs: **welcher** Server hat geantwortet und **wie**.
|
||||
* Die vier Ausgänge sind fachlich verschieden und dürfen nie zu „geht/geht
|
||||
* nicht" verkürzt werden:
|
||||
*
|
||||
* - `ok` — Server antwortet mit Adressen. Alles in Ordnung.
|
||||
* - `unbekannt` — Server antwortet, kennt den Namen aber nicht (NXDOMAIN).
|
||||
* Das ist ein **funktionierender** Server. Bei einem
|
||||
* internen Namen heißt es „falscher Server gefragt", bei
|
||||
* einem externen „der Name existiert wirklich nicht".
|
||||
* - `ohneAdresse` — Server antwortet, liefert aber nichts Brauchbares
|
||||
* (SERVFAIL, REFUSED). Das ist eine Störung.
|
||||
* - `keineAntwort` — gar nichts kam zurück: Server aus, falsche Adresse,
|
||||
* Firewall. Die häufigste echte Ursache vor Ort.
|
||||
*/
|
||||
|
||||
import type { MeasureStatus } from '../types';
|
||||
|
||||
/** Antwort eines einzelnen befragten Servers */
|
||||
export interface DnsAntwort {
|
||||
server: string;
|
||||
name: string;
|
||||
status: 'ok' | 'unbekannt' | 'ohneAdresse' | 'keineAntwort' | 'fehler';
|
||||
ips: string[];
|
||||
latenzMs?: number;
|
||||
autoritativ?: boolean;
|
||||
meldung?: string;
|
||||
}
|
||||
|
||||
export interface DnsUrteil {
|
||||
status: MeasureStatus;
|
||||
label: string;
|
||||
zeilen: string[];
|
||||
hinweis?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Private IPv4-Adresse nach RFC 1918 (plus Link-Local 169.254.x)?
|
||||
*
|
||||
* Entscheidet, ob zwei Antworten „von verschiedener Sorte" sind — siehe
|
||||
* Kommentar in `bewerteDns()`. Bewusst ohne Regex auf Zeichenketten wie
|
||||
* „192.168.": 172.16–172.31 lässt sich so nicht sauber prüfen, und genau
|
||||
* dieser Bereich kommt in Firmennetzen häufig vor.
|
||||
*/
|
||||
export function istPrivat(ip: string): boolean {
|
||||
const t = ip.split('.').map((n) => Number(n));
|
||||
if (t.length !== 4 || t.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
|
||||
if (t[0] === 10) return true;
|
||||
if (t[0] === 192 && t[1] === 168) return true;
|
||||
if (t[0] === 172 && t[1] >= 16 && t[1] <= 31) return true;
|
||||
if (t[0] === 169 && t[1] === 254) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Eine Antwort als Protokollzeile */
|
||||
export function antwortZeile(a: DnsAntwort): string {
|
||||
const kopf = `${a.server}: `;
|
||||
switch (a.status) {
|
||||
case 'ok':
|
||||
return (
|
||||
kopf +
|
||||
`${a.ips.slice(0, 3).join(', ')}` +
|
||||
(a.latenzMs !== undefined ? ` (${a.latenzMs} ms)` : '') +
|
||||
(a.autoritativ ? ' · zuständig für diese Zone' : '')
|
||||
);
|
||||
case 'unbekannt':
|
||||
return kopf + `antwortet, kennt „${a.name}" aber nicht (NXDOMAIN)`;
|
||||
case 'ohneAdresse':
|
||||
return kopf + `antwortet ohne Adresse${a.meldung ? ` — ${a.meldung}` : ''}`;
|
||||
case 'keineAntwort':
|
||||
return kopf + 'keine Antwort';
|
||||
default:
|
||||
return kopf + `nicht auswertbar${a.meldung ? ` — ${a.meldung}` : ''}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aus den Antworten aller befragten Server das Urteil bilden.
|
||||
*
|
||||
* @param antworten in der Reihenfolge, in der gefragt wurde (eigene Server zuerst)
|
||||
*/
|
||||
export function bewerteDns(antworten: DnsAntwort[]): DnsUrteil {
|
||||
const zeilen = antworten.map(antwortZeile);
|
||||
if (antworten.length === 0) {
|
||||
return {
|
||||
status: 3,
|
||||
label: 'Nicht messbar — kein DNS-Server bekannt',
|
||||
zeilen,
|
||||
hinweis:
|
||||
'Das Gerät hat vom Netz keinen DNS-Server gemeldet bekommen. Es wurde nichts geprüft.',
|
||||
};
|
||||
}
|
||||
|
||||
const ok = antworten.filter((a) => a.status === 'ok');
|
||||
const stumm = antworten.filter((a) => a.status === 'keineAntwort');
|
||||
const gestoert = antworten.filter((a) => a.status === 'ohneAdresse' || a.status === 'fehler');
|
||||
const unbekannt = antworten.filter((a) => a.status === 'unbekannt');
|
||||
|
||||
// Kein einziger Server hat brauchbar geantwortet
|
||||
if (ok.length === 0 && unbekannt.length === 0) {
|
||||
return {
|
||||
status: 2,
|
||||
label:
|
||||
stumm.length === antworten.length
|
||||
? `Kein DNS-Server antwortet (${antworten.length} geprüft)`
|
||||
: 'DNS-Server antworten, liefern aber keine Adressen',
|
||||
zeilen,
|
||||
hinweis:
|
||||
'Ohne Namensauflösung funktioniert im Netz praktisch nichts — weder ' +
|
||||
'Internetzugang noch Updates, und Kameras oder Wechselrichter erreichen ' +
|
||||
'ihren Herstellerdienst nicht.',
|
||||
};
|
||||
}
|
||||
|
||||
// Ein Server antwortet, kennt den Namen aber nicht — und keiner kennt ihn
|
||||
if (ok.length === 0) {
|
||||
return {
|
||||
status: 1,
|
||||
label: `Name „${antworten[0].name}" ist keinem befragten Server bekannt`,
|
||||
zeilen,
|
||||
hinweis:
|
||||
'Die Server arbeiten (sie antworten mit „Name unbekannt"), der Name ' +
|
||||
'existiert dort nur nicht. Bei einem internen Namen wurde vermutlich der ' +
|
||||
'falsche Server gefragt, bei einem externen gibt es den Namen wirklich nicht.',
|
||||
};
|
||||
}
|
||||
|
||||
// Mindestens einer liefert Adressen — Rest bewerten
|
||||
if (stumm.length > 0 || gestoert.length > 0) {
|
||||
const betroffen = [...stumm, ...gestoert].map((a) => a.server).join(', ');
|
||||
return {
|
||||
status: 1,
|
||||
label: `Namensauflösung geht, aber ${stumm.length + gestoert.length} von ${antworten.length} Servern versagen`,
|
||||
zeilen,
|
||||
hinweis:
|
||||
`Nicht erreichbar oder gestört: ${betroffen}. Solange ein anderer Server ` +
|
||||
'antwortet, merkt der Kunde davon nichts — bis dieser ausfällt. Bei einem ' +
|
||||
'ausgefallenen ersten Server dauert außerdem jeder Verbindungsaufbau ' +
|
||||
'spürbar länger, weil erst dessen Zeitüberschreitung abgewartet wird.',
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
* Widersprüchliche Antworten — aber nur die aussagekräftige Sorte.
|
||||
*
|
||||
* Ein reiner Adressunterschied taugt NICHT als Befund: große Dienste
|
||||
* (Google, Microsoft, jedes CDN) antworten standortabhängig, zwei Resolver
|
||||
* liefern dort fast immer verschiedene IPs. Im Emulator gegen `www.google.de`
|
||||
* nachgestellt: 10.0.2.3 → 192.178.24.195, 9.9.9.9 → 192.178.183.94, beides
|
||||
* völlig in Ordnung. Eine Warnung darauf käme bei praktisch jeder Messung und
|
||||
* würde im Kundendokument eine gelbe Ampel ohne Befund erzeugen.
|
||||
*
|
||||
* Aussagekräftig ist der Sortenunterschied: privat gegen öffentlich. Genau so
|
||||
* sieht ein Split-Horizon-Eintrag aus (gewollt, im Firmennetz normal) — oder
|
||||
* ein zweiter, nicht gewollter DNS-Dienst, der auf sich selbst umbiegt.
|
||||
* Beide Antworten stehen ohnehin einzeln im Protokoll; der Techniker sieht
|
||||
* den Unterschied also auch ohne Ampel.
|
||||
*/
|
||||
const sorten = new Set(ok.map((a) => (a.ips.some(istPrivat) ? 'privat' : 'oeffentlich')));
|
||||
if (sorten.size > 1) {
|
||||
return {
|
||||
status: 1,
|
||||
label: 'DNS-Server antworten mit unterschiedlichen Adressarten',
|
||||
zeilen,
|
||||
hinweis:
|
||||
'Ein Server liefert für denselben Namen eine Adresse aus dem lokalen ' +
|
||||
'Netz, ein anderer eine öffentliche. Im Firmennetz ist das gewollt, wenn ' +
|
||||
'ein interner Server eigene Einträge pflegt — sonst ein Hinweis auf einen ' +
|
||||
'zweiten, nicht gewollten DNS-Dienst.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 0,
|
||||
label: `Namensauflösung in Ordnung (${ok.length} von ${antworten.length} Servern befragt)`,
|
||||
zeilen,
|
||||
};
|
||||
}
|
||||
80
src/lib/tools/netzwerk/dnscheck.ts
Normal file
80
src/lib/tools/netzwerk/dnscheck.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
/**
|
||||
* Tool: DNS-Prüfung — fragt die DNS-Server des Netzes einzeln und namentlich.
|
||||
*
|
||||
* Abgrenzung zur Internet-Kette: dort wird über den System-Resolver geprüft,
|
||||
* ob Namensauflösung überhaupt funktioniert. Hier geht je ein UDP-Paket an je
|
||||
* einen bestimmten Server. Nur so lässt sich sagen, WELCHER Server hakt — und
|
||||
* beim Kunden ist genau das die Reparaturanweisung („die Fritzbox antwortet,
|
||||
* der zweite Eintrag im DHCP zeigt ins Leere").
|
||||
*
|
||||
* Die Server kommen aus der Netzkonfiguration des Geräts (`LinkInfo.dns`),
|
||||
* also aus dem, was der Kunde per DHCP verteilt. Zusätzlich lässt sich ein
|
||||
* Vergleichsserver angeben: antwortet der, während die eigenen schweigen,
|
||||
* liegt es am Server und nicht an der Leitung.
|
||||
*/
|
||||
|
||||
import { scanner } from '../../scanner';
|
||||
import type { Tool } from '../types';
|
||||
import { bewerteDns, type DnsAntwort } from './dns-bewertung';
|
||||
|
||||
export { bewerteDns, antwortZeile } from './dns-bewertung';
|
||||
export type { DnsAntwort, DnsUrteil } from './dns-bewertung';
|
||||
|
||||
export const dnsCheckTool: Tool = {
|
||||
id: 'dnscheck',
|
||||
category: 'netzwerk',
|
||||
name: 'DNS-Prüfung',
|
||||
icon: 'globe',
|
||||
description: 'Fragt jeden DNS-Server des Netzes einzeln — zeigt, welcher antwortet und welcher nicht.',
|
||||
scope: 'protocol',
|
||||
quickRun: true,
|
||||
params: [
|
||||
{ key: 'name', label: 'Name zum Auflösen', type: 'text', default: 'www.google.de' },
|
||||
{
|
||||
key: 'vergleich',
|
||||
label: 'Vergleichsserver (leer = keiner)',
|
||||
type: 'text',
|
||||
default: '9.9.9.9',
|
||||
placeholder: '9.9.9.9',
|
||||
},
|
||||
],
|
||||
async run(ctx) {
|
||||
const name = String(ctx.params.name || 'www.google.de').trim();
|
||||
const vergleich = String(ctx.params.vergleich ?? '').trim();
|
||||
|
||||
// DNS-Server aus der Netzkonfiguration — bevorzugt vom Standard-Anschluss.
|
||||
const { links } = await scanner.linkInfo();
|
||||
const link = links.find((l) => l.isDefault && l.ipv4) ?? links.find((l) => l.ipv4);
|
||||
const eigene = (link?.dns ?? []).filter((s) => !!s);
|
||||
|
||||
// Vergleichsserver ans Ende, aber nie doppelt fragen.
|
||||
const serverListe = [...eigene];
|
||||
if (vergleich && !serverListe.includes(vergleich)) {
|
||||
serverListe.push(vergleich);
|
||||
}
|
||||
|
||||
const antworten: DnsAntwort[] = [];
|
||||
for (const server of serverListe) {
|
||||
const r = await scanner.dnsQuery({ server, name, timeoutMs: 2000 });
|
||||
antworten.push(r);
|
||||
}
|
||||
|
||||
const urteil = bewerteDns(antworten);
|
||||
|
||||
return {
|
||||
label: urteil.label,
|
||||
measureStatus: urteil.status,
|
||||
result: {
|
||||
ziel: name,
|
||||
dns: eigene.length ? eigene : undefined,
|
||||
gefragteServer: serverListe,
|
||||
antworten: urteil.zeilen,
|
||||
hinweis: urteil.hinweis,
|
||||
// Die Grenze gehört ins Kundendokument: gemessen wurde je ein Paket an
|
||||
// je einen Server, nicht die Namensauflösung des ganzen Netzes.
|
||||
verfahren:
|
||||
'Je eine direkte Anfrage (UDP/53) an jeden Server, ohne den Zwischenspeicher des Geräts.',
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -121,8 +121,15 @@ export interface LinkInfo {
|
|||
prefixLength?: number;
|
||||
gateway?: string;
|
||||
dns?: string[];
|
||||
/** DHCP-Server (nur für WLAN ermittelbar) */
|
||||
/** DHCP-Server, von dem dieser Anschluss seine Adresse bezogen hat */
|
||||
dhcpServer?: string;
|
||||
/**
|
||||
* Woher die DHCP-Angabe stammt: 'system' = LinkProperties (ab API 30, gilt
|
||||
* für jeden Anschlusstyp), 'wlan' = das deprecated DhcpInfo (nur WLAN, dafür
|
||||
* mit Lease-Dauer). Ohne diese Angabe ist im Nachhinein nicht zu klären, ob
|
||||
* ein fehlender Wert an der Leitung lag oder an der Android-Version.
|
||||
*/
|
||||
dhcpQuelle?: 'system' | 'wlan';
|
||||
leaseSec?: number;
|
||||
/** Verbindungsgeschwindigkeit in Mbit/s — WLAN: linkSpeed; Ethernet: /sys/class/net/<iface>/speed */
|
||||
linkSpeedMbps?: number;
|
||||
|
|
@ -134,6 +141,15 @@ export interface LinkInfo {
|
|||
bssid?: string;
|
||||
/** Empfangsstärke in dBm (nur wenn type='wifi') */
|
||||
rssi?: number;
|
||||
/**
|
||||
* Androids eigenes Urteil: das System hat über diese Verbindung erfolgreich
|
||||
* einen Erreichbarkeitsdienst abgerufen (NET_CAPABILITY_VALIDATED). Kostet
|
||||
* uns keine Messung und ist die verlässlichste Auskunft über ein Gäste-WLAN
|
||||
* mit Anmeldeseite, die ohne Root zu bekommen ist.
|
||||
*/
|
||||
validated?: boolean;
|
||||
/** System hat eine Anmeldeseite erkannt (NET_CAPABILITY_CAPTIVE_PORTAL) */
|
||||
captivePortal?: boolean;
|
||||
}
|
||||
|
||||
/** Einzelne RSSI-Probe des WLAN-Empfangstrackers */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,128 @@
|
|||
<script lang="ts">
|
||||
import AppHeader from '$lib/components/AppHeader.svelte';
|
||||
import { debugLog } from '$lib/debuglog.svelte';
|
||||
import { internetKetteTool } from '$lib/tools/internet/internetkette';
|
||||
import { dnsCheckTool } from '$lib/tools/netzwerk/dnscheck';
|
||||
import { scanner } from '$lib/scanner';
|
||||
import type { Protocol } from '$lib/types';
|
||||
|
||||
/*
|
||||
* Selbsttest der Internet-Kette — misst, ohne irgendetwas zu speichern.
|
||||
*
|
||||
* Zwei Gründe, warum das hier sitzt und nicht nur im Werkzeug-Grid:
|
||||
* - Vor Ort beantwortet er die erste aller Fragen („kommt das Messgerät
|
||||
* selbst überhaupt ins Netz?"), bevor man ein Protokoll anlegt.
|
||||
* - Er ist der einzige Weg, die nativen Methoden im Emulator zu prüfen,
|
||||
* solange die Testinstanz nur HTTP spricht: der WebView blockiert aus
|
||||
* der Origin `https://localhost` jede HTTP-Anfrage (Mixed Content),
|
||||
* ohne Server gibt es keine Aufträge und damit kein Protokoll.
|
||||
*/
|
||||
let selbsttest = $state<{ laeuft: boolean; label: string; status: number; zeilen: string[] } | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
/**
|
||||
* Ein Werkzeug ohne Protokoll laufen lassen und das Ergebnis anzeigen.
|
||||
* Gemeinsam für beide Selbsttests — sie unterscheiden sich nur im Werkzeug
|
||||
* und darin, welche Ergebnisfelder als Zeilen taugen.
|
||||
*/
|
||||
async function laufeOhneSpeichern(
|
||||
tool: typeof internetKetteTool,
|
||||
zeilenAus: (res: Record<string, unknown>) => string[],
|
||||
) {
|
||||
selbsttest = { laeuft: true, label: 'misst …', status: -1, zeilen: [] };
|
||||
try {
|
||||
/*
|
||||
* Vorgabewerte des Werkzeugs übernehmen. Sie stehen in `tool.params` und
|
||||
* werden sonst erst vom ToolDialog gesetzt — ein direkter Aufruf mit `{}`
|
||||
* misst deshalb etwas anderes als der Techniker vor Ort. Aufgefallen im
|
||||
* Emulator: die DNS-Prüfung fragte „1 von 1 Servern", weil der
|
||||
* Vergleichsserver aus dem Vorgabewert fehlte.
|
||||
*/
|
||||
const params: Record<string, string | number> = {};
|
||||
for (const f of tool.params) {
|
||||
if (f.default !== undefined) params[f.key] = f.default;
|
||||
}
|
||||
const r = await tool.run({
|
||||
params,
|
||||
// Die Werkzeuge lesen das Protokoll nicht — hier wird nichts gespeichert.
|
||||
protocol: {} as unknown as Protocol,
|
||||
});
|
||||
selbsttest = {
|
||||
laeuft: false,
|
||||
label: r.label,
|
||||
status: r.measureStatus,
|
||||
zeilen: zeilenAus(r.result as Record<string, unknown>),
|
||||
};
|
||||
} catch (e) {
|
||||
selbsttest = {
|
||||
laeuft: false,
|
||||
label: 'Fehler beim Messen',
|
||||
status: 2,
|
||||
zeilen: [e instanceof Error ? e.message : String(e)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function dnsTesten() {
|
||||
return laufeOhneSpeichern(dnsCheckTool, (res) => [
|
||||
...((res.antworten as string[]) ?? []),
|
||||
...(res.gefragteServer ? [`Befragt: ${(res.gefragteServer as string[]).join(', ')}`] : []),
|
||||
...(res.hinweis ? [String(res.hinweis)] : []),
|
||||
]);
|
||||
}
|
||||
|
||||
async function ketteTesten() {
|
||||
selbsttest = { laeuft: true, label: 'misst …', status: -1, zeilen: [] };
|
||||
try {
|
||||
const r = await internetKetteTool.run({
|
||||
params: {},
|
||||
// Das Werkzeug liest das Protokoll nicht — hier wird nichts gespeichert.
|
||||
protocol: {} as unknown as Protocol,
|
||||
});
|
||||
const res = r.result as Record<string, unknown>;
|
||||
// Anschlussdaten dazu: DHCP-Server, Lease und Link-Speed sieht man sonst
|
||||
// nur im IP-Test, und der braucht ein Protokoll (also einen Server).
|
||||
const { links } = await scanner.linkInfo();
|
||||
const anschluesse = links.map((l) =>
|
||||
[
|
||||
`${l.iface} (${l.type})${l.isDefault ? ' [Standardroute]' : ''}`,
|
||||
l.ipv4 ? `IP ${l.ipv4}/${l.prefixLength ?? '?'}` : 'keine IP',
|
||||
l.gateway ? `Gateway ${l.gateway}` : 'kein Gateway',
|
||||
`DHCP-Server ${l.dhcpServer ?? 'nicht ermittelbar'}${l.dhcpQuelle ? ` (${l.dhcpQuelle})` : ''}`,
|
||||
`Lease ${l.leaseSec !== undefined ? l.leaseSec + ' s' : 'nicht ermittelbar'}`,
|
||||
l.linkSpeedMbps ? `${l.linkSpeedMbps} Mbit/s` : 'Geschwindigkeit unbekannt',
|
||||
].join(' · '),
|
||||
);
|
||||
selbsttest = {
|
||||
laeuft: false,
|
||||
label: r.label,
|
||||
status: r.measureStatus,
|
||||
zeilen: [
|
||||
...((res.stufen as string[]) ?? []),
|
||||
`Fehlerglied: ${res.fehlerglied}`,
|
||||
...(res.systemUrteil ? [String(res.systemUrteil)] : []),
|
||||
...(res.hinweis ? [String(res.hinweis)] : []),
|
||||
'— Anschlüsse —',
|
||||
...anschluesse,
|
||||
],
|
||||
};
|
||||
} catch (e) {
|
||||
selbsttest = {
|
||||
laeuft: false,
|
||||
label: 'Fehler beim Messen',
|
||||
status: 2,
|
||||
zeilen: [e instanceof Error ? e.message : String(e)],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const statusFarbe: Record<number, string> = {
|
||||
0: 'text-emerald-400',
|
||||
1: 'text-amber-400',
|
||||
2: 'text-red-400',
|
||||
3: 'text-zinc-400',
|
||||
};
|
||||
|
||||
const levelColor: Record<string, string> = {
|
||||
info: 'text-zinc-400',
|
||||
|
|
@ -48,6 +170,33 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="border-b border-zinc-800 bg-zinc-900 px-3 py-2">
|
||||
<button
|
||||
class="w-full rounded bg-zinc-800 px-3 py-2 text-sm active:bg-zinc-700 disabled:opacity-50"
|
||||
onclick={ketteTesten}
|
||||
disabled={selbsttest?.laeuft}
|
||||
>
|
||||
Internet-Kette prüfen (misst nur, speichert nichts)
|
||||
</button>
|
||||
<button
|
||||
class="mt-1.5 w-full rounded bg-zinc-800 px-3 py-2 text-sm active:bg-zinc-700 disabled:opacity-50"
|
||||
onclick={dnsTesten}
|
||||
disabled={selbsttest?.laeuft}
|
||||
>
|
||||
DNS-Server prüfen (misst nur, speichert nichts)
|
||||
</button>
|
||||
{#if selbsttest}
|
||||
<p class="mt-2 text-sm {statusFarbe[selbsttest.status] ?? 'text-zinc-400'}">
|
||||
{selbsttest.label}
|
||||
</p>
|
||||
<ul class="mt-1 space-y-0.5 font-mono text-xs text-zinc-400">
|
||||
{#each selbsttest.zeilen as z, i (i)}
|
||||
<li class="break-words">{z}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-2 font-mono text-xs">
|
||||
{#if debugLog.entries.length === 0}
|
||||
<p class="p-4 text-center text-zinc-500">Keine Einträge.</p>
|
||||
|
|
|
|||
68
tools/pruefe-dns.mjs
Normal file
68
tools/pruefe-dns.mjs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Prueft die Bewertung der DNS-Pruefung gegen konstruierte Antworten.
|
||||
*
|
||||
* Der Browser-Mock liefert auch hier nur den Gutfall. Entscheidend ist, dass
|
||||
* "Server antwortet, kennt den Namen nicht" (NXDOMAIN) NICHT wie ein Ausfall
|
||||
* behandelt wird - das ist ein funktionierender Server.
|
||||
*
|
||||
* Start: node tools/pruefe-dns.mjs
|
||||
*/
|
||||
const { bewerteDns, istPrivat } = await import('../src/lib/tools/netzwerk/dns-bewertung.ts');
|
||||
|
||||
const ok = (server, ips = ['142.250.185.67']) =>
|
||||
({ server, name: 'www.google.de', status: 'ok', ips, latenzMs: 12 });
|
||||
const stumm = (server) =>
|
||||
({ server, name: 'www.google.de', status: 'keineAntwort', ips: [], meldung: 'Keine Antwort innerhalb von 2000 ms' });
|
||||
const nxdomain = (server) =>
|
||||
({ server, name: 'intern.firma.local', status: 'unbekannt', ips: [], latenzMs: 4 });
|
||||
const servfail = (server) =>
|
||||
({ server, name: 'www.google.de', status: 'ohneAdresse', ips: [], latenzMs: 9, meldung: 'Serverfehler (SERVFAIL)' });
|
||||
|
||||
const faelle = [
|
||||
{ name: 'Alle Server antworten gleich -> GRUEN',
|
||||
a: [ok('192.168.1.1'), ok('9.9.9.9')], soll: 0 },
|
||||
{ name: 'Kein Server antwortet -> ROT',
|
||||
a: [stumm('192.168.1.1'), stumm('192.168.1.2')], soll: 2 },
|
||||
{ name: 'Erster Server stumm, Vergleichsserver geht -> GELB (faellt sonst nicht auf)',
|
||||
a: [stumm('192.168.1.2'), ok('9.9.9.9')], soll: 1 },
|
||||
{ name: 'SERVFAIL ueberall -> ROT',
|
||||
a: [servfail('192.168.1.1')], soll: 2 },
|
||||
{ name: 'NXDOMAIN ueberall -> GELB, KEIN Ausfall (Server arbeiten)',
|
||||
a: [nxdomain('192.168.1.1'), nxdomain('9.9.9.9')], soll: 1 },
|
||||
{ name: 'Privat vs. oeffentlich -> GELB (Split-Horizon oder Fremd-DNS)',
|
||||
a: [ok('192.168.1.1', ['10.0.0.5']), ok('9.9.9.9', ['142.250.185.67'])], soll: 1 },
|
||||
{ name: 'Zwei verschiedene OEFFENTLICHE Adressen -> GRUEN (CDN, kein Befund)',
|
||||
a: [ok('10.0.2.3', ['192.178.24.195']), ok('9.9.9.9', ['192.178.183.94'])], soll: 0 },
|
||||
{ name: 'Zwei verschiedene PRIVATE Adressen -> GRUEN (zwei interne Server, beide intern)',
|
||||
a: [ok('192.168.1.1', ['192.168.1.50']), ok('192.168.1.2', ['192.168.1.51'])], soll: 0 },
|
||||
{ name: 'Gar kein DNS-Server bekannt -> NICHT MESSBAR',
|
||||
a: [], soll: 3 },
|
||||
{ name: 'Ein Server ok, einer SERVFAIL -> GELB',
|
||||
a: [ok('192.168.1.1'), servfail('192.168.1.2')], soll: 1 },
|
||||
];
|
||||
|
||||
let fehler = 0;
|
||||
for (const f of faelle) {
|
||||
const r = bewerteDns(f.a);
|
||||
const okk = r.status === f.soll;
|
||||
if (!okk) fehler++;
|
||||
console.log(`${okk ? 'OK ' : 'FEHL'} ${f.name}`);
|
||||
console.log(` status=${r.status} (soll ${f.soll}) ${r.label}`);
|
||||
}
|
||||
// Adressbereiche einzeln nachrechnen - 172.16-31 ist der Bereich, den eine
|
||||
// naive Zeichenkettenpruefung falsch macht.
|
||||
const privatFaelle = [
|
||||
['10.0.0.5', true], ['192.168.1.1', true], ['172.16.0.1', true],
|
||||
['172.31.255.254', true], ['169.254.1.1', true],
|
||||
['172.15.0.1', false], ['172.32.0.1', false], ['8.8.8.8', false],
|
||||
['142.250.185.67', false], ['193.168.1.1', false],
|
||||
];
|
||||
for (const [ip, soll] of privatFaelle) {
|
||||
const ist = istPrivat(ip);
|
||||
const okk = ist === soll;
|
||||
if (!okk) fehler++;
|
||||
console.log(`${okk ? 'OK ' : 'FEHL'} istPrivat("${ip}") = ${ist} (soll ${soll})`);
|
||||
}
|
||||
|
||||
console.log(fehler === 0 ? 'ALLE FAELLE KORREKT' : `${fehler} FEHLER`);
|
||||
process.exit(fehler === 0 ? 0 : 1);
|
||||
46
tools/pruefe-geraeteaktionen.mjs
Normal file
46
tools/pruefe-geraeteaktionen.mjs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Prueft die Zuordnung Geraetetyp -> Werkzeuge.
|
||||
*
|
||||
* Anlass: "SNMP-Switch" stand an jeder IP-Kamera und erzeugte dort eine
|
||||
* Messung "nicht messbar" im Kundenprotokoll. Wichtiger als das Wegsortieren
|
||||
* ist aber, dass NICHTS verschwindet: deviceType ist geraten.
|
||||
*
|
||||
* Start: node tools/pruefe-geraeteaktionen.mjs
|
||||
*/
|
||||
const { passtZuGeraet, teileNachGeraetetyp } =
|
||||
await import('../src/lib/tools/geraeteaktionen.ts');
|
||||
|
||||
const WERKZEUGE = [{ id: 'ping' }, { id: 'portscan' }, { id: 'snmp' }];
|
||||
|
||||
const faelle = [
|
||||
{ typ: 'Kamera', snmpVorn: false, name: 'IP-Kamera -> SNMP nachrangig' },
|
||||
{ typ: 'IP-Kamera', snmpVorn: false, name: 'Schreibweise mit Praefix' },
|
||||
{ typ: 'Switch', snmpVorn: true, name: 'Switch -> SNMP vorn' },
|
||||
{ typ: 'SNMP-Switch', snmpVorn: true, name: 'Teilstring trifft' },
|
||||
{ typ: 'Drucker', snmpVorn: true, name: 'Drucker koennen SNMP (Toner/Zaehler)' },
|
||||
{ typ: 'NAS', snmpVorn: true, name: 'NAS -> SNMP vorn' },
|
||||
{ typ: 'Router', snmpVorn: true, name: 'Router -> SNMP vorn' },
|
||||
{ typ: 'Windows-PC', snmpVorn: false, name: 'PC -> SNMP nachrangig' },
|
||||
{ typ: 'Wallbox', snmpVorn: false, name: 'Wallbox spricht Modbus, nicht SNMP' },
|
||||
{ typ: 'IoT/SPS', snmpVorn: false, name: 'SPS spricht Modbus, nicht SNMP' },
|
||||
{ typ: '', snmpVorn: true, name: 'LEER -> alles gleichrangig (nie weniger)' },
|
||||
{ typ: undefined, snmpVorn: true, name: 'UNBEKANNT -> alles gleichrangig' },
|
||||
{ typ: 'Linux-Gerät', snmpVorn: false, name: 'Linux-Geraet -> nachrangig, aber erreichbar' },
|
||||
];
|
||||
|
||||
let fehler = 0;
|
||||
for (const f of faelle) {
|
||||
const { passend, nachrangig } = teileNachGeraetetyp(WERKZEUGE, f.typ);
|
||||
const snmpVorn = passend.some((t) => t.id === 'snmp');
|
||||
// Zwei harte Zusicherungen: die erwartete Einsortierung UND dass kein
|
||||
// Werkzeug verschwindet.
|
||||
const vollzaehlig = passend.length + nachrangig.length === WERKZEUGE.length;
|
||||
const pingVorn = passend.some((t) => t.id === 'ping');
|
||||
const portVorn = passend.some((t) => t.id === 'portscan');
|
||||
const ok = snmpVorn === f.snmpVorn && vollzaehlig && pingVorn && portVorn;
|
||||
if (!ok) fehler++;
|
||||
console.log(`${ok ? 'OK ' : 'FEHL'} ${f.name}`);
|
||||
console.log(` typ="${f.typ}" vorn=[${passend.map((t) => t.id)}] nachrangig=[${nachrangig.map((t) => t.id)}]`);
|
||||
}
|
||||
console.log(fehler === 0 ? 'ALLE FAELLE KORREKT' : `${fehler} FEHLER`);
|
||||
process.exit(fehler === 0 ? 0 : 1);
|
||||
119
tools/pruefe-kette.mjs
Normal file
119
tools/pruefe-kette.mjs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
/**
|
||||
* Prueft die Bewertung der Internet-Kette gegen konstruierte Faelle mit von
|
||||
* Hand ermitteltem Sollwert.
|
||||
*
|
||||
* Warum ueberhaupt: der Browser-Mock liefert fuer DNS und HTTPS immer den
|
||||
* guten Fall, dort ist die Kette also IMMER gruen. Genau die Faelle, wegen
|
||||
* derer das Werkzeug gebaut wurde (Gateway still, DNS tot, Anmeldeseite),
|
||||
* kommen im Mock nicht vor.
|
||||
*
|
||||
* Start: node tools/pruefe-kette.mjs
|
||||
* (laedt die TS-Datei direkt — Node 22.6+ entfernt die Typannotationen selbst;
|
||||
* deshalb darf kette-bewertung.ts keinen Laufzeit-Import enthalten)
|
||||
*/
|
||||
|
||||
const { bewerteKette } = await import('../src/lib/tools/internet/kette-bewertung.ts');
|
||||
|
||||
const anschlussOk = { name: 'Anschluss', ok: true, detail: 'LAN (Kabel) eth0, IP 192.168.1.42/24' };
|
||||
const gwOk = { name: 'Gateway', ok: true, detail: '192.168.1.1 antwortet in 2 ms', stillErlaubt: true };
|
||||
const gwStill = { name: 'Gateway', ok: false, detail: 'antwortet auf keines von 4 Paketen', stillErlaubt: true };
|
||||
const gwUnbekannt = { name: 'Gateway', ok: null, detail: 'kein Standard-Gateway gemeldet', stillErlaubt: true };
|
||||
const dnsOk = { name: 'Namensauflösung', ok: true, detail: 'www.google.de → 142.250.185.67 (23 ms)' };
|
||||
const dnsTot = { name: 'Namensauflösung', ok: false, detail: 'nicht auflösbar (UnknownHostException)' };
|
||||
const httpsOk = { name: 'HTTPS-Abruf', ok: true, detail: 'HTTP 204 in 58 ms' };
|
||||
const httpsTot = { name: 'HTTPS-Abruf', ok: false, detail: 'kein Verbindungsaufbau (timeout)' };
|
||||
|
||||
const faelle = [
|
||||
{
|
||||
name: 'Alles in Ordnung',
|
||||
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
|
||||
portal: false,
|
||||
system: true,
|
||||
soll: { status: 0, glied: '' },
|
||||
},
|
||||
{
|
||||
name: 'Gateway antwortet nicht auf Ping, Internet geht -> GRUEN mit Hinweis',
|
||||
stufen: [anschlussOk, gwStill, dnsOk, httpsOk],
|
||||
portal: false,
|
||||
system: true,
|
||||
soll: { status: 0, glied: '', hinweisEnthaelt: 'Werkseinstellung' },
|
||||
},
|
||||
{
|
||||
name: 'Gateway tot UND alles danach tot -> Fehlerglied Gateway',
|
||||
stufen: [anschlussOk, gwStill, dnsTot, httpsTot],
|
||||
portal: false,
|
||||
system: false,
|
||||
soll: { status: 2, glied: 'Gateway' },
|
||||
},
|
||||
{
|
||||
name: 'Gateway ok, DNS tot -> Fehlerglied Namensaufloesung',
|
||||
stufen: [anschlussOk, gwOk, dnsTot, httpsTot],
|
||||
portal: false,
|
||||
system: false,
|
||||
soll: { status: 2, glied: 'Namensauflösung' },
|
||||
},
|
||||
{
|
||||
name: 'DNS ok, HTTPS tot -> Fehlerglied HTTPS-Abruf',
|
||||
stufen: [anschlussOk, gwOk, dnsOk, httpsTot],
|
||||
portal: false,
|
||||
system: false,
|
||||
soll: { status: 2, glied: 'HTTPS-Abruf' },
|
||||
},
|
||||
{
|
||||
name: 'Umleitung auf Anmeldeseite -> GELB "Anmeldeseite", NICHT rot "HTTPS tot"',
|
||||
stufen: [anschlussOk, gwOk, dnsOk, { name: 'HTTPS-Abruf', ok: false, detail: 'HTTP 302 → Portal' }],
|
||||
portal: true,
|
||||
system: false,
|
||||
soll: { status: 1, glied: 'Anmeldeseite', hinweisEnthaelt: 'Gäste-WLAN' },
|
||||
},
|
||||
{
|
||||
name: 'Portal nur ueber das Systemflag erkannt -> GELB Anmeldeseite',
|
||||
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
|
||||
portal: true,
|
||||
system: false,
|
||||
soll: { status: 1, glied: 'Anmeldeseite' },
|
||||
},
|
||||
{
|
||||
name: 'Alles ok, aber Android bestaetigt die Verbindung nicht -> GELB',
|
||||
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
|
||||
portal: false,
|
||||
system: false,
|
||||
soll: { status: 1, glied: '', hinweisEnthaelt: 'gefilterte' },
|
||||
},
|
||||
{
|
||||
name: 'Kein Anschluss (keine IP) -> NICHT MESSBAR, keine Aussage ueber das Kundennetz',
|
||||
stufen: [{ name: 'Anschluss', ok: null, detail: 'keine IP-Adresse' }],
|
||||
portal: false,
|
||||
system: undefined,
|
||||
soll: { status: 3, glied: 'Anschluss' },
|
||||
},
|
||||
{
|
||||
name: 'Kein Gateway gemeldet, Rest geht -> GRUEN mit Hinweis "nicht geprueft"',
|
||||
stufen: [anschlussOk, gwUnbekannt, dnsOk, httpsOk],
|
||||
portal: false,
|
||||
system: true,
|
||||
soll: { status: 0, glied: '', hinweisEnthaelt: 'nicht geprüft' },
|
||||
},
|
||||
{
|
||||
name: 'Systemurteil unbekannt (aeltere Android-Version) -> GRUEN, kein Gelb aus Unwissen',
|
||||
stufen: [anschlussOk, gwOk, dnsOk, httpsOk],
|
||||
portal: false,
|
||||
system: undefined,
|
||||
soll: { status: 0, glied: '' },
|
||||
},
|
||||
];
|
||||
|
||||
let fehler = 0;
|
||||
for (const f of faelle) {
|
||||
const r = bewerteKette(f.stufen, f.portal, f.system);
|
||||
let ok = r.status === f.soll.status && r.fehlerglied === f.soll.glied;
|
||||
if (f.soll.hinweisEnthaelt && !(r.hinweis || '').includes(f.soll.hinweisEnthaelt)) ok = false;
|
||||
if (!ok) fehler++;
|
||||
console.log(`${ok ? 'OK ' : 'FEHL'} ${f.name}`);
|
||||
console.log(` status=${r.status} (soll ${f.soll.status}) glied="${r.fehlerglied}" (soll "${f.soll.glied}")`);
|
||||
console.log(` Label: ${r.label}`);
|
||||
if (r.hinweis) console.log(` Hinweis: ${r.hinweis.slice(0, 120)}`);
|
||||
console.log();
|
||||
}
|
||||
console.log(fehler === 0 ? 'ALLE FAELLE KORREKT' : `${fehler} FEHLER`);
|
||||
process.exit(fehler === 0 ? 0 : 1);
|
||||
|
|
@ -3,11 +3,11 @@
|
|||
* ausgerechnetem Sollwert. Die Rechnung landet als Verfuegbarkeitsaussage im
|
||||
* Kundendokument — sie muss stimmen, nicht plausibel aussehen.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
|
||||
|
||||
const { buildMonitorMeasurement } = await import('/tmp/claude-1000/-mnt-17---Entwicklungen-20---Projekte-NetzwerkDiagnose/c1dc785b-f713-4aae-a06d-1fe09a25cd1a/scratchpad/monitor.mjs');
|
||||
// Laedt die TS-Datei direkt (Node 22.6+ entfernt die Typannotationen selbst).
|
||||
// Vorher stand hier der Pfad einer von Hand erzeugten .mjs-Kopie in einem
|
||||
// Sitzungsverzeichnis unter /tmp — nach dessen Wegfall lief dieser Pruefer
|
||||
// nicht mehr, waehrend im Repo "alle Faelle korrekt" stand.
|
||||
const { buildMonitorMeasurement } = await import('../src/lib/monitor-auswertung.ts');
|
||||
|
||||
const T0 = new Date('2026-08-16T09:00:00').getTime();
|
||||
const min = (m) => T0 + m * 60000;
|
||||
|
|
|
|||
Loading…
Reference in a new issue