From 3f29a373acd19ab03fab2c35d07d305652a14315 Mon Sep 17 00:00:00 2001 From: Eduard Wisch Date: Wed, 19 Aug 2026 18:29:32 +0200 Subject: [PATCH] SIP-Erreichbarkeit, Fingerabdruck-Anmeldung, Token raus aus der Adresszeile [apk] Die vier letzten offenen Punkte der Roadmap. SIP-Erreichbarkeit (neues Werkzeug, Kategorie Telefonie) - Sip.kt: OPTIONS nach RFC 3261 ueber UDP, bei Schweigen TCP nachgeschoben. Antwortet nur TCP, ist das der Fingerabdruck einer Firewall, die UDP verwirft - eine andere Reparatur als "Anlage aus". - sip-bewertung.ts ohne Laufzeit-Import, damit tools/pruefe-sip.mjs die Regeln direkt nachrechnen kann (14 Faelle + 4 Texte, alle korrekt). - Leitsatz: JEDE Statuszeile beweist eine antwortende Instanz. 401/407 sind deshalb gruen mit Hinweis "Anmeldung verlangt" - beim Provider der Normalzustand. Als Ausfall gemeldet wuerde der Techniker am falschen Ende suchen. - RTP-Sprachqualitaet bewusst NICHT: ohne aufgebautes Gespraech nicht ehrlich messbar. Die Grenze steht im Ergebnis und landet im Kunden-PDF. Fingerabdruck-Anmeldung (Biometrie.kt, BiometricPrompt + Android-Keystore) - Der Kern ist nicht der Dialog, sondern was danach passiert: die Klartextfassung des Tokens wird aus den Capacitor Preferences ENTFERNT. Vorher stand es offen in CapacitorStorage.xml und war auf einem entsperrten Geraet ohne Weiteres lesbar - ein zusaetzlich verschluesseltes Token haette daran nichts geaendert. - Kein zweiter Faktor: der Finger ersetzt das Passwort (Eddys Vorgabe). Gueltigkeit und Widerruf bleiben bei awlauth - hinterlegt ist dasselbe Token. - 401 unterscheidet jetzt "Token abgelehnt" von "gar nicht angemeldet". Ohne das loeschte der erste Startaufruf die gerade eingerichtete Anmeldung wieder (im Emulator aufgefallen, Ursache im logcat). Token nicht mehr in der Adresszeile - updateDownloadUrl() ohne ?jwt=; der APK-Herunterlader im Plugin schickt Authorization: Bearer. instanceFollowRedirects=false, sonst ginge der Header bei einer Weiterleitung an ein fremdes Ziel mit. - pdfUrl() war seit dem Initialcommit tot (kein Aufrufer) und ist durch fetchProtocolPdf() ersetzt - Header statt Token in der URL. Im Emulator geprueft: SIP gegen einen eigenen Responder (200/401/Port zu), Fingerabdruck vollstaendig durchgespielt (einrichten, Neustart, anmelden, abbrechen), Speicher jeweils nachgesehen. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 50 +++- android/app/build.gradle | 4 + .../de/data_it_solution/netdiag/Biometrie.kt | 274 ++++++++++++++++++ .../netdiag/NetDiagScannerPlugin.kt | 129 ++++++++- .../java/de/data_it_solution/netdiag/Sip.kt | 246 ++++++++++++++++ src/lib/api.ts | 101 ++++++- src/lib/auth.svelte.ts | 99 ++++++- src/lib/messfelder.ts | 20 ++ src/lib/scanner.ts | 92 ++++++ src/lib/tools/index.ts | 7 +- src/lib/tools/telefonie/sip-bewertung.ts | 224 ++++++++++++++ src/lib/tools/telefonie/sipcheck.ts | 131 +++++++++ src/lib/updater.ts | 15 +- src/routes/einstellungen/+page.svelte | 55 ++++ src/routes/login/+page.svelte | 38 ++- tools/pruefe-sip.mjs | 144 +++++++++ 16 files changed, 1602 insertions(+), 27 deletions(-) create mode 100644 android/app/src/main/java/de/data_it_solution/netdiag/Biometrie.kt create mode 100644 android/app/src/main/java/de/data_it_solution/netdiag/Sip.kt create mode 100644 src/lib/tools/telefonie/sip-bewertung.ts create mode 100644 src/lib/tools/telefonie/sipcheck.ts create mode 100644 tools/pruefe-sip.mjs diff --git a/README.md b/README.md index 4bd9c2f..bcec546 100644 --- a/README.md +++ b/README.md @@ -51,14 +51,17 @@ src/lib/ overlay.svelte.ts Overlay-Stapel für den Hardware-Zurück-Knopf toast.svelte.ts kurze Rückmeldungen backButton.svelte.ts Hardware-Back (Single-Instance, KB #480/#549) - updater.ts APK-Auto-Update-Prüfung (KB #363) + updater.ts APK-Auto-Update-Prüfung (KB #363). Das Token geht als + Authorization-Header mit, nicht in der Adresse. tools/ erweiterbare Tool-Plattform index.ts Registry — neues Tool hier eintragen netzwerk/ IP-Scan, Port, Ping, IP-Konflikt, SNMP, Traceroute, Stress internet/ Durchsatz-Test - telefonie/ leer — geplant: SIP-Erreichbarkeit über OPTIONS. - RTP-Sprachqualität bewusst NICHT (ohne aufgebautes - Gespräch nicht ehrlich messbar, siehe ROADMAP_UMSETZUNG.md) + telefonie/ SIP-Erreichbarkeit über OPTIONS (sipcheck.ts) samt + Bewertung in sip-bewertung.ts. Jede Statuszeile beweist + eine antwortende Instanz — 401 heißt „erreichbar, will + Zugangsdaten", nicht „tot". RTP-Sprachqualität bewusst + NICHT: ohne aufgebautes Gespräch nicht ehrlich messbar. src/routes/ login/ auftraege/ kunden/ einstellungen/ protokoll/[id]/ Hauptseite mit Werkzeugen, Geräten, Messungen @@ -82,6 +85,7 @@ node tools/pruefe-dns.mjs # DNS: NXDOMAIN ist KEIN Ausfall node tools/pruefe-geraetediff.mjs # Geräte-Vergleich, alle Fälle OHNE MAC node tools/pruefe-geraeteaktionen.mjs # Sortierung nach Gerätetyp node tools/pruefe-serveradresse.mjs # http:// bei https-App abweisen +node tools/pruefe-sip.mjs # SIP: 401/405 sind ERREICHBAR, kein Ausfall node tools/test-recommend.mjs # Kanalempfehlung ``` @@ -130,6 +134,44 @@ Eintrag im Interface `NetDiagScannerPlugin` **und** ein Zweig im Browser-Mock prüfen, und ein Mock, der nur den Gutfall zeigt, bestätigt genau die Annahme, die im Feld nicht gilt (KB #1086). +## Anmeldung + +Die App hängt am zentralen Auth-Modul **awlauth** (7. AWL-App). Das Token kommt aus +`auth.php` und geht bei jeder Anfrage als `Authorization: Bearer` mit — auch beim +APK-Download und beim PDF-Abruf. **Nie in der Adresse:** ein Token in der URL steht in +jedem Zugriffs- und Proxy-Log und gilt sieben Tage für die gesamte Kunden-API. +(Einzige, befristete Ausnahme: `update.php?download=1` für App-Fassungen im Feld — +siehe ChangeLog des Moduls 1.3.0.) + +### Fingerabdruck statt Passwort + +Einschaltbar unter Einstellungen → Konto. Was dabei passiert: + +1. Das Sitzungs-Token wird mit einem AES-256/GCM-Schlüssel aus dem **Android-Keystore** + verschlüsselt (`setUserAuthenticationRequired`), Chiffrat und IV liegen in + `shared_prefs/netdiag_biometrie.xml`. +2. Die **Klartextfassung wird entfernt** (`Preferences.remove('token')`). Genau das ist + der Sicherheitsgewinn: vorher stand das Token offen in `CapacitorStorage.xml` und war + auf einem entsperrten Gerät ohne Weiteres lesbar. +3. Beim nächsten Start zeigt die Login-Maske „Mit Fingerabdruck anmelden". Der Finger + entschlüsselt das Token; es bleibt danach nur im Arbeitsspeicher. + +Kein zweiter Faktor — der Finger **ersetzt** das Passwort (Vorgabe von Eddy). Die +Gültigkeit bestimmt weiterhin `AWLAUTH_TTL`, und „Gerät abmelden" in der +awlauth-Geräteliste wirkt unverändert: hinterlegt ist dasselbe Token. Wird es +serverseitig ungültig (401 **mit** Token), räumt die App die Hinterlegung selbst weg. +Ein 401 **ohne** Token ist dagegen nur „noch nicht angemeldet" und lässt sie stehen — +diese Unterscheidung ist nicht theoretisch: ohne sie löschte der erste Startaufruf die +gerade eingerichtete Anmeldung wieder. + +Wird dem Gerät ein weiterer Fingerabdruck hinzugefügt, macht Android den Schlüssel +ungültig (`setInvalidatedByBiometricEnrollment`) — die App sagt das im Klartext und +verlangt einmal das Passwort. Gewollt: sonst könnte jemand mit dem entsperrten Gerät +einfach seinen eigenen Finger anlernen. + +WebAuthn wie in den PWAs geht hier nicht — im Capacitor-WebView ist es nicht verlässlich +verfügbar (KB #855). + ## Bedienung 1. **Anmelden** (Dolibarr-Zugang; auf dem Gerät zusätzlich Server-URL). diff --git a/android/app/build.gradle b/android/app/build.gradle index 84ebf75..03f7145 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -54,6 +54,10 @@ dependencies { implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion" implementation project(':capacitor-android') implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0" + // Fingerabdruck-Anmeldung: BiometricPrompt der Systembibliothek statt einer + // eigenen Abfrage. Sie bringt den Systemdialog, die Sperrlogik nach zu + // vielen Fehlversuchen und die Anbindung an den Keystore mit. + implementation "androidx.biometric:biometric:1.1.0" testImplementation "junit:junit:$junitVersion" androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/Biometrie.kt b/android/app/src/main/java/de/data_it_solution/netdiag/Biometrie.kt new file mode 100644 index 0000000..6cd1274 --- /dev/null +++ b/android/app/src/main/java/de/data_it_solution/netdiag/Biometrie.kt @@ -0,0 +1,274 @@ +package de.data_it_solution.netdiag + +import android.content.Context +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyPermanentlyInvalidatedException +import android.security.keystore.KeyProperties +import android.util.Base64 +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt +import androidx.fragment.app.FragmentActivity +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +/** + * Fingerabdruck-Anmeldung: das Sitzungs-Token liegt verschlüsselt auf dem Gerät + * und wird nur nach erfolgreicher Biometrie wieder herausgegeben. + * + * **Warum nicht WebAuthn wie in den sechs PWAs:** im Capacitor-WebView ist + * WebAuthn nicht verlässlich verfügbar (KB #855: Android-WebViews unterstützen + * es nicht durchgängig, und der Credential-Manager ist beim ersten Aufruf oft + * kalt). Deshalb der native Weg über `BiometricPrompt`. + * + * **Was hier NICHT passiert: ein zweiter Faktor.** Nach Eddys Vorgabe ersetzt + * der Fingerabdruck das Passwort, er kommt nicht dazu. Wer den Finger auflegt, + * bekommt genau das Token, mit dem er sich vorher per Passwort angemeldet hat. + * + * **Widerrufbarkeit bleibt:** hinterlegt wird das ganz normale awlauth-Token. + * „Gerät abmelden" in der awlauth-Geräteliste entwertet es serverseitig — der + * Fingerabdruck öffnet dann eine Sitzung, die es nicht mehr gibt, und die API + * antwortet mit 401. Es entsteht also kein zweiter, an der Sitzungsliste + * vorbeilaufender Zugang; genau das war der Fehler des alten netdiag-JWT. + * + * Schlüsselführung: AES-256/GCM im Android-Keystore, `setUserAuthenticationRequired`. + * Der Schlüssel verlässt den Keystore nie, und ohne Biometrie gibt er nichts + * heraus. `setInvalidatedByBiometricEnrollment(true)` ist Absicht: wird dem + * Gerät ein weiterer Fingerabdruck hinzugefügt, wird der Schlüssel ungültig und + * das hinterlegte Token unbrauchbar. Sonst könnte jemand, der das entsperrte + * Gerät kurz in der Hand hat, seinen eigenen Finger anlernen und käme damit an + * Eddys Kundendaten. + */ +object Biometrie { + + private const val KEYSTORE = "AndroidKeyStore" + private const val KEY_ALIAS = "netdiag_biometrie_v1" + private const val PREFS = "netdiag_biometrie" + private const val PREF_TOKEN = "token_chiffrat" + private const val PREF_IV = "token_iv" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + + /** Ergebnis einer Statusabfrage */ + data class Status( + /** Gerät kann Biometrie und hat mindestens einen Finger angelernt */ + val verfuegbar: Boolean, + /** ein Token ist hinterlegt */ + val hinterlegt: Boolean, + /** Klartextgrund, wenn nicht verfügbar */ + val grund: String, + ) + + fun status(context: Context): Status { + val bm = BiometricManager.from(context) + val code = bm.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) + val grund = when (code) { + BiometricManager.BIOMETRIC_SUCCESS -> "" + BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE -> + "Dieses Gerät hat keinen Fingerabdrucksensor" + BiometricManager.BIOMETRIC_ERROR_HW_UNAVAILABLE -> + "Der Fingerabdrucksensor ist gerade nicht verfügbar" + BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED -> + "Auf dem Gerät ist kein Fingerabdruck eingerichtet — bitte zuerst in den Systemeinstellungen anlegen" + BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> + "Für die Fingerabdruck-Anmeldung fehlt ein Sicherheitsupdate des Systems" + else -> "Fingerabdruck-Anmeldung auf diesem Gerät nicht möglich" + } + return Status( + verfuegbar = code == BiometricManager.BIOMETRIC_SUCCESS, + hinterlegt = hatToken(context), + grund = grund, + ) + } + + fun hatToken(context: Context): Boolean { + val p = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + return !p.getString(PREF_TOKEN, null).isNullOrEmpty() + } + + fun loeschen(context: Context) { + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE).edit().clear().apply() + try { + val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } + if (ks.containsAlias(KEY_ALIAS)) ks.deleteEntry(KEY_ALIAS) + } catch (e: Exception) { + /* Schlüssel schon weg — nichts zu tun */ + } + } + + /** + * Token hinterlegen. Zeigt den Systemdialog, verschlüsselt danach. + * + * @param fertig (ok, meldung) — meldung ist bei ok=false für den Benutzer bestimmt + */ + fun hinterlegen( + activity: FragmentActivity, + token: String, + fertig: (Boolean, String) -> Unit, + ) { + val cipher: Cipher + try { + loeschen(activity) // alter Schlüssel/Chiffrat weg — ein Token je Gerät + val key = schluesselErzeugen() + cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, key) + } catch (e: Exception) { + fertig(false, "Schlüssel konnte nicht angelegt werden: ${e.message}") + return + } + + prompt( + activity, + titel = "Fingerabdruck einrichten", + untertitel = "Damit meldet sich NetDiag künftig ohne Passwort an", + cipher = cipher, + beiErfolg = { c -> + try { + val chiffrat = c.doFinal(token.toByteArray(Charsets.UTF_8)) + val p = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + p.edit() + .putString(PREF_TOKEN, Base64.encodeToString(chiffrat, Base64.NO_WRAP)) + .putString(PREF_IV, Base64.encodeToString(c.iv, Base64.NO_WRAP)) + .apply() + fertig(true, "") + } catch (e: Exception) { + loeschen(activity) + fertig(false, "Token konnte nicht verschlüsselt werden: ${e.message}") + } + }, + beiFehler = { meldung -> fertig(false, meldung) }, + ) + } + + /** + * Hinterlegtes Token nach erfolgreicher Biometrie herausgeben. + * + * @param fertig (token oder null, meldung) + */ + fun abrufen( + activity: FragmentActivity, + fertig: (String?, String) -> Unit, + ) { + val p = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val chiffratB64 = p.getString(PREF_TOKEN, null) + val ivB64 = p.getString(PREF_IV, null) + if (chiffratB64.isNullOrEmpty() || ivB64.isNullOrEmpty()) { + fertig(null, "Für dieses Gerät ist keine Fingerabdruck-Anmeldung eingerichtet") + return + } + + val cipher: Cipher + try { + val ks = KeyStore.getInstance(KEYSTORE).apply { load(null) } + val key = ks.getKey(KEY_ALIAS, null) as? SecretKey + if (key == null) { + loeschen(activity) + fertig(null, "Die Fingerabdruck-Anmeldung muss neu eingerichtet werden") + return + } + val iv = Base64.decode(ivB64, Base64.NO_WRAP) + cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(128, iv)) + } catch (e: KeyPermanentlyInvalidatedException) { + /* + * Genau der gewollte Fall: seit dem Hinterlegen wurde ein Finger + * hinzugefügt oder die Displaysperre entfernt. Das Token ist damit + * unbrauchbar — aufräumen und ehrlich sagen, was zu tun ist. + */ + loeschen(activity) + fertig(null, "Die Fingerabdrücke des Geräts haben sich geändert — bitte einmal mit Passwort anmelden") + return + } catch (e: Exception) { + loeschen(activity) + fertig(null, "Fingerabdruck-Anmeldung nicht möglich: ${e.message}") + return + } + + prompt( + activity, + titel = "Anmelden", + untertitel = "Fingerabdruck statt Passwort", + cipher = cipher, + beiErfolg = { c -> + try { + val chiffrat = Base64.decode(chiffratB64, Base64.NO_WRAP) + fertig(String(c.doFinal(chiffrat), Charsets.UTF_8), "") + } catch (e: Exception) { + loeschen(activity) + fertig(null, "Hinterlegtes Token nicht lesbar — bitte neu einrichten") + } + }, + beiFehler = { meldung -> fertig(null, meldung) }, + ) + } + + /* --------------------------------------------------------------------- */ + + private fun schluesselErzeugen(): SecretKey { + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE) + val bau = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .setUserAuthenticationRequired(true) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + // Neuer Finger angelernt -> Schlüssel ungültig (siehe Klassenkopf) + bau.setInvalidatedByBiometricEnrollment(true) + } + gen.init(bau.build()) + return gen.generateKey() + } + + /** Systemdialog anzeigen; der Cipher wird dabei freigeschaltet. */ + private fun prompt( + activity: FragmentActivity, + titel: String, + untertitel: String, + cipher: Cipher, + beiErfolg: (Cipher) -> Unit, + beiFehler: (String) -> Unit, + ) { + activity.runOnUiThread { + val info = BiometricPrompt.PromptInfo.Builder() + .setTitle(titel) + .setSubtitle(untertitel) + .setNegativeButtonText("Abbrechen") + // Nur echte Biometrie: die Geräte-PIN als Ausweichweg wäre ein + // ANDERER Nachweis als der, für den Eddy sich entschieden hat — + // und sie ist meist deutlich schwächer als das Passwort. + .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG) + .setConfirmationRequired(false) + .build() + + val prompt = BiometricPrompt( + activity, + androidx.core.content.ContextCompat.getMainExecutor(activity), + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + val c = result.cryptoObject?.cipher + if (c == null) { + beiFehler("Der Sicherheitsschlüssel wurde nicht freigegeben") + return + } + beiErfolg(c) + } + + override fun onAuthenticationError(code: Int, meldung: CharSequence) { + // Abbruch durch den Benutzer ist kein Fehler, den man + // ihm noch einmal erklären müsste. + val abbruch = code == BiometricPrompt.ERROR_USER_CANCELED || + code == BiometricPrompt.ERROR_NEGATIVE_BUTTON || + code == BiometricPrompt.ERROR_CANCELED + beiFehler(if (abbruch) "abgebrochen" else meldung.toString()) + } + }, + ) + prompt.authenticate(info, BiometricPrompt.CryptoObject(cipher)) + } + } +} diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt index 7fcf07f..ee09f59 100644 --- a/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt +++ b/android/app/src/main/java/de/data_it_solution/netdiag/NetDiagScannerPlugin.kt @@ -2145,6 +2145,52 @@ class NetDiagScannerPlugin : Plugin() { } } + /** + * SIP-Erreichbarkeit: eine OPTIONS-Anfrage an die Telefonanlage bzw. den + * Provider und die Statuszeile zurueckmelden. + * + * Warum OPTIONS und nicht REGISTER: OPTIONS braucht keine Zugangsdaten, + * loest keine Anmeldung aus und laesst kein Telefon klingeln. Und weil + * JEDE Statuszeile eine antwortende Instanz beweist — auch 401 oder 405 —, + * trennt schon die blosse Antwort die beiden Faelle, um die es beim Kunden + * geht: „Weg zur Anlage offen, es hakt an den Zugangsdaten" gegen „da + * antwortet niemand". + * + * Details zum Protokoll und zur Reihenfolge UDP -> TCP: `Sip.kt`. + */ + @PluginMethod + fun sipOptions(call: PluginCall) { + val host = call.getString("host")?.trim().orEmpty() + if (host.isEmpty()) return call.reject("host fehlt") + val port = (call.getInt("port") ?: 5060).coerceIn(1, 65535) + val transport = (call.getString("transport") ?: "auto").lowercase() + val timeoutMs = (call.getInt("timeoutMs") ?: 3000).coerceIn(500, 20_000) + io.launch { + try { + val r = withContext(Dispatchers.IO) { Sip.options(host, port, transport, timeoutMs) } + val arr = JSArray() + for (v in r.versuche) { + val o = JSObject() + .put("transport", v.transport) + .put("statusCode", v.statusCode) + .put("statusText", v.statusText) + .put("server", v.server) + .put("allow", v.allow) + .put("fehler", v.fehler) + if (v.latenzMs != null) o.put("latenzMs", round1(v.latenzMs)) + arr.put(o) + } + resolve(call, JSObject() + .put("host", r.host) + .put("ip", r.ip) + .put("port", r.port) + .put("versuche", arr)) + } catch (e: Exception) { + call.reject("sipOptions: ${e.message}") + } + } + } + /** * Eine URL abrufen und Statuszeile plus Laufzeit melden. * @@ -2404,6 +2450,62 @@ class NetDiagScannerPlugin : Plugin() { .put("mode", run?.mode ?: "connected")) } + /* --------------------------------------------------------------------- */ + /* Fingerabdruck-Anmeldung */ + /* --------------------------------------------------------------------- */ + + /** + * Kann dieses Geraet die Fingerabdruck-Anmeldung, und ist sie eingerichtet? + * + * Liefert `grund` als Klartext, wenn nicht — die App zeigt ihn direkt an, + * statt den Schalter kommentarlos auszugrauen. + */ + @PluginMethod + fun biometrieStatus(call: PluginCall) { + val st = Biometrie.status(context) + resolve(call, JSObject() + .put("verfuegbar", st.verfuegbar) + .put("hinterlegt", st.hinterlegt) + .put("grund", st.grund)) + } + + /** + * Sitzungs-Token verschluesselt hinterlegen (nach Fingerabdruck-Bestaetigung). + * + * Hinterlegt wird das ganz normale awlauth-Token — kein zweiter, eigener + * Zugang. „Geraet abmelden" in der awlauth-Geraeteliste entwertet es + * weiterhin serverseitig. + */ + @PluginMethod + fun biometrieHinterlegen(call: PluginCall) { + val token = call.getString("token")?.trim().orEmpty() + if (token.isEmpty()) return call.reject("token fehlt") + val act = activity as? androidx.fragment.app.FragmentActivity + ?: return call.reject("Fingerabdruck-Dialog nicht moeglich (keine passende Activity)") + Biometrie.hinterlegen(act, token) { ok, meldung -> + resolve(call, JSObject().put("ok", ok).put("meldung", meldung)) + } + } + + /** Hinterlegtes Token nach Fingerabdruck herausgeben (null, wenn abgelehnt). */ + @PluginMethod + fun biometrieAbrufen(call: PluginCall) { + val act = activity as? androidx.fragment.app.FragmentActivity + ?: return call.reject("Fingerabdruck-Dialog nicht moeglich (keine passende Activity)") + Biometrie.abrufen(act) { token, meldung -> + resolve(call, JSObject() + .put("token", token) + .put("meldung", meldung)) + } + } + + /** Hinterlegung entfernen (Abmelden, Schalter aus, abgelaufenes Token). */ + @PluginMethod + fun biometrieLoeschen(call: PluginCall) { + Biometrie.loeschen(context) + resolve(call, JSObject().put("ok", true)) + } + /* --------------------------------------------------------------------- */ /* App-Update: APK herunterladen und Paketinstaller öffnen */ /* --------------------------------------------------------------------- */ @@ -2420,6 +2522,10 @@ class NetDiagScannerPlugin : Plugin() { @PluginMethod fun installUpdate(call: PluginCall) { val url = call.getString("url") ?: return call.reject("url fehlt") + // Das Sitzungs-Token kommt als eigener Parameter und geht als Header + // raus. Frueher stand es als ?jwt= in der Adresse und landete damit in + // jedem Zugriffs- und Proxy-Log auf dem Weg. + val token = call.getString("token") ?: return call.reject("token fehlt") io.launch { try { // Ab Android 8: App braucht das Recht, Pakete zu installieren @@ -2435,7 +2541,7 @@ class NetDiagScannerPlugin : Plugin() { } val apk = File(context.cacheDir, "NetDiag-update.apk") - downloadApk(url, apk) + downloadApk(url, token, apk) val uri = FileProvider.getUriForFile( context, "${context.packageName}.fileprovider", apk @@ -2452,15 +2558,30 @@ class NetDiagScannerPlugin : Plugin() { } } - /** APK streamend in `target` laden und dabei `updateProgress`-Events senden. */ - private fun downloadApk(url: String, target: File) { + /** + * APK streamend in `target` laden und dabei `updateProgress`-Events senden. + * + * `instanceFollowRedirects` bleibt aus: HttpURLConnection wuerde den + * Authorization-Header bei einer Weiterleitung an ein fremdes Ziel + * mitschicken. Der Update-Proxy liefert die Datei direkt aus (readfile), + * eine Weiterleitung waere hier also ohnehin ein Fehlerfall und wird als + * solcher gemeldet. + */ + private fun downloadApk(url: String, token: String, target: File) { val conn = (URL(url).openConnection() as HttpURLConnection).apply { connectTimeout = 15_000 readTimeout = 120_000 - instanceFollowRedirects = true + instanceFollowRedirects = false + setRequestProperty("Authorization", "Bearer $token") } try { val code = conn.responseCode + if (code == 401 || code == 403) { + throw Exception("Nicht angemeldet — bitte neu anmelden und erneut versuchen") + } + if (code in 300..399) { + throw Exception("Update-Adresse leitet weiter (HTTP $code) — Serverkonfiguration pruefen") + } if (code != 200) throw Exception("Download fehlgeschlagen (HTTP $code)") val total = conn.contentLength.toLong() // -1 wenn unbekannt var read = 0L diff --git a/android/app/src/main/java/de/data_it_solution/netdiag/Sip.kt b/android/app/src/main/java/de/data_it_solution/netdiag/Sip.kt new file mode 100644 index 0000000..b5205df --- /dev/null +++ b/android/app/src/main/java/de/data_it_solution/netdiag/Sip.kt @@ -0,0 +1,246 @@ +package de.data_it_solution.netdiag + +import java.io.BufferedInputStream +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.Socket +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import kotlin.random.Random + +/** + * SIP-Erreichbarkeitsprüfung über OPTIONS (RFC 3261, Abschnitt 11). + * + * Die Frage beim Kunden lautet fast nie „ist die Sprachqualität gut", sondern + * „warum klingelt das Telefon nicht". Dafür genügt der Nachweis, dass an der + * eingetragenen Adresse überhaupt eine SIP-Instanz sitzt und antwortet. + * OPTIONS ist dafür die vorgesehene Frage: sie baut kein Gespräch auf, klingelt + * nirgends und wird von jeder Anlage beantwortet. + * + * **Jede Statuszeile ist ein Beweis.** Auch 401, 403 und 405 belegen eine + * antwortende Instanz — sie sagen nur „nicht ohne Anmeldung" bzw. „diese + * Methode nicht". Für den Techniker ist das der entscheidende Unterschied zu + * „gar nichts kommt zurück": im ersten Fall ist die Leitung bis zur Anlage + * offen und es geht um Zugangsdaten, im zweiten ist die Anlage, die Adresse + * oder der Weg dorthin das Problem. + * + * **RTP/Sprachqualität bewusst nicht.** Ohne Gegenstelle und aufgebautes + * Gespräch lässt sich kein RTP-Jitter messen. Was sich aus Rundlaufzeiten + * rechnen ließe, wäre eine andere Größe mit demselben Namen — im Kundendokument + * wäre das eine Falschaussage. + * + * UDP zuerst, denn so gut wie jede Anlage im Handwerksnetz spricht UDP/5060. + * Schweigt UDP, wird TCP nachgeschoben: antwortet TCP, während UDP stumm + * bleibt, ist das der Fingerabdruck einer Firewall, die UDP verwirft — eine + * völlig andere Reparatur als „Anlage aus". + */ +object Sip { + + /** Was bei einem Versuch über EINEN Transportweg herauskam. */ + data class Versuch( + val transport: String, + val statusCode: Int?, + val statusText: String?, + val latenzMs: Double?, + val server: String?, + val allow: String?, + /** null = alles gut; sonst Kurzgrund ("timeout", "verbindung", "netz", "unlesbar") */ + val fehler: String?, + ) + + /** Ergebnis der gesamten Prüfung (ggf. über zwei Transportwege). */ + data class Result( + val host: String, + val ip: String?, + val port: Int, + val versuche: List, + ) + + /** + * Eine SIP-Instanz mit OPTIONS anfragen. + * + * @param host Adresse oder Name der Anlage/des Providers + * @param port SIP-Port (Standard 5060) + * @param transport "udp", "tcp" oder "auto" (UDP, bei Schweigen TCP) + * @param timeoutMs Wartezeit je Transportweg + */ + fun options( + host: String, + port: Int = 5060, + transport: String = "auto", + timeoutMs: Int = 3000, + ): Result { + val ip: String? = try { + InetAddress.getByName(host).hostAddress + } catch (e: UnknownHostException) { + // Name nicht auflösbar: keine Aussage über die Anlage möglich. + return Result(host, null, port, listOf( + Versuch("dns", null, null, null, null, null, "name") + )) + } + + val versuche = mutableListOf() + val wegUdp = transport == "udp" || transport == "auto" + val wegTcp = transport == "tcp" + + if (wegUdp) { + versuche += udp(host, ip, port, timeoutMs) + } + // Bei "auto" nur nachschieben, wenn UDP nichts geliefert hat — eine + // antwortende Anlage soll nicht zweimal befragt werden. + val udpStumm = versuche.lastOrNull()?.statusCode == null + if (wegTcp || (transport == "auto" && udpStumm)) { + versuche += tcp(host, ip, port, timeoutMs) + } + return Result(host, ip, port, versuche) + } + + /* --------------------------------------------------------------------- */ + + private fun udp(host: String, ip: String?, port: Int, timeoutMs: Int): Versuch { + var socket: DatagramSocket? = null + val t0 = System.nanoTime() + try { + socket = DatagramSocket() + socket.soTimeout = timeoutMs + val ziel = InetAddress.getByName(ip ?: host) + socket.connect(ziel, port) + val lokal = socket.localAddress?.hostAddress ?: "0.0.0.0" + val anfrage = request(host, port, "UDP", lokal, socket.localPort).toByteArray(Charsets.UTF_8) + socket.send(DatagramPacket(anfrage, anfrage.size, ziel, port)) + + // Bis zur Zeitgrenze lesen: eine vorläufige Antwort (1xx) ist noch + // kein Ergebnis, danach kommt die endgültige. + val ende = System.nanoTime() + timeoutMs * 1_000_000L + val puffer = ByteArray(4096) + while (System.nanoTime() < ende) { + val paket = DatagramPacket(puffer, puffer.size) + socket.receive(paket) + val text = String(paket.data, 0, paket.length, Charsets.UTF_8) + val ms = (System.nanoTime() - t0) / 1_000_000.0 + val v = auswerten("udp", text, ms) ?: continue // 1xx -> weiterlesen + return v + } + return Versuch("udp", null, null, null, null, null, "timeout") + } catch (e: SocketTimeoutException) { + return Versuch("udp", null, null, null, null, null, "timeout") + } catch (e: Exception) { + return Versuch("udp", null, null, null, null, null, kurzgrund(e)) + } finally { + socket?.close() + } + } + + private fun tcp(host: String, ip: String?, port: Int, timeoutMs: Int): Versuch { + var socket: Socket? = null + val t0 = System.nanoTime() + try { + socket = Socket() + socket.connect(InetSocketAddress(ip ?: host, port), timeoutMs) + socket.soTimeout = timeoutMs + val lokal = socket.localAddress?.hostAddress ?: "0.0.0.0" + val anfrage = request(host, port, "TCP", lokal, socket.localPort) + socket.getOutputStream().write(anfrage.toByteArray(Charsets.UTF_8)) + socket.getOutputStream().flush() + + val ein = BufferedInputStream(socket.getInputStream()) + val puffer = ByteArray(4096) + val ende = System.nanoTime() + timeoutMs * 1_000_000L + val gesammelt = StringBuilder() + while (System.nanoTime() < ende) { + val n = ein.read(puffer) + if (n <= 0) break + gesammelt.append(String(puffer, 0, n, Charsets.UTF_8)) + val ms = (System.nanoTime() - t0) / 1_000_000.0 + val v = auswerten("tcp", gesammelt.toString(), ms) + if (v != null) return v + } + return Versuch("tcp", null, null, null, null, null, "timeout") + } catch (e: SocketTimeoutException) { + return Versuch("tcp", null, null, null, null, null, "timeout") + } catch (e: Exception) { + return Versuch("tcp", null, null, null, null, null, kurzgrund(e)) + } finally { + try { + socket?.close() + } catch (e: Exception) { + /* schon zu */ + } + } + } + + /** + * Antworttext auswerten. + * + * @return null, wenn (noch) keine endgültige Antwort vorliegt — vorläufige + * 1xx-Antworten und angefangene Pakete gehören dazu. + */ + private fun auswerten(transport: String, text: String, ms: Double): Versuch? { + val zeilen = text.split("\r\n", "\n") + val start = zeilen.firstOrNull()?.trim() ?: return null + if (!start.startsWith("SIP/2.0")) { + // Kein SIP: an Port 5060 sitzt etwas anderes. Das ist ein Befund, + // keine Zeitüberschreitung — sonst sucht der Techniker am falschen Ende. + return Versuch(transport, null, null, ms, null, null, "kein-sip") + } + val teile = start.split(" ", limit = 3) + val code = teile.getOrNull(1)?.toIntOrNull() + ?: return Versuch(transport, null, null, ms, null, null, "unlesbar") + if (code < 200) return null // vorläufig (100 Trying) — es kommt noch etwas + val grund = teile.getOrNull(2)?.trim().orEmpty() + + var server: String? = null + var allow: String? = null + for (z in zeilen.drop(1)) { + val i = z.indexOf(':') + if (i <= 0) continue + val name = z.substring(0, i).trim().lowercase() + val wert = z.substring(i + 1).trim() + when (name) { + "server", "user-agent" -> if (server.isNullOrEmpty()) server = wert + "allow" -> if (allow.isNullOrEmpty()) allow = wert + } + } + return Versuch(transport, code, grund.ifEmpty { null }, ms, server, allow, null) + } + + /** OPTIONS-Anfrage nach RFC 3261 zusammenbauen. */ + private fun request(host: String, port: Int, transport: String, lokal: String, lokalPort: Int): String { + val branch = "z9hG4bK" + zufall(10) + val tag = zufall(8) + val callId = zufall(16) + "@" + lokal + val ziel = if (port == 5060) "sip:$host" else "sip:$host:$port" + return buildString { + append("OPTIONS $ziel SIP/2.0\r\n") + append("Via: SIP/2.0/$transport $lokal:$lokalPort;branch=$branch;rport\r\n") + append("Max-Forwards: 70\r\n") + append("From: ;tag=$tag\r\n") + append("To: <$ziel>\r\n") + append("Call-ID: $callId\r\n") + append("CSeq: 1 OPTIONS\r\n") + append("Contact: \r\n") + append("Accept: application/sdp\r\n") + append("User-Agent: NetDiag\r\n") + append("Content-Length: 0\r\n") + append("\r\n") + } + } + + private fun zufall(n: Int): String { + val zeichen = "abcdefghijklmnopqrstuvwxyz0123456789" + return (1..n).map { zeichen[Random.nextInt(zeichen.length)] }.joinToString("") + } + + /** Ausnahme auf einen Kurzgrund abbilden, den die App übersetzen kann. */ + private fun kurzgrund(e: Exception): String { + val m = (e.message ?: "").lowercase() + return when { + m.contains("econnrefused") || m.contains("connection refused") -> "abgewiesen" + m.contains("ehostunreach") || m.contains("unreachable") -> "netz" + m.contains("etimedout") || m.contains("timed out") -> "timeout" + else -> "verbindung" + } + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index b366709..3f16308 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -15,8 +15,18 @@ const FETCH_TIMEOUT_MS = 15_000; let serverUrl = ''; let token = ''; -/** Callback, der bei 401 (Sitzung abgelaufen) ausgelöst wird */ -let onAuthFailure: (() => void) | null = null; +/** + * Callback bei 401. + * + * `mitToken` unterscheidet die zwei Fälle, die derselbe Statuscode sind: + * `true` = wir hatten ein Token und der Server hat es abgelehnt (abgelaufen + * oder Gerät abgemeldet), `false` = es war gar keines da. Ohne diese + * Unterscheidung räumt ein Aufruf im nicht angemeldeten Zustand die + * Fingerabdruck-Hinterlegung weg — genau das ist beim ersten Emulator-Test + * passiert: Startaufruf ohne Token -> 401 -> Hinterlegung gelöscht, der + * Anmeldeknopf war weg. + */ +let onAuthFailure: ((mitToken: boolean) => void) | null = null; /** Fehlerklasse für API-Antworten */ export class ApiError extends Error { @@ -36,7 +46,7 @@ export async function initApi(): Promise { } /** Callback für abgelaufene Sitzung registrieren */ -export function setAuthFailureHandler(fn: () => void): void { +export function setAuthFailureHandler(fn: (mitToken: boolean) => void): void { onAuthFailure = fn; } @@ -63,6 +73,20 @@ export async function clearToken(): Promise { await Preferences.remove({ key: 'token' }); } +/** + * Token aus dem Klartext-Speicher des Geräts entfernen, die LAUFENDE Sitzung + * aber behalten. + * + * Das ist der Kern der Fingerabdruck-Anmeldung: solange das Token zusätzlich + * in den Capacitor Preferences steht, liegt es unverschlüsselt auf dem Gerät + * (`/data/data/…/shared_prefs/CapacitorStorage.xml`) und der Fingerabdruck + * schützt nichts — wer das entsperrte Handy hat, liest es dort einfach aus. + * Nach dem Hinterlegen im Keystore muss die Klartextfassung deshalb weg. + */ +export async function tokenNurImSpeicher(): Promise { + await Preferences.remove({ key: 'token' }); +} + /** Vollständige URL für einen API-Pfad bauen */ function url(endpoint: string): string { return `${serverUrl}${API_PATH}/${endpoint}`; @@ -74,6 +98,7 @@ function url(endpoint: string): string { async function request(endpoint: string, options: RequestInit = {}): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + const mitToken = token !== ''; const headers: Record = { Accept: 'application/json' }; if (token) headers['Authorization'] = `Bearer ${token}`; @@ -93,7 +118,7 @@ async function request(endpoint: string, options: RequestInit = {}): Promise< if (res.status === 401) { await clearToken(); - onAuthFailure?.(); + onAuthFailure?.(mitToken); throw new ApiError(401, 'Sitzung abgelaufen, bitte neu anmelden'); } @@ -137,6 +162,33 @@ export async function login(loginName: string, password: string): Promise { + const vorher = token; + token = vorhandenesToken; + try { + await request<{ orders: unknown[] }>('orders.php?open=1'); + } catch (e) { + token = vorher; + throw e; + } + /* + * Bewusst KEIN setToken(): das Token bleibt im Arbeitsspeicher und wandert + * nicht zurück in die Klartext-Preferences. Sonst wäre es nach der ersten + * Fingerabdruck-Anmeldung wieder ungeschützt auf dem Gerät, und der + * Keystore-Umweg hätte sich erledigt. + */ +} + /** Aufträge laden — nur aktive (offene), optional Suchtext */ export function listOrders(opts: { open?: boolean; q?: string } = {}): Promise<{ orders: Order[] }> { const p = new URLSearchParams(); @@ -270,9 +322,30 @@ export function syncProtocol(protocol: Protocol): Promise { }); } -/** URL zum Protokoll-PDF (inkl. Token als Query-Parameter) */ -export function pdfUrl(serverProtocolId: number): string { - return `${serverUrl}${API_PATH}/pdf.php?id=${serverProtocolId}&jwt=${encodeURIComponent(token)}`; +/** + * Protokoll-PDF vom Server holen. + * + * Bewusst als Blob über fetch statt als fertige Adresse: eine URL mit + * `?jwt=` landet in Zugriffs- und Proxy-Logs, und das Token gilt sieben Tage + * für die gesamte Kunden-API. Der Rückgabewert lässt sich teilen + * (`navigator.share`) oder über eine Blob-URL anzeigen. + * + * Kein `request()`, weil die Antwort kein JSON ist — die 401-Behandlung + * ist deshalb hier wiederholt. + */ +export async function fetchProtocolPdf(serverProtocolId: number): Promise { + const res = await fetch(`${serverUrl}${API_PATH}/pdf.php?id=${serverProtocolId}`, { + headers: { Authorization: `Bearer ${token}`, Accept: 'application/pdf' }, + }).catch(() => { + throw new ApiError(0, 'Keine Verbindung zum Server'); + }); + if (res.status === 401) { + await clearToken(); + onAuthFailure?.(true); + throw new ApiError(401, 'Sitzung abgelaufen, bitte neu anmelden'); + } + if (!res.ok) throw new ApiError(res.status, `PDF konnte nicht geladen werden (${res.status})`); + return res.blob(); } export interface DebugLogUpload { @@ -297,7 +370,17 @@ export function checkAppUpdate(): Promise<{ version: string | null }> { return request<{ version: string | null }>('update.php'); } -/** Authentifizierte Download-URL der aktuellsten APK (über den Server-Proxy) */ +/** + * Download-Adresse der aktuellsten APK (über den Server-Proxy) — **ohne** + * Token. Den schickt der Herunterlader als `Authorization: Bearer`-Header; + * früher stand er hier in der Adresse und damit in jedem Zugriffslog. + */ export function updateDownloadUrl(): string { - return `${serverUrl}${API_PATH}/update.php?download=1&jwt=${encodeURIComponent(token)}`; + return `${serverUrl}${API_PATH}/update.php?download=1`; +} + +/** Aktuelles Sitzungs-Token — nur für Aufrufer, die selbst HTTP sprechen + * (nativer APK-Herunterlader im Kotlin-Plugin). */ +export function authToken(): string { + return token; } diff --git a/src/lib/auth.svelte.ts b/src/lib/auth.svelte.ts index 6603ebd..1408561 100644 --- a/src/lib/auth.svelte.ts +++ b/src/lib/auth.svelte.ts @@ -4,13 +4,17 @@ import { Preferences } from '@capacitor/preferences'; import { + authToken, clearToken, getServerUrl, initApi, isLoggedIn, login as apiLogin, + loginMitToken, setAuthFailureHandler, + tokenNurImSpeicher, } from './api'; +import { scanner } from './scanner'; import type { NetUser } from './types'; class AuthState { @@ -19,20 +23,93 @@ class AuthState { /** true wenn auf dem Gerät noch keine Server-URL hinterlegt ist */ needsServerUrl = $state(false); ready = $state(false); + /** Fingerabdruck-Anmeldung ist auf diesem Gerät eingerichtet */ + biometrieHinterlegt = $state(false); + /** Gerät könnte Fingerabdruck (Sensor da, mindestens ein Finger angelernt) */ + biometrieMoeglich = $state(false); + /** Klartextgrund, wenn nicht möglich */ + biometrieGrund = $state(''); /** Beim App-Start: gespeicherte Daten laden */ async init(): Promise { await initApi(); - setAuthFailureHandler(() => this.handleAuthFailure()); + setAuthFailureHandler((mitToken) => this.handleAuthFailure(mitToken)); const storedUser = (await Preferences.get({ key: 'user' })).value; if (storedUser) this.user = JSON.parse(storedUser) as NetUser; this.loggedIn = isLoggedIn(); this.needsServerUrl = getServerUrl() === '' && !import.meta.env.DEV; + await this.biometrieStatusLaden(); this.ready = true; } + /** Zustand der Fingerabdruck-Anmeldung vom Gerät holen */ + async biometrieStatusLaden(): Promise { + try { + const st = await scanner.biometrieStatus(); + this.biometrieMoeglich = st.verfuegbar; + this.biometrieHinterlegt = st.hinterlegt; + this.biometrieGrund = st.grund; + } catch { + this.biometrieMoeglich = false; + this.biometrieHinterlegt = false; + this.biometrieGrund = 'Fingerabdruck-Anmeldung auf diesem Gerät nicht verfügbar'; + } + } + + /** + * Mit Fingerabdruck anmelden. + * + * Wirft mit Klartextgrund, wenn es nicht klappt. Ist das hinterlegte Token + * serverseitig nicht mehr gültig (Gerät in der awlauth-Liste abgemeldet, + * Gültigkeit abgelaufen), wird die Hinterlegung entfernt — sonst bekäme der + * Techniker bei jedem Start denselben Dialog mit demselben toten Token. + */ + async loginMitBiometrie(): Promise { + const { token, meldung } = await scanner.biometrieAbrufen(); + if (!token) { + throw new Error(meldung || 'Fingerabdruck nicht erkannt'); + } + try { + await loginMitToken(token); + } catch (e) { + await this.biometrieEntfernen(); + throw new Error( + 'Die hinterlegte Anmeldung gilt nicht mehr (abgelaufen oder Gerät abgemeldet) — bitte einmal mit Passwort anmelden', + ); + } + const storedUser = (await Preferences.get({ key: 'user' })).value; + if (storedUser) this.user = JSON.parse(storedUser) as NetUser; + this.loggedIn = true; + } + + /** Fingerabdruck-Anmeldung einrichten (nur sinnvoll, wenn angemeldet) */ + async biometrieEinrichten(): Promise { + const token = authToken(); + if (!token) throw new Error('Erst anmelden, dann den Fingerabdruck einrichten'); + const { ok, meldung } = await scanner.biometrieHinterlegen({ token }); + if (!ok) throw new Error(meldung || 'Einrichten abgebrochen'); + /* + * Erst jetzt die Klartextfassung entfernen — in dieser Reihenfolge, sonst + * stünde die App nach einem Fehlschlag beim Hinterlegen ohne Token da. + * Die laufende Sitzung bleibt bestehen; erst beim nächsten Start verlangt + * die App den Finger. + */ + await tokenNurImSpeicher(); + await this.biometrieStatusLaden(); + } + + /** Hinterlegung entfernen */ + async biometrieEntfernen(): Promise { + try { + await scanner.biometrieLoeschen(); + } catch { + /* Gerät kennt die Methode nicht — dann gibt es auch nichts zu löschen */ + } + await this.biometrieStatusLaden(); + } + /** Anmelden */ async login(loginName: string, password: string): Promise { const res = await apiLogin(loginName, password); @@ -41,18 +118,32 @@ class AuthState { await Preferences.set({ key: 'user', value: JSON.stringify(res.user) }); } - /** Abmelden */ + /** + * Abmelden. Nimmt die Fingerabdruck-Hinterlegung mit: sonst meldete der + * Finger genau die Sitzung wieder an, die der Techniker gerade beendet hat. + */ async logout(): Promise { await clearToken(); await Preferences.remove({ key: 'user' }); + await this.biometrieEntfernen(); this.user = null; this.loggedIn = false; } - /** Wird bei 401 vom API-Client ausgelöst */ - private handleAuthFailure(): void { + /** + * Wird bei 401 vom API-Client ausgelöst. + * + * Die Hinterlegung wird NUR entfernt, wenn tatsächlich ein Token abgelehnt + * wurde (`mitToken`) — dann ist es abgelaufen oder das Gerät wurde in der + * awlauth-Liste abgemeldet, und das verschlüsselt hinterlegte Token ist + * dasselbe und damit ebenfalls wertlos. Ein 401 ohne Token heißt dagegen + * bloß „noch nicht angemeldet"; würde man dabei löschen, verschwände die + * Fingerabdruck-Anmeldung genau in dem Moment, in dem sie gebraucht wird. + */ + private handleAuthFailure(mitToken: boolean): void { this.loggedIn = false; this.user = null; + if (mitToken) void this.biometrieEntfernen(); } } diff --git a/src/lib/messfelder.ts b/src/lib/messfelder.ts index d5e2a55..0233fb9 100644 --- a/src/lib/messfelder.ts +++ b/src/lib/messfelder.ts @@ -58,6 +58,25 @@ const FELDER: Record = { // fehlt der Schlüssel hier oder in netdiagKundenfelder(), steht im // Kundendokument nur noch die Ampel. fehlerglied: { label: 'Fehlerglied' }, + // SIP-Erreichbarkeit. `befund` ist die eigentliche Aussage: „401 Anmeldung + // verlangt" ist ein erreichbarer Anschluss, keine Störung — ohne den Text + // bliebe davon nur eine Ampel übrig. Gegenstück in netdiagKundenfelder(). + befund: { label: 'Befund' }, + statuszeile: { label: 'Antwort der Gegenstelle' }, + antwortWeg: { label: 'Antwort über' }, + anmeldungNoetig: { label: 'Anmeldung verlangt' }, + versuche: { label: 'Versuche je Transportweg' }, + unterstuetzteMethoden: { label: 'Unterstützte SIP-Methoden' }, + zielIp: { label: 'Adresse des Ziels' }, + zielHerkunft: { label: 'Herkunft des Ziels' }, + // NICHT 'port': der Schlüssel ist weiter unten schon vom SNMP-Werkzeug als + // „Switch-Port" belegt. Ein zweiter Eintrag desselben Namens gewinnt nicht, + // er verschwindet — im Kundendokument stünde dann „Switch-Port: 5060". + zielPort: { label: 'Port' }, + // Steht in den Unterobjekten von `versuche`. Ohne Einheit stand dort im + // Emulator „UDP · 200 OK · 134" — eine nackte Zahl, die niemand einordnen + // kann. objektZeile() zieht die Einheit aus dieser Tabelle. + laufzeitMs: { label: 'Laufzeit', einheit: 'ms' }, stufen: { label: 'Geprüfte Stufen' }, systemUrteil: { label: 'Urteil des Betriebssystems' }, validated: { label: 'Internetverbindung bestätigt' }, @@ -208,6 +227,7 @@ const TOOL_NAMEN: Record = { monitor: 'Geräte-Monitor', iptest: 'IP-Test (Dose)', wifitrack: 'WLAN-Empfang', + sipcheck: 'SIP-Erreichbarkeit', // nicht mehr vorhandene Werkzeuge, kommen in Altprotokollen noch vor dhcpcheck: 'DHCP-Prüfung', wifiscan: 'WLAN-Scan', diff --git a/src/lib/scanner.ts b/src/lib/scanner.ts index b6b8e29..18d1ce0 100644 --- a/src/lib/scanner.ts +++ b/src/lib/scanner.ts @@ -243,6 +243,29 @@ export interface DnsQueryResult { meldung?: string; } +/** Ein SIP-OPTIONS-Versuch über EINEN Transportweg */ +export interface SipVersuchResult { + transport: string; + /** Statuscode der Antwort (200, 401, 405 …) — null, wenn nichts kam */ + statusCode: number | null; + statusText: string | null; + latenzMs?: number | null; + /** Inhalt von `Server:` bzw. `User-Agent:` der Gegenstelle */ + server?: string | null; + /** Inhalt von `Allow:` — welche SIP-Methoden die Gegenstelle beherrscht */ + allow?: string | null; + /** null = geantwortet; sonst 'timeout' | 'abgewiesen' | 'netz' | 'kein-sip' | 'name' | 'unlesbar' | 'verbindung' */ + fehler?: string | null; +} + +export interface SipOptionsResult { + host: string; + ip: string | null; + port: number; + /** ein Eintrag je versuchtem Transportweg, in der Reihenfolge des Versuchs */ + versuche: SipVersuchResult[]; +} + /** Schnittstelle des nativen Plugins */ export interface NetDiagScannerPlugin { /** Aktuelles Subnetz des Geräts ermitteln (z.B. "192.168.1.0/24") */ @@ -389,6 +412,29 @@ export interface NetDiagScannerPlugin { * auf Port 53, ohne den Zwischenspeicher des Systems). */ dnsQuery(opts: { server: string; name: string; timeoutMs?: number }): Promise; + /** + * SIP-Erreichbarkeit über eine OPTIONS-Anfrage prüfen (kein Anruf, keine + * Zugangsdaten). Bei `transport: 'auto'` wird nach einem stummen UDP-Versuch + * TCP nachgeschoben — antwortet nur TCP, ist das der Fingerabdruck einer + * Firewall, die SIP über UDP verwirft. + */ + sipOptions(opts: { + host: string; + port?: number; + transport?: string; + timeoutMs?: number; + }): Promise; + /** + * Fingerabdruck-Anmeldung: Zustand des Geräts abfragen. + * `grund` ist Klartext für die Anzeige, wenn `verfuegbar` false ist. + */ + biometrieStatus(): Promise<{ verfuegbar: boolean; hinterlegt: boolean; grund: string }>; + /** Sitzungs-Token hinterlegen — zeigt den Fingerabdruck-Dialog */ + biometrieHinterlegen(opts: { token: string }): Promise<{ ok: boolean; meldung: string }>; + /** Hinterlegtes Token abrufen — zeigt den Fingerabdruck-Dialog */ + biometrieAbrufen(): Promise<{ token: string | null; meldung: string }>; + /** Hinterlegung entfernen */ + biometrieLoeschen(): Promise<{ ok: boolean }>; /** 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 */ @@ -942,6 +988,52 @@ const mock: NetDiagScannerPlugin = { autoritativ: false, }; }, + /* + * SIP im Browser-Mock: bewusst der Gutfall mit „200 OK", und bewusst + * NICHT mehr. Die interessanten Fälle (401 Anmeldung verlangt, 405, nur TCP + * antwortet, gar keine Antwort) rechnet `tools/pruefe-sip.mjs` gegen + * `sip-bewertung.ts` nach — im Browser gibt es keine Telefonanlage, gegen + * die sich das ehrlich nachstellen ließe. + */ + async sipOptions(opts) { + return { + host: opts.host, + ip: '192.168.10.1', + port: opts.port ?? 5060, + versuche: [ + { + transport: 'udp', + statusCode: 200, + statusText: 'OK', + latenzMs: rnd(3, 30), + server: 'FRITZ!OS', + allow: 'INVITE, ACK, OPTIONS, CANCEL, BYE, NOTIFY, INFO', + fehler: null, + }, + ], + }; + }, + /* + * Biometrie gibt es im Browser nicht — und sie wird hier auch NICHT + * vorgetäuscht. Ein Mock, der „verfügbar" meldet, würde die Einstellung + * anzeigen und beim Antippen ins Leere laufen. `grund` sagt genau das. + */ + async biometrieStatus() { + return { + verfuegbar: false, + hinterlegt: false, + grund: 'Fingerabdruck-Anmeldung gibt es nur in der App auf dem Gerät', + }; + }, + async biometrieHinterlegen() { + return { ok: false, meldung: 'Im Browser nicht möglich' }; + }, + async biometrieAbrufen() { + return { token: null, meldung: 'Im Browser nicht möglich' }; + }, + async biometrieLoeschen() { + return { ok: true }; + }, async startWifiScan() { return { triggered: true }; }, diff --git a/src/lib/tools/index.ts b/src/lib/tools/index.ts index ca68dc5..cab0015 100644 --- a/src/lib/tools/index.ts +++ b/src/lib/tools/index.ts @@ -22,6 +22,8 @@ import { tracerouteTool } from './netzwerk/traceroute'; import { internetKetteTool } from './internet/internetkette'; import { iperfTool } from './internet/iperf'; +import { sipCheckTool } from './telefonie/sipcheck'; + /** * Alle registrierten Tools. * @@ -45,7 +47,10 @@ export const TOOLS: Tool[] = [ // wenn der Kunde „Internet geht nicht" sagt. internetKetteTool, iperfTool, - // Telefonie: folgt (SIP-Registrierung, FreePBX-Check, RTP-Qualität) + // Telefonie. Bewusst nur die Erreichbarkeit: RTP-Sprachqualität lässt sich + // ohne aufgebautes Gespräch und Gegenstelle nicht ehrlich messen (siehe + // Kopf von sipcheck.ts). + sipCheckTool, ]; /** Tool nach ID finden */ diff --git a/src/lib/tools/telefonie/sip-bewertung.ts b/src/lib/tools/telefonie/sip-bewertung.ts new file mode 100644 index 0000000..5f76794 --- /dev/null +++ b/src/lib/tools/telefonie/sip-bewertung.ts @@ -0,0 +1,224 @@ +/** + * Bewertung einer SIP-OPTIONS-Prüfung — reine Logik, keine Laufzeit-Abhängigkeit. + * + * Bewusst ohne Import von `scanner` o.ä.: nur so kann `tools/pruefe-sip.mjs` + * die Regeln direkt laden und mit konstruierten Antworten nachrechnen. Der + * Browser-Mock zeigt bei Netzwerkwerkzeugen den Gutfall und beweist nichts. + * + * Leitsatz (steht auch in `Sip.kt`): **jede Statuszeile beweist eine + * antwortende Instanz.** 401/403/407 heißen „nicht ohne Anmeldung", 405/501 + * heißen „diese Frage beantworte ich nicht" — beides setzt voraus, dass die + * Anlage das Paket bekommen, gelesen und beantwortet hat. Genau das ist die + * Aussage, die der Techniker braucht. + */ + +import type { MeasureStatus } from '../types'; + +/** Ein Versuch über einen Transportweg (Rückgabe des nativen Teils) */ +export interface SipVersuch { + transport: string; + statusCode: number | null; + statusText: string | null; + latenzMs?: number | null; + server?: string | null; + allow?: string | null; + /** null = Antwort erhalten; sonst 'timeout' | 'abgewiesen' | 'netz' | 'kein-sip' | 'name' | 'unlesbar' | 'verbindung' */ + fehler?: string | null; +} + +export interface SipMessung { + host: string; + ip?: string | null; + port: number; + versuche: SipVersuch[]; +} + +export interface SipUrteil { + status: MeasureStatus; + /** Kurzfassung für die Protokollzeile */ + kurz: string; + /** Klartext-Befund für Techniker und Kundendokument */ + befund: string; + /** wenn vorhanden: der Transportweg, über den geantwortet wurde */ + antwortWeg: string | null; + /** Statuszeile der Antwort, z.B. "200 OK" */ + statuszeile: string | null; + /** true, wenn eine Anmeldung verlangt wurde (401/407) — kein Fehler */ + anmeldungNoetig: boolean; +} + +/** Text zu den Kurzgründen des nativen Teils */ +export function fehlerText(grund: string | null | undefined, port: number): string { + switch (grund) { + case 'timeout': + return `Keine Antwort auf Port ${port}`; + case 'abgewiesen': + return `Verbindung abgewiesen (Port ${port} zu)`; + case 'netz': + return 'Ziel im Netz nicht erreichbar'; + case 'kein-sip': + return `An Port ${port} antwortet etwas, aber kein SIP`; + case 'name': + return 'Name lässt sich nicht auflösen'; + case 'unlesbar': + return 'Antwort nicht lesbar'; + case 'verbindung': + return 'Verbindung nicht möglich'; + default: + return 'Unbekannter Fehler'; + } +} + +/** Antwortender Versuch (mit Statuszeile) — der zählt für das Urteil */ +function ersterMitAntwort(versuche: SipVersuch[]): SipVersuch | null { + return versuche.find((v) => typeof v.statusCode === 'number') ?? null; +} + +/** + * Aus einer Messung ein Urteil bilden. + * + * Die Schwellen im Einzelnen: + * - **2xx** → Grün. Die Anlage antwortet vollständig. + * - **401/407** → Grün mit Hinweis. Ein Registrar, der ohne Zugangsdaten + * antwortet, wäre die Fehlkonfiguration; „Anmeldung verlangt" ist der + * Normalzustand und beweist die Erreichbarkeit. + * - **403** → Gelb. Erreichbar, aber die Instanz weist ab (Sperre, falsche + * Domain, IP nicht freigegeben) — das muss jemand ansehen. + * - **404/405/501** → Gelb. Antwortende Instanz, die diese Frage nicht mag + * oder das Ziel nicht kennt. Erreichbarkeit ja, Konfiguration prüfen. + * - **5xx/6xx** → Gelb. Antwort ja, Dienst gestört. + * - **keine Antwort** → Rot, wenn ein Transportweg abgewiesen wurde oder + * beide schwiegen. Rot heißt hier: hier muss jemand ran. + * - **Name nicht auflösbar** → „nicht messbar" (Status 3), keine Aussage über + * die Anlage. Das ist ein DNS-Befund, kein Telefonie-Befund. + */ +export function bewerteSip(m: SipMessung): SipUrteil { + const antwort = ersterMitAntwort(m.versuche); + const ziel = m.port === 5060 ? m.host : `${m.host}:${m.port}`; + + if (!antwort) { + // Nichts geantwortet — Ursache aus den Versuchen ableiten. + const gruende = m.versuche.map((v) => v.fehler ?? 'timeout'); + if (gruende.includes('name')) { + return { + status: 3, + kurz: `${m.host}: Name nicht auflösbar`, + befund: + `Der Name „${m.host}" ließ sich nicht auflösen. Über die Telefonanlage sagt das ` + + 'nichts aus — zuerst die Namensauflösung klären (DNS-Prüfung), dann erneut messen.', + antwortWeg: null, + statuszeile: null, + anmeldungNoetig: false, + }; + } + if (gruende.includes('kein-sip')) { + return { + status: 2, + kurz: `${ziel}: kein SIP an diesem Port`, + befund: + `An Port ${m.port} von ${m.host} antwortet ein Dienst, aber er spricht kein SIP. ` + + 'Entweder ist der Port falsch oder dort läuft etwas anderes.', + antwortWeg: null, + statuszeile: null, + anmeldungNoetig: false, + }; + } + const wege = m.versuche.map((v) => `${v.transport.toUpperCase()}: ${fehlerText(v.fehler, m.port)}`); + /* + * „Abgewiesen" ist nicht „keine Antwort": ein TCP-RST beweist ein + * erreichbares Gerät mit geschlossenem Port, Schweigen kann auch ein + * Filter unterwegs sein. Beim Kunden führt das zu zwei verschiedenen + * Handgriffen — die Protokollzeile muss den Unterschied hergeben. + */ + const abgewiesen = gruende.includes('abgewiesen'); + return { + status: 2, + kurz: abgewiesen ? `${ziel}: Port geschlossen` : `${ziel}: keine Antwort`, + befund: + (abgewiesen + ? `${m.host} ist erreichbar, weist die Verbindung auf Port ${m.port} aber ab ` + + `(${wege.join(' · ')}). Das Gerät antwortet also — auf diesem Port läuft nur kein ` + + 'SIP-Dienst. Zu prüfen: richtiger Port, läuft der Telefondienst überhaupt? ' + : `${m.host} hat auf die SIP-Anfrage nicht geantwortet (${wege.join(' · ')}). `) + + (abgewiesen + ? '' + : 'Zu prüfen: läuft die Anlage, stimmen Adresse und Port, sperrt eine Firewall den Weg? ' + + 'Eine ausbleibende UDP-Antwort allein beweist nicht, dass nichts da ist — ein Filter ' + + 'verwirft Pakete auch stillschweigend.'), + antwortWeg: null, + statuszeile: null, + anmeldungNoetig: false, + }; + } + + const code = antwort.statusCode as number; + const zeile = `${code}${antwort.statusText ? ' ' + antwort.statusText : ''}`; + const weg = antwort.transport.toUpperCase(); + const via = `${m.host} antwortet über ${weg} auf Port ${m.port} mit „${zeile}"`; + const wer = antwort.server ? ` Gegenstelle meldet sich als „${antwort.server}".` : ''; + const nurTcp = + antwort.transport === 'tcp' && m.versuche.some((v) => v.transport === 'udp' && v.statusCode === null); + const tcpHinweis = nurTcp + ? ' Achtung: über UDP kam keine Antwort, nur über TCP — das ist das typische Bild einer ' + + 'Firewall, die SIP über UDP verwirft. Viele Anlagen sind auf UDP eingestellt.' + : ''; + + if (code >= 200 && code < 300) { + return { + status: nurTcp ? 1 : 0, + kurz: `${ziel}: ${zeile} (${weg})`, + befund: `${via}.${wer}${tcpHinweis}`, + antwortWeg: antwort.transport, + statuszeile: zeile, + anmeldungNoetig: false, + }; + } + if (code === 401 || code === 407) { + return { + status: nurTcp ? 1 : 0, + kurz: `${ziel}: erreichbar, Anmeldung verlangt (${zeile})`, + befund: + `${via}. Das ist der Normalfall bei einer Anlage oder einem Provider mit Zugangsdaten: ` + + `die Instanz ist erreichbar und antwortet, sie will für weitere Schritte nur eine ` + + `Anmeldung sehen.${wer}${tcpHinweis}`, + antwortWeg: antwort.transport, + statuszeile: zeile, + anmeldungNoetig: true, + }; + } + if (code === 403) { + return { + status: 1, + kurz: `${ziel}: erreichbar, weist ab (${zeile})`, + befund: + `${via}. Die Instanz ist erreichbar, weist die Anfrage aber ab — üblich bei gesperrtem ` + + `Konto, falscher Domain oder einer IP, die nicht freigegeben ist.${wer}${tcpHinweis}`, + antwortWeg: antwort.transport, + statuszeile: zeile, + anmeldungNoetig: false, + }; + } + if (code === 404 || code === 405 || code === 501) { + return { + status: 1, + kurz: `${ziel}: erreichbar, ${zeile}`, + befund: + `${via}. Erreichbar ist die Instanz damit nachweislich; sie beantwortet diese Anfrage ` + + `nur nicht inhaltlich. Für die Erreichbarkeit ist das ausreichend, für die Konfiguration ` + + `ein Hinweis.${wer}${tcpHinweis}`, + antwortWeg: antwort.transport, + statuszeile: zeile, + anmeldungNoetig: false, + }; + } + return { + status: 1, + kurz: `${ziel}: antwortet mit ${zeile}`, + befund: + `${via}. Die Instanz antwortet, meldet aber eine Störung. Der Weg dorthin ist in Ordnung, ` + + `das Problem liegt auf der Gegenseite.${wer}${tcpHinweis}`, + antwortWeg: antwort.transport, + statuszeile: zeile, + anmeldungNoetig: false, + }; +} diff --git a/src/lib/tools/telefonie/sipcheck.ts b/src/lib/tools/telefonie/sipcheck.ts new file mode 100644 index 0000000..422b580 --- /dev/null +++ b/src/lib/tools/telefonie/sipcheck.ts @@ -0,0 +1,131 @@ +/** + * Tool: SIP-Erreichbarkeit — fragt die Telefonanlage oder den Provider mit + * einer OPTIONS-Anfrage und meldet die Statuszeile. + * + * Was die Messung beantwortet: „Kommt mein Netz überhaupt bis zur SIP-Instanz + * durch, und antwortet sie?" Das ist die Frage, an der beim Kunden fast jede + * Telefonstörung hängt — vor allen Zugangsdaten und allen Endgeräten. + * + * Was sie ausdrücklich NICHT beantwortet: die Sprachqualität. Dafür bräuchte + * es ein aufgebautes Gespräch und eine Gegenstelle, die RTP zurückschickt. + * Aus Rundlaufzeiten „Jitter" zu errechnen, wäre eine andere Größe unter + * demselben Namen — im Kundendokument also eine Falschaussage. Deshalb steht + * die Grenze im Ergebnis (`verfahren`) und landet mit im PDF. + * + * Voreinstellung Gateway: in Handwerksnetzen ist die Telefonanlage meistens + * die Fritzbox, und die ist das Gateway. Damit hat der Techniker beim ersten + * Antippen einen sinnvollen Wert und muss nichts abtippen. + */ + +import { scanner } from '../../scanner'; +import type { Tool } from '../types'; +import { bewerteSip, fehlerText, type SipMessung, type SipVersuch } from './sip-bewertung'; + +export { bewerteSip, fehlerText } from './sip-bewertung'; +export type { SipMessung, SipUrteil, SipVersuch } from './sip-bewertung'; + +export const sipCheckTool: Tool = { + id: 'sipcheck', + category: 'telefonie', + name: 'SIP-Erreichbarkeit', + icon: 'phone', + description: + 'Fragt Telefonanlage oder Provider mit OPTIONS — antwortet dort eine SIP-Instanz? Ohne Anruf, ohne Zugangsdaten.', + scope: 'protocol', + quickRun: true, + params: [ + { key: 'host', label: 'Anlage / Provider', type: 'text', placeholder: 'z. B. fritz.box' }, + { key: 'port', label: 'Port', type: 'number', default: 5060 }, + { + key: 'transport', + label: 'Transport', + type: 'select', + default: 'auto', + options: [ + { value: 'auto', label: 'UDP, bei Schweigen TCP' }, + { value: 'udp', label: 'nur UDP' }, + { value: 'tcp', label: 'nur TCP' }, + ], + }, + ], + + async preview(ctx) { + const host = String(ctx.params.host ?? '').trim() || (await gatewayVorschlag()); + return { + lines: [ + { label: 'Ziel', value: host || 'nicht gesetzt' }, + { label: 'Port', value: String(ctx.params.port ?? 5060) }, + { label: 'Verfahren', value: 'SIP OPTIONS — kein Anruf, kein Klingeln' }, + ], + ...(host ? {} : { warning: 'Ohne Ziel kann nicht gemessen werden — bitte Anlage oder Provider eintragen.' }), + }; + }, + + async run(ctx) { + // Kein Ziel angegeben: das Gateway ist in kleinen Netzen fast immer die + // Anlage. Das ist eine Annahme — sie steht deshalb sichtbar im Ergebnis. + const eingetragen = String(ctx.params.host ?? '').trim(); + const host = eingetragen || (await gatewayVorschlag()); + if (!host) { + return { + label: 'SIP-Erreichbarkeit: kein Ziel angegeben', + measureStatus: 3, + result: { + hinweis: + 'Es war kein Ziel eingetragen und es ließ sich auch kein Gateway ermitteln — ' + + 'ohne Adresse der Anlage ist keine Messung möglich.', + }, + }; + } + + const port = Number(ctx.params.port ?? 5060) || 5060; + const transport = String(ctx.params.transport ?? 'auto'); + + const roh = await scanner.sipOptions({ host, port, transport, timeoutMs: 3000 }); + const messung: SipMessung = { + host: roh.host ?? host, + ip: roh.ip ?? null, + port: roh.port ?? port, + versuche: (roh.versuche ?? []) as SipVersuch[], + }; + const urteil = bewerteSip(messung); + + return { + label: `SIP ${urteil.kurz}`, + measureStatus: urteil.status, + result: { + ziel: messung.host, + zielIp: messung.ip ?? null, + zielPort: messung.port, + ...(eingetragen ? {} : { zielHerkunft: 'Gateway des Anschlusses (kein Ziel eingetragen)' }), + antwortWeg: urteil.antwortWeg ? urteil.antwortWeg.toUpperCase() : null, + statuszeile: urteil.statuszeile, + anmeldungNoetig: urteil.anmeldungNoetig, + befund: urteil.befund, + versuche: messung.versuche.map((v) => ({ + transport: v.transport.toUpperCase(), + antwort: typeof v.statusCode === 'number' ? `${v.statusCode} ${v.statusText ?? ''}`.trim() : null, + laufzeitMs: v.latenzMs ?? null, + hinweis: v.fehler ? fehlerText(v.fehler, messung.port) : null, + })), + gegenstelle: messung.versuche.find((v) => v.server)?.server ?? null, + unterstuetzteMethoden: messung.versuche.find((v) => v.allow)?.allow ?? null, + verfahren: + 'Eine SIP-OPTIONS-Anfrage (RFC 3261). Es wird kein Gespräch aufgebaut und kein Telefon ' + + 'gerufen. Die Sprachqualität (RTP-Jitter, Paketverlust im Gespräch) ist damit NICHT ' + + 'gemessen — dafür wäre ein echtes Gespräch mit Gegenstelle nötig.', + }, + }; + }, +}; + +/** Gateway des Standardanschlusses — Vorschlag, wenn kein Ziel eingetragen ist */ +async function gatewayVorschlag(): Promise { + try { + const { links } = await scanner.linkInfo(); + const link = links.find((l) => l.isDefault && l.gateway) ?? links.find((l) => l.gateway); + return link?.gateway ?? ''; + } catch { + return ''; + } +} diff --git a/src/lib/updater.ts b/src/lib/updater.ts index ec53f6a..3f98043 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -15,7 +15,7 @@ */ import { Capacitor, registerPlugin, type PluginListenerHandle } from '@capacitor/core'; -import { checkAppUpdate, updateDownloadUrl } from './api'; +import { authToken, checkAppUpdate, updateDownloadUrl } from './api'; /** Aktuelle Build-Version (von der CI injiziert, im Dev 'dev') */ export const APP_VERSION: string = import.meta.env.VITE_APP_VERSION ?? 'dev'; @@ -34,7 +34,7 @@ export type UpdateProgressCb = (percent: number) => void; * als `updateProgress`-Event. */ interface NetDiagUpdaterPlugin { - installUpdate(opts: { url: string }): Promise<{ started: boolean }>; + installUpdate(opts: { url: string; token: string }): Promise<{ started: boolean }>; addListener( eventName: 'updateProgress', cb: (data: { percent: number }) => void, @@ -92,7 +92,14 @@ export async function installUpdate( onProgress?: UpdateProgressCb, ): Promise { if (!Capacitor.isNativePlatform()) { - window.open(info.downloadUrl, '_system'); + // Browser-Dev: keine Adresse mit Token mehr aufmachen — holen, dann als + // Blob anbieten. window.open() koennte den Header nicht mitschicken. + const res = await fetch(info.downloadUrl, { + headers: { Authorization: `Bearer ${authToken()}` }, + }); + if (!res.ok) throw new Error(`APK-Download fehlgeschlagen (HTTP ${res.status})`); + const href = URL.createObjectURL(await res.blob()); + window.open(href, '_system'); return; } @@ -101,7 +108,7 @@ export async function installUpdate( handle = await updaterPlugin.addListener('updateProgress', (d) => onProgress(d.percent)); } try { - await updaterPlugin.installUpdate({ url: info.downloadUrl }); + await updaterPlugin.installUpdate({ url: info.downloadUrl, token: authToken() }); } finally { await handle?.remove(); } diff --git a/src/routes/einstellungen/+page.svelte b/src/routes/einstellungen/+page.svelte index d64295d..e06187f 100644 --- a/src/routes/einstellungen/+page.svelte +++ b/src/routes/einstellungen/+page.svelte @@ -16,6 +16,36 @@ let installing = $state(false); let percent = $state(0); let wifiDemo = $state(false); + let fingerBusy = $state(false); + + /** + * Fingerabdruck-Anmeldung ein-/ausschalten. + * + * Eingerichtet wird das aktuelle Sitzungs-Token — es bleibt über die + * awlauth-Geräteliste widerrufbar, es entsteht also kein zweiter Zugang am + * Server vorbei. Abmelden entfernt die Hinterlegung automatisch mit. + */ + async function toggleFinger(e: Event) { + const an = (e.currentTarget as HTMLInputElement).checked; + if (fingerBusy) return; + fingerBusy = true; + try { + if (an) { + await auth.biometrieEinrichten(); + toast.show('Fingerabdruck eingerichtet — beim nächsten Start ohne Passwort', 'success'); + } else { + await auth.biometrieEntfernen(); + toast.show('Fingerabdruck-Anmeldung entfernt', 'success'); + } + } catch (err) { + const m = err instanceof Error ? err.message : 'Einrichten fehlgeschlagen'; + if (m !== 'abgebrochen') toast.show(m, 'error', 8000); + // Schalter zurückstellen: er darf nichts behaupten, was nicht gilt + await auth.biometrieStatusLaden(); + } finally { + fingerBusy = false; + } + } onMount(async () => { wifiDemo = await ladeDemoSchalter(); @@ -224,6 +254,31 @@

{auth.user?.canWrite ? 'Schreibrecht vorhanden' : 'Nur Lesezugriff'}

+ + + {#if auth.biometrieMoeglich} + + {:else if auth.biometrieGrund} +

Fingerabdruck-Anmeldung: {auth.biometrieGrund}

+ {/if} diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte index 8f20963..496d593 100644 --- a/src/routes/login/+page.svelte +++ b/src/routes/login/+page.svelte @@ -5,12 +5,36 @@ import { getServerUrl, setServerUrl, ApiError } from '$lib/api'; import { toast } from '$lib/toast.svelte'; import { Capacitor } from '@capacitor/core'; - import { Eye, EyeOff } from 'lucide-svelte'; + import { Eye, EyeOff, Fingerprint } from 'lucide-svelte'; let server = $state(getServerUrl()); let loginName = $state(''); let password = $state(''); let busy = $state(false); + let fingerBusy = $state(false); + + /** + * Fingerabdruck-Anmeldung. Erscheint nur, wenn auf diesem Gerät auch + * wirklich ein Token hinterlegt ist — ein Knopf, der erst beim Antippen + * sagt „nicht eingerichtet", wäre auf der Baustelle nur ärgerlich. + */ + async function mitFinger() { + if (fingerBusy) return; + fingerBusy = true; + try { + await auth.loginMitBiometrie(); + await sync.start(); + toast.show('Angemeldet', 'success'); + goto('/auftraege/'); + } catch (err) { + const m = err instanceof Error ? err.message : 'Fingerabdruck-Anmeldung fehlgeschlagen'; + // „abgebrochen" kommt vom Systemdialog, wenn der Benutzer selbst + // abbricht — das muss ihm niemand erklären. + if (m !== 'abgebrochen') toast.show(m, 'error', 8000); + } finally { + fingerBusy = false; + } + } // Server-URL nur auf dem Gerät nötig (im Browser läuft der Vite-Proxy) const needsServer = Capacitor.isNativePlatform(); @@ -121,5 +145,17 @@ > {busy ? 'Anmelden …' : 'Anmelden'} + + {#if auth.biometrieHinterlegt} + + {/if} diff --git a/tools/pruefe-sip.mjs b/tools/pruefe-sip.mjs new file mode 100644 index 0000000..a1b2a05 --- /dev/null +++ b/tools/pruefe-sip.mjs @@ -0,0 +1,144 @@ +/** + * Prueft die Bewertung der SIP-Erreichbarkeit gegen konstruierte Antworten. + * + * Der Kernsatz, der hier nachgerechnet wird: JEDE Statuszeile beweist eine + * antwortende Instanz. Eine 401 ("bitte anmelden") oder eine 405 ("Methode + * nicht erlaubt") darf deshalb NIE als Ausfall im Kundendokument landen - + * genau das waere die Falschaussage, wegen der ein Techniker am falschen Ende + * sucht. Ebenso wichtig: der Fall "nur ueber TCP kam etwas" muss auffallen, + * weil er auf einen UDP-Filter zeigt, waehrend die Anlage selbst laeuft. + * + * Start: node tools/pruefe-sip.mjs + */ +const { bewerteSip, fehlerText } = await import('../src/lib/tools/telefonie/sip-bewertung.ts'); + +const antwort = (transport, code, text, extra = {}) => ({ + transport, + statusCode: code, + statusText: text, + latenzMs: 12, + server: extra.server ?? null, + allow: extra.allow ?? null, + fehler: null, +}); +const stumm = (transport, grund = 'timeout') => ({ + transport, + statusCode: null, + statusText: null, + latenzMs: null, + server: null, + allow: null, + fehler: grund, +}); +const m = (versuche, host = 'fritz.box', port = 5060) => ({ host, ip: '192.168.1.1', port, versuche }); + +const faelle = [ + { + name: '200 OK ueber UDP -> GRUEN', + m: m([antwort('udp', 200, 'OK', { server: 'FRITZ!OS' })]), + soll: 0, + }, + { + name: '401 Unauthorized -> GRUEN + Anmeldung verlangt (Normalfall beim Provider)', + m: m([antwort('udp', 401, 'Unauthorized')], 'sip.stadtwerke-nms.de'), + soll: 0, + pruefe: (r) => r.anmeldungNoetig === true, + }, + { + name: '407 Proxy Authentication Required -> GRUEN + Anmeldung verlangt', + m: m([antwort('udp', 407, 'Proxy Authentication Required')]), + soll: 0, + pruefe: (r) => r.anmeldungNoetig === true, + }, + { + name: '405 Method Not Allowed -> GELB, aber ERREICHBAR (kein Ausfall!)', + m: m([antwort('udp', 405, 'Method Not Allowed')]), + soll: 1, + pruefe: (r) => r.statuszeile === '405 Method Not Allowed', + }, + { + name: '404 Not Found -> GELB (Instanz da, Ziel unbekannt)', + m: m([antwort('udp', 404, 'Not Found')]), + soll: 1, + }, + { + name: '403 Forbidden -> GELB (erreichbar, weist ab)', + m: m([antwort('udp', 403, 'Forbidden')]), + soll: 1, + }, + { + name: '503 Service Unavailable -> GELB (Weg in Ordnung, Gegenseite gestoert)', + m: m([antwort('udp', 503, 'Service Unavailable')]), + soll: 1, + }, + { + name: 'UDP stumm, TCP antwortet 200 -> GELB + Hinweis auf UDP-Filter', + m: m([stumm('udp'), antwort('tcp', 200, 'OK')]), + soll: 1, + pruefe: (r) => r.antwortWeg === 'tcp' && /UDP/.test(r.befund), + }, + { + name: 'UDP stumm, TCP antwortet 401 -> GELB (nicht gruen: der Filter bleibt ein Befund)', + m: m([stumm('udp'), antwort('tcp', 401, 'Unauthorized')]), + soll: 1, + pruefe: (r) => r.anmeldungNoetig === true, + }, + { + name: 'Beide Wege stumm -> ROT', + m: m([stumm('udp'), stumm('tcp')]), + soll: 2, + pruefe: (r) => r.statuszeile === null, + }, + { + name: 'Verbindung abgewiesen (Port zu) -> ROT, aber NICHT als "keine Antwort" betitelt', + m: m([stumm('udp'), stumm('tcp', 'abgewiesen')]), + soll: 2, + pruefe: (r) => /Port geschlossen/.test(r.kurz) && /erreichbar/.test(r.befund), + }, + { + name: 'Name nicht aufloesbar -> NICHT MESSBAR (DNS-Befund, keine Aussage ueber die Anlage)', + m: m([stumm('dns', 'name')], 'pbx.kunde.local'), + soll: 3, + }, + { + name: 'Antwort ist kein SIP -> ROT mit eigenem Befund (nicht "keine Antwort")', + m: m([stumm('udp', 'kein-sip')]), + soll: 2, + pruefe: (r) => /kein SIP/.test(r.kurz), + }, + { + name: 'Nicht-Standardport erscheint im Kurztext', + m: m([antwort('udp', 200, 'OK')], 'pbx.kunde.de', 5080), + soll: 0, + pruefe: (r) => r.kurz.includes('pbx.kunde.de:5080'), + }, +]; + +let fehler = 0; +for (const f of faelle) { + const r = bewerteSip(f.m); + const statusOk = r.status === f.soll; + const extraOk = f.pruefe ? f.pruefe(r) : true; + if (!statusOk || !extraOk) fehler++; + console.log(`${statusOk && extraOk ? 'OK ' : 'FEHL'} ${f.name}`); + console.log(` status=${r.status} (soll ${f.soll}) ${r.kurz}`); + if (!extraOk) console.log(` Zusatzpruefung fehlgeschlagen: ${r.befund}`); +} + +// Die Fehlertexte muessen den Port nennen - "keine Antwort" allein schickt den +// Techniker sonst auf die Suche ohne Anhaltspunkt. +const textFaelle = [ + ['timeout', 5060, /Port 5060/], + ['abgewiesen', 5080, /Port 5080/], + ['kein-sip', 5060, /kein SIP/], + ['name', 5060, /aufl/], +]; +for (const [grund, port, muster] of textFaelle) { + const t = fehlerText(grund, port); + const okk = muster.test(t); + if (!okk) fehler++; + console.log(`${okk ? 'OK ' : 'FEHL'} fehlerText("${grund}", ${port}) = "${t}"`); +} + +console.log(fehler === 0 ? 'ALLE FAELLE KORREKT' : `${fehler} FEHLER`); +process.exit(fehler === 0 ? 0 : 1);