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)
This commit is contained in:
jevb
2026-03-28 18:23:18 +01:00
parent b8879fe237
commit 032456758e
5 changed files with 82 additions and 16 deletions
@@ -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(']');
+26 -8
View File
@@ -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<number> {
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<number>("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");
}
@@ -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<void>();
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)
// -----------------------------------------------------------------------
+2
View File
@@ -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:
+14 -2
View File
@@ -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)