host our own shit

This commit is contained in:
KingKDot
2026-07-17 16:01:48 -05:00
parent 9a274811e8
commit f404f22b29
16 changed files with 462 additions and 17 deletions
@@ -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)
}
+22 -3
View File
@@ -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;
+19 -1
View File
@@ -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;
}
+22 -2
View File
@@ -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
+43
View File
@@ -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:
+53 -1
View File
@@ -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:
+49
View File
@@ -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: