diff --git a/Overlord-Client/cmd/agent/handlers/webrtc.go b/Overlord-Client/cmd/agent/handlers/webrtc.go index d9bf4ee..dce5a53 100644 --- a/Overlord-Client/cmd/agent/handlers/webrtc.go +++ b/Overlord-Client/cmd/agent/handlers/webrtc.go @@ -103,6 +103,7 @@ func handleWebrtcPublish(ctx context.Context, env *runtime.Env, cmdID string, pa PublishToken: token, TLSInsecureSkipVerify: env.Cfg.TLSInsecureSkipVerify, TLSCAPath: env.Cfg.TLSCAPath, + ICEServers: parseICEServers(payload["iceServers"]), HasVideo: hasVideo, HasAudio: hasAudio, } @@ -155,6 +156,7 @@ func handleWebrtcP2POffer(ctx context.Context, env *runtime.Env, cmdID string, p kindStr := string(kind) callbacks := webrtcpub.P2POfferCallbacks{ + ICEServers: parseICEServers(payload["iceServers"]), OnICE: func(c webrtcpub.ICECandidate) { if c.Candidate == "" { return @@ -203,6 +205,64 @@ func handleWebrtcP2POffer(ctx context.Context, env *runtime.Env, cmdID string, p return nil } +func parseICEServers(value interface{}) []webrtcpub.ICEServer { + items, ok := value.([]interface{}) + if !ok { + return nil + } + servers := make([]webrtcpub.ICEServer, 0, len(items)) + for _, item := range items { + if len(servers) >= 8 { + break + } + entry, ok := item.(map[string]interface{}) + if !ok { + continue + } + var urls []string + switch raw := entry["urls"].(type) { + case string: + urls = appendICEURL(urls, raw) + case []interface{}: + for _, value := range raw { + if url, ok := value.(string); ok && len(urls) < 8 { + urls = appendICEURL(urls, url) + } + } + case []string: + for _, url := range raw { + if len(urls) < 8 { + urls = appendICEURL(urls, url) + } + } + } + if len(urls) == 0 { + continue + } + username, _ := entry["username"].(string) + credential, _ := entry["credential"].(string) + servers = append(servers, webrtcpub.ICEServer{URLs: urls, Username: username, Credential: credential}) + } + return servers +} + +func appendICEURL(urls []string, value string) []string { + if len(value) == 0 || len(value) > 512 { + return urls + } + valid := false + for _, prefix := range []string{"stun:", "stuns:", "turn:", "turns:"} { + if len(value) >= len(prefix) && value[:len(prefix)] == prefix { + valid = true + break + } + } + if !valid { + return urls + } + return append(urls, value) +} + func handleWebrtcP2PIce(_ context.Context, env *runtime.Env, cmdID string, payload map[string]interface{}) error { sessionID, _ := payload["sessionId"].(string) candidate, _ := payload["candidate"].(string) diff --git a/Overlord-Client/cmd/agent/handlers/webrtc_ice_test.go b/Overlord-Client/cmd/agent/handlers/webrtc_ice_test.go new file mode 100644 index 0000000..1fe2c02 --- /dev/null +++ b/Overlord-Client/cmd/agent/handlers/webrtc_ice_test.go @@ -0,0 +1,33 @@ +package handlers + +import "testing" + +func TestParseICEServers(t *testing.T) { + servers := parseICEServers([]interface{}{ + map[string]interface{}{"urls": []interface{}{"stun:turn.example.test:3478"}}, + map[string]interface{}{ + "urls": []interface{}{"turn:turn.example.test:3478?transport=udp", "turn:turn.example.test:3478?transport=tcp", "https://invalid.example"}, + "username": "expiry:agent", + "credential": "derived-secret", + }, + }) + if len(servers) != 2 { + t.Fatalf("server count = %d, want 2", len(servers)) + } + if len(servers[1].URLs) != 2 { + t.Fatalf("TURN URL count = %d, want 2", len(servers[1].URLs)) + } + if servers[1].Username != "expiry:agent" || servers[1].Credential != "derived-secret" { + t.Fatal("TURN credentials were not preserved") + } +} + +func TestParseICEServersRejectsInvalidInput(t *testing.T) { + servers := parseICEServers([]interface{}{ + map[string]interface{}{"urls": "https://not-an-ice-server"}, + "invalid", + }) + if len(servers) != 0 { + t.Fatalf("server count = %d, want 0", len(servers)) + } +} diff --git a/Overlord-Client/cmd/agent/webrtcpub/p2p_pion.go b/Overlord-Client/cmd/agent/webrtcpub/p2p_pion.go index 9e165b7..eadb010 100644 --- a/Overlord-Client/cmd/agent/webrtcpub/p2p_pion.go +++ b/Overlord-Client/cmd/agent/webrtcpub/p2p_pion.go @@ -112,11 +112,15 @@ func StartP2POffer(ctx context.Context, kind Kind, sessionID string, offerSDP st webrtc.WithMediaEngine(mediaEngine), webrtc.WithInterceptorRegistry(interceptorRegistry), ) - pc, err := api.NewPeerConnection(webrtc.Configuration{ - ICEServers: []webrtc.ICEServer{ - {URLs: []string{"stun:stun.l.google.com:19302"}}, - }, - }) + iceServers := make([]webrtc.ICEServer, 0, len(opts.ICEServers)) + for _, server := range opts.ICEServers { + iceServers = append(iceServers, webrtc.ICEServer{ + URLs: server.URLs, + Username: server.Username, + Credential: server.Credential, + }) + } + pc, err := api.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers}) if err != nil { return "", fmt.Errorf("new peer connection: %w", err) } diff --git a/Overlord-Client/cmd/agent/webrtcpub/state.go b/Overlord-Client/cmd/agent/webrtcpub/state.go index bb1d83f..11f11a1 100644 --- a/Overlord-Client/cmd/agent/webrtcpub/state.go +++ b/Overlord-Client/cmd/agent/webrtcpub/state.go @@ -213,6 +213,8 @@ type Options struct { TLSInsecureSkipVerify bool // TLSCAPath is an optional custom CA bundle. TLSCAPath string + // ICEServers contains the server-issued, short-lived Coturn configuration. + ICEServers []ICEServer // HasVideo / HasAudio select which tracks to add to the peer connection. HasVideo bool HasAudio bool @@ -224,7 +226,14 @@ type ICECandidate struct { SDPMLineIndex uint16 `msgpack:"sdpMLineIndex"` } +type ICEServer struct { + URLs []string + Username string + Credential string +} + type P2POfferCallbacks struct { + ICEServers []ICEServer OnICE func(c ICECandidate) OnClose func() OnBandwidthEstimate func(bps int) diff --git a/Overlord-Client/cmd/agent/webrtcpub/whip_pion.go b/Overlord-Client/cmd/agent/webrtcpub/whip_pion.go index a6aaa42..1620292 100644 --- a/Overlord-Client/cmd/agent/webrtcpub/whip_pion.go +++ b/Overlord-Client/cmd/agent/webrtcpub/whip_pion.go @@ -19,6 +19,7 @@ import ( "sync/atomic" "time" + "github.com/pion/interceptor" "github.com/pion/rtcp" "github.com/pion/webrtc/v4" "github.com/pion/webrtc/v4/pkg/media" @@ -144,8 +145,23 @@ func Start(ctx context.Context, kind Kind, opts Options) (*Publisher, error) { } } - api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine)) - pc, err := api.NewPeerConnection(webrtc.Configuration{}) + interceptorRegistry := &interceptor.Registry{} + if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil { + return nil, fmt.Errorf("register WebRTC interceptors: %w", err) + } + api := webrtc.NewAPI( + webrtc.WithMediaEngine(mediaEngine), + webrtc.WithInterceptorRegistry(interceptorRegistry), + ) + iceServers := make([]webrtc.ICEServer, 0, len(opts.ICEServers)) + for _, server := range opts.ICEServers { + iceServers = append(iceServers, webrtc.ICEServer{ + URLs: server.URLs, + Username: server.Username, + Credential: server.Credential, + }) + } + pc, err := api.NewPeerConnection(webrtc.Configuration{ICEServers: iceServers}) if err != nil { return nil, fmt.Errorf("new peer connection: %w", err) } diff --git a/Overlord-Server/public/assets/webrtc-p2p.js b/Overlord-Server/public/assets/webrtc-p2p.js index 6949f26..d40f4fb 100644 --- a/Overlord-Server/public/assets/webrtc-p2p.js +++ b/Overlord-Server/public/assets/webrtc-p2p.js @@ -23,7 +23,7 @@ export class P2PClient { * @param {(msg: object) => void} opts.send Send a JSON-encodable msg over the viewer's WS. * @param {(state: string) => void} [opts.onState] * @param {(stats: object) => void} [opts.onStats] - * @param {string[]} [opts.iceServers] STUN URLs. Default: Google public STUN. + * @param {RTCIceServer[]} [opts.iceServers] Optional explicit ICE configuration. */ constructor(opts) { this.videoEl = opts.videoEl || null; @@ -31,7 +31,7 @@ export class P2PClient { this.send = opts.send; this.onState = opts.onState || (() => {}); this.onStats = opts.onStats || (() => {}); - this.iceServers = opts.iceServers || ["stun:stun.l.google.com:19302"]; + this.iceServers = Array.isArray(opts.iceServers) ? opts.iceServers : null; this.pc = null; this.pendingRemoteCandidates = []; this.statsSampler = null; @@ -40,8 +40,9 @@ export class P2PClient { async start() { if (this.pc) await this.stop(); + const iceServers = await this.resolveIceServers(); const pc = new RTCPeerConnection({ - iceServers: this.iceServers.map((u) => ({ urls: u })), + iceServers, }); this.pc = pc; this.statsSampler = new WebRTCStatsSampler(pc, this.onStats); @@ -81,6 +82,24 @@ export class P2PClient { this.send({ type: "webrtc_p2p_offer", sdp: pc.localDescription.sdp }); } + async resolveIceServers() { + if (this.iceServers) return this.iceServers; + const clientId = new URLSearchParams(window.location.search).get("clientId"); + if (!clientId) return []; + try { + const response = await fetch(`/api/webrtc/ice-config?clientId=${encodeURIComponent(clientId)}`, { + credentials: "same-origin", + cache: "no-store", + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = await response.json(); + return Array.isArray(body?.iceServers) ? body.iceServers : []; + } catch (error) { + console.warn("p2p: failed to load self-hosted ICE configuration; using host candidates only", error); + return []; + } + } + /** Handle an SDP answer relayed from the agent. */ async onAnswer(sdp) { if (!this.pc || !sdp) return; diff --git a/Overlord-Server/public/assets/whep.js b/Overlord-Server/public/assets/whep.js index b727f0e..1a1a9f6 100644 --- a/Overlord-Server/public/assets/whep.js +++ b/Overlord-Server/public/assets/whep.js @@ -34,7 +34,8 @@ export class WhepClient { async start() { if (this.pc) await this.stop(); - const pc = new RTCPeerConnection({}); + const iceServers = await this.resolveIceServers(); + const pc = new RTCPeerConnection({ iceServers }); this.pc = pc; this.statsSampler = new WebRTCStatsSampler(pc, this.onStats); if (this.videoEl) pc.addTransceiver("video", { direction: "recvonly" }); @@ -80,6 +81,23 @@ export class WhepClient { await pc.setRemoteDescription({ type: "answer", sdp: answerSDP }); } + async resolveIceServers() { + const clientId = new URLSearchParams(window.location.search).get("clientId"); + if (!clientId) return []; + try { + const response = await fetch(`/api/webrtc/ice-config?clientId=${encodeURIComponent(clientId)}`, { + credentials: "same-origin", + cache: "no-store", + }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const body = await response.json(); + return Array.isArray(body?.iceServers) ? body.iceServers : []; + } catch (error) { + console.warn("whep: failed to load self-hosted ICE configuration; using host candidates only", error); + return []; + } + } + async stop() { const pc = this.pc; const url = this.resourceURL; diff --git a/Overlord-Server/src/server/routes/webrtc-routes.ts b/Overlord-Server/src/server/routes/webrtc-routes.ts index 121c18b..d29a88a 100644 --- a/Overlord-Server/src/server/routes/webrtc-routes.ts +++ b/Overlord-Server/src/server/routes/webrtc-routes.ts @@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto"; import { authenticateRequest } from "../../auth"; import { requireClientAccess } from "../../rbac"; import { logger } from "../../logger"; +import { issueTurnIceServers } from "../turn-credentials"; const MEDIAMTX_URL = (process.env.OVERLORD_MEDIAMTX_URL || "http://localhost:8889").replace(/\/+$/, ""); @@ -33,6 +34,24 @@ export function webrtcStreamPathFor(clientId: string, kind: WebrtcKind): string export async function handleWebrtcRoutes(req: Request, url: URL): Promise { if (!url.pathname.startsWith("/api/webrtc/")) return null; + if (url.pathname === "/api/webrtc/ice-config") { + if (req.method !== "GET") return new Response("Method Not Allowed", { status: 405 }); + const clientId = url.searchParams.get("clientId") || ""; + if (!/^[A-Za-z0-9_.-]+$/.test(clientId)) return new Response("Bad Request", { status: 400 }); + const user = await authenticateRequest(req); + if (!user) return new Response("Unauthorized", { status: 401 }); + try { + requireClientAccess(user, clientId); + } catch (error) { + if (error instanceof Response) return error; + return new Response("Forbidden", { status: 403 }); + } + return Response.json( + { iceServers: issueTurnIceServers(`${clientId}:viewer`) }, + { headers: { "cache-control": "no-store" } }, + ); + } + const rest = url.pathname.slice("/api/webrtc/".length); // /api/webrtc/agents///(whip|whep)[/] const match = rest.match(/^(agents\/[A-Za-z0-9_.\-]+\/(?:desktop|backstage|webcam|audio))\/(whip|whep)(\/[A-Za-z0-9_.\-]+)?$/); diff --git a/Overlord-Server/src/server/turn-credentials.test.ts b/Overlord-Server/src/server/turn-credentials.test.ts new file mode 100644 index 0000000..d565a31 --- /dev/null +++ b/Overlord-Server/src/server/turn-credentials.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; +import { createHmac } from "node:crypto"; +import { buildTurnIceServers } from "./turn-credentials"; + +describe("Coturn REST credentials", () => { + test("issues matching short-lived HMAC credentials", () => { + const servers = buildTurnIceServers({ + host: "turn.example.test", + port: 3478, + realm: "overlord", + secret: "test-master-secret", + ttlSeconds: 3600, + }, "agent:desktop/session", 1_700_000_000_000); + + expect(servers[0]).toEqual({ urls: ["stun:turn.example.test:3478"] }); + const turn = servers[1]; + expect(turn.urls).toEqual([ + "turn:turn.example.test:3478?transport=udp", + "turn:turn.example.test:3478?transport=tcp", + ]); + expect(turn.username).toBe("1700003600:agent_desktop_session"); + expect(turn.credential).toBe( + createHmac("sha1", "test-master-secret").update(turn.username!).digest("base64"), + ); + expect(JSON.stringify(servers)).not.toContain("test-master-secret"); + }); +}); diff --git a/Overlord-Server/src/server/turn-credentials.ts b/Overlord-Server/src/server/turn-credentials.ts new file mode 100644 index 0000000..9e30a6a --- /dev/null +++ b/Overlord-Server/src/server/turn-credentials.ts @@ -0,0 +1,69 @@ +import { createHmac } from "node:crypto"; +import { readFileSync } from "node:fs"; + +export type OverlordIceServer = { + urls: string[]; + username?: string; + credential?: string; +}; + +type TurnConfig = { + host: string; + port: number; + realm: string; + secret: string; + ttlSeconds: number; +}; + +function configuredSecret(): string { + const direct = process.env.OVERLORD_TURN_SECRET?.trim(); + if (direct) return direct; + const path = process.env.OVERLORD_TURN_SECRET_FILE?.trim(); + if (!path) return ""; + try { + return readFileSync(path, "utf8").trim(); + } catch { + return ""; + } +} + +function loadTurnConfig(): TurnConfig | null { + const host = (process.env.OVERLORD_TURN_HOST || "").trim(); + const secret = configuredSecret(); + if (!host || !secret || /[\s\/?#]/.test(host)) return null; + const portValue = Number(process.env.OVERLORD_TURN_PORT || 3478); + const port = Number.isInteger(portValue) && portValue > 0 && portValue <= 65535 ? portValue : 3478; + const ttlValue = Number(process.env.OVERLORD_TURN_CREDENTIAL_TTL_SECONDS || 3600); + const ttlSeconds = Math.max(300, Math.min(86400, Number.isFinite(ttlValue) ? Math.floor(ttlValue) : 3600)); + return { + host, + port, + realm: (process.env.OVERLORD_TURN_REALM || "overlord").trim() || "overlord", + secret, + ttlSeconds, + }; +} + +function safeIdentity(identity: string): string { + return identity.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 96) || "peer"; +} + +export function buildTurnIceServers(config: TurnConfig, identity: string, nowMs = Date.now()): OverlordIceServer[] { + const expiresAt = Math.floor(nowMs / 1000) + config.ttlSeconds; + const username = `${expiresAt}:${safeIdentity(identity)}`; + const credential = createHmac("sha1", config.secret).update(username).digest("base64"); + const authority = `${config.host}:${config.port}`; + return [ + { urls: [`stun:${authority}`] }, + { + urls: [`turn:${authority}?transport=udp`, `turn:${authority}?transport=tcp`], + username, + credential, + }, + ]; +} + +export function issueTurnIceServers(identity: string, nowMs = Date.now()): OverlordIceServer[] { + const config = loadTurnConfig(); + return config ? buildTurnIceServers(config, identity, nowMs) : []; +} diff --git a/Overlord-Server/src/server/ws-console-rd-backstage.ts b/Overlord-Server/src/server/ws-console-rd-backstage.ts index faad2df..46738c0 100644 --- a/Overlord-Server/src/server/ws-console-rd-backstage.ts +++ b/Overlord-Server/src/server/ws-console-rd-backstage.ts @@ -13,6 +13,7 @@ import type { ClientInfo } from "../types"; import { canUserAccessClient } from "../users"; import { setClientWebcamInfo } from "../db"; import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes"; +import { issueTurnIceServers } from "./turn-credentials"; import { buildViewerFrameBuffer, decodeViewerPayload, @@ -413,6 +414,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket kind: "desktop", hasVideo: true, hasAudio: false, + iceServers: issueTurnIceServers(`${clientId}:desktop:whip`), }); safeSendViewer(ws, { type: "webrtc_ready", @@ -703,7 +705,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket const sdp = typeof (payload as any).sdp === "string" ? (payload as any).sdp : ""; if (!sdp) break; const sessionId = createP2PSession(ws, clientId, "desktop"); - sendDesktopCommand(target, "webrtc_p2p_offer", { sessionId, sdp, kind: "desktop", hasVideo: true, hasAudio: false }); + sendDesktopCommand(target, "webrtc_p2p_offer", { sessionId, sdp, kind: "desktop", hasVideo: true, hasAudio: false, iceServers: issueTurnIceServers(`${clientId}:desktop:${sessionId}`) }); break; } case "webrtc_p2p_ice": { @@ -1089,6 +1091,7 @@ export function handleWebcamViewerMessage(ws: ServerWebSocket, raw: kind: "webcam", hasVideo: true, hasAudio: false, + iceServers: issueTurnIceServers(`${clientId}:webcam:whip`), }); safeSendViewer(ws, { type: "webrtc_ready", @@ -1129,7 +1132,7 @@ export function handleWebcamViewerMessage(ws: ServerWebSocket, raw: const sdp = typeof (payload as any).sdp === "string" ? (payload as any).sdp : ""; if (!sdp) break; const sessionId = createP2PSession(ws, clientId, "webcam"); - sendDesktopCommand(target, "webrtc_p2p_offer", { sessionId, sdp, kind: "webcam", hasVideo: true, hasAudio: false }); + sendDesktopCommand(target, "webrtc_p2p_offer", { sessionId, sdp, kind: "webcam", hasVideo: true, hasAudio: false, iceServers: issueTurnIceServers(`${clientId}:webcam:${sessionId}`) }); break; } case "webrtc_p2p_ice": { @@ -1222,6 +1225,7 @@ export function handlebackstageViewerMessage(ws: ServerWebSocket, ra kind: "backstage", hasVideo: true, hasAudio: false, + iceServers: issueTurnIceServers(`${clientId}:backstage:whip`), }); safeSendViewer(ws, { type: "webrtc_ready", @@ -1445,7 +1449,7 @@ export function handlebackstageViewerMessage(ws: ServerWebSocket, ra const sdp = typeof (payload as any).sdp === "string" ? (payload as any).sdp : ""; if (!sdp) break; const sessionId = createP2PSession(ws, clientId, "backstage"); - sendbackstageCommand(target, "webrtc_p2p_offer", { sessionId, sdp, kind: "backstage", hasVideo: true, hasAudio: false }); + sendbackstageCommand(target, "webrtc_p2p_offer", { sessionId, sdp, kind: "backstage", hasVideo: true, hasAudio: false, iceServers: issueTurnIceServers(`${clientId}:backstage:${sessionId}`) }); break; } case "webrtc_p2p_ice": { diff --git a/Overlord-Server/src/server/ws-desktop-audio.ts b/Overlord-Server/src/server/ws-desktop-audio.ts index fcd920f..667c39c 100644 --- a/Overlord-Server/src/server/ws-desktop-audio.ts +++ b/Overlord-Server/src/server/ws-desktop-audio.ts @@ -6,6 +6,7 @@ import * as sessionManager from "../sessions/sessionManager"; import type { DesktopAudioViewer, SocketData } from "../sessions/types"; import { canUserAccessClient } from "../users"; import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes"; +import { issueTurnIceServers } from "./turn-credentials"; import { clearP2PSessionForViewer, createP2PSession, @@ -93,6 +94,7 @@ export function handleDesktopAudioViewerMessage(ws: ServerWebSocket, kind: "audio", hasVideo: false, hasAudio: true, + iceServers: issueTurnIceServers(`${clientId}:audio:whip`), }); safeJson(ws, { type: "webrtc_ready", @@ -119,6 +121,7 @@ export function handleDesktopAudioViewerMessage(ws: ServerWebSocket, kind: "audio", hasVideo: false, hasAudio: true, + iceServers: issueTurnIceServers(`${clientId}:audio:${sessionId}`), }); return; } diff --git a/README.md b/README.md index ed177fa..ebe2e08 100644 --- a/README.md +++ b/README.md @@ -349,7 +349,7 @@ Output: `release/prod-package/` The remote desktop viewer has a **Transport** dropdown with three modes: - **Canvas** (default): H.264 / JPEG / block frames over the existing WebSocket, decoded into a ``. Highest latency, works anywhere the WS does. -- **WebRTC P2P**: browser ↔ agent direct. The server only relays SDP and ICE candidates over the existing WS — MediaMTX is not involved. Lowest latency. Fails when both sides are behind aggressive symmetric NAT. +- **WebRTC P2P**: browser ↔ agent direct when possible, with the bundled Coturn server as a fallback for restrictive or symmetric NAT. MediaMTX is not involved. Lowest latency on a direct route. - **WebRTC Relayed**: agent publishes to a MediaMTX sidecar via WHIP, browser plays via WHEP. The server proxies signaling so the existing JWT auth + per-client RBAC still apply. Lowest-effort fallback when P2P can't punch through. ### Building agents with WebRTC @@ -362,6 +362,26 @@ The build tag (`overlord_webrtc`) is also available if you build agents outside go build -tags overlord_webrtc ./cmd/agent ``` +### Coturn STUN / TURN + +Compose starts `overlord-coturn` for ICE traversal in both P2P and MediaMTX Relayed modes. A one-time init service generates a random master secret in the private `overlord-turn-secret` Docker volume. Overlord uses that secret to issue one-hour Coturn REST credentials independently to the authenticated P2P viewer and agent. MediaMTX reads the secret inside Docker and also generates expiring credentials for its WHIP/WHEP clients. No Google STUN server is used, and the master secret is never sent directly to a peer. + +The defaults work for same-machine testing. For LAN or internet access, set the address that both the browser and agent can reach in a `.env` file next to the compose file: + +```env +OVERLORD_TURN_HOST=turn.example.com +OVERLORD_TURN_EXTERNAL_IP=203.0.113.10 +``` + +`OVERLORD_TURN_HOST` is placed in the ICE URLs and may be a public DNS name or IP. `OVERLORD_TURN_EXTERNAL_IP` tells Coturn which public address to advertise when the Docker host is behind NAT; omit it when Coturn binds the public interface directly. Do not use the Docker service name because peers outside the Compose network cannot resolve or reach it. + +Allow and forward these inbound ports to the Coturn host: + +- `3478/udp` and `3478/tcp` for STUN and TURN client connections. +- `49160-49200/udp` for TURN relay allocations. + +The bundled setup supports ordinary TURN over UDP and TCP. It intentionally does not enable `turns:` until a trusted Coturn TLS certificate and public hostname are configured. + ### MediaMTX sidecar Compose starts an `overlord-mediamtx` service for Relayed mode. It needs: @@ -369,7 +389,7 @@ Compose starts an `overlord-mediamtx` service for Relayed mode. It needs: - Port `8189/udp` and `8189/tcp` reachable from operators (WebRTC ICE traffic). The Windows / macOS compose publishes these; Linux uses host networking and shares the host's interfaces directly. - No auth config — the Overlord server proxies every WHIP/WHEP request through `/api/webrtc/...` and enforces the existing operator JWT + RBAC there. -If you only ever want P2P, you can comment out the `mediamtx:` service in your compose file — only Relayed mode depends on it. +If you only ever want P2P, you can comment out the `mediamtx:` service in your compose file — only Relayed mode depends on it. MediaMTX is configured to use the same Coturn service as a client-only ICE fallback, but it is not itself a STUN/TURN server; Coturn handles all relay allocations. ### LAN / public access diff --git a/docker-compose.quickstart.yml b/docker-compose.quickstart.yml index 7a0ff39..59c6deb 100644 --- a/docker-compose.quickstart.yml +++ b/docker-compose.quickstart.yml @@ -49,11 +49,17 @@ services: - OVERLORD_CLIENT_BUILD_CACHE_DIR=/app/client-build-cache - OVERLORD_FILE_UPLOAD_INTENT_TTL_MS=1800000 - OVERLORD_FILE_UPLOAD_PULL_TTL_MS=1800000 + - OVERLORD_TURN_HOST=${OVERLORD_TURN_HOST:-127.0.0.1} + - OVERLORD_TURN_PORT=${OVERLORD_TURN_PORT:-3478} + - OVERLORD_TURN_REALM=${OVERLORD_TURN_REALM:-overlord} + - OVERLORD_TURN_SECRET_FILE=/run/overlord-turn/secret + - OVERLORD_TURN_CREDENTIAL_TTL_SECONDS=${OVERLORD_TURN_CREDENTIAL_TTL_SECONDS:-3600} volumes: - overlord-data:/app/data - overlord-certs:/app/certs - overlord-client-build-cache:/app/client-build-cache - overlord-plugins:/app/plugins + - overlord-turn-secret:/run/overlord-turn:ro # Uncomment to mount host certbot certs read-only # - /etc/letsencrypt:/etc/letsencrypt:ro # Uncomment to use a static config file instead of env vars @@ -66,9 +72,46 @@ services: timeout: 10s retries: 3 start_period: 40s + depends_on: + turn-secret-init: + condition: service_completed_successfully + + turn-secret-init: + image: alpine:3.22 + command: ["/bin/sh", "-ec", "umask 077; if [ ! -s /run/overlord-turn/secret ]; then head -c 32 /dev/urandom | base64 > /run/overlord-turn/secret; fi; IFS= read -r turn_secret < /run/overlord-turn/secret; printf 'webrtcICEServers2:\n - url: \"stun:%s:%s\"\n - url: \"turn:%s:%s?transport=udp\"\n username: AUTH_SECRET\n password: \"%s\"\n clientOnly: true\n - url: \"turn:%s:%s?transport=tcp\"\n username: AUTH_SECRET\n password: \"%s\"\n clientOnly: true\n' \"$${OVERLORD_TURN_HOST}\" \"$${OVERLORD_TURN_PORT}\" \"$${OVERLORD_TURN_HOST}\" \"$${OVERLORD_TURN_PORT}\" \"$$turn_secret\" \"$${OVERLORD_TURN_HOST}\" \"$${OVERLORD_TURN_PORT}\" \"$$turn_secret\" > /run/overlord-turn/mediamtx.yml; chmod 0444 /run/overlord-turn/secret /run/overlord-turn/mediamtx.yml"] + environment: + OVERLORD_TURN_HOST: ${OVERLORD_TURN_HOST:-127.0.0.1} + OVERLORD_TURN_PORT: ${OVERLORD_TURN_PORT:-3478} + volumes: + - overlord-turn-secret:/run/overlord-turn + restart: "no" + + coturn: + image: coturn/coturn:4.14.0-r0-alpine + container_name: overlord-coturn + network_mode: host + entrypoint: ["/bin/sh", "-ec"] + command: + - | + secret="$$(cat /run/overlord-turn/secret)" + external="" + if [ -n "$${OVERLORD_TURN_EXTERNAL_IP:-}" ]; then external="--external-ip=$${OVERLORD_TURN_EXTERNAL_IP}"; fi + exec turnserver --fingerprint --use-auth-secret --static-auth-secret="$$secret" --realm="$${OVERLORD_TURN_REALM:-overlord}" --listening-port=3478 --min-port=49160 --max-port=49200 --no-tls --no-dtls --no-multicast-peers --log-file=stdout $$external + environment: + OVERLORD_TURN_REALM: ${OVERLORD_TURN_REALM:-overlord} + OVERLORD_TURN_EXTERNAL_IP: ${OVERLORD_TURN_EXTERNAL_IP:-} + volumes: + - overlord-turn-secret:/run/overlord-turn:ro + depends_on: + turn-secret-init: + condition: service_completed_successfully + restart: unless-stopped + security_opt: + - no-new-privileges:true volumes: overlord-data: overlord-certs: overlord-client-build-cache: overlord-plugins: + overlord-turn-secret: diff --git a/docker-compose.windows.yml b/docker-compose.windows.yml index ab55b64..59d4cbb 100644 --- a/docker-compose.windows.yml +++ b/docker-compose.windows.yml @@ -49,13 +49,22 @@ services: OVERLORD_FILE_UPLOAD_PULL_TTL_MS: ${OVERLORD_FILE_UPLOAD_PULL_TTL_MS:-1800000} # WebRTC: Bun reaches MediaMTX via the docker service name on bridge net. OVERLORD_MEDIAMTX_URL: ${OVERLORD_MEDIAMTX_URL:-http://mediamtx:8889} + OVERLORD_TURN_HOST: ${OVERLORD_TURN_HOST:-127.0.0.1} + OVERLORD_TURN_PORT: ${OVERLORD_TURN_PORT:-3478} + OVERLORD_TURN_REALM: ${OVERLORD_TURN_REALM:-overlord} + OVERLORD_TURN_SECRET_FILE: /run/overlord-turn/secret + OVERLORD_TURN_CREDENTIAL_TTL_SECONDS: ${OVERLORD_TURN_CREDENTIAL_TTL_SECONDS:-3600} depends_on: - - mediamtx + mediamtx: + condition: service_started + turn-secret-init: + condition: service_completed_successfully volumes: - overlord-data:/app/data - overlord-certs:/app/certs - overlord-client-build-cache:/app/client-build-cache - overlord-plugins:/app/plugins + - overlord-turn-secret:/run/overlord-turn:ro # Uncomment to mount host certbot certs read-only # - /etc/letsencrypt:/etc/letsencrypt:ro # Uncomment to use a static config file instead of env vars @@ -72,10 +81,47 @@ services: retries: 3 start_period: 40s + turn-secret-init: + image: alpine:3.22 + command: ["/bin/sh", "-ec", "umask 077; if [ ! -s /run/overlord-turn/secret ]; then head -c 32 /dev/urandom | base64 > /run/overlord-turn/secret; fi; IFS= read -r turn_secret < /run/overlord-turn/secret; printf 'webrtcICEServers2:\n - url: \"stun:%s:%s\"\n - url: \"turn:%s:%s?transport=udp\"\n username: AUTH_SECRET\n password: \"%s\"\n clientOnly: true\n - url: \"turn:%s:%s?transport=tcp\"\n username: AUTH_SECRET\n password: \"%s\"\n clientOnly: true\n' \"$${OVERLORD_TURN_HOST}\" \"$${OVERLORD_TURN_PORT}\" \"$${OVERLORD_TURN_HOST}\" \"$${OVERLORD_TURN_PORT}\" \"$$turn_secret\" \"$${OVERLORD_TURN_HOST}\" \"$${OVERLORD_TURN_PORT}\" \"$$turn_secret\" > /run/overlord-turn/mediamtx.yml; chmod 0444 /run/overlord-turn/secret /run/overlord-turn/mediamtx.yml"] + environment: + OVERLORD_TURN_HOST: ${OVERLORD_TURN_HOST:-127.0.0.1} + OVERLORD_TURN_PORT: ${OVERLORD_TURN_PORT:-3478} + volumes: + - overlord-turn-secret:/run/overlord-turn + restart: "no" + + coturn: + image: coturn/coturn:4.14.0-r0-alpine + container_name: overlord-coturn + ports: + - "3478:3478/udp" + - "3478:3478/tcp" + - "49160-49200:49160-49200/udp" + entrypoint: ["/bin/sh", "-ec"] + command: + - | + secret="$$(cat /run/overlord-turn/secret)" + external="" + if [ -n "$${OVERLORD_TURN_EXTERNAL_IP:-}" ]; then external="--external-ip=$${OVERLORD_TURN_EXTERNAL_IP}"; fi + exec turnserver --fingerprint --use-auth-secret --static-auth-secret="$$secret" --realm="$${OVERLORD_TURN_REALM:-overlord}" --listening-port=3478 --min-port=49160 --max-port=49200 --no-tls --no-dtls --no-multicast-peers --log-file=stdout $$external + environment: + OVERLORD_TURN_REALM: ${OVERLORD_TURN_REALM:-overlord} + OVERLORD_TURN_EXTERNAL_IP: ${OVERLORD_TURN_EXTERNAL_IP:-127.0.0.1} + volumes: + - overlord-turn-secret:/run/overlord-turn:ro + depends_on: + turn-secret-init: + condition: service_completed_successfully + restart: unless-stopped + security_opt: + - no-new-privileges:true + mediamtx: image: bluenviron/mediamtx:latest container_name: overlord-mediamtx restart: unless-stopped + command: ["/run/overlord-turn/mediamtx.yml"] ports: # WHIP/WHEP signaling only reached by the Bun container internally, # so we don't publish 8889. ICE UDP/TCP must be reachable from the @@ -97,9 +143,15 @@ services: # a different machine, override with the host's LAN/public IP, e.g.: # OVERLORD_WEBRTC_ADDITIONAL_HOSTS=127.0.0.1,192.168.1.42 MTX_WEBRTCADDITIONALHOSTS: ${OVERLORD_WEBRTC_ADDITIONAL_HOSTS:-127.0.0.1} + volumes: + - overlord-turn-secret:/run/overlord-turn:ro + depends_on: + turn-secret-init: + condition: service_completed_successfully volumes: overlord-data: overlord-certs: overlord-client-build-cache: overlord-plugins: + overlord-turn-secret: diff --git a/docker-compose.yml b/docker-compose.yml index b8fdd61..7ba8888 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,11 @@ services: # Host networking on Linux → localhost works. WebRTC is opt-in; leave the # toggle in the UI off if MediaMTX is not running. OVERLORD_MEDIAMTX_URL: ${OVERLORD_MEDIAMTX_URL:-http://localhost:8889} + OVERLORD_TURN_HOST: ${OVERLORD_TURN_HOST:-127.0.0.1} + OVERLORD_TURN_PORT: ${OVERLORD_TURN_PORT:-3478} + OVERLORD_TURN_REALM: ${OVERLORD_TURN_REALM:-overlord} + OVERLORD_TURN_SECRET_FILE: /run/overlord-turn/secret + OVERLORD_TURN_CREDENTIAL_TTL_SECONDS: ${OVERLORD_TURN_CREDENTIAL_TTL_SECONDS:-3600} volumes: # overlord-data also caches downloaded cross-compile toolchains # under /app/data/toolchains, fetched on first agent build for a @@ -61,6 +66,7 @@ services: - overlord-certs:/app/certs - overlord-client-build-cache:/app/client-build-cache - overlord-plugins:/app/plugins + - overlord-turn-secret:/run/overlord-turn:ro # Uncomment to mount host certbot certs read-only # - /etc/letsencrypt:/etc/letsencrypt:ro # Uncomment to use a static config file instead of env vars @@ -76,6 +82,42 @@ services: timeout: 10s retries: 3 start_period: 40s + depends_on: + turn-secret-init: + condition: service_completed_successfully + + turn-secret-init: + image: alpine:3.22 + command: ["/bin/sh", "-ec", "umask 077; if [ ! -s /run/overlord-turn/secret ]; then head -c 32 /dev/urandom | base64 > /run/overlord-turn/secret; fi; IFS= read -r turn_secret < /run/overlord-turn/secret; printf 'webrtcICEServers2:\n - url: \"stun:%s:%s\"\n - url: \"turn:%s:%s?transport=udp\"\n username: AUTH_SECRET\n password: \"%s\"\n clientOnly: true\n - url: \"turn:%s:%s?transport=tcp\"\n username: AUTH_SECRET\n password: \"%s\"\n clientOnly: true\n' \"$${OVERLORD_TURN_HOST}\" \"$${OVERLORD_TURN_PORT}\" \"$${OVERLORD_TURN_HOST}\" \"$${OVERLORD_TURN_PORT}\" \"$$turn_secret\" \"$${OVERLORD_TURN_HOST}\" \"$${OVERLORD_TURN_PORT}\" \"$$turn_secret\" > /run/overlord-turn/mediamtx.yml; chmod 0444 /run/overlord-turn/secret /run/overlord-turn/mediamtx.yml"] + environment: + OVERLORD_TURN_HOST: ${OVERLORD_TURN_HOST:-127.0.0.1} + OVERLORD_TURN_PORT: ${OVERLORD_TURN_PORT:-3478} + volumes: + - overlord-turn-secret:/run/overlord-turn + restart: "no" + + coturn: + image: coturn/coturn:4.14.0-r0-alpine + container_name: overlord-coturn + network_mode: host + entrypoint: ["/bin/sh", "-ec"] + command: + - | + secret="$$(cat /run/overlord-turn/secret)" + external="" + if [ -n "$${OVERLORD_TURN_EXTERNAL_IP:-}" ]; then external="--external-ip=$${OVERLORD_TURN_EXTERNAL_IP}"; fi + exec turnserver --fingerprint --use-auth-secret --static-auth-secret="$$secret" --realm="$${OVERLORD_TURN_REALM:-overlord}" --listening-port=3478 --min-port=49160 --max-port=49200 --no-tls --no-dtls --no-multicast-peers --log-file=stdout $$external + environment: + OVERLORD_TURN_REALM: ${OVERLORD_TURN_REALM:-overlord} + OVERLORD_TURN_EXTERNAL_IP: ${OVERLORD_TURN_EXTERNAL_IP:-} + volumes: + - overlord-turn-secret:/run/overlord-turn:ro + depends_on: + turn-secret-init: + condition: service_completed_successfully + restart: unless-stopped + security_opt: + - no-new-privileges:true mediamtx: image: bluenviron/mediamtx:latest @@ -83,6 +125,12 @@ services: # Share the host network so WebRTC ICE candidates use the host's real # interfaces directly — no extra config needed for local or LAN browsers. network_mode: host + command: ["/run/overlord-turn/mediamtx.yml"] + volumes: + - overlord-turn-secret:/run/overlord-turn:ro + depends_on: + turn-secret-init: + condition: service_completed_successfully restart: unless-stopped environment: # Disable every protocol except WebRTC. The Overlord server proxies all @@ -110,3 +158,4 @@ volumes: overlord-certs: overlord-client-build-cache: overlord-plugins: + overlord-turn-secret: