host our own shit
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Response | null> {
|
||||
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/<clientId>/<kind>/(whip|whep)[/<sessionId>]
|
||||
const match = rest.match(/^(agents\/[A-Za-z0-9_.\-]+\/(?:desktop|backstage|webcam|audio))\/(whip|whep)(\/[A-Za-z0-9_.\-]+)?$/);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) : [];
|
||||
}
|
||||
@@ -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<SocketData>
|
||||
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<SocketData>
|
||||
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<SocketData>, 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<SocketData>, 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<SocketData>, 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<SocketData>, 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": {
|
||||
|
||||
@@ -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<SocketData>,
|
||||
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<SocketData>,
|
||||
kind: "audio",
|
||||
hasVideo: false,
|
||||
hasAudio: true,
|
||||
iceServers: issueTurnIceServers(`${clientId}:audio:${sessionId}`),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -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 `<canvas>`. 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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user