fix: proxy LiveKit through HTTPS to fix mixed-content block

- Add reverse proxy at /livekit/* that forwards to LiveKit server
- Server sends relative URL "/livekit" in voice_token; client
  resolves to wss://server:port/livekit using known server host
- Fix API secret minimum length (32 chars required by LiveKit)
- Pass TLS config to LiveKit process manager for TURN certs
This commit is contained in:
jevb
2026-03-20 06:11:09 +01:00
parent 620fac778d
commit bf3fada16b
8 changed files with 106 additions and 15 deletions
+23 -3
View File
@@ -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<boolean>("enhancedNoiseSuppression", false);
+3 -1
View File
@@ -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();
+40
View File
@@ -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
}
+6 -1
View File
@@ -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()
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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"},
}
+26 -5
View File
@@ -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)
+4 -1
View File
@@ -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"))
}
}