mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
@@ -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(']');
|
||||
|
||||
@@ -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)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user