feat(app): Schloss-Symbol fuer die Verbindung + abgelaufenes Zertifikat erlaubbar

Neben dem Zahnrad zeigt ein Schloss, wie sicher die Verbindung zu ARIA ist:
Schloss = verschluesselt und in Ordnung, Schloss mit ! = Zertifikat-Problem
(abgelaufen oder abgelehnt), durchgestrichen = unverschluesselt (TLS-Fallback
oder so eingestellt). Antippen erklaert es in einfachen Worten; einmal pro
App-Start kommt ein Hinweis, wenn das Zertifikat abgelaufen ist.

Einstellungen → Verbindung: "Abgelaufenes Zertifikat akzeptieren". Toleriert
GENAU das Ablaufdatum: die Kette wird zum Zeitpunkt kurz vor dem Ablauf gegen
die System-CAs geprueft, der Hostname weiter von OkHttp. Selbstsignierte oder
fremde Zertifikate kommen auch mit Schalter nicht durch (gegen das echte,
abgelaufene RVS-Zertifikat und badssl.com getestet). Gilt fuer WebSocket und
fetch (z.B. App-Update vom RVS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 17:31:51 +02:00
co-authored by Claude Opus 5.5
parent 7da552f249
commit 3b63831c86
7 changed files with 514 additions and 3 deletions
+21 -3
View File
@@ -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 = () => {
<StatusBar barStyle="light-content" backgroundColor="#0D0D1A" />
<NavigationContainer theme={DarkTheme}>
<Tab.Navigator
tabBar={(props) => (
// Tab-Leiste + Schloss-Symbol rechts neben dem Zahnrad
<View style={styles.tabBarRow}>
<View style={styles.tabBarTabs}>
<BottomTabBar {...props} />
</View>
<SecurityLock onOpenSettings={() => props.navigation.navigate('Einstellungen')} />
</View>
)}
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,
@@ -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()
@@ -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<X509TrustManager>().first()
}
private fun remember(status: String, chain: Array<X509Certificate>?, detail: String = "") {
lastStatus = status
lastDetail = detail
val leaf = chain?.firstOrNull()
lastNotAfter = leaf?.notAfter?.time ?: 0
lastSubject = leaf?.subjectX500Principal?.name ?: ""
}
private fun isExpired(chain: Array<X509Certificate>): 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<X509Certificate>) {
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<X509Certificate>, 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<X509Certificate>, authType: String) =
check(chain) { inner.checkServerTrusted(chain, authType) }
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String, socket: Socket?) =
check(chain) {
if (ext != null) ext.checkServerTrusted(chain, authType, socket)
else inner.checkServerTrusted(chain, authType)
}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String, engine: SSLEngine?) =
check(chain) {
if (ext != null) ext.checkServerTrusted(chain, authType, engine)
else inner.checkServerTrusted(chain, authType)
}
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) =
inner.checkClientTrusted(chain, authType)
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String, socket: Socket?) =
if (ext != null) ext.checkClientTrusted(chain, authType, socket) else inner.checkClientTrusted(chain, authType)
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String, engine: SSLEngine?) =
if (ext != null) ext.checkClientTrusted(chain, authType, engine) else inner.checkClientTrusted(chain, authType)
override fun getAcceptedIssuers(): Array<X509Certificate> = 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)
}
}
@@ -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<NativeModule> {
return listOf(TlsTrustModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
+150
View File
@@ -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<Props> = ({ onOpenSettings }) => {
const [sec, setSec] = useState<SecurityState>(rvs.getSecurity());
const [open, setOpen] = useState(false);
const alerted = useRef<Set<string>>(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 <View style={styles.slot} />;
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 (
<>
<TouchableOpacity style={styles.slot} onPress={() => setOpen(true)} accessibilityLabel={title}>
<View>
<Text style={[styles.lock, crossed && styles.lockDim]}>{'🔒'}</Text>
{crossed && <View style={styles.strike} />}
{warn && <Text style={styles.bang}>!</Text>}
</View>
</TouchableOpacity>
<Modal visible={open} transparent animationType="fade" onRequestClose={() => setOpen(false)}>
<TouchableOpacity style={styles.overlay} activeOpacity={1} onPress={() => setOpen(false)}>
<View style={styles.box}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.text}>{text}</Text>
<View style={styles.legend}>
<Text style={styles.legendRow}>{'🔒'} verschlüsselt, alles in Ordnung</Text>
<Text style={styles.legendRow}>{'🔒'}<Text style={styles.legendBang}>!</Text> Problem mit dem Zertifikat</Text>
<Text style={styles.legendRow}>{'🔒'}<Text style={styles.legendStrike}>/</Text> nicht verschlüsselt</Text>
</View>
<View style={styles.buttons}>
{(sec.kind === 'cert_error' || sec.kind === 'expired' || sec.kind === 'fallback') && (
<TouchableOpacity style={styles.btn} onPress={() => { setOpen(false); onOpenSettings(); }}>
<Text style={styles.btnText}>Einstellungen</Text>
</TouchableOpacity>
)}
<TouchableOpacity style={[styles.btn, styles.btnPrimary]} onPress={() => setOpen(false)}>
<Text style={styles.btnText}>OK</Text>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
</Modal>
</>
);
};
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;
+26
View File
@@ -178,6 +178,14 @@ const SettingsScreen: React.FC = () => {
const winDims = useWindowDimensions();
const [connectionState, setConnectionState] = useState<ConnectionState>('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 = () => {
<TouchableOpacity style={styles.connectButton} onPress={connectManually}>
<Text style={styles.connectButtonText}>Verbinden</Text>
</TouchableOpacity>
<View style={[styles.toggleRow, {marginTop: 16, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 12}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Abgelaufenes Zertifikat akzeptieren</Text>
<Text style={styles.toggleHint}>
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.
</Text>
</View>
<Switch
value={allowExpiredCert}
onValueChange={handleAllowExpiredCert}
trackColor={{ false: '#2A2A3E', true: '#FF9F0A' }}
thumbColor={allowExpiredCert ? '#FFFFFF' : '#666680'}
/>
</View>
</View>
{/* === Verbindungslog === */}
+113
View File
@@ -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<void> {
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<void> {
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<boolean> {
try { return !!(await TlsTrust?.getStatus())?.allowExpired; } catch (_) { return false; }
}
async setAllowExpiredCert(value: boolean): Promise<void> {
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...`);