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>
This commit is contained in:
Eduard Wisch 2026-08-16 23:24:32 +02:00
parent 7d79358c02
commit 2f7b1c91a4
10 changed files with 714 additions and 1 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

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

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

@ -62,6 +62,9 @@ const FELDER: Record<string, Feld> = {
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' },

View file

@ -225,6 +225,24 @@ export interface HttpsResult {
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") */
@ -366,6 +384,11 @@ export interface NetDiagScannerPlugin {
* 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 */
@ -907,6 +930,18 @@ const mock: NetDiagScannerPlugin = {
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

@ -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';
@ -36,6 +37,7 @@ export const TOOLS: Tool[] = [
ipConflictTool,
snmpTool,
tracerouteTool,
dnsCheckTool,
stressTestTool,
// Internet — die Kette steht bewusst vorn: sie ist die Erstdiagnose,
// wenn der Kunde „Internet geht nicht" sagt.

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

@ -2,6 +2,7 @@
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';
@ -20,6 +21,57 @@
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 {
@ -126,6 +178,13 @@
>
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}

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