Compare commits

..

No commits in common. "7236017e87ec47facf0bcb0d74571fe2fbefe34f" and "bbf24c06b8322a29d09c161488aa00e2b38f9d3d" have entirely different histories.

20 changed files with 199 additions and 1842 deletions

View file

@ -1,196 +0,0 @@
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,16 +1923,6 @@ class NetDiagScannerPlugin : Plugin() {
.put("iface", iface) .put("iface", iface)
.put("type", type) .put("type", type)
.put("isDefault", iface == defaultIface) .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 (ipv4 != null) obj.put("ipv4", ipv4)
if (prefix > 0) obj.put("prefixLength", prefix) if (prefix > 0) obj.put("prefixLength", prefix)
if (gateway != null) obj.put("gateway", gateway) if (gateway != null) obj.put("gateway", gateway)
@ -1946,40 +1936,6 @@ class NetDiagScannerPlugin : Plugin() {
(type == "ethernet" && ifLower.matches(Regex("eth[1-9].*"))) (type == "ethernet" && ifLower.matches(Regex("eth[1-9].*")))
if (isUsb) obj.put("isUsbEthernet", true) 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 // Link-Speed-Quellen
if (type == "wifi") { if (type == "wifi") {
try { try {
@ -1999,11 +1955,7 @@ class NetDiagScannerPlugin : Plugin() {
} }
@Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo @Suppress("DEPRECATION") val dhcp = wifi.dhcpInfo
if (dhcp != null && dhcp.serverAddress != 0) { if (dhcp != null && dhcp.serverAddress != 0) {
// Nur füllen, was die moderne API oben nicht liefert obj.put("dhcpServer", intToIp(dhcp.serverAddress))
if (!obj.has("dhcpServer")) {
obj.put("dhcpServer", intToIp(dhcp.serverAddress))
obj.put("dhcpQuelle", "wlan")
}
if (dhcp.leaseDuration > 0) obj.put("leaseSec", dhcp.leaseDuration) if (dhcp.leaseDuration > 0) obj.put("leaseSec", dhcp.leaseDuration)
} }
} catch (_: Exception) { /* WLAN-Info nicht greifbar */ } } catch (_: Exception) { /* WLAN-Info nicht greifbar */ }
@ -2011,7 +1963,6 @@ class NetDiagScannerPlugin : Plugin() {
val mbps = readEthernetSpeed(iface) val mbps = readEthernetSpeed(iface)
if (mbps != null) obj.put("linkSpeedMbps", mbps) if (mbps != null) obj.put("linkSpeedMbps", mbps)
} }
arr.put(obj) arr.put(obj)
} }
resolve(call, JSObject().put("links", arr)) resolve(call, JSObject().put("links", arr))
@ -2038,171 +1989,6 @@ 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 */ /* WLAN-Empfangstracker — RSSI live beim Durchgehen durchs Gebäude */
/* --------------------------------------------------------------------- */ /* --------------------------------------------------------------------- */

View file

@ -13,7 +13,6 @@
import AmpelBadge from './AmpelBadge.svelte'; import AmpelBadge from './AmpelBadge.svelte';
import type { Device, Measurement } from '$lib/types'; import type { Device, Measurement } from '$lib/types';
import type { Tool } from '$lib/tools/types'; import type { Tool } from '$lib/tools/types';
import { teileNachGeraetetyp } from '$lib/tools/geraeteaktionen';
let { let {
device, device,
@ -31,9 +30,6 @@
onrename?: () => void; onrename?: () => void;
} = $props(); } = $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) // Index = MeasureStatus (0 ok, 1 warn, 2 fail, 3 nicht messbar)
const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess']; const ampel = ['ampel-ok', 'ampel-warn', 'ampel-fail', 'ampel-unmess'];
const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500']; const ampelDot = ['bg-emerald-500', 'bg-amber-400', 'bg-red-500', 'bg-zinc-500'];
@ -136,14 +132,8 @@
{/each} {/each}
{#if tools.length && onrun} {#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"> <div class="mt-2 flex flex-wrap gap-1.5">
{#each aufgeteilt.passend as tool (tool.id)} {#each tools as tool (tool.id)}
<button <button
class="rounded bg-zinc-800 px-2 py-1 text-xs text-sky-300 active:bg-zinc-700" class="rounded bg-zinc-800 px-2 py-1 text-xs text-sky-300 active:bg-zinc-700"
onclick={() => onrun?.(tool)} onclick={() => onrun?.(tool)}
@ -151,15 +141,6 @@
{tool.name} {tool.name}
</button> </button>
{/each} {/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> </div>
{/if} {/if}
</div> </div>

View file

@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type { Tool, ToolParamField, ToolPreview } from '$lib/tools/types'; import type { Tool, ToolPreview } from '$lib/tools/types';
import type { Device, Protocol } from '$lib/types'; import type { Device, Protocol } from '$lib/types';
import { debugLog } from '$lib/debuglog.svelte'; import { debugLog } from '$lib/debuglog.svelte';
import { scanner } from '$lib/scanner'; import { scanner } from '$lib/scanner';
@ -41,22 +41,6 @@
...Object.fromEntries(tool.params.map((p) => [p.key, p.default ?? ''])), ...Object.fromEntries(tool.params.map((p) => [p.key, p.default ?? ''])),
...(startParams ?? {}), ...(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 busy = $state(false);
let error = $state(''); let error = $state('');
@ -206,22 +190,9 @@
{/each} {/each}
</select> </select>
{:else} {: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 <input
class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2" class="rounded-lg border border-zinc-700 bg-zinc-800 px-3 py-2"
type={field.type === 'number' ? 'number' : 'text'} type={field.type === 'number' ? 'number' : 'text'}
inputmode={tastaturModus(field)}
autocapitalize="off"
autocorrect="off"
spellcheck="false"
placeholder={field.placeholder ?? ''} placeholder={field.placeholder ?? ''}
bind:value={params[field.key]} bind:value={params[field.key]}
/> />

View file

@ -54,23 +54,9 @@ const FELDER: Record<string, Feld> = {
geraete: { label: 'Je Gerät' }, geraete: { label: 'Je Gerät' },
ausfaelle: { label: 'Einzelne Ausfälle' }, ausfaelle: { label: 'Einzelne Ausfälle' },
anzahlGeraete: { label: 'Überwachte Geräte' }, 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 // Netz / DHCP / Dose
server: { label: 'DHCP-Server' }, server: { label: 'DHCP-Server' },
dhcpServer: { 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' }, lease: { label: 'Lease-Dauer', einheit: 's' },
leaseSec: { label: 'Lease-Dauer', einheit: 's' }, leaseSec: { label: 'Lease-Dauer', einheit: 's' },
gateway: { label: 'Gateway' }, gateway: { label: 'Gateway' },

View file

@ -1,199 +0,0 @@
/**
* 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,13 +24,196 @@
*/ */
import { addMeasurement } from './protocols'; import { addMeasurement } from './protocols';
import { buildMonitorMeasurement } from './monitor-auswertung'; import type {
import type { DeviceMonitorSession, Measurement, Protocol } from './types'; DeviceMonitorSession,
MeasureStatus,
Measurement,
MonitorEvent,
Protocol,
} from './types';
// Die Rechnung selbst liegt in monitor-auswertung.ts, damit sie ohne function fmtTime(ts: number): string {
// Capacitor-Abhängigkeiten geladen und geprüft werden kann (tools/pruefe-monitor.mjs). return new Date(ts).toLocaleTimeString('de-DE', {
export { buildMonitorMeasurement } from './monitor-auswertung'; hour: '2-digit',
export type { GeraetAuswertung } from './monitor-auswertung'; 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(' '),
},
};
}
/** /**
* Sitzung abschließen und als Messung ins Protokoll legen. * Sitzung abschließen und als Messung ins Protokoll legen.

View file

@ -206,43 +206,6 @@ export interface ThroughputResult {
measured?: boolean; 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 */ /** Schnittstelle des nativen Plugins */
export interface NetDiagScannerPlugin { export interface NetDiagScannerPlugin {
/** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */ /** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */
@ -369,26 +332,6 @@ export interface NetDiagScannerPlugin {
}>; }>;
/** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */ /** Alle aktiven Netzwerk-Interfaces auflisten (WLAN, Ethernet incl. USB-RJ45, Mobilfunk) */
linkInfo(): Promise<{ links: LinkInfo[] }>; 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 */ /** Aktiven WLAN-Scan triggern (System-Cache nachladen); `triggered=false` bei Throttling */
startWifiScan(): Promise<{ triggered: boolean }>; startWifiScan(): Promise<{ triggered: boolean }>;
/** WLAN-Empfangstracker für ein BSSID starten — live RSSI per `wifiSignal`-Event */ /** WLAN-Empfangstracker für ein BSSID starten — live RSSI per `wifiSignal`-Event */
@ -892,7 +835,6 @@ const mock: NetDiagScannerPlugin = {
gateway: '192.168.1.1', gateway: '192.168.1.1',
dns: ['192.168.1.1', '1.1.1.1'], dns: ['192.168.1.1', '1.1.1.1'],
linkSpeedMbps: 1000, linkSpeedMbps: 1000,
validated: true,
}, },
{ {
iface: 'wlan0', iface: 'wlan0',
@ -909,39 +851,10 @@ const mock: NetDiagScannerPlugin = {
ssid: 'AllesWattLaeuft', ssid: 'AllesWattLaeuft',
bssid: 'AA:BB:CC:11:22:33', bssid: 'AA:BB:CC:11:22:33',
rssi: -52, 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() { async startWifiScan() {
return { triggered: true }; return { triggered: true };
}, },

View file

@ -1,86 +0,0 @@
/**
* 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,7 +9,6 @@
import type { Tool, ToolCategory } from './types'; import type { Tool, ToolCategory } from './types';
import { dnsCheckTool } from './netzwerk/dnscheck';
import { ipConflictTool } from './netzwerk/ipconflict'; import { ipConflictTool } from './netzwerk/ipconflict';
import { ipScanTool } from './netzwerk/ipscan'; import { ipScanTool } from './netzwerk/ipscan';
import { pingTool } from './netzwerk/ping'; import { pingTool } from './netzwerk/ping';
@ -18,7 +17,6 @@ import { snmpTool } from './netzwerk/snmp';
import { stressTestTool } from './netzwerk/stresstest'; import { stressTestTool } from './netzwerk/stresstest';
import { tracerouteTool } from './netzwerk/traceroute'; import { tracerouteTool } from './netzwerk/traceroute';
import { internetKetteTool } from './internet/internetkette';
import { iperfTool } from './internet/iperf'; import { iperfTool } from './internet/iperf';
/** /**
@ -37,11 +35,8 @@ export const TOOLS: Tool[] = [
ipConflictTool, ipConflictTool,
snmpTool, snmpTool,
tracerouteTool, tracerouteTool,
dnsCheckTool,
stressTestTool, stressTestTool,
// Internet — die Kette steht bewusst vorn: sie ist die Erstdiagnose, // Internet
// wenn der Kunde „Internet geht nicht" sagt.
internetKetteTool,
iperfTool, iperfTool,
// Telefonie: folgt (SIP-Registrierung, FreePBX-Check, RTP-Qualität) // Telefonie: folgt (SIP-Registrierung, FreePBX-Check, RTP-Qualität)
]; ];

View file

@ -1,156 +0,0 @@
/**
* 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

@ -1,162 +0,0 @@
/**
* 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

@ -1,181 +0,0 @@
/**
* 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

@ -1,80 +0,0 @@
/**
* 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,15 +121,8 @@ export interface LinkInfo {
prefixLength?: number; prefixLength?: number;
gateway?: string; gateway?: string;
dns?: string[]; dns?: string[];
/** DHCP-Server, von dem dieser Anschluss seine Adresse bezogen hat */ /** DHCP-Server (nur für WLAN ermittelbar) */
dhcpServer?: string; 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; leaseSec?: number;
/** Verbindungsgeschwindigkeit in Mbit/s — WLAN: linkSpeed; Ethernet: /sys/class/net/<iface>/speed */ /** Verbindungsgeschwindigkeit in Mbit/s — WLAN: linkSpeed; Ethernet: /sys/class/net/<iface>/speed */
linkSpeedMbps?: number; linkSpeedMbps?: number;
@ -141,15 +134,6 @@ export interface LinkInfo {
bssid?: string; bssid?: string;
/** Empfangsstärke in dBm (nur wenn type='wifi') */ /** Empfangsstärke in dBm (nur wenn type='wifi') */
rssi?: number; 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 */ /** Einzelne RSSI-Probe des WLAN-Empfangstrackers */

View file

@ -1,128 +1,6 @@
<script lang="ts"> <script lang="ts">
import AppHeader from '$lib/components/AppHeader.svelte'; import AppHeader from '$lib/components/AppHeader.svelte';
import { debugLog } from '$lib/debuglog.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> = { const levelColor: Record<string, string> = {
info: 'text-zinc-400', info: 'text-zinc-400',
@ -170,33 +48,6 @@
</button> </button>
</div> </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"> <div class="flex-1 overflow-y-auto p-2 font-mono text-xs">
{#if debugLog.entries.length === 0} {#if debugLog.entries.length === 0}
<p class="p-4 text-center text-zinc-500">Keine Einträge.</p> <p class="p-4 text-center text-zinc-500">Keine Einträge.</p>

View file

@ -1,68 +0,0 @@
/**
* 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

@ -1,46 +0,0 @@
/**
* 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);

View file

@ -1,119 +0,0 @@
/**
* 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 * ausgerechnetem Sollwert. Die Rechnung landet als Verfuegbarkeitsaussage im
* Kundendokument sie muss stimmen, nicht plausibel aussehen. * Kundendokument sie muss stimmen, nicht plausibel aussehen.
*/ */
// Laedt die TS-Datei direkt (Node 22.6+ entfernt die Typannotationen selbst). import { readFileSync } from 'node:fs';
// 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 { buildMonitorMeasurement } = await import('/tmp/claude-1000/-mnt-17---Entwicklungen-20---Projekte-NetzwerkDiagnose/c1dc785b-f713-4aae-a06d-1fe09a25cd1a/scratchpad/monitor.mjs');
const T0 = new Date('2026-08-16T09:00:00').getTime(); const T0 = new Date('2026-08-16T09:00:00').getTime();
const min = (m) => T0 + m * 60000; const min = (m) => T0 + m * 60000;