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