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
@@ -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;
}