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