package api import ( "context" "fmt" "io" "log/slog" "net/http" "net/http/httputil" "net/url" "strings" "github.com/coder/websocket" ) // NewLiveKitProxy creates a reverse proxy handler that forwards both HTTP // and WebSocket 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, allowedOrigins []string) http.Handler { target, err := url.Parse(livekitURL) if err != nil { slog.Error("invalid LiveKit URL — falling back to localhost:7880", "url", livekitURL, "error", err) target, _ = url.Parse("http://localhost:7880") } // Normalise scheme for HTTP proxy target. httpTarget := *target switch httpTarget.Scheme { case "ws": httpTarget.Scheme = "http" case "wss": httpTarget.Scheme = "https" } // Normalise scheme for WebSocket proxy target. wsTarget := *target switch wsTarget.Scheme { case "http": wsTarget.Scheme = "ws" case "https": wsTarget.Scheme = "wss" } httpProxy := &httputil.ReverseProxy{ Director: func(req *http.Request) { req.URL.Scheme = httpTarget.Scheme req.URL.Host = httpTarget.Host req.Host = httpTarget.Host }, } // Paths that must never be forwarded to LiveKit (internal/admin endpoints). // Matched as exact path segments to avoid false positives (e.g. "/user-metrics"). blockedSegments := map[string]bool{"admin": true, "metrics": true, "debug": true, "twirp": true} return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Enforce the path allowlist and Origin check for EVERY request, // including WebSocket upgrades — otherwise a client could reach a // blocked/admin endpoint simply by sending an Upgrade header. // Block sensitive LiveKit endpoints (exact segment match). for seg := range strings.SplitSeq(strings.ToLower(r.URL.Path), "/") { if blockedSegments[seg] { writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", Message: "access denied", }) return } } // Validate Origin header (mirrors WS OriginPatterns). if !isOriginAllowed(r, allowedOrigins) { slog.Warn("livekit proxy: origin rejected", "origin", r.Header.Get("Origin"), "path", r.URL.Path, "remote", r.RemoteAddr) writeJSON(w, http.StatusForbidden, errorResponse{ Error: "FORBIDDEN", Message: "access denied", }) return } // Detect WebSocket upgrade requests. if isWebSocketUpgrade(r) { proxyWebSocket(w, r, &wsTarget, allowedOrigins) return } httpProxy.ServeHTTP(w, r) }) } func isWebSocketUpgrade(r *http.Request) bool { for _, v := range r.Header.Values("Connection") { if strings.EqualFold(strings.TrimSpace(v), "upgrade") { return strings.EqualFold(r.Header.Get("Upgrade"), "websocket") } } return false } // firstPartyClientOrigins are the fixed webview origins of OwnCord's own // desktop client (Tauri): WebView2 on Windows uses http(s)://tauri.localhost, // WKWebView/WebKitGTK use the tauri:// scheme. The client's chat connection // goes through its Rust proxy (which sends no Origin header), but the LiveKit // JS SDK's signal requests and validate probes are issued directly from the // webview and DO carry these origins — without this allowance, voice fails // with 403 on every default install (empty allowed_origins) for any client // not on the server machine. // // Allowing them matches the trust already extended to absent-Origin requests: // a remote website can never present these origins (browsers resolve // *.localhost to loopback per RFC 6761 semantics and the tauri:// scheme is // not reachable from web content), so this does not widen the CSRF surface — // only code running on the user's own machine could ever send them, which is // outside the web attacker model this check defends against. var firstPartyClientOrigins = []string{ "http://tauri.localhost", "https://tauri.localhost", "tauri://localhost", } // isOriginAllowed checks whether the request's Origin header matches one of // the allowed origins. Requests with no Origin header (e.g. same-origin or // non-browser) and the first-party desktop client's webview origins are // always permitted. Beyond those, an empty allowedOrigins list denies all // cross-origin requests (require explicit "*" wildcard to allow all). func isOriginAllowed(r *http.Request, allowedOrigins []string) bool { origin := r.Header.Get("Origin") if origin == "" { return true // non-browser or same-origin requests } // Browsers attach the page's own origin to every WebSocket handshake // (fetch may omit it same-origin; WS never does). An Origin whose host // matches the request's Host is a page this server itself served — // same-origin, not cross-origin. This mirrors websocket.Accept's default // policy, which the chat WS endpoint already applies; web content on // another origin can never present it (the browser pins Origin), so it // does not widen the CSRF surface. if u, err := url.Parse(origin); err == nil && u.Host != "" && strings.EqualFold(u.Host, r.Host) { return true } for _, firstParty := range firstPartyClientOrigins { if strings.EqualFold(origin, firstParty) { return true } } if len(allowedOrigins) == 0 { return false // no allowlist configured — deny cross-origin } for _, pattern := range allowedOrigins { if pattern == "*" { return true } if strings.EqualFold(origin, pattern) { return true } } return false } // proxyWebSocket opens a backend WS connection and shovels data in both // directions until either side closes. func proxyWebSocket(w http.ResponseWriter, r *http.Request, target *url.URL, allowedOrigins []string) { // Build backend URL preserving the request path and query. backendURL := *target backendURL.Path = r.URL.Path backendURL.RawQuery = r.URL.RawQuery // Connect to LiveKit backend. backConn, dialResp, err := websocket.Dial(r.Context(), backendURL.String(), &websocket.DialOptions{ Subprotocols: r.Header.Values("Sec-WebSocket-Protocol"), }) if dialResp != nil && dialResp.Body != nil { defer dialResp.Body.Close() //nolint:errcheck // best-effort close } if err != nil { // websocket.Dial wraps a *url.Error, which embeds the full request URL — // including the access_token query parameter, a live LiveKit room-join // JWT. Anyone with log access (stdout, a shipper, or the admin panel's // live view, whose ring buffer captures DEBUG+ regardless of the // configured stdout level) could replay it inside its 5-minute TTL as // the victim's participant identity. Strip the credential before the // error reaches slog: the raw query blob first, so an encoded form is // caught too, then the decoded token. safeErr := redactKey(err.Error(), backendURL.RawQuery) safeErr = redactKey(safeErr, backendURL.Query().Get("access_token")) slog.Warn("livekit proxy: backend dial failed", "host", backendURL.Host, "path", backendURL.Path, "err", safeErr) writeJSON(w, http.StatusBadGateway, errorResponse{ Error: "BAD_GATEWAY", Message: "backend unavailable", }) return } defer backConn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort close on defer // Accept the frontend WebSocket. frontConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ Subprotocols: []string{backConn.Subprotocol()}, OriginPatterns: allowedOrigins, }) if err != nil { slog.Warn("livekit proxy: frontend accept failed", "err", err) return } defer frontConn.Close(websocket.StatusNormalClosure, "") //nolint:errcheck // best-effort close on defer // Use a cancellable context so when one direction finishes, the other // goroutine's copyWS read/write is unblocked and can drain cleanly. ctx, cancel := context.WithCancel(r.Context()) defer cancel() errc := make(chan error, 2) // Frontend → Backend go func() { errc <- copyWS(ctx, backConn, frontConn) }() // Backend → Frontend go func() { errc <- copyWS(ctx, frontConn, backConn) }() // Wait for either direction to finish, then cancel+drain both. <-errc cancel() <-errc } // wsProxyMaxMessageSize is the maximum WebSocket message size the LiveKit // proxy will forward. Messages exceeding this are dropped to prevent OOM. // 256 KB is generous for LiveKit signaling (typically < 10 KB). const wsProxyMaxMessageSize = 256 * 1024 // copyWS reads messages from src and writes them to dst until an error or // context cancellation. H-5: Messages exceeding wsProxyMaxMessageSize are // rejected to prevent memory exhaustion via oversized frames. func copyWS(ctx context.Context, dst, src *websocket.Conn) error { for { msgType, reader, err := src.Reader(ctx) if err != nil { return err } // Wrap reader with a size limit to prevent OOM from oversized messages. limited := io.LimitReader(reader, wsProxyMaxMessageSize+1) writer, err := dst.Writer(ctx, msgType) if err != nil { return err } n, copyErr := io.Copy(writer, limited) if copyErr != nil { return copyErr } if n > wsProxyMaxMessageSize { return fmt.Errorf("livekit proxy: message exceeds %d byte limit", wsProxyMaxMessageSize) } if closeErr := writer.Close(); closeErr != nil { return closeErr } } }