Compare commits

...

5 commits

Author SHA1 Message Date
7236017e87 Trigger: APK-Rebuild [apk]
All checks were successful
Build APK / build-apk (push) Successful in 3m14s
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 06:41:25 +02:00
2f7b1c91a4 DNS-Pruefung: eigener UDP-Client fragt jeden Server namentlich
Neu: Dns.kt (eigener DNS-Client auf UDP/53, Aufbau wie Snmp.kt) und das
Werkzeug "DNS-Pruefung". Unterschied zur Internet-Kette: dort antwortet, wen
das System fuer richtig haelt, und der Systemcache liegt dazwischen. Hier geht
je ein Paket an je einen Server aus der Netzkonfiguration - nur so laesst sich
sagen, WELCHER Server hakt, und genau das ist beim Kunden die
Reparaturanweisung ("die Fritzbox antwortet, der zweite DHCP-Eintrag zeigt
ins Leere").

Vier Ausgaenge, bewusst nicht zu "geht/geht nicht" verkuerzt:
ok, unbekannt (NXDOMAIN - ein FUNKTIONIERENDER Server!), ohneAdresse
(SERVFAIL/REFUSED - Stoerung), keineAntwort (Ausfall).

Beim Emulator-Test einen Fehlalarm gefunden und behoben: die erste Fassung
meldete "DNS-Server antworten unterschiedlich", sobald zwei Resolver
verschiedene IPs lieferten. Gegen www.google.de gemessen taten sie das sofort
(10.0.2.3 -> 192.178.24.195, 9.9.9.9 -> 192.178.183.94) - bei jedem CDN normal.
Die Warnung waere bei praktisch jeder Messung gekommen und haette im
Kundendokument eine gelbe Ampel ohne Befund erzeugt. Gewertet wird jetzt nur
noch der Sortenunterschied privat gegen oeffentlich - so sieht Split-Horizon
oder ein Fremd-DNS aus. Beide Antworten stehen ohnehin einzeln im Protokoll.

tools/pruefe-dns.mjs: 10 Bewertungsfaelle plus 10 Adressbereiche fuer
istPrivat() (172.16-31 macht eine naive Zeichenkettenpruefung falsch).

Im Emulator gemessen: 10.0.2.3 und 9.9.9.9 beide befragt und beantwortet;
der Cache-Effekt ist sichtbar (16 ms lokal, 500 ms zu Quad9).

Ausserdem: inputmode fuer die Eingabefelder im ToolDialog ('decimal' bei
Adress-/Netzfeldern, damit die Tastatur einen Punkt hat - mit 'numeric' laesst
sich keine IP eingeben), Autokorrektur und Grossschreibung aus (sonst wird aus
"fritz.box" beim Tippen "Fritz.Box").

Der Debug-Selbsttest uebernimmt jetzt die Vorgabewerte des Werkzeugs - vorher
mass er mit leeren Parametern etwas anderes als der Techniker vor Ort (die
DNS-Pruefung fragte "1 von 1 Servern", weil der Vergleichsserver fehlte).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:24:32 +02:00
7d79358c02 Geraete-Aktionen nach Geraetetyp sortieren statt sie zu verstecken
"SNMP-Switch" stand an jeder IP-Kamera und erzeugte dort zuverlaessig eine
Messung "nicht messbar" im Kundenprotokoll - eine Zeile, die niemandem hilft
und den Abnahmebeleg verwaessert.

Bewusst KEIN hartes Filtern: Device.deviceType ist freier Text aus zwei
unabhaengigen Quellen (Kotlin guessDeviceType, TS typeFromMdns) und wird aus
MAC-Hersteller, mDNS-Diensten und offenen Ports geraten. Ein managed Switch,
der als "Linux-Geraet" durchgeht, verloere beim Filtern ausgerechnet SNMP -
schlimmer als die ueberfluessige Zeile, die wir loswerden wollen. Unpassende
Werkzeuge stehen deshalb nachrangig (kleiner, blasser, mit Titel-Hinweis)
hinter den passenden, statt zu verschwinden. Leerer oder unbekannter Typ:
alles gleichrangig, nie weniger.

Die SNMP-Liste ist absichtlich grosszuegig (Switch, Router, Gateway, NAS,
Drucker, Server, USV, Accesspoint, Firewall) - Drucker sind in der Praxis die
haeufigste SNMP-Quelle ueberhaupt.

tools/pruefe-geraeteaktionen.mjs rechnet 13 Faelle nach und prueft dabei zwei
Zusicherungen: die erwartete Einsortierung UND dass kein Werkzeug verschwindet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:14:06 +02:00
c954afc889 DHCP-Server auch an der Kabeldose ermitteln
linkInfo() nutzt jetzt LinkProperties.getDhcpServerAddress() (ab Android 11)
fuer JEDEN Anschlusstyp, und zwar VOR dem alten WLAN-Pfad. Bisher kam die
Angabe nur aus WifiManager.dhcpInfo - deprecated und nur fuer WLAN. An einer
Netzwerkdose blieb sie deshalb leer, obwohl das Geraet seine Adresse sichtbar
per DHCP bezogen hat. Fuer ein Abnahmeprotokoll ist "von welchem Server kommt
die Adresse" aber gerade an der Dose die interessante Frage (zweiter Router im
Netz, vergessener Testserver).

Die Lease-DAUER gibt es auf diesem Weg nicht - sie steckt nur im deprecated
DhcpInfo und nur fuer WLAN. Sie zu erfinden oder als 0 zu schreiben waere eine
Falschaussage; genau so hat "0" schon einmal als "0 DHCP-Server (!)" in Rot in
einem ausgelieferten Kundendokument gestanden.

Neu: dhcpQuelle ('system' | 'wlan') nach dem Muster von prefixQuelle in
getLocalSubnet. Ohne diese Angabe ist im Nachhinein nicht zu klaeren, ob ein
fehlender Wert an der Leitung lag oder an der Android-Version.

Der Debug-Selbsttest zeigt jetzt zusaetzlich alle Anschluesse mit IP, Gateway,
DHCP-Server (samt Quelle), Lease und Geschwindigkeit - im Emulator bestaetigt:
"wlan0 (wifi) [Standardroute] - IP 10.0.2.16/24 - Gateway 10.0.2.2 -
DHCP-Server 10.0.2.2 (system) - Lease 86400 s". Der Wert kommt also ueber die
neue API, nicht ueber den WLAN-Fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 23:11:08 +02:00
460b85d708 Internet-Kette: Anschluss, Gateway, Namensaufloesung und HTTPS in einem Lauf
Die haeufigste Aussage beim Kunden ist "Internet geht nicht". Bisher musste
der Techniker Ping, Traceroute und Browser einzeln bedienen und selbst
schlussfolgern, welches Glied hakt. Das Werkzeug misst alle vier Stufen in
einem Lauf und benennt das Fehlerglied.

Neue native Methoden: dnsResolve (System-Resolver; die Grenze - Server nicht
waehlbar, Cache dazwischen - steht im Code und im Ergebnis) und httpsCheck.
Bei httpsCheck ist instanceFollowRedirects=false keine Feinheit, sondern der
Kern: ein Gaeste-WLAN beantwortet jede Anfrage mit einer Umleitung auf sein
Portal. Wer ihr folgt, bekommt ein sauberes 200 und meldet "Internet in
Ordnung", obwohl ohne Anmeldung nichts geht. linkInfo liefert zusaetzlich
NET_CAPABILITY_VALIDATED und _CAPTIVE_PORTAL - Androids eigenes Urteil,
das uns keine einzige Messung kostet.

Drei Entscheidungen fuer die Ehrlichkeit der Aussage:
1. Die Kette bricht nicht ab. Sehr viele Router beantworten Ping
   grundsaetzlich nicht - ein Abbruch dort meldete "Gateway tot" bei
   voellig gesundem Internet.
2. Eine stille Stufe ist kein Fehler, wenn eine spaetere antwortet. Wer eine
   Antwort aus dem Internet bekommt, hat sein Gateway benutzt. Ergebnis:
   gruen mit Hinweis statt rot.
3. Anmeldeseite schlaegt Fehlerglied. Sonst stuende im Kundendokument "Kein
   Internet - HTTPS antwortet nicht", obwohl der Techniker nur auf "Anmelden"
   tippen muss. Beim Pruefen aufgefallen und korrigiert.

Die Bewertung liegt in kette-bewertung.ts ohne jeden Laufzeit-Import, damit
tools/pruefe-kette.mjs sie laden kann: elf konstruierte Faelle gegen von Hand
ermittelte Sollwerte, alle korrekt. Der Browser-Mock liefert fuer DNS und
HTTPS bewusst nur den Gutfall und beweist nichts - das steht dort auch so.

Dabei repariert: tools/pruefe-monitor.mjs importierte eine .mjs-Kopie aus
einem /tmp-Sitzungsverzeichnis und lief nach dessen Wegfall nicht mehr,
waehrend in der ROADMAP "alle Faelle korrekt" stand. Die Rechnung liegt jetzt
in monitor-auswertung.ts, beide Pruefer laden die TS-Datei direkt.

Neu in der Debug-Seite: "Internet-Kette pruefen (misst nur, speichert nichts)".
Beantwortet vor Ort die erste aller Fragen - kommt das Messgeraet selbst ins
Netz? - und ist der einzige Weg, die native Schicht im Emulator zu pruefen,
solange die Testinstanz nur HTTP spricht (der WebView blockiert aus der
Origin https://localhost jede HTTP-Anfrage, siehe ROADMAP).

Im Emulator geprueft: Gutfall (alle vier Stufen mit Messwerten, validated=ja)
und Flugmodus (Status "nicht messbar", Fehlerglied Anschluss, ausdruecklich
KEINE Aussage ueber das Kundennetz).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-16 22:56:20 +02:00
20 changed files with 1842 additions and 199 deletions

View 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"
}
}

View file

@ -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 */
/* --------------------------------------------------------------------- */

View file

@ -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>

View file

@ -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]}
/>

View file

@ -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' },

View 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(' '),
},
};
}

View file

@ -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.

View file

@ -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 };
},

View 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 };
}

View file

@ -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)
];

View 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';
}
}

View 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,
};
}

View 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.16172.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,
};
}

View 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.',
},
};
},
};

View file

@ -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 */

View file

@ -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
View 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);

View 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
View 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);

View file

@ -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;