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,
|
PublishToken: token,
|
||||||
TLSInsecureSkipVerify: env.Cfg.TLSInsecureSkipVerify,
|
TLSInsecureSkipVerify: env.Cfg.TLSInsecureSkipVerify,
|
||||||
TLSCAPath: env.Cfg.TLSCAPath,
|
TLSCAPath: env.Cfg.TLSCAPath,
|
||||||
|
ICEServers: parseICEServers(payload["iceServers"]),
|
||||||
HasVideo: hasVideo,
|
HasVideo: hasVideo,
|
||||||
HasAudio: hasAudio,
|
HasAudio: hasAudio,
|
||||||
}
|
}
|
||||||
@@ -155,6 +156,7 @@ func handleWebrtcP2POffer(ctx context.Context, env *runtime.Env, cmdID string, p
|
|||||||
kindStr := string(kind)
|
kindStr := string(kind)
|
||||||
|
|
||||||
callbacks := webrtcpub.P2POfferCallbacks{
|
callbacks := webrtcpub.P2POfferCallbacks{
|
||||||
|
ICEServers: parseICEServers(payload["iceServers"]),
|
||||||
OnICE: func(c webrtcpub.ICECandidate) {
|
OnICE: func(c webrtcpub.ICECandidate) {
|
||||||
if c.Candidate == "" {
|
if c.Candidate == "" {
|
||||||
return
|
return
|
||||||
@@ -203,6 +205,64 @@ func handleWebrtcP2POffer(ctx context.Context, env *runtime.Env, cmdID string, p
|
|||||||
return nil
|
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 {
|
func handleWebrtcP2PIce(_ context.Context, env *runtime.Env, cmdID string, payload map[string]interface{}) error {
|
||||||
sessionID, _ := payload["sessionId"].(string)
|
sessionID, _ := payload["sessionId"].(string)
|
||||||
candidate, _ := payload["candidate"].(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.WithMediaEngine(mediaEngine),
|
||||||
webrtc.WithInterceptorRegistry(interceptorRegistry),
|
webrtc.WithInterceptorRegistry(interceptorRegistry),
|
||||||
)
|
)
|
||||||
pc, err := api.NewPeerConnection(webrtc.Configuration{
|
iceServers := make([]webrtc.ICEServer, 0, len(opts.ICEServers))
|
||||||
ICEServers: []webrtc.ICEServer{
|
for _, server := range opts.ICEServers {
|
||||||
{URLs: []string{"stun:stun.l.google.com:19302"}},
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("new peer connection: %w", err)
|
return "", fmt.Errorf("new peer connection: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,6 +213,8 @@ type Options struct {
|
|||||||
TLSInsecureSkipVerify bool
|
TLSInsecureSkipVerify bool
|
||||||
// TLSCAPath is an optional custom CA bundle.
|
// TLSCAPath is an optional custom CA bundle.
|
||||||
TLSCAPath string
|
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 / HasAudio select which tracks to add to the peer connection.
|
||||||
HasVideo bool
|
HasVideo bool
|
||||||
HasAudio bool
|
HasAudio bool
|
||||||
@@ -224,7 +226,14 @@ type ICECandidate struct {
|
|||||||
SDPMLineIndex uint16 `msgpack:"sdpMLineIndex"`
|
SDPMLineIndex uint16 `msgpack:"sdpMLineIndex"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ICEServer struct {
|
||||||
|
URLs []string
|
||||||
|
Username string
|
||||||
|
Credential string
|
||||||
|
}
|
||||||
|
|
||||||
type P2POfferCallbacks struct {
|
type P2POfferCallbacks struct {
|
||||||
|
ICEServers []ICEServer
|
||||||
OnICE func(c ICECandidate)
|
OnICE func(c ICECandidate)
|
||||||
OnClose func()
|
OnClose func()
|
||||||
OnBandwidthEstimate func(bps int)
|
OnBandwidthEstimate func(bps int)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/pion/interceptor"
|
||||||
"github.com/pion/rtcp"
|
"github.com/pion/rtcp"
|
||||||
"github.com/pion/webrtc/v4"
|
"github.com/pion/webrtc/v4"
|
||||||
"github.com/pion/webrtc/v4/pkg/media"
|
"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))
|
interceptorRegistry := &interceptor.Registry{}
|
||||||
pc, err := api.NewPeerConnection(webrtc.Configuration{})
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("new peer connection: %w", err)
|
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 {(msg: object) => void} opts.send Send a JSON-encodable msg over the viewer's WS.
|
||||||
* @param {(state: string) => void} [opts.onState]
|
* @param {(state: string) => void} [opts.onState]
|
||||||
* @param {(stats: object) => void} [opts.onStats]
|
* @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) {
|
constructor(opts) {
|
||||||
this.videoEl = opts.videoEl || null;
|
this.videoEl = opts.videoEl || null;
|
||||||
@@ -31,7 +31,7 @@ export class P2PClient {
|
|||||||
this.send = opts.send;
|
this.send = opts.send;
|
||||||
this.onState = opts.onState || (() => {});
|
this.onState = opts.onState || (() => {});
|
||||||
this.onStats = opts.onStats || (() => {});
|
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.pc = null;
|
||||||
this.pendingRemoteCandidates = [];
|
this.pendingRemoteCandidates = [];
|
||||||
this.statsSampler = null;
|
this.statsSampler = null;
|
||||||
@@ -40,8 +40,9 @@ export class P2PClient {
|
|||||||
async start() {
|
async start() {
|
||||||
if (this.pc) await this.stop();
|
if (this.pc) await this.stop();
|
||||||
|
|
||||||
|
const iceServers = await this.resolveIceServers();
|
||||||
const pc = new RTCPeerConnection({
|
const pc = new RTCPeerConnection({
|
||||||
iceServers: this.iceServers.map((u) => ({ urls: u })),
|
iceServers,
|
||||||
});
|
});
|
||||||
this.pc = pc;
|
this.pc = pc;
|
||||||
this.statsSampler = new WebRTCStatsSampler(pc, this.onStats);
|
this.statsSampler = new WebRTCStatsSampler(pc, this.onStats);
|
||||||
@@ -81,6 +82,24 @@ export class P2PClient {
|
|||||||
this.send({ type: "webrtc_p2p_offer", sdp: pc.localDescription.sdp });
|
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. */
|
/** Handle an SDP answer relayed from the agent. */
|
||||||
async onAnswer(sdp) {
|
async onAnswer(sdp) {
|
||||||
if (!this.pc || !sdp) return;
|
if (!this.pc || !sdp) return;
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export class WhepClient {
|
|||||||
async start() {
|
async start() {
|
||||||
if (this.pc) await this.stop();
|
if (this.pc) await this.stop();
|
||||||
|
|
||||||
const pc = new RTCPeerConnection({});
|
const iceServers = await this.resolveIceServers();
|
||||||
|
const pc = new RTCPeerConnection({ iceServers });
|
||||||
this.pc = pc;
|
this.pc = pc;
|
||||||
this.statsSampler = new WebRTCStatsSampler(pc, this.onStats);
|
this.statsSampler = new WebRTCStatsSampler(pc, this.onStats);
|
||||||
if (this.videoEl) pc.addTransceiver("video", { direction: "recvonly" });
|
if (this.videoEl) pc.addTransceiver("video", { direction: "recvonly" });
|
||||||
@@ -80,6 +81,23 @@ export class WhepClient {
|
|||||||
await pc.setRemoteDescription({ type: "answer", sdp: answerSDP });
|
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() {
|
async stop() {
|
||||||
const pc = this.pc;
|
const pc = this.pc;
|
||||||
const url = this.resourceURL;
|
const url = this.resourceURL;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomBytes } from "node:crypto";
|
|||||||
import { authenticateRequest } from "../../auth";
|
import { authenticateRequest } from "../../auth";
|
||||||
import { requireClientAccess } from "../../rbac";
|
import { requireClientAccess } from "../../rbac";
|
||||||
import { logger } from "../../logger";
|
import { logger } from "../../logger";
|
||||||
|
import { issueTurnIceServers } from "../turn-credentials";
|
||||||
|
|
||||||
const MEDIAMTX_URL = (process.env.OVERLORD_MEDIAMTX_URL || "http://localhost:8889").replace(/\/+$/, "");
|
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> {
|
export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Response | null> {
|
||||||
if (!url.pathname.startsWith("/api/webrtc/")) return 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);
|
const rest = url.pathname.slice("/api/webrtc/".length);
|
||||||
// /api/webrtc/agents/<clientId>/<kind>/(whip|whep)[/<sessionId>]
|
// /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_.\-]+)?$/);
|
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 { canUserAccessClient } from "../users";
|
||||||
import { setClientWebcamInfo } from "../db";
|
import { setClientWebcamInfo } from "../db";
|
||||||
import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes";
|
import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes";
|
||||||
|
import { issueTurnIceServers } from "./turn-credentials";
|
||||||
import {
|
import {
|
||||||
buildViewerFrameBuffer,
|
buildViewerFrameBuffer,
|
||||||
decodeViewerPayload,
|
decodeViewerPayload,
|
||||||
@@ -413,6 +414,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
|||||||
kind: "desktop",
|
kind: "desktop",
|
||||||
hasVideo: true,
|
hasVideo: true,
|
||||||
hasAudio: false,
|
hasAudio: false,
|
||||||
|
iceServers: issueTurnIceServers(`${clientId}:desktop:whip`),
|
||||||
});
|
});
|
||||||
safeSendViewer(ws, {
|
safeSendViewer(ws, {
|
||||||
type: "webrtc_ready",
|
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 : "";
|
const sdp = typeof (payload as any).sdp === "string" ? (payload as any).sdp : "";
|
||||||
if (!sdp) break;
|
if (!sdp) break;
|
||||||
const sessionId = createP2PSession(ws, clientId, "desktop");
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "webrtc_p2p_ice": {
|
case "webrtc_p2p_ice": {
|
||||||
@@ -1089,6 +1091,7 @@ export function handleWebcamViewerMessage(ws: ServerWebSocket<SocketData>, raw:
|
|||||||
kind: "webcam",
|
kind: "webcam",
|
||||||
hasVideo: true,
|
hasVideo: true,
|
||||||
hasAudio: false,
|
hasAudio: false,
|
||||||
|
iceServers: issueTurnIceServers(`${clientId}:webcam:whip`),
|
||||||
});
|
});
|
||||||
safeSendViewer(ws, {
|
safeSendViewer(ws, {
|
||||||
type: "webrtc_ready",
|
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 : "";
|
const sdp = typeof (payload as any).sdp === "string" ? (payload as any).sdp : "";
|
||||||
if (!sdp) break;
|
if (!sdp) break;
|
||||||
const sessionId = createP2PSession(ws, clientId, "webcam");
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "webrtc_p2p_ice": {
|
case "webrtc_p2p_ice": {
|
||||||
@@ -1222,6 +1225,7 @@ export function handlebackstageViewerMessage(ws: ServerWebSocket<SocketData>, ra
|
|||||||
kind: "backstage",
|
kind: "backstage",
|
||||||
hasVideo: true,
|
hasVideo: true,
|
||||||
hasAudio: false,
|
hasAudio: false,
|
||||||
|
iceServers: issueTurnIceServers(`${clientId}:backstage:whip`),
|
||||||
});
|
});
|
||||||
safeSendViewer(ws, {
|
safeSendViewer(ws, {
|
||||||
type: "webrtc_ready",
|
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 : "";
|
const sdp = typeof (payload as any).sdp === "string" ? (payload as any).sdp : "";
|
||||||
if (!sdp) break;
|
if (!sdp) break;
|
||||||
const sessionId = createP2PSession(ws, clientId, "backstage");
|
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;
|
break;
|
||||||
}
|
}
|
||||||
case "webrtc_p2p_ice": {
|
case "webrtc_p2p_ice": {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as sessionManager from "../sessions/sessionManager";
|
|||||||
import type { DesktopAudioViewer, SocketData } from "../sessions/types";
|
import type { DesktopAudioViewer, SocketData } from "../sessions/types";
|
||||||
import { canUserAccessClient } from "../users";
|
import { canUserAccessClient } from "../users";
|
||||||
import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes";
|
import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes";
|
||||||
|
import { issueTurnIceServers } from "./turn-credentials";
|
||||||
import {
|
import {
|
||||||
clearP2PSessionForViewer,
|
clearP2PSessionForViewer,
|
||||||
createP2PSession,
|
createP2PSession,
|
||||||
@@ -93,6 +94,7 @@ export function handleDesktopAudioViewerMessage(ws: ServerWebSocket<SocketData>,
|
|||||||
kind: "audio",
|
kind: "audio",
|
||||||
hasVideo: false,
|
hasVideo: false,
|
||||||
hasAudio: true,
|
hasAudio: true,
|
||||||
|
iceServers: issueTurnIceServers(`${clientId}:audio:whip`),
|
||||||
});
|
});
|
||||||
safeJson(ws, {
|
safeJson(ws, {
|
||||||
type: "webrtc_ready",
|
type: "webrtc_ready",
|
||||||
@@ -119,6 +121,7 @@ export function handleDesktopAudioViewerMessage(ws: ServerWebSocket<SocketData>,
|
|||||||
kind: "audio",
|
kind: "audio",
|
||||||
hasVideo: false,
|
hasVideo: false,
|
||||||
hasAudio: true,
|
hasAudio: true,
|
||||||
|
iceServers: issueTurnIceServers(`${clientId}:audio:${sessionId}`),
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ Output: `release/prod-package/`
|
|||||||
The remote desktop viewer has a **Transport** dropdown with three modes:
|
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.
|
- **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.
|
- **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
|
### 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
|
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
|
### MediaMTX sidecar
|
||||||
|
|
||||||
Compose starts an `overlord-mediamtx` service for Relayed mode. It needs:
|
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.
|
- 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.
|
- 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
|
### LAN / public access
|
||||||
|
|
||||||
|
|||||||
@@ -49,11 +49,17 @@ services:
|
|||||||
- OVERLORD_CLIENT_BUILD_CACHE_DIR=/app/client-build-cache
|
- OVERLORD_CLIENT_BUILD_CACHE_DIR=/app/client-build-cache
|
||||||
- OVERLORD_FILE_UPLOAD_INTENT_TTL_MS=1800000
|
- OVERLORD_FILE_UPLOAD_INTENT_TTL_MS=1800000
|
||||||
- OVERLORD_FILE_UPLOAD_PULL_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:
|
volumes:
|
||||||
- overlord-data:/app/data
|
- overlord-data:/app/data
|
||||||
- overlord-certs:/app/certs
|
- overlord-certs:/app/certs
|
||||||
- overlord-client-build-cache:/app/client-build-cache
|
- overlord-client-build-cache:/app/client-build-cache
|
||||||
- overlord-plugins:/app/plugins
|
- overlord-plugins:/app/plugins
|
||||||
|
- overlord-turn-secret:/run/overlord-turn:ro
|
||||||
# Uncomment to mount host certbot certs read-only
|
# Uncomment to mount host certbot certs read-only
|
||||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
# Uncomment to use a static config file instead of env vars
|
# Uncomment to use a static config file instead of env vars
|
||||||
@@ -66,9 +72,46 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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:
|
volumes:
|
||||||
overlord-data:
|
overlord-data:
|
||||||
overlord-certs:
|
overlord-certs:
|
||||||
overlord-client-build-cache:
|
overlord-client-build-cache:
|
||||||
overlord-plugins:
|
overlord-plugins:
|
||||||
|
overlord-turn-secret:
|
||||||
|
|||||||
@@ -49,13 +49,22 @@ services:
|
|||||||
OVERLORD_FILE_UPLOAD_PULL_TTL_MS: ${OVERLORD_FILE_UPLOAD_PULL_TTL_MS:-1800000}
|
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.
|
# WebRTC: Bun reaches MediaMTX via the docker service name on bridge net.
|
||||||
OVERLORD_MEDIAMTX_URL: ${OVERLORD_MEDIAMTX_URL:-http://mediamtx:8889}
|
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:
|
depends_on:
|
||||||
- mediamtx
|
mediamtx:
|
||||||
|
condition: service_started
|
||||||
|
turn-secret-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
volumes:
|
volumes:
|
||||||
- overlord-data:/app/data
|
- overlord-data:/app/data
|
||||||
- overlord-certs:/app/certs
|
- overlord-certs:/app/certs
|
||||||
- overlord-client-build-cache:/app/client-build-cache
|
- overlord-client-build-cache:/app/client-build-cache
|
||||||
- overlord-plugins:/app/plugins
|
- overlord-plugins:/app/plugins
|
||||||
|
- overlord-turn-secret:/run/overlord-turn:ro
|
||||||
# Uncomment to mount host certbot certs read-only
|
# Uncomment to mount host certbot certs read-only
|
||||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
# Uncomment to use a static config file instead of env vars
|
# Uncomment to use a static config file instead of env vars
|
||||||
@@ -72,10 +81,47 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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:
|
mediamtx:
|
||||||
image: bluenviron/mediamtx:latest
|
image: bluenviron/mediamtx:latest
|
||||||
container_name: overlord-mediamtx
|
container_name: overlord-mediamtx
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
command: ["/run/overlord-turn/mediamtx.yml"]
|
||||||
ports:
|
ports:
|
||||||
# WHIP/WHEP signaling only reached by the Bun container internally,
|
# WHIP/WHEP signaling only reached by the Bun container internally,
|
||||||
# so we don't publish 8889. ICE UDP/TCP must be reachable from the
|
# 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.:
|
# 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
|
# OVERLORD_WEBRTC_ADDITIONAL_HOSTS=127.0.0.1,192.168.1.42
|
||||||
MTX_WEBRTCADDITIONALHOSTS: ${OVERLORD_WEBRTC_ADDITIONAL_HOSTS:-127.0.0.1}
|
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:
|
volumes:
|
||||||
overlord-data:
|
overlord-data:
|
||||||
overlord-certs:
|
overlord-certs:
|
||||||
overlord-client-build-cache:
|
overlord-client-build-cache:
|
||||||
overlord-plugins:
|
overlord-plugins:
|
||||||
|
overlord-turn-secret:
|
||||||
|
|||||||
@@ -53,6 +53,11 @@ services:
|
|||||||
# Host networking on Linux → localhost works. WebRTC is opt-in; leave the
|
# Host networking on Linux → localhost works. WebRTC is opt-in; leave the
|
||||||
# toggle in the UI off if MediaMTX is not running.
|
# toggle in the UI off if MediaMTX is not running.
|
||||||
OVERLORD_MEDIAMTX_URL: ${OVERLORD_MEDIAMTX_URL:-http://localhost:8889}
|
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:
|
volumes:
|
||||||
# overlord-data also caches downloaded cross-compile toolchains
|
# overlord-data also caches downloaded cross-compile toolchains
|
||||||
# under /app/data/toolchains, fetched on first agent build for a
|
# under /app/data/toolchains, fetched on first agent build for a
|
||||||
@@ -61,6 +66,7 @@ services:
|
|||||||
- overlord-certs:/app/certs
|
- overlord-certs:/app/certs
|
||||||
- overlord-client-build-cache:/app/client-build-cache
|
- overlord-client-build-cache:/app/client-build-cache
|
||||||
- overlord-plugins:/app/plugins
|
- overlord-plugins:/app/plugins
|
||||||
|
- overlord-turn-secret:/run/overlord-turn:ro
|
||||||
# Uncomment to mount host certbot certs read-only
|
# Uncomment to mount host certbot certs read-only
|
||||||
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
# - /etc/letsencrypt:/etc/letsencrypt:ro
|
||||||
# Uncomment to use a static config file instead of env vars
|
# Uncomment to use a static config file instead of env vars
|
||||||
@@ -76,6 +82,42 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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:
|
mediamtx:
|
||||||
image: bluenviron/mediamtx:latest
|
image: bluenviron/mediamtx:latest
|
||||||
@@ -83,6 +125,12 @@ services:
|
|||||||
# Share the host network so WebRTC ICE candidates use the host's real
|
# Share the host network so WebRTC ICE candidates use the host's real
|
||||||
# interfaces directly — no extra config needed for local or LAN browsers.
|
# interfaces directly — no extra config needed for local or LAN browsers.
|
||||||
network_mode: host
|
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
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
# Disable every protocol except WebRTC. The Overlord server proxies all
|
# Disable every protocol except WebRTC. The Overlord server proxies all
|
||||||
@@ -110,3 +158,4 @@ volumes:
|
|||||||
overlord-certs:
|
overlord-certs:
|
||||||
overlord-client-build-cache:
|
overlord-client-build-cache:
|
||||||
overlord-plugins:
|
overlord-plugins:
|
||||||
|
overlord-turn-secret:
|
||||||
|
|||||||
Reference in New Issue
Block a user