diff --git a/Client/tauri-client/src/lib/livekitSession.ts b/Client/tauri-client/src/lib/livekitSession.ts index 2c177fbb..099fe5a5 100644 --- a/Client/tauri-client/src/lib/livekitSession.ts +++ b/Client/tauri-client/src/lib/livekitSession.ts @@ -40,6 +40,8 @@ let ws: WsClient | null = null; let noiseSuppressor: NoiseSuppressor | null = null; let onErrorCallback: ((message: string) => void) | null = null; let currentChannelId: number | null = null; +/** Server host (e.g. "192.168.0.247:8443") for constructing LiveKit proxy URL. */ +let serverHost: string | null = null; /** The raw mic stream acquired for RNNoise processing (must be stopped on cleanup). */ let rawMicStream: MediaStream | null = null; @@ -243,6 +245,23 @@ export function setWsClient(client: WsClient): void { ws = client; } +/** Set the server host for constructing LiveKit proxy URLs. */ +export function setServerHost(host: string): void { + serverHost = host; +} + +/** + * Resolve a LiveKit URL. If the server sends a relative path like "/livekit", + * construct the full wss:// URL using the server host. This proxies LiveKit + * signaling through OwnCord's HTTPS to avoid mixed-content blocks. + */ +function resolveLiveKitUrl(url: string): string { + if (url.startsWith("/") && serverHost !== null) { + return `wss://${serverHost}${url}`; + } + return url; +} + /** Set error callback for UI feedback (e.g. toast on connection failure). */ export function setOnError(cb: (message: string) => void): void { onErrorCallback = cb; @@ -301,9 +320,10 @@ export async function handleVoiceToken( room.on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakersChanged); room.on(RoomEvent.Disconnected, handleDisconnected); - // Connect to LiveKit server - await room.connect(url, token); - log.info("Connected to LiveKit room", { channelId, url }); + // Connect to LiveKit server (resolve proxy URL if relative path) + const resolvedUrl = resolveLiveKitUrl(url); + await room.connect(resolvedUrl, token); + log.info("Connected to LiveKit room", { channelId, url: resolvedUrl }); // Enable microphone: use RNNoise if Enhanced Noise Suppression is on const enhancedNS = loadPref("enhancedNoiseSuppression", false); diff --git a/Client/tauri-client/src/pages/MainPage.ts b/Client/tauri-client/src/pages/MainPage.ts index eda2d1f1..5aae27a0 100644 --- a/Client/tauri-client/src/pages/MainPage.ts +++ b/Client/tauri-client/src/pages/MainPage.ts @@ -32,6 +32,7 @@ import { setOnRemoteVideoRemoved, clearOnRemoteVideo, setWsClient, + setServerHost as setLiveKitServerHost, setOnError as setVoiceOnError, clearOnError as clearVoiceOnError, } from "@lib/livekitSession"; @@ -79,10 +80,11 @@ export function createMainPage(options: MainPageOptions): MountableComponent { // Let voiceSession send signaling messages over this WS connection setWsClient(ws); - // Set server host for resolving relative attachment URLs + // Set server host for resolving relative attachment URLs and LiveKit proxy const apiConfig = api.getConfig(); if (apiConfig.host) { setServerHost(apiConfig.host); + setLiveKitServerHost(apiConfig.host); } const limiters = createRateLimiterSet(); diff --git a/Server/api/livekit_proxy.go b/Server/api/livekit_proxy.go new file mode 100644 index 00000000..950fb3f0 --- /dev/null +++ b/Server/api/livekit_proxy.go @@ -0,0 +1,40 @@ +package api + +import ( + "net/http" + "net/http/httputil" + "net/url" +) + +// NewLiveKitProxy creates a reverse proxy handler that forwards requests +// to the LiveKit server. This allows the client to reach LiveKit through +// OwnCord's existing HTTPS server, avoiding mixed-content blocks in +// WebView2 (secure page → insecure WebSocket). +// +// The client connects to wss://server:8443/livekit/ which is proxied to +// ws://localhost:7880/ on the LiveKit server. +func NewLiveKitProxy(livekitURL string) http.Handler { + target, err := url.Parse(livekitURL) + if err != nil { + // Fall back to default if URL is invalid + target, _ = url.Parse("http://localhost:7880") + } + + // Convert ws:// to http:// for the proxy target + switch target.Scheme { + case "ws": + target.Scheme = "http" + case "wss": + target.Scheme = "https" + } + + proxy := &httputil.ReverseProxy{ + Director: func(req *http.Request) { + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + req.Host = target.Host + }, + } + + return proxy +} diff --git a/Server/api/router.go b/Server/api/router.go index 96da77e1..eb9d94dd 100644 --- a/Server/api/router.go +++ b/Server/api/router.go @@ -75,7 +75,7 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri // Optionally start a companion LiveKit process. if cfg.Voice.LiveKitBinaryPath != "" { - proc := ws.NewLiveKitProcess(&cfg.Voice, cfg.Server.DataDir) + proc := ws.NewLiveKitProcess(&cfg.Voice, &cfg.TLS, cfg.Server.DataDir) if startErr := proc.Start(); startErr != nil { slog.Error("failed to start LiveKit process", "error", startErr) } else { @@ -88,6 +88,11 @@ func NewRouter(cfg *config.Config, database *db.DB, ver string, logBuf *admin.Ri if lkErr == nil { r.Post("/api/v1/livekit/webhook", ws.MountWebhookRoute(hub, cfg.Voice.LiveKitAPIKey, cfg.Voice.LiveKitAPISecret)) + + // Reverse proxy LiveKit signaling through OwnCord's HTTPS server. + // This avoids mixed-content blocks (secure page → insecure WS). + // Client connects to wss://server:8443/livekit/* → ws://localhost:7880/* + r.Handle("/livekit/*", http.StripPrefix("/livekit", NewLiveKitProxy(cfg.Voice.LiveKitURL))) } go hub.Run() diff --git a/Server/config/config.go b/Server/config/config.go index 6501759d..a77fbade 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -101,7 +101,7 @@ func defaults() Config { }, Voice: VoiceConfig{ LiveKitAPIKey: "devkey", - LiveKitAPISecret: "secret", + LiveKitAPISecret: "owncord-dev-secret-key-min-32chars", LiveKitURL: "ws://localhost:7880", Quality: "medium", }, @@ -139,8 +139,8 @@ upload: storage_dir: "data/uploads" voice: - livekit_api_key: "devkey" # LiveKit API key - livekit_api_secret: "secret" # LiveKit API secret + livekit_api_key: "devkey" # LiveKit API key + livekit_api_secret: "owncord-dev-secret-key-min-32chars" # LiveKit API secret (min 32 chars) livekit_url: "ws://localhost:7880" # LiveKit server WebSocket URL # livekit_binary: "" # path to livekit-server binary; empty = don't auto-start # quality: "medium" # low | medium | high diff --git a/Server/config/config_test.go b/Server/config/config_test.go index c059118a..aff08bce 100644 --- a/Server/config/config_test.go +++ b/Server/config/config_test.go @@ -242,7 +242,7 @@ func TestLoadVoiceConfigDefaults(t *testing.T) { }{ {"Voice.Quality", cfg.Voice.Quality, "medium"}, {"Voice.LiveKitAPIKey", cfg.Voice.LiveKitAPIKey, "devkey"}, - {"Voice.LiveKitAPISecret", cfg.Voice.LiveKitAPISecret, "secret"}, + {"Voice.LiveKitAPISecret", cfg.Voice.LiveKitAPISecret, "owncord-dev-secret-key-min-32chars"}, {"Voice.LiveKitURL", cfg.Voice.LiveKitURL, "ws://localhost:7880"}, } diff --git a/Server/ws/livekit_process.go b/Server/ws/livekit_process.go index dfc34a64..629cf0c9 100644 --- a/Server/ws/livekit_process.go +++ b/Server/ws/livekit_process.go @@ -20,8 +20,9 @@ import ( // LiveKitProcess manages the companion livekit-server binary. type LiveKitProcess struct { - cfg *config.VoiceConfig - dataDir string + cfg *config.VoiceConfig + tlsCfg *config.TLSConfig + dataDir string mu sync.Mutex cmd *exec.Cmd @@ -31,9 +32,12 @@ type LiveKitProcess struct { // NewLiveKitProcess creates a new process manager. It does not start the // process — call Start() to launch the LiveKit server. -func NewLiveKitProcess(cfg *config.VoiceConfig, dataDir string) *LiveKitProcess { +// The tlsCfg is used to configure TLS on the LiveKit server using the same +// certs as OwnCord (avoids mixed-content blocks in WebView2). +func NewLiveKitProcess(cfg *config.VoiceConfig, tlsCfg *config.TLSConfig, dataDir string) *LiveKitProcess { return &LiveKitProcess{ cfg: cfg, + tlsCfg: tlsCfg, dataDir: dataDir, } } @@ -42,6 +46,23 @@ func NewLiveKitProcess(cfg *config.VoiceConfig, dataDir string) *LiveKitProcess func (p *LiveKitProcess) generateConfig() (string, error) { cfgPath := filepath.Join(p.dataDir, "livekit.yaml") + // Build TLS section if OwnCord has TLS configured with cert files. + tlsSection := "" + if p.tlsCfg != nil && p.tlsCfg.CertFile != "" && p.tlsCfg.KeyFile != "" { + // Resolve cert/key paths relative to the data directory's parent + // (same working directory as chatserver). + certFile := p.tlsCfg.CertFile + keyFile := p.tlsCfg.KeyFile + tlsSection = fmt.Sprintf(` +turn: + enabled: true + tls_port: 5349 + udp_port: 3478 + cert_file: %s + key_file: %s +`, certFile, keyFile) + } + content := fmt.Sprintf(`# Auto-generated by OwnCord — do not edit manually. port: 7880 rtc: @@ -49,10 +70,10 @@ rtc: port_range_end: 60000 use_external_ip: true keys: - %s: %s + %s: %s%s logging: level: info -`, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret) +`, p.cfg.LiveKitAPIKey, p.cfg.LiveKitAPISecret, tlsSection) if err := os.MkdirAll(p.dataDir, 0o755); err != nil { return "", fmt.Errorf("creating data dir: %w", err) diff --git a/Server/ws/voice_handlers.go b/Server/ws/voice_handlers.go index 98410d8f..c6a9a522 100644 --- a/Server/ws/voice_handlers.go +++ b/Server/ws/voice_handlers.go @@ -103,7 +103,10 @@ func (h *Hub) handleVoiceJoin(c *Client, payload json.RawMessage) { slog.Error("ws handleVoiceJoin GenerateToken", "err", tokenErr, "user_id", c.userID) // Non-fatal: voice join still succeeds at the DB/state level. } else { - c.sendMsg(buildVoiceToken(channelID, token, h.livekit.URL())) + // Send "/livekit" as URL — client constructs the full wss:// URL + // from its server connection. Proxied through OwnCord's HTTPS + // to avoid mixed-content blocks in WebView2. + c.sendMsg(buildVoiceToken(channelID, token, "/livekit")) } }