From 032456758ed454bf42e67f477b0882794ff5d171 Mon Sep 17 00:00:00 2001 From: jevb Date: Sat, 28 Mar 2026 18:23:18 +0100 Subject: [PATCH] fix: LiveKit voice connection for remote clients behind reverse proxy - Fix race condition: handleDisconnected no longer nulls the room during initial connect, allowing the retry loop to complete all 3 attempts - Fix TLS proxy port: default to 443 instead of 8443 when server host has no explicit port (servers behind nginx/reverse proxy) - Fix cert store key: strip :443 suffix so LiveKit proxy fingerprint lookup matches ws_proxy's stored key format - Add node_ip config option for LiveKit WebRTC ICE candidates (required for remote users behind NAT) - Add resolved URL to all connection error/retry/reconnect logs for easier debugging - Add diagnostic logging to resolveLiveKitUrl showing which path was taken (direct/proxy/passthrough) --- .../src-tauri/src/livekit_proxy.rs | 13 +++---- Client/tauri-client/src/lib/livekitSession.ts | 34 ++++++++++++++----- .../tests/unit/livekit-session.test.ts | 33 ++++++++++++++++++ Server/config/config.go | 2 ++ Server/ws/livekit_process.go | 16 +++++++-- 5 files changed, 82 insertions(+), 16 deletions(-) diff --git a/Client/tauri-client/src-tauri/src/livekit_proxy.rs b/Client/tauri-client/src-tauri/src/livekit_proxy.rs index 522bfb0e..ac55f812 100644 --- a/Client/tauri-client/src-tauri/src/livekit_proxy.rs +++ b/Client/tauri-client/src-tauri/src/livekit_proxy.rs @@ -151,12 +151,11 @@ impl rustls::client::danger::ServerCertVerifier for PinnedVerifier { // Tauri commands // --------------------------------------------------------------------------- -/// Extract the hostname (with port) from a "host:port" string, stripping the -/// port to produce the key used in the cert store (matching ws_proxy's format). +/// Produce the cert store key matching ws_proxy's format. +/// ws_proxy extracts the host from "wss://host/path" which omits port 443. +/// We normalise by stripping the default ":443" suffix so the keys match. fn cert_store_key(remote_host: &str) -> String { - // ws_proxy stores fingerprints keyed by "host:port" extracted from the wss:// URL. - // The remote_host we receive is already in "host:port" format. - remote_host.to_string() + remote_host.strip_suffix(":443").unwrap_or(remote_host).to_string() } /// Load the stored certificate fingerprint for a host from the Tauri cert store. @@ -357,7 +356,9 @@ async fn handle_connection( let connector = tokio_rustls::TlsConnector::from(Arc::new(tls_config)); // Parse hostname (strip brackets for IPv6, e.g. "[::1]:8443"). - let (raw_hostname, _port) = remote_host.rsplit_once(':').unwrap_or((remote_host, "8443")); + // Default to port 443 (standard HTTPS) when no port is specified — the + // server is typically behind a reverse proxy (nginx) on the standard port. + let (raw_hostname, _port) = remote_host.rsplit_once(':').unwrap_or((remote_host, "443")); let hostname = raw_hostname .trim_start_matches('[') .trim_end_matches(']'); diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index a5f75e47..6a5cc32f 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -364,6 +364,13 @@ export class LiveKitSession { private handleDisconnected = (reason?: DisconnectReason): void => { log.info("LiveKit room disconnected", { reason }); + // During the initial connect/retry loop in handleVoiceToken, let that loop + // handle failures. If we run leaveVoice() here it nulls this.room, which + // causes the retry loop to abort immediately (this.room === null guard). + if (this.connecting) { + log.info("Disconnect during initial connect — deferring to retry loop"); + return; + } const isUnexpected = reason !== DisconnectReason.CLIENT_INITIATED; if (isUnexpected && this.latestToken !== null && this.currentChannelId !== null && this.lastUrl !== null) { // Attempt auto-reconnect with stored token before giving up. @@ -415,7 +422,7 @@ export class LiveKitSession { this.room = this.createRoom(); const resolvedUrl = await this.resolveLiveKitUrl(url, directUrl); await this.room.connect(resolvedUrl, token); - log.info("Auto-reconnect succeeded", { attempt, channelId }); + log.info("Auto-reconnect succeeded", { attempt, channelId, url: resolvedUrl }); this.room.startAudio().catch((err) => log.debug("Failed to start audio after reconnect", err)); await this.restoreLocalVoiceState("reconnect"); this.setupAudioPipeline(); @@ -428,7 +435,7 @@ export class LiveKitSession { this.requestTokenRefresh(); return; } catch (err) { - log.warn("Auto-reconnect failed", { attempt, error: err }); + log.warn("Auto-reconnect failed", { attempt, url, error: err }); if (this.room !== null) { this.room.removeAllListeners(); this.room.disconnect().catch((err) => log.warn("Failed to disconnect room after reconnect failure", err)); @@ -451,14 +458,20 @@ export class LiveKitSession { if (this.serverHost !== null) { const host = this.serverHost.split(":")[0] ?? ""; const isLocal = host === "localhost" || host === "127.0.0.1" || host === "::1"; - if (isLocal && directUrl) return directUrl; + if (isLocal && directUrl) { + log.debug("LiveKit URL resolved via direct (local)", { url: directUrl }); + return directUrl; + } if (proxyPath.startsWith("/")) { // Remote server: route through the local Rust TLS proxy so WebView2 // doesn't reject self-signed certificates on the LiveKit signal WS. const port = await this.ensureLiveKitProxy(); - return `ws://127.0.0.1:${port}${proxyPath}`; + const resolved = `ws://127.0.0.1:${port}${proxyPath}`; + log.debug("LiveKit URL resolved via TLS proxy", { url: resolved, remoteHost: this.serverHost }); + return resolved; } } + log.debug("LiveKit URL resolved as passthrough", { url: proxyPath }); return proxyPath; } @@ -466,8 +479,12 @@ export class LiveKitSession { private async ensureLiveKitProxy(): Promise { if (this.liveKitProxyPort !== null) return this.liveKitProxyPort; if (this.serverHost === null) throw new Error("no server host for LiveKit proxy"); + // Ensure host:port format — default to 443 (standard HTTPS) when the + // server is behind a reverse proxy. Without an explicit port, the Rust + // proxy would default to 8443 which may not be exposed. + const hostWithPort = this.serverHost.includes(":") ? this.serverHost : `${this.serverHost}:443`; this.liveKitProxyPort = await invoke("start_livekit_proxy", { - remoteHost: this.serverHost, + remoteHost: hostWithPort, }); log.info("LiveKit TLS proxy started on localhost", { port: this.liveKitProxyPort }); return this.liveKitProxyPort; @@ -634,9 +651,10 @@ export class LiveKitSession { } if (this.room !== null) this.leaveVoice(false); this.connecting = true; + let resolvedUrl = ""; try { this.room = this.createRoom(); - const resolvedUrl = await this.resolveLiveKitUrl(url, directUrl); + resolvedUrl = await this.resolveLiveKitUrl(url, directUrl); const MAX_RETRIES = 3; const RETRY_DELAY_MS = 2000; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { @@ -664,7 +682,7 @@ export class LiveKitSession { break; } catch (connectErr) { if (attempt < MAX_RETRIES) { - log.warn("LiveKit connect failed, retrying", { attempt, maxRetries: MAX_RETRIES, error: connectErr }); + log.warn("LiveKit connect failed, retrying", { attempt, maxRetries: MAX_RETRIES, url: resolvedUrl, error: connectErr }); await new Promise((r) => setTimeout(r, RETRY_DELAY_MS)); if (this.room === null) throw connectErr; this.room.removeAllListeners(); @@ -712,7 +730,7 @@ export class LiveKitSession { log.info("Voice session active", { channelId }); } } catch (err) { - log.error("Failed to connect to LiveKit", err); + log.error("Failed to connect to LiveKit", { url: resolvedUrl, error: err }); if (this.room !== null) { this.onErrorCallback?.("Failed to join voice — connection error"); } diff --git a/Client/tauri-client/tests/unit/livekit-session.test.ts b/Client/tauri-client/tests/unit/livekit-session.test.ts index e42382f0..dd786a09 100644 --- a/Client/tauri-client/tests/unit/livekit-session.test.ts +++ b/Client/tauri-client/tests/unit/livekit-session.test.ts @@ -599,6 +599,39 @@ describe("LiveKitSession", () => { }); }); + describe("handleDisconnected during initial connect", () => { + it("does not null the room when connecting flag is true", async () => { + session.setServerHost("localhost:7880"); + session.setWsClient({ send: vi.fn() } as any); + + // Make connect hang so we can trigger Disconnected mid-connect + const connectDeferred = createDeferred(); + mockRoom.connect.mockImplementation(() => connectDeferred.promise); + + // Capture the Disconnected handler registered via room.on() + let disconnectedHandler: ((reason?: number) => void) | undefined; + mockRoom.on.mockImplementation((event: string, handler: any) => { + if (event === "disconnected") disconnectedHandler = handler; + return mockRoom; + }); + + const tokenPromise = session.handleVoiceToken("test-token", "/livekit", 1, "ws://localhost:7880"); + await Promise.resolve(); // Let handleVoiceToken reach room.connect() + + // Simulate LiveKit emitting Disconnected with JOIN_FAILURE (reason 7) + // while the connect() is still in progress + expect(disconnectedHandler).toBeDefined(); + disconnectedHandler!(7); + + // The room should NOT have been nulled — retry loop is still in control + expect((session as any).room).not.toBeNull(); + + // Resolve connect to let the flow complete normally + connectDeferred.resolve(undefined); + await tokenPromise; + }); + }); + // ----------------------------------------------------------------------- // Screenshare audio controls (Spec 1) // ----------------------------------------------------------------------- diff --git a/Server/config/config.go b/Server/config/config.go index 37c63e72..835a9249 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -38,6 +38,7 @@ type VoiceConfig struct { LiveKitAPISecret string `koanf:"livekit_api_secret"` // LiveKit API secret LiveKitURL string `koanf:"livekit_url"` // LiveKit server WebSocket URL (e.g. ws://localhost:7880) LiveKitBinaryPath string `koanf:"livekit_binary"` // path to livekit-server binary; empty = don't auto-start + NodeIP string `koanf:"node_ip"` // public IP for WebRTC ICE candidates; empty = auto-detect Quality string `koanf:"quality"` // low | medium | high } @@ -144,6 +145,7 @@ voice: # livekit_api_secret: "" # LiveKit API secret (REQUIRED, min 32 chars — generate a unique secret) livekit_url: "ws://localhost:7880" # LiveKit server WebSocket URL # livekit_binary: "" # path to livekit-server binary; empty = don't auto-start + # node_ip: "" # public IP for WebRTC media (required for remote users behind NAT) # quality: "medium" # low | medium | high # github: diff --git a/Server/ws/livekit_process.go b/Server/ws/livekit_process.go index de8eac91..b54f4644 100644 --- a/Server/ws/livekit_process.go +++ b/Server/ws/livekit_process.go @@ -66,13 +66,25 @@ func (p *LiveKitProcess) generateConfig() (string, error) { } } } + // Build node_ip line only when configured (required for remote users behind NAT). + // Validate: must be a plain IP address (no YAML-breaking chars). + nodeIPLine := "" + if p.cfg.NodeIP != "" { + for _, ch := range p.cfg.NodeIP { + if ch == '"' || ch == '\\' || ch == '\n' || ch == '\r' || ch == '#' || ch == '{' || ch == '}' { + return "", fmt.Errorf("node_ip contains unsafe character %q", string(ch)) + } + } + nodeIPLine = fmt.Sprintf("\n node_ip: \"%s\"", p.cfg.NodeIP) + } + content := fmt.Sprintf(`# Auto-generated by OwnCord — do not edit manually. port: 7880 rtc: port_range_start: 50000 port_range_end: 60000 - use_external_ip: true + use_external_ip: true%s pli_throttle: low_quality: 500ms mid_quality: 1s @@ -83,7 +95,7 @@ keys: logging: level: info -`, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret) +`, nodeIPLine, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret) if err := os.MkdirAll(p.dataDir, 0o755); err != nil { return "", fmt.Errorf("creating data dir: %w", err)