host our own shit
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user