diff --git a/android/App.tsx b/android/App.tsx
index 2baf3f8..956f2d4 100644
--- a/android/App.tsx
+++ b/android/App.tsx
@@ -6,15 +6,16 @@
*/
import React, { useEffect } from 'react';
-import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
+import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet, View } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
-import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
+import { BottomTabBar, createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import WorkspaceScreen from './src/workspace/WorkspaceScreen';
import SettingsScreen from './src/screens/SettingsScreen';
import ViewModeToggle from './src/components/ViewModeToggle';
+import SecurityLock from './src/components/SecurityLock';
import rvs from './src/services/rvs';
import { initLogger, installGlobalCrashReporter } from './src/services/logger';
import { acquireBackgroundAudio } from './src/services/backgroundAudio';
@@ -138,6 +139,15 @@ const App: React.FC = () => {
(
+ // Tab-Leiste + Schloss-Symbol rechts neben dem Zahnrad
+
+
+
+
+ props.navigation.navigate('Einstellungen')} />
+
+ )}
screenOptions={({ route }) => ({
headerStyle: styles.header,
headerTitleStyle: styles.headerTitle,
@@ -205,10 +215,18 @@ const styles = StyleSheet.create({
fontSize: 18,
fontWeight: '700',
},
- tabBar: {
+ tabBarRow: {
+ flexDirection: 'row',
backgroundColor: '#12122A',
borderTopColor: '#1E1E2E',
borderTopWidth: 1,
+ },
+ tabBarTabs: {
+ flex: 1,
+ },
+ tabBar: {
+ backgroundColor: '#12122A',
+ borderTopWidth: 0,
height: 60,
paddingBottom: 6,
paddingTop: 4,
diff --git a/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt b/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt
index 28478ab..30943a1 100644
--- a/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt
+++ b/android/android/app/src/main/java/com/ariacockpit/MainApplication.kt
@@ -26,6 +26,7 @@ class MainApplication : Application(), ReactApplication {
add(PhoneCallPackage())
add(BackgroundAudioPackage())
add(ShareIntentPackage())
+ add(TlsTrustPackage())
}
override fun getJSMainModuleName(): String = "index"
@@ -42,6 +43,8 @@ class MainApplication : Application(), ReactApplication {
override fun onCreate() {
super.onCreate()
SoLoader.init(this, false)
+ // Vor der ersten Verbindung: Zertifikatspruefung (abgelaufen erlaubt?)
+ TlsTrust.install(this)
if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
// If you opted-in for the New Architecture, we load the native entry point for this app.
load()
diff --git a/android/android/app/src/main/java/com/ariacockpit/TlsTrustModule.kt b/android/android/app/src/main/java/com/ariacockpit/TlsTrustModule.kt
new file mode 100644
index 0000000..57994f7
--- /dev/null
+++ b/android/android/app/src/main/java/com/ariacockpit/TlsTrustModule.kt
@@ -0,0 +1,185 @@
+package com.ariacockpit
+
+import android.content.Context
+import com.facebook.react.bridge.Arguments
+import com.facebook.react.bridge.Promise
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.bridge.ReactContextBaseJavaModule
+import com.facebook.react.bridge.ReactMethod
+import com.facebook.react.modules.network.NetworkingModule
+import com.facebook.react.modules.websocket.WebSocketModule
+import java.net.Socket
+import java.security.KeyStore
+import java.security.cert.CertPathValidator
+import java.security.cert.CertificateException
+import java.security.cert.CertificateExpiredException
+import java.security.cert.CertificateFactory
+import java.security.cert.PKIXParameters
+import java.security.cert.X509Certificate
+import java.util.Date
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLEngine
+import javax.net.ssl.TrustManagerFactory
+import javax.net.ssl.X509ExtendedTrustManager
+import javax.net.ssl.X509TrustManager
+
+/**
+ * Zertifikatspruefung fuer RVS-Verbindung (WebSocket) und fetch.
+ *
+ * Normal pruefen wir wie Android selbst. Mit dem Schalter "Abgelaufenes
+ * Zertifikat akzeptieren" wird GENAU EIN Fehler toleriert: das Ablaufdatum.
+ * Kette und Aussteller werden dann zum Zeitpunkt kurz vor dem Ablauf geprueft
+ * (war das Zertifikat damals gueltig, ist es echt), der Hostname prueft OkHttp
+ * ohnehin separat. Ein fremdes oder selbstgebautes Zertifikat kommt also auch
+ * mit Schalter nicht durch — sonst koennte sich jeder dazwischenhaengen.
+ *
+ * Nebenbei merkt sich das Modul, wie die letzte Pruefung ausging, damit die
+ * App das Schloss-Symbol richtig zeigen kann.
+ */
+object TlsTrust {
+ private const val PREFS = "aria_tls"
+ private const val KEY_ALLOW_EXPIRED = "allow_expired"
+
+ // ok | expired_accepted | expired | invalid | unknown
+ @Volatile var lastStatus: String = "unknown"
+ @Volatile var lastDetail: String = ""
+ @Volatile var lastNotAfter: Long = 0
+ @Volatile var lastSubject: String = ""
+ @Volatile var allowExpired: Boolean = false
+ private set
+
+ private var appContext: Context? = null
+
+ fun install(context: Context) {
+ appContext = context.applicationContext
+ allowExpired = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ .getBoolean(KEY_ALLOW_EXPIRED, false)
+ try {
+ val tm = ExpiryTolerantTrustManager(defaultTrustManager())
+ val ssl = SSLContext.getInstance("TLS").apply { init(null, arrayOf(tm), null) }
+ val factory = ssl.socketFactory
+ WebSocketModule.setCustomClientBuilder { b -> b.sslSocketFactory(factory, tm) }
+ NetworkingModule.setCustomClientBuilder { b -> b.sslSocketFactory(factory, tm) }
+ } catch (e: Exception) {
+ // Lieber normale Pruefung als gar keine Verbindung
+ android.util.Log.w("TlsTrust", "Eigene Zertifikatspruefung nicht aktiv: ${e.message}")
+ }
+ }
+
+ fun saveAllowExpired(value: Boolean) {
+ allowExpired = value
+ appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
+ ?.edit()?.putBoolean(KEY_ALLOW_EXPIRED, value)?.apply()
+ }
+
+ private fun defaultTrustManager(): X509TrustManager {
+ val tmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
+ tmf.init(null as KeyStore?)
+ return tmf.trustManagers.filterIsInstance().first()
+ }
+
+ private fun remember(status: String, chain: Array?, detail: String = "") {
+ lastStatus = status
+ lastDetail = detail
+ val leaf = chain?.firstOrNull()
+ lastNotAfter = leaf?.notAfter?.time ?: 0
+ lastSubject = leaf?.subjectX500Principal?.name ?: ""
+ }
+
+ private fun isExpired(chain: Array): Boolean =
+ chain.any { c ->
+ try { c.checkValidity(); false } catch (_: CertificateExpiredException) { true } catch (_: Exception) { false }
+ }
+
+ /** Kette gegen die System-Zertifizierungsstellen pruefen — zum Zeitpunkt
+ * kurz vor dem fruehesten Ablauf in der Kette. */
+ private fun chainValidBeforeExpiry(chain: Array) {
+ val ks = KeyStore.getInstance("AndroidCAStore").apply { load(null) }
+ val params = PKIXParameters(ks).apply {
+ isRevocationEnabled = false
+ date = Date(chain.minOf { it.notAfter.time } - 60_000)
+ }
+ // Mitgeschickte Wurzel (selbstsigniert) gehoert nicht in den Pfad — aber NIE
+ // das Server-Zertifikat selbst: ein leerer Pfad gilt als gueltig, ein
+ // selbstgebautes Zertifikat kaeme sonst durch.
+ if (chain.isEmpty()) throw CertificateException("Leere Zertifikatskette")
+ val path = listOf(chain[0]) + chain.drop(1).filter { it.subjectX500Principal != it.issuerX500Principal }
+ val certPath = CertificateFactory.getInstance("X.509").generateCertPath(path)
+ CertPathValidator.getInstance("PKIX").validate(certPath, params)
+ }
+
+ private class ExpiryTolerantTrustManager(private val inner: X509TrustManager) : X509ExtendedTrustManager() {
+ private val ext = inner as? X509ExtendedTrustManager
+
+ private fun check(chain: Array, run: () -> Unit) {
+ try {
+ run()
+ remember("ok", chain)
+ } catch (e: CertificateException) {
+ if (!isExpired(chain)) {
+ remember("invalid", chain, e.message ?: "")
+ throw e
+ }
+ if (!allowExpired) {
+ remember("expired", chain, e.message ?: "")
+ throw e
+ }
+ try {
+ chainValidBeforeExpiry(chain)
+ } catch (inner: Exception) {
+ // Abgelaufen UND sonst nicht in Ordnung → nicht akzeptieren
+ remember("invalid", chain, inner.message ?: "")
+ throw e
+ }
+ remember("expired_accepted", chain)
+ }
+ }
+
+ override fun checkServerTrusted(chain: Array, authType: String) =
+ check(chain) { inner.checkServerTrusted(chain, authType) }
+
+ override fun checkServerTrusted(chain: Array, authType: String, socket: Socket?) =
+ check(chain) {
+ if (ext != null) ext.checkServerTrusted(chain, authType, socket)
+ else inner.checkServerTrusted(chain, authType)
+ }
+
+ override fun checkServerTrusted(chain: Array, authType: String, engine: SSLEngine?) =
+ check(chain) {
+ if (ext != null) ext.checkServerTrusted(chain, authType, engine)
+ else inner.checkServerTrusted(chain, authType)
+ }
+
+ override fun checkClientTrusted(chain: Array, authType: String) =
+ inner.checkClientTrusted(chain, authType)
+
+ override fun checkClientTrusted(chain: Array, authType: String, socket: Socket?) =
+ if (ext != null) ext.checkClientTrusted(chain, authType, socket) else inner.checkClientTrusted(chain, authType)
+
+ override fun checkClientTrusted(chain: Array, authType: String, engine: SSLEngine?) =
+ if (ext != null) ext.checkClientTrusted(chain, authType, engine) else inner.checkClientTrusted(chain, authType)
+
+ override fun getAcceptedIssuers(): Array = inner.acceptedIssuers
+ }
+}
+
+class TlsTrustModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
+ override fun getName() = "TlsTrust"
+
+ @ReactMethod
+ fun setAllowExpired(value: Boolean, promise: Promise) {
+ TlsTrust.saveAllowExpired(value)
+ promise.resolve(value)
+ }
+
+ @ReactMethod
+ fun getStatus(promise: Promise) {
+ val m = Arguments.createMap()
+ m.putString("status", TlsTrust.lastStatus)
+ m.putString("detail", TlsTrust.lastDetail)
+ m.putDouble("notAfter", TlsTrust.lastNotAfter.toDouble())
+ m.putString("subject", TlsTrust.lastSubject)
+ m.putBoolean("allowExpired", TlsTrust.allowExpired)
+ promise.resolve(m)
+ }
+}
diff --git a/android/android/app/src/main/java/com/ariacockpit/TlsTrustPackage.kt b/android/android/app/src/main/java/com/ariacockpit/TlsTrustPackage.kt
new file mode 100644
index 0000000..5a38a0f
--- /dev/null
+++ b/android/android/app/src/main/java/com/ariacockpit/TlsTrustPackage.kt
@@ -0,0 +1,16 @@
+package com.ariacockpit
+
+import com.facebook.react.ReactPackage
+import com.facebook.react.bridge.NativeModule
+import com.facebook.react.bridge.ReactApplicationContext
+import com.facebook.react.uimanager.ViewManager
+
+class TlsTrustPackage : ReactPackage {
+ override fun createNativeModules(reactContext: ReactApplicationContext): List {
+ return listOf(TlsTrustModule(reactContext))
+ }
+
+ override fun createViewManagers(reactContext: ReactApplicationContext): List> {
+ return emptyList()
+ }
+}
diff --git a/android/src/components/SecurityLock.tsx b/android/src/components/SecurityLock.tsx
new file mode 100644
index 0000000..93855eb
--- /dev/null
+++ b/android/src/components/SecurityLock.tsx
@@ -0,0 +1,150 @@
+/**
+ * Schloss-Symbol in der Tab-Leiste (neben dem Zahnrad): zeigt, ob die
+ * Verbindung zu ARIA verschluesselt ist.
+ *
+ * 🔒 verschluesselt, alles in Ordnung
+ * 🔒 + ! Problem mit dem Zertifikat (abgelaufen / abgelehnt)
+ * 🔒 durchgestrichen keine Verschluesselung (Fallback oder so eingestellt)
+ *
+ * Antippen erklaert in einfachen Worten, was das Symbol bedeutet.
+ * Einmal pro App-Start gibt's ausserdem einen Hinweis, wenn das Zertifikat
+ * abgelaufen ist — damit es nicht unbemerkt bleibt.
+ */
+
+import React, { useEffect, useRef, useState } from 'react';
+import { Alert, Modal, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
+import rvs, { SecurityState } from '../services/rvs';
+
+interface Props {
+ onOpenSettings: () => void;
+}
+
+const fmtDate = (ms: number) =>
+ ms ? new Date(ms).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric',
+ hour: '2-digit', minute: '2-digit' }) : 'unbekannt';
+
+const SecurityLock: React.FC = ({ onOpenSettings }) => {
+ const [sec, setSec] = useState(rvs.getSecurity());
+ const [open, setOpen] = useState(false);
+ const alerted = useRef>(new Set());
+
+ useEffect(() => rvs.onSecurityChange(setSec), []);
+
+ // Einmaliger Hinweis pro App-Start
+ useEffect(() => {
+ if (sec.kind === 'expired' && !alerted.current.has('expired')) {
+ alerted.current.add('expired');
+ Alert.alert('Zertifikat abgelaufen',
+ `Die Verbindung zu ARIA ist weiterhin verschlüsselt, aber das Sicherheits-Zertifikat `
+ + `des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen. Bitte dem Admin Bescheid geben.`);
+ } else if (sec.kind === 'cert_error' && sec.certStatus === 'expired' && !alerted.current.has('cert_error')) {
+ alerted.current.add('cert_error');
+ Alert.alert('Keine Verbindung — Zertifikat abgelaufen',
+ `Das Sicherheits-Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen, `
+ + `deshalb verbindet sich die App nicht.\n\nIn den Einstellungen unter „Verbindung“ kann man `
+ + `abgelaufene Zertifikate vorübergehend erlauben.`,
+ [{ text: 'OK' }, { text: 'Einstellungen', onPress: onOpenSettings }]);
+ }
+ }, [sec, onOpenSettings]);
+
+ const warn = sec.kind === 'expired' || sec.kind === 'cert_error';
+ const crossed = sec.kind === 'fallback' || sec.kind === 'plain';
+ if (sec.kind === 'unknown') return ;
+
+ let title = '';
+ let text = '';
+ if (sec.kind === 'secure') {
+ title = 'Verbindung verschlüsselt';
+ text = 'Alles in Ordnung. Die Verbindung zu ARIA ist verschlüsselt und das Zertifikat des Servers ist gültig.';
+ } else if (sec.kind === 'expired') {
+ title = 'Zertifikat abgelaufen';
+ text = `Die Verbindung ist verschlüsselt, aber das Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} `
+ + 'abgelaufen. Die App akzeptiert es, weil das in den Einstellungen so erlaubt ist. '
+ + 'Geprüft wird trotzdem, dass es wirklich das Zertifikat dieses Servers ist.\n\n'
+ + 'Das Zertifikat sollte erneuert werden.';
+ } else if (sec.kind === 'cert_error') {
+ title = 'Keine Verbindung — Zertifikat-Problem';
+ text = sec.certStatus === 'expired'
+ ? `Das Zertifikat des Servers ist seit ${fmtDate(sec.notAfter)} abgelaufen. Deshalb verbindet sich die App nicht.\n\n`
+ + 'In den Einstellungen unter „Verbindung“ kann man abgelaufene Zertifikate vorübergehend erlauben.'
+ : 'Das Zertifikat des Servers ist ungültig oder gehört nicht zu diesem Server. Zur Sicherheit verbindet sich '
+ + 'die App nicht.' + (sec.reason ? `\n\nGrund: ${sec.reason}` : '');
+ } else if (sec.kind === 'fallback') {
+ title = 'Nicht verschlüsselt (Notlösung)';
+ text = 'Die verschlüsselte Verbindung hat nicht geklappt. Die App ist deshalb ersatzweise UNVERSCHLÜSSELT '
+ + 'verbunden (TLS-Fallback).'
+ + (sec.certStatus === 'expired' ? `\n\nGrund: Das Zertifikat ist seit ${fmtDate(sec.notAfter)} abgelaufen.`
+ : sec.certStatus === 'invalid' ? '\n\nGrund: Das Zertifikat des Servers ist ungültig.'
+ : '\n\nGrund: Die verschlüsselte Verbindung kam nicht zustande.');
+ } else if (sec.kind === 'plain') {
+ title = 'Nicht verschlüsselt';
+ text = 'Für diese Verbindung ist keine Verschlüsselung eingestellt. Das ist nur im eigenen Heimnetz in Ordnung.';
+ }
+
+ return (
+ <>
+ setOpen(true)} accessibilityLabel={title}>
+
+ {'🔒'}
+ {crossed && }
+ {warn && !}
+
+
+
+ setOpen(false)}>
+ setOpen(false)}>
+
+ {title}
+ {text}
+
+
+ {'🔒'} verschlüsselt, alles in Ordnung
+ {'🔒'}! Problem mit dem Zertifikat
+ {'🔒'}/ nicht verschlüsselt
+
+
+
+ {(sec.kind === 'cert_error' || sec.kind === 'expired' || sec.kind === 'fallback') && (
+ { setOpen(false); onOpenSettings(); }}>
+ Einstellungen
+
+ )}
+ setOpen(false)}>
+ OK
+
+
+
+
+
+ >
+ );
+};
+
+const styles = StyleSheet.create({
+ slot: { width: 52, alignItems: 'center', justifyContent: 'center' },
+ lock: { fontSize: 20 },
+ lockDim: { opacity: 0.7 },
+ strike: {
+ position: 'absolute', left: -3, right: -3, top: '50%', height: 2.5,
+ backgroundColor: '#FF3B30', transform: [{ rotate: '-45deg' }],
+ },
+ bang: {
+ position: 'absolute', right: -8, top: -6, minWidth: 15, height: 15, borderRadius: 8,
+ backgroundColor: '#FF9F0A', color: '#000', fontSize: 11, fontWeight: '900',
+ textAlign: 'center', lineHeight: 15, overflow: 'hidden',
+ },
+ overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.6)', justifyContent: 'center', padding: 24 },
+ box: { backgroundColor: '#1A1A2E', borderRadius: 14, padding: 20 },
+ title: { color: '#FFFFFF', fontSize: 17, fontWeight: '700', marginBottom: 10 },
+ text: { color: '#C8C8D8', fontSize: 14, lineHeight: 20 },
+ legend: { marginTop: 16, paddingTop: 12, borderTopWidth: 1, borderTopColor: '#2A2A3E' },
+ legendRow: { color: '#9090A8', fontSize: 13, marginVertical: 2 },
+ legendBang: { color: '#FF9F0A', fontWeight: '900' },
+ legendStrike: { color: '#FF3B30', fontWeight: '900' },
+ buttons: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 16, gap: 10 },
+ btn: { paddingVertical: 9, paddingHorizontal: 16, borderRadius: 8, backgroundColor: '#2A2A3E' },
+ btnPrimary: { backgroundColor: '#0096FF' },
+ btnText: { color: '#FFFFFF', fontWeight: '600' },
+});
+
+export default SecurityLock;
diff --git a/android/src/screens/SettingsScreen.tsx b/android/src/screens/SettingsScreen.tsx
index dc1a01c..5eee6b5 100644
--- a/android/src/screens/SettingsScreen.tsx
+++ b/android/src/screens/SettingsScreen.tsx
@@ -178,6 +178,14 @@ const SettingsScreen: React.FC = () => {
const winDims = useWindowDimensions();
const [connectionState, setConnectionState] = useState('disconnected');
const [manualToken, setManualToken] = useState('');
+ // Abgelaufenes Server-Zertifikat trotzdem akzeptieren (Kette + Hostname werden weiter geprueft)
+ const [allowExpiredCert, setAllowExpiredCert] = useState(false);
+ useEffect(() => { rvs.getAllowExpiredCert().then(setAllowExpiredCert); }, []);
+ const handleAllowExpiredCert = useCallback(async (value: boolean) => {
+ setAllowExpiredCert(value);
+ await rvs.setAllowExpiredCert(value);
+ rvs.connect(true); // gleich mit der neuen Regel neu verbinden
+ }, []);
const [manualHost, setManualHost] = useState('');
const [manualPort, setManualPort] = useState('8765');
const [currentMode, setCurrentMode] = useState('normal');
@@ -1450,6 +1458,24 @@ const SettingsScreen: React.FC = () => {
Verbinden
+
+
+
+ Abgelaufenes Zertifikat akzeptieren
+
+ Notlösung, wenn das Zertifikat des Servers abgelaufen ist. Die Verbindung bleibt
+ verschlüsselt, und es wird weiter geprüft, dass es wirklich das Zertifikat dieses
+ Servers ist — nur das Ablaufdatum wird ignoriert. Wieder ausschalten, sobald das
+ Zertifikat erneuert ist.
+
+
+
+
{/* === Verbindungslog === */}
diff --git a/android/src/services/rvs.ts b/android/src/services/rvs.ts
index 04f2728..9b188c6 100644
--- a/android/src/services/rvs.ts
+++ b/android/src/services/rvs.ts
@@ -7,6 +7,9 @@
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
+import { NativeModules } from 'react-native';
+
+const TlsTrust: any = NativeModules.TlsTrust;
// --- Typen ---
@@ -27,6 +30,29 @@ export interface ConnectionConfig {
useTLS: boolean;
}
+/**
+ * Wie sicher ist die Verbindung? Fuer das Schloss-Symbol in der Tab-Leiste.
+ * secure — verschluesselt, Zertifikat in Ordnung
+ * expired — verschluesselt, aber das Zertifikat ist abgelaufen (per Schalter erlaubt)
+ * cert_error— keine Verbindung, weil das Zertifikat abgelehnt wurde
+ * fallback — verbunden OHNE Verschluesselung, weil TLS fehlschlug
+ * plain — verbunden ohne Verschluesselung, so konfiguriert
+ * unknown — (noch) nichts Genaues
+ */
+export type SecurityKind = 'secure' | 'expired' | 'cert_error' | 'fallback' | 'plain' | 'unknown';
+
+export interface SecurityState {
+ kind: SecurityKind;
+ /** Pruefergebnis der App: ok | expired_accepted | expired | invalid | unknown */
+ certStatus: string;
+ /** Ablaufdatum des Server-Zertifikats (ms), 0 = unbekannt */
+ notAfter: number;
+ /** Bei fallback/cert_error: warum TLS nicht ging */
+ reason: string;
+}
+
+type SecurityCallback = (state: SecurityState) => void;
+
type MessageCallback = (message: RVSMessage) => void;
type StateCallback = (state: ConnectionState) => void;
@@ -64,6 +90,10 @@ class RVSConnection {
private logListeners: LogCallback[] = [];
private connectionLog: ConnectionLogEntry[] = [];
private usingTLSFallback: boolean = false;
+ private securityListeners: SecurityCallback[] = [];
+ private security: SecurityState = { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' };
+ /** Letzter Zertifikats-Fehler beim wss://-Versuch (bleibt bis wss:// wieder klappt) */
+ private certFailure: { status: string; notAfter: number; detail: string } | null = null;
// --- Konfiguration ---
@@ -216,6 +246,7 @@ class RVSConnection {
const tlsInfo = this.usingTLSFallback ? ' (TLS-Fallback: ws://)' : '';
this.log('info', `Verbunden${tlsInfo}`);
this.setState('connected');
+ this.updateSecurityOnOpen(useTLS);
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
this.startHeartbeat();
};
@@ -239,6 +270,10 @@ class RVSConnection {
this.clearTimers();
this.ws = null;
this.setState('disconnected');
+ this.setSecurity(this.certFailure
+ ? { kind: 'cert_error', certStatus: this.certFailure.status, notAfter: this.certFailure.notAfter,
+ reason: this.certFailure.detail }
+ : { kind: 'unknown', certStatus: 'unknown', notAfter: 0, reason: '' });
// Sticky-Fallback-Reset: beim naechsten Reconnect wieder primary
// (wss://) versuchen statt fuer immer auf ws:// zu kleben. War
@@ -258,6 +293,7 @@ class RVSConnection {
this.ws.onerror = (error) => {
const errorMsg = (error as any)?.message || 'Unbekannter Fehler';
this.log('error', `WebSocket-Fehler: ${errorMsg}`);
+ if (useTLS) this.checkCertFailure(errorMsg);
// TLS-Fallback: Wenn wss:// fehlschlaegt, auf ws:// wechseln
if (this.config?.useTLS && !this.usingTLSFallback) {
@@ -287,6 +323,83 @@ class RVSConnection {
}
}
+ // --- Verbindungs-Sicherheit (Schloss-Symbol) ---
+
+ getSecurity(): SecurityState {
+ return this.security;
+ }
+
+ onSecurityChange(callback: SecurityCallback): () => void {
+ this.securityListeners.push(callback);
+ return () => {
+ this.securityListeners = this.securityListeners.filter(cb => cb !== callback);
+ };
+ }
+
+ private setSecurity(next: SecurityState): void {
+ const cur = this.security;
+ if (cur.kind === next.kind && cur.certStatus === next.certStatus
+ && cur.notAfter === next.notAfter && cur.reason === next.reason) return;
+ this.security = next;
+ this.securityListeners.forEach(cb => cb(next));
+ }
+
+ private async nativeCertStatus(): Promise<{ status: string; notAfter: number; detail: string }> {
+ try {
+ const r = await TlsTrust?.getStatus();
+ return { status: r?.status || 'unknown', notAfter: Number(r?.notAfter) || 0, detail: r?.detail || '' };
+ } catch (_) {
+ return { status: 'unknown', notAfter: 0, detail: '' };
+ }
+ }
+
+ private async updateSecurityOnOpen(viaTLS: boolean): Promise {
+ if (viaTLS) {
+ this.certFailure = null;
+ const c = await this.nativeCertStatus();
+ if (c.status === 'expired_accepted') {
+ this.log('warn', `Zertifikat abgelaufen (${new Date(c.notAfter).toLocaleDateString()}) — per Einstellung akzeptiert`);
+ }
+ this.setSecurity({ kind: c.status === 'expired_accepted' ? 'expired' : 'secure',
+ certStatus: c.status, notAfter: c.notAfter, reason: '' });
+ } else if (this.config?.useTLS) {
+ // wss:// ging nicht, ws:// schon → unverschluesselt per Fallback
+ this.setSecurity({ kind: 'fallback', certStatus: this.certFailure?.status || 'unknown',
+ notAfter: this.certFailure?.notAfter || 0, reason: this.certFailure?.detail || '' });
+ } else {
+ this.setSecurity({ kind: 'plain', certStatus: 'unknown', notAfter: 0, reason: '' });
+ }
+ }
+
+ /** Lag der wss://-Fehler am Zertifikat? Dann merken (fuer Schloss + Hinweis). */
+ private async checkCertFailure(errorMsg: string): Promise {
+ if (!/cert|trust anchor|chain validation|handshake|ssl/i.test(errorMsg)) return;
+ const c = await this.nativeCertStatus();
+ if (c.status !== 'expired' && c.status !== 'invalid') return;
+ this.certFailure = { status: c.status, notAfter: c.notAfter, detail: c.detail || errorMsg };
+ this.log('error', c.status === 'expired'
+ ? `Server-Zertifikat abgelaufen seit ${new Date(c.notAfter).toLocaleString()}`
+ : 'Server-Zertifikat ungueltig');
+ if (this.state !== 'connected') {
+ this.setSecurity({ kind: 'cert_error', certStatus: c.status, notAfter: c.notAfter,
+ reason: this.certFailure.detail });
+ } else if (this.security.kind === 'fallback') {
+ // ws://-Fallback war schneller verbunden als diese Pruefung — Grund nachtragen
+ this.setSecurity({ ...this.security, certStatus: c.status, notAfter: c.notAfter,
+ reason: this.certFailure.detail });
+ }
+ }
+
+ /** Schalter "Abgelaufenes Zertifikat akzeptieren" */
+ async getAllowExpiredCert(): Promise {
+ try { return !!(await TlsTrust?.getStatus())?.allowExpired; } catch (_) { return false; }
+ }
+
+ async setAllowExpiredCert(value: boolean): Promise {
+ try { await TlsTrust?.setAllowExpired(value); } catch (_) {}
+ this.log('info', `Abgelaufene Zertifikate ${value ? 'werden akzeptiert' : 'werden abgelehnt'}`);
+ }
+
/** Reconnect mit exponentiellem Backoff planen */
private scheduleReconnect(): void {
this.log('info', `Reconnect in ${this.reconnectDelay / 1000}s...`);