Files
overlord/Overlord-Server/src/server/routes/webrtc-routes.ts
T

136 lines
4.7 KiB
TypeScript

import { randomBytes } from "node:crypto";
import { authenticateRequest } from "../../auth";
import { requireClientAccess, requireFeatureAccess } from "../../rbac";
import { logger } from "../../logger";
import { issueTurnIceServers } from "../turn-credentials";
const MEDIAMTX_URL = (process.env.OVERLORD_MEDIAMTX_URL || "http://localhost:8889").replace(/\/+$/, "");
const PUBLISH_TOKEN_TTL_MS = 60_000;
type PublishToken = { clientId: string; expiresAt: number };
const publishTokens = new Map<string, PublishToken>();
function prunePublishTokens(now = Date.now()) {
for (const [t, entry] of publishTokens) {
if (entry.expiresAt < now) publishTokens.delete(t);
}
}
export function issueWebrtcPublishToken(clientId: string): string {
prunePublishTokens();
const token = randomBytes(24).toString("base64url");
publishTokens.set(token, { clientId, expiresAt: Date.now() + PUBLISH_TOKEN_TTL_MS });
return token;
}
export type WebrtcKind = "desktop" | "backstage" | "webcam" | "audio";
const WEBRTC_FEATURES = {
desktop: "remote_desktop",
backstage: "backstage",
webcam: "webcam",
audio: "voice",
} as const;
export function webrtcStreamPathFor(clientId: string, kind: WebrtcKind): string {
return `agents/${clientId}/${kind}`;
}
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_.\-]+)?$/);
if (!match) return new Response("Not Found", { status: 404 });
const [, streamPath, kind, sessionSuffix] = match;
const clientId = streamPath.split("/")[1];
if (kind === "whip") {
const authHeader = req.headers.get("authorization") || "";
const m = authHeader.match(/^Bearer\s+(.+)$/i);
const token = m?.[1] || "";
const entry = publishTokens.get(token);
if (!entry || entry.clientId !== clientId || entry.expiresAt < Date.now()) {
return new Response("Unauthorized", { status: 401 });
}
if (req.method === "DELETE") {
publishTokens.delete(token);
} else {
entry.expiresAt = Date.now() + PUBLISH_TOKEN_TTL_MS;
}
} else {
const user = await authenticateRequest(req);
if (!user) return new Response("Unauthorized", { status: 401 });
try {
requireClientAccess(user, clientId);
const streamKind = streamPath.split("/")[2] as WebrtcKind;
requireFeatureAccess(user, WEBRTC_FEATURES[streamKind]);
} catch (error) {
if (error instanceof Response) return error;
return new Response("Forbidden", { status: 403 });
}
}
const upstreamUrl = `${MEDIAMTX_URL}/${streamPath}/${kind}${sessionSuffix || ""}`;
const upstreamHeaders = new Headers();
for (const [k, v] of req.headers) {
const lower = k.toLowerCase();
if (lower === "host" || lower === "authorization" || lower === "cookie") continue;
upstreamHeaders.set(k, v);
}
let body: ArrayBuffer | undefined;
if (req.method !== "GET" && req.method !== "HEAD" && req.method !== "DELETE") {
body = await req.arrayBuffer();
}
let upstreamResp: Response;
try {
upstreamResp = await fetch(upstreamUrl, {
method: req.method,
headers: upstreamHeaders,
body,
});
} catch (err) {
logger.warn(`[webrtc] upstream fetch failed (${upstreamUrl}): ${(err as Error).message}`);
return new Response("Bad Gateway", { status: 502 });
}
const respHeaders = new Headers(upstreamResp.headers);
const loc = respHeaders.get("location");
if (loc) {
try {
const abs = new URL(loc, MEDIAMTX_URL);
respHeaders.set("location", `/api/webrtc${abs.pathname}`);
} catch { }
}
return new Response(upstreamResp.body, {
status: upstreamResp.status,
statusText: upstreamResp.statusText,
headers: respHeaders,
});
}