From 2f7b1c91a45c16eb70f3a42c65f8f8a507441db3 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Sun, 16 Aug 2026 23:24:32 +0200 Subject: [PATCH] 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) --- .../java/de/data_it_solution/netdiag/Dns.kt | 196 ++++++++++++++++++ .../netdiag/NetDiagScannerPlugin.kt | 60 ++++++ src/lib/components/ToolDialog.svelte | 31 ++- src/lib/messfelder.ts | 3 + src/lib/scanner.ts | 35 ++++ src/lib/tools/index.ts | 2 + src/lib/tools/netzwerk/dns-bewertung.ts | 181 ++++++++++++++++ src/lib/tools/netzwerk/dnscheck.ts | 80 +++++++ src/routes/debug/+page.svelte | 59 ++++++ tools/pruefe-dns.mjs | 68 ++++++ 10 files changed, 714 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/java/de/data_it_solution/netdiag/Dns.kt create mode 100644 src/lib/tools/netzwerk/dns-bewertung.ts create mode 100644 src/lib/tools/netzwerk/dnscheck.ts create mode 100644 tools/pruefe-dns.mjs diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/Dns.kt b/android/app/src/main/java/de/data_it_solution/netdiag/Dns.kt new file mode 100644 index 0000000..3f87579 --- /dev/null +++ b/android/app/src/main/java/de/data_it_solution/netdiag/Dns.kt @@ -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, 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() + 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" + } +} diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt index 59903f9..7fcf07f 100644 --- a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt +++ b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt @@ -2085,6 +2085,66 @@ class NetDiagScannerPlugin : Plugin() { } } + /** + * 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. * diff --git a/src/lib/components/ToolDialog.svelte b/src/lib/components/ToolDialog.svelte index 7ebcff1..5089a15 100644 --- a/src/lib/components/ToolDialog.svelte +++ b/src/lib/components/ToolDialog.svelte @@ -1,5 +1,5 @@