Einstellungen, zwischen Runtime-Konfiguration und App-Onboarding: alle Verbindungen im Raum des konfigurierten Tokens — Art, Name, Details, Adresse, verbunden seit, zuletzt aktiv. RVS merkt sich pro Verbindung, wer sie ist: aus Nachrichten, die ohnehin fliessen (sat_hello, host_hello, worker_hello, update_check der App, llm_cluster_*), oder aus dem neuen client_hello (Bridge, Diagnostic, App mit Geraetename). Adresse aus X-Forwarded-For hinter einem Reverse-Proxy. room_clients_query liefert NUR den eigenen Raum; room_clients von Clients wird nicht weitergereicht. Typen in der Allow-List ergaenzt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
513 lines
18 KiB
TypeScript
513 lines
18 KiB
TypeScript
/**
|
|
* RVS (Rendezvous Server) - WebSocket-Verbindungsmanager
|
|
*
|
|
* Verwaltet die persistente WebSocket-Verbindung zwischen der ARIA Cockpit App
|
|
* und dem Rendezvous Server. Unterstützt Auto-Reconnect, Heartbeat und
|
|
* typisierte Nachrichten.
|
|
*/
|
|
|
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
import { NativeModules, Platform } from 'react-native';
|
|
|
|
const TlsTrust: any = NativeModules.TlsTrust;
|
|
|
|
// --- Typen ---
|
|
|
|
export type ConnectionState = 'connecting' | 'connected' | 'disconnected';
|
|
|
|
export type MessageType = 'chat' | 'audio' | 'file' | 'location' | 'mode' | 'log' | 'event' | 'update_available' | string;
|
|
|
|
export interface RVSMessage {
|
|
type: MessageType;
|
|
payload: Record<string, unknown>;
|
|
timestamp: number;
|
|
}
|
|
|
|
export interface ConnectionConfig {
|
|
host: string;
|
|
port: number;
|
|
token: string;
|
|
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;
|
|
|
|
/** Einzelner Eintrag im Verbindungslog */
|
|
export interface ConnectionLogEntry {
|
|
timestamp: number;
|
|
level: 'info' | 'warn' | 'error';
|
|
message: string;
|
|
}
|
|
|
|
type LogCallback = (entry: ConnectionLogEntry) => void;
|
|
|
|
// --- Konstanten ---
|
|
|
|
const HEARTBEAT_INTERVAL_MS = 25_000;
|
|
const INITIAL_RECONNECT_DELAY_MS = 1_000;
|
|
const MAX_RECONNECT_DELAY_MS = 30_000;
|
|
const RECONNECT_BACKOFF_FACTOR = 2;
|
|
const MAX_LOG_ENTRIES = 100;
|
|
|
|
// --- RVS-Klasse ---
|
|
|
|
class RVSConnection {
|
|
private ws: WebSocket | null = null;
|
|
private config: ConnectionConfig | null = null;
|
|
private state: ConnectionState = 'disconnected';
|
|
|
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private reconnectDelay: number = INITIAL_RECONNECT_DELAY_MS;
|
|
private shouldReconnect: boolean = false;
|
|
|
|
private messageListeners: MessageCallback[] = [];
|
|
private stateListeners: StateCallback[] = [];
|
|
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 ---
|
|
|
|
/** Verbindungsdaten setzen (z.B. nach QR-Scan) */
|
|
setConfig(config: ConnectionConfig): void {
|
|
this.config = config;
|
|
this.saveConfig(config);
|
|
}
|
|
|
|
getConfig(): ConnectionConfig | null {
|
|
return this.config;
|
|
}
|
|
|
|
getState(): ConnectionState {
|
|
return this.state;
|
|
}
|
|
|
|
// --- Verbindung ---
|
|
|
|
/** Verbindung zum RVS aufbauen. force=true: bestehende Connection hart
|
|
* schliessen + neu verbinden (auch wenn JS denkt readyState=OPEN — kann
|
|
* nach Hintergrund-Pause ein Zombie-WS sein wo TCP tot ist aber JS-State
|
|
* noch OPEN zeigt; in dem Fall war "Bereits verbunden" ein No-Op und
|
|
* Stefan musste manuell zigmal klicken). */
|
|
connect(force: boolean = false): void {
|
|
if (!this.config) {
|
|
this.log('warn', 'Keine Verbindungskonfiguration vorhanden');
|
|
return;
|
|
}
|
|
|
|
if (!force && this.ws?.readyState === WebSocket.OPEN) {
|
|
this.log('info', 'Bereits verbunden');
|
|
return;
|
|
}
|
|
|
|
// Wenn ein WS-Objekt da ist (Zombie oder lebend), sauber abreissen
|
|
// bevor wir einen neuen aufbauen — sonst gibt's zwei parallele
|
|
// Verbindungen + doppelte Events.
|
|
if (this.ws) {
|
|
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
|
|
this.detachSocket(this.ws);
|
|
this.ws = null;
|
|
}
|
|
|
|
this.shouldReconnect = true;
|
|
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
this.usingTLSFallback = false;
|
|
this.clearTimers();
|
|
this.log('info', `Verbindungsaufbau zu ${this.config.host}:${this.config.port} (TLS: ${this.config.useTLS ? 'ja' : 'nein'})`);
|
|
this.establishConnection();
|
|
}
|
|
|
|
/** Verbindung trennen (kein Auto-Reconnect) */
|
|
disconnect(): void {
|
|
this.shouldReconnect = false;
|
|
this.clearTimers();
|
|
|
|
if (this.ws) {
|
|
this.ws.close(1000, 'Benutzer hat getrennt');
|
|
this.ws = null;
|
|
}
|
|
|
|
this.log('info', 'Verbindung getrennt (manuell)');
|
|
this.setState('disconnected');
|
|
}
|
|
|
|
/**
|
|
* Ist die Verbindung WIRKLICH offen? Fragt den Socket selbst, nicht den
|
|
* gemerkten Zustand — der kann (z.B. nach einem Zombie) falsch sein. Wer
|
|
* davon abhaengig macht, ob ueberhaupt gesendet wird, muss das Echte wissen.
|
|
*/
|
|
isSocketOpen(): boolean {
|
|
return !!this.ws && this.ws.readyState === WebSocket.OPEN;
|
|
}
|
|
|
|
/** Nachricht an den RVS senden */
|
|
send(type: MessageType, payload: Record<string, unknown>): void {
|
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
console.warn('[RVS] Kann nicht senden - nicht verbunden');
|
|
return;
|
|
}
|
|
|
|
const message: RVSMessage = {
|
|
type,
|
|
payload,
|
|
timestamp: Date.now(),
|
|
};
|
|
|
|
this.ws.send(JSON.stringify(message));
|
|
}
|
|
|
|
// --- Event-Listener ---
|
|
|
|
/** Callback fuer eingehende Nachrichten registrieren */
|
|
onMessage(callback: MessageCallback): () => void {
|
|
this.messageListeners.push(callback);
|
|
// Gibt Unsubscribe-Funktion zurueck
|
|
return () => {
|
|
this.messageListeners = this.messageListeners.filter(cb => cb !== callback);
|
|
};
|
|
}
|
|
|
|
/** Callback fuer Verbindungsstatus-Aenderungen registrieren */
|
|
onStateChange(callback: StateCallback): () => void {
|
|
this.stateListeners.push(callback);
|
|
return () => {
|
|
this.stateListeners = this.stateListeners.filter(cb => cb !== callback);
|
|
};
|
|
}
|
|
|
|
/** Callback fuer Verbindungslog-Eintraege registrieren */
|
|
onLog(callback: LogCallback): () => void {
|
|
this.logListeners.push(callback);
|
|
return () => {
|
|
this.logListeners = this.logListeners.filter(cb => cb !== callback);
|
|
};
|
|
}
|
|
|
|
/** Gesamten Verbindungslog abrufen */
|
|
getConnectionLog(): ConnectionLogEntry[] {
|
|
return [...this.connectionLog];
|
|
}
|
|
|
|
// --- Interne Methoden ---
|
|
|
|
/** Eintrag ins Verbindungslog schreiben */
|
|
private log(level: ConnectionLogEntry['level'], message: string): void {
|
|
const entry: ConnectionLogEntry = { timestamp: Date.now(), level, message };
|
|
this.connectionLog = [...this.connectionLog.slice(-(MAX_LOG_ENTRIES - 1)), entry];
|
|
this.logListeners.forEach(cb => cb(entry));
|
|
const prefix = level === 'error' ? 'ERROR' : level === 'warn' ? 'WARN' : 'INFO';
|
|
console.log(`[RVS] [${prefix}] ${message}`);
|
|
}
|
|
|
|
private establishConnection(): void {
|
|
if (!this.config) return;
|
|
|
|
this.setState('connecting');
|
|
|
|
const useTLS = this.config.useTLS && !this.usingTLSFallback;
|
|
const protocol = useTLS ? 'wss' : 'ws';
|
|
const url = `${protocol}://${this.config.host}:${this.config.port}?token=${this.config.token}`;
|
|
|
|
this.log('info', `Verbinde: ${protocol}://${this.config.host}:${this.config.port}`);
|
|
|
|
try {
|
|
this.ws = new WebSocket(url);
|
|
|
|
this.ws.onopen = () => {
|
|
const tlsInfo = this.usingTLSFallback ? ' (TLS-Fallback: ws://)' : '';
|
|
this.log('info', `Verbunden${tlsInfo}`);
|
|
this.setState('connected');
|
|
this.updateSecurityOnOpen(useTLS);
|
|
this.sendClientHello();
|
|
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
this.startHeartbeat();
|
|
};
|
|
|
|
const socket = this.ws; // genau DIESE Verbindung (nicht 'this.ws' spaeter)
|
|
this.ws.onmessage = (event: WebSocketMessageEvent) => {
|
|
// Nur die AKTUELLE Verbindung darf Nachrichten einspeisen. Sonst
|
|
// koennte eine alte, laengst aufgegebene Verbindung weiter Daten
|
|
// liefern und vortaeuschen, es sei alles in Ordnung.
|
|
if (socket !== this.ws) return;
|
|
try {
|
|
const message: RVSMessage = JSON.parse(event.data as string);
|
|
this.notifyMessageListeners(message);
|
|
} catch (err) {
|
|
this.log('error', `Nachricht parsen fehlgeschlagen: ${err}`);
|
|
}
|
|
};
|
|
|
|
this.ws.onclose = (event) => {
|
|
this.log('info', `Verbindung geschlossen (Code: ${event.code}, Reason: ${event.reason || '-'})`);
|
|
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
|
|
// der Hauptgrund warum die App nach Hintergrund-Rueckkehr nicht
|
|
// mehr verband — TLS-Handshake-Timeout in einem Reconnect → Fallback
|
|
// auf ws:// → Caddy refused → endlos im Fallback haengen.
|
|
if (this.usingTLSFallback) {
|
|
this.log('info', 'Reset TLS-Fallback fuer naechsten Reconnect (zurueck zu wss://)');
|
|
this.usingTLSFallback = false;
|
|
}
|
|
|
|
if (this.shouldReconnect) {
|
|
this.scheduleReconnect();
|
|
}
|
|
};
|
|
|
|
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) {
|
|
this.usingTLSFallback = true;
|
|
// shouldReconnect kurz deaktivieren damit onclose keinen
|
|
// parallelen Reconnect ausloest — wir machen das selbst
|
|
this.shouldReconnect = false;
|
|
this.log('warn', 'TLS fehlgeschlagen — Fallback auf ws:// (ohne TLS)');
|
|
this.clearTimers();
|
|
if (this.ws) {
|
|
this.detachSocket(this.ws); // inkl. onmessage — sonst bleibt ein Zombie, der weiter empfaengt
|
|
}
|
|
this.ws = null;
|
|
this.shouldReconnect = true;
|
|
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
|
|
this.establishConnection();
|
|
return;
|
|
}
|
|
};
|
|
} catch (err) {
|
|
this.log('error', `Verbindungsfehler: ${err}`);
|
|
this.setState('disconnected');
|
|
|
|
if (this.shouldReconnect) {
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Beim RVS vorstellen — Diagnostic zeigt so, welches Handy im Raum ist. */
|
|
private sendClientHello(): void {
|
|
try {
|
|
const c: any = (Platform as any).constants || {};
|
|
const device = [c.Manufacturer, c.Model].filter(Boolean).join(' ') || Platform.OS;
|
|
let version = '';
|
|
try { version = require('../../package.json').version || ''; } catch (_) {}
|
|
this.send('client_hello', { kind: 'App', name: device,
|
|
detail: `Android ${c.Release || Platform.Version}`, version });
|
|
} catch (_) {}
|
|
}
|
|
|
|
// --- 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...`);
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.establishConnection();
|
|
}, this.reconnectDelay);
|
|
|
|
// Exponentieller Backoff: 1s -> 2s -> 4s -> 8s -> ... -> max 30s
|
|
this.reconnectDelay = Math.min(
|
|
this.reconnectDelay * RECONNECT_BACKOFF_FACTOR,
|
|
MAX_RECONNECT_DELAY_MS,
|
|
);
|
|
}
|
|
|
|
/** Heartbeat starten (alle 25 Sekunden) */
|
|
private startHeartbeat(): void {
|
|
this.heartbeatTimer = setInterval(() => {
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify({ type: 'heartbeat', timestamp: Date.now() }));
|
|
}
|
|
}, HEARTBEAT_INTERVAL_MS);
|
|
}
|
|
|
|
/**
|
|
* Klemmt eine Verbindung VOLLSTAENDIG ab, bevor sie verworfen wird.
|
|
*
|
|
* Wichtig: frueher wurden nur onclose/onerror entfernt — onmessage blieb
|
|
* haengen. Eine aufgegebene (Zombie-)Verbindung lieferte dadurch weiter
|
|
* Nachrichten an die App, waehrend gesendet wurde ueber die neue/tote.
|
|
* Symptom: die App zeigt "getrennt", ARIAs Sprachausgabe kommt aber trotzdem
|
|
* an — und nichts, was man schreibt oder spricht, erreicht den Server.
|
|
*/
|
|
private detachSocket(ws: WebSocket): void {
|
|
try {
|
|
ws.onopen = null;
|
|
ws.onmessage = null; // <- das fehlte
|
|
ws.onclose = null; // verhindert doppelten Reconnect
|
|
ws.onerror = null;
|
|
ws.close();
|
|
} catch (_) {}
|
|
}
|
|
|
|
private clearTimers(): void {
|
|
if (this.heartbeatTimer) {
|
|
clearInterval(this.heartbeatTimer);
|
|
this.heartbeatTimer = null;
|
|
}
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
}
|
|
|
|
private setState(state: ConnectionState): void {
|
|
if (this.state !== state) {
|
|
this.state = state;
|
|
this.stateListeners.forEach(cb => cb(state));
|
|
}
|
|
}
|
|
|
|
private notifyMessageListeners(message: RVSMessage): void {
|
|
this.messageListeners.forEach(cb => cb(message));
|
|
}
|
|
|
|
// --- Persistenz ---
|
|
|
|
private static readonly STORAGE_KEY = 'rvs_config';
|
|
|
|
private async saveConfig(config: ConnectionConfig): Promise<void> {
|
|
try {
|
|
await AsyncStorage.setItem(RVSConnection.STORAGE_KEY, JSON.stringify(config));
|
|
console.log('[RVS] Konfiguration gespeichert');
|
|
} catch (err) {
|
|
console.error('[RVS] Fehler beim Speichern:', err);
|
|
}
|
|
}
|
|
|
|
async loadConfig(): Promise<ConnectionConfig | null> {
|
|
try {
|
|
const data = await AsyncStorage.getItem(RVSConnection.STORAGE_KEY);
|
|
if (data) {
|
|
this.config = JSON.parse(data);
|
|
console.log('[RVS] Konfiguration geladen');
|
|
return this.config;
|
|
}
|
|
return null;
|
|
} catch (err) {
|
|
console.error('[RVS] Fehler beim Laden:', err);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Singleton-Instanz
|
|
const rvs = new RVSConnection();
|
|
export default rvs;
|