dshost-plugin 0.1.15 → 0.1.17

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.
Files changed (2) hide show
  1. package/lib/core.js +702 -3
  2. package/package.json +2 -1
package/lib/core.js CHANGED
@@ -19,8 +19,93 @@ const WS_SEND_HIGH_WATER = parseInt(process.env.WS_SEND_HIGH_WATER || String(8 *
19
19
  const RECONNECT_BASE_MS = 1000;
20
20
  const RECONNECT_MAX_MS = 60000;
21
21
 
22
+ const IGNORE_IFACE_RE = /^(lo|docker\d*|br-[0-9a-f]+|veth[0-9a-f]+|virbr\d*|cni\d*|flannel\d*)$/i;
23
+ const TRANSPARENT_GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64');
24
+
25
+ /**
26
+ * Enumerate and classify all candidate IPv4 addresses for intranet direct probing.
27
+ * Excludes loopback, internal interfaces, Docker bridges, container veths,
28
+ * sing-box/clash virtual tun interfaces (e.g. 172.18.0.1), and public WAN IPs.
29
+ * Categorizes EasyTier (10.144.x.x), Tailscale (100.64-127.x.x), and LAN (192.168.x.x, 10.x.x.x).
30
+ * @returns {Array<{ip: string, name: string, type: 'lan'|'vpn', desc: string, prio: number}>}
31
+ */
32
+ export function collectCandidateIpv4s() {
33
+ const list = [];
34
+ const seenIps = new Set();
35
+ try {
36
+ const ifaces = os.networkInterfaces();
37
+ for (const [name, addrs] of Object.entries(ifaces)) {
38
+ if (IGNORE_IFACE_RE.test(name)) continue;
39
+ for (const a of addrs || []) {
40
+ if (a.family !== 'IPv4' || a.internal) continue;
41
+ const ip = a.address;
42
+ if (!ip || ip.startsWith('127.') || ip.startsWith('169.254.') || ip === '0.0.0.0') continue;
43
+ if (seenIps.has(ip)) continue;
44
+
45
+ // 1. Identify virtual tun/tap interfaces
46
+ const isTunTap = /^(tun|tap|utun)/i.test(name);
47
+ const isEasyTier = ip.startsWith('10.144.') || /easytier/i.test(name);
48
+ const isTailscale = /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip) || /tailscale/i.test(name);
49
+
50
+ // If it's a tun/tap interface but NOT a recognized mesh VPN, it is a local proxy
51
+ // (e.g. sing-box/clash fake-ip or 172.18.0.1 tun) -> STRICTLY EXCLUDE.
52
+ if (isTunTap && !isEasyTier && !isTailscale) {
53
+ continue;
54
+ }
55
+
56
+ // 2. Filter out 172.16.0.0/12 (Docker networks, sing-box tun endpoints)
57
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) {
58
+ continue;
59
+ }
60
+
61
+ let type = null;
62
+ let desc = name;
63
+ let prio = 99;
64
+
65
+ if (isEasyTier) {
66
+ type = 'vpn';
67
+ desc = 'EasyTier 专网';
68
+ prio = 1; // Prioritize mesh VPN across heterogeneous networks
69
+ } else if (isTailscale) {
70
+ type = 'vpn';
71
+ desc = 'Tailscale 专网';
72
+ prio = 1;
73
+ } else if (ip.startsWith('192.168.')) {
74
+ type = 'lan';
75
+ desc = '物理局域网 (192.168.x)';
76
+ prio = 2;
77
+ } else if (ip.startsWith('10.')) {
78
+ type = 'lan';
79
+ desc = '私有内网 (10.x)';
80
+ prio = 3;
81
+ } else {
82
+ // Public WAN IPs MUST NEVER be reported as intranet direct probe candidates!
83
+ continue;
84
+ }
85
+
86
+ seenIps.add(ip);
87
+ list.push({ ip, name, type, desc, prio });
88
+ }
89
+ }
90
+ } catch {}
91
+
92
+ // Explicit override or additional candidate IPs via env var
93
+ const envCandidates = (process.env.DSH_CANDIDATE_IPS || '').split(',').map((s) => s.trim()).filter(Boolean);
94
+ for (const raw of envCandidates) {
95
+ if (!seenIps.has(raw) && /^(\d{1,3}\.){3}\d{1,3}$/.test(raw)) {
96
+ seenIps.add(raw);
97
+ list.unshift({ ip: raw, name: 'env', type: 'lan', desc: '指定内网地址', prio: 0 });
98
+ }
99
+ }
100
+
101
+ list.sort((a, b) => a.prio - b.prio);
102
+ return list;
103
+ }
104
+
22
105
  /** First non-internal IPv4 of this host (for the dashboard), or null. */
23
106
  function localIPv4() {
107
+ const c = collectCandidateIpv4s();
108
+ if (c.length > 0) return c[0].ip;
24
109
  try {
25
110
  for (const addrs of Object.values(os.networkInterfaces())) {
26
111
  for (const a of addrs || []) {
@@ -242,20 +327,28 @@ function detectDshMetadata() {
242
327
  }
243
328
 
244
329
  /** Collect local system info for the relay dashboard. */
245
- export function collectSystemInfo(defaultVersion) {
330
+ export function collectSystemInfo(defaultVersion, lanBridgeInfo = null) {
246
331
  const disks = collectDisks();
247
332
  const diskTotal = disks.reduce((a, d) => a + d.total, 0);
248
333
  const diskUsed = disks.reduce((a, d) => a + d.used, 0);
249
334
  const diskFree = disks.reduce((a, d) => a + d.free, 0);
250
335
  const cpus = os.cpus();
251
336
  const meta = detectDshMetadata();
337
+ const candidates = collectCandidateIpv4s();
338
+ const bridgePort = lanBridgeInfo?.port || parseInt(process.env.LAN_BRIDGE_PORT || '3088', 10);
339
+ const bridgeEnabled = lanBridgeInfo ? lanBridgeInfo.enabled : process.env.LAN_BRIDGE_ENABLED !== '0';
252
340
  return {
253
341
  instanceId: getEffectiveInstanceId(),
254
342
  hostname: os.hostname(),
255
343
  platform: os.platform(),
256
344
  release: os.release(),
257
345
  arch: os.arch(),
258
- ip: localIPv4(),
346
+ ip: candidates[0]?.ip || localIPv4(),
347
+ network: {
348
+ bridgePort,
349
+ bridgeEnabled,
350
+ candidates,
351
+ },
259
352
  cpuModel: cpus[0]?.model || '',
260
353
  cpuCores: cpus.length,
261
354
  loadAvg: os.loadavg(),
@@ -347,6 +440,244 @@ export function normalizeServices(raw, log) {
347
440
  return out;
348
441
  }
349
442
 
443
+ /**
444
+ * Start lightweight LAN bridge listener for browser probing and direct LAN offloading.
445
+ * - Handles /__dsh_probe.gif and /__dsh_probe with CORS + Private Network Access headers.
446
+ * - Reverse proxies all other requests to local DSH (127.0.0.1:3080) rewriting Host/Origin.
447
+ * - Forwards WebSocket upgrades cleanly.
448
+ */
449
+ export function startLanBridge({
450
+ port = 3088,
451
+ dshHost = '127.0.0.1',
452
+ dshPort = 3080,
453
+ getInstanceId: getInstId = getInstanceId,
454
+ getWebToken = null,
455
+ getAuthCookie = null,
456
+ mintAuthCookie = null,
457
+ log = console,
458
+ }) {
459
+ let isListening = false;
460
+ // Track authenticated client IPs with expiration (7 days)
461
+ const authenticatedClients = new Map();
462
+
463
+ function isClientAuthed(ip, cookieHeader = '') {
464
+ if (cookieHeader.includes('dsh-auth-') || cookieHeader.includes('dsh_lan_auth=1')) {
465
+ return true;
466
+ }
467
+ const exp = authenticatedClients.get(ip);
468
+ if (exp && exp > Date.now()) {
469
+ return true;
470
+ }
471
+ return false;
472
+ }
473
+
474
+ function markClientAuthed(ip) {
475
+ if (ip) {
476
+ authenticatedClients.set(ip, Date.now() + 7 * 86400000);
477
+ if (authenticatedClients.size > 1000) {
478
+ const now = Date.now();
479
+ for (const [k, v] of authenticatedClients) {
480
+ if (v <= now) authenticatedClients.delete(k);
481
+ }
482
+ }
483
+ }
484
+ }
485
+
486
+ const server = http.createServer((req, res) => {
487
+ // CORS + PNA headers for modern browser cross-network access
488
+ res.setHeader('Access-Control-Allow-Origin', '*');
489
+ res.setHeader('Access-Control-Allow-Private-Network', 'true');
490
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS, HEAD');
491
+ res.setHeader('Access-Control-Allow-Headers', '*');
492
+
493
+ if (req.method === 'OPTIONS') {
494
+ res.writeHead(204);
495
+ res.end();
496
+ return;
497
+ }
498
+
499
+ const rawUrl = req.url || '/';
500
+ const pathOnly = rawUrl.split('?')[0];
501
+
502
+ // 1. Lightweight probe image for PNA / Mixed Content bypass
503
+ if (pathOnly === '/__dsh_probe.gif') {
504
+ res.setHeader('Content-Type', 'image/gif');
505
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
506
+ res.writeHead(200);
507
+ res.end(TRANSPARENT_GIF);
508
+ return;
509
+ }
510
+
511
+ // 2. JSON probe endpoint
512
+ if (pathOnly === '/__dsh_probe') {
513
+ res.setHeader('Content-Type', 'application/json');
514
+ res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0');
515
+ res.writeHead(200);
516
+ res.end(JSON.stringify({
517
+ ok: true,
518
+ instanceId: getInstId ? getInstId() : null,
519
+ hostname: os.hostname(),
520
+ ts: Date.now(),
521
+ }));
522
+ return;
523
+ }
524
+
525
+ const clientIp = req.socket?.remoteAddress;
526
+ const incomingCookie = req.headers.cookie || '';
527
+
528
+ // Check if request query contains a launch/direct token
529
+ let hasValidTokenInQuery = false;
530
+ let queryToken = null;
531
+ try {
532
+ const u = new URL(rawUrl, 'http://127.0.0.1');
533
+ queryToken = u.searchParams.get('token');
534
+ const activeToken = getWebToken ? getWebToken() : null;
535
+ if (queryToken && activeToken && queryToken === activeToken) {
536
+ hasValidTokenInQuery = true;
537
+ markClientAuthed(clientIp);
538
+ }
539
+ } catch {}
540
+
541
+ const clientAuthed = hasValidTokenInQuery || isClientAuthed(clientIp, incomingCookie);
542
+
543
+ // 3. Reverse proxy to local dsh on 127.0.0.1:3080
544
+ const dshHeaders = { ...req.headers };
545
+ dshHeaders.host = `${dshHost}:${dshPort}`;
546
+ if (dshHeaders.origin) {
547
+ dshHeaders.origin = `http://${dshHost}:${dshPort}`;
548
+ }
549
+ if (clientIp) {
550
+ dshHeaders['x-forwarded-for'] = clientIp;
551
+ }
552
+
553
+ // Auth injection: If request lacks dsh-auth- cookie, but client is authenticated
554
+ // (via token exchange, dsh_lan_auth cookie, or client IP match),
555
+ // inject active authCookie so local DSH never 401s on browser cross-origin redirects
556
+ const currentAuthCookie = getAuthCookie ? getAuthCookie() : null;
557
+ if (!incomingCookie.includes('dsh-auth-') && clientAuthed && currentAuthCookie) {
558
+ dshHeaders.cookie = incomingCookie ? `${incomingCookie}; ${currentAuthCookie}` : currentAuthCookie;
559
+ }
560
+
561
+ const dshReq = http.request(
562
+ `http://${dshHost}:${dshPort}${rawUrl}`,
563
+ {
564
+ method: req.method,
565
+ headers: dshHeaders,
566
+ },
567
+ (dshRes) => {
568
+ const resHeaders = { ...dshRes.headers };
569
+
570
+ // Rewrite Set-Cookie: SameSite=Strict -> SameSite=Lax
571
+ // Also attach dsh_lan_auth cookie if client is authenticated
572
+ const sc = resHeaders['set-cookie'];
573
+ if (sc) {
574
+ const list = Array.isArray(sc) ? sc : [sc];
575
+ const rewritten = list.map((c) => c.replace(/;\s*SameSite=Strict/i, '; SameSite=Lax'));
576
+ if (clientAuthed) {
577
+ rewritten.push('dsh_lan_auth=1; Path=/; SameSite=Lax; Max-Age=2592000');
578
+ }
579
+ resHeaders['set-cookie'] = rewritten;
580
+ } else if (hasValidTokenInQuery || clientAuthed) {
581
+ resHeaders['set-cookie'] = ['dsh_lan_auth=1; Path=/; SameSite=Lax; Max-Age=2592000'];
582
+ }
583
+
584
+ if (dshRes.statusCode === 401 && mintAuthCookie) {
585
+ mintAuthCookie().catch(() => {});
586
+ }
587
+
588
+ res.writeHead(dshRes.statusCode, resHeaders);
589
+ dshRes.pipe(res);
590
+ }
591
+ );
592
+
593
+ dshReq.on('error', (err) => {
594
+ if (!res.headersSent) {
595
+ res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
596
+ res.end(`LAN Bridge Error: unable to connect to local DSH at ${dshHost}:${dshPort} (${err.message})`);
597
+ }
598
+ });
599
+
600
+ req.pipe(dshReq);
601
+ });
602
+
603
+ // Handle WebSocket Upgrade (e.g. terminals, RPC streams)
604
+ server.on('upgrade', (req, socket, head) => {
605
+ const clientIp = req.socket?.remoteAddress;
606
+ const incomingCookie = req.headers.cookie || '';
607
+ const dshHeaders = { ...req.headers };
608
+ dshHeaders.host = `${dshHost}:${dshPort}`;
609
+ if (dshHeaders.origin) {
610
+ dshHeaders.origin = `http://${dshHost}:${dshPort}`;
611
+ }
612
+
613
+ const currentAuthCookie = getAuthCookie ? getAuthCookie() : null;
614
+ const clientAuthed = isClientAuthed(clientIp, incomingCookie);
615
+ if (!incomingCookie.includes('dsh-auth-') && (clientAuthed || currentAuthCookie) && currentAuthCookie) {
616
+ dshHeaders.cookie = incomingCookie ? `${incomingCookie}; ${currentAuthCookie}` : currentAuthCookie;
617
+ }
618
+
619
+ const proxyReq = http.request({
620
+ hostname: dshHost,
621
+ port: dshPort,
622
+ path: req.url,
623
+ method: req.method,
624
+ headers: dshHeaders,
625
+ });
626
+
627
+ proxyReq.on('upgrade', (proxyRes, proxySocket, proxyHead) => {
628
+ let rawHeaders = `HTTP/1.1 101 Switching Protocols\r\n`;
629
+ for (const [k, v] of Object.entries(proxyRes.headers)) {
630
+ if (Array.isArray(v)) {
631
+ for (const item of v) rawHeaders += `${k}: ${item}\r\n`;
632
+ } else {
633
+ rawHeaders += `${k}: ${v}\r\n`;
634
+ }
635
+ }
636
+ rawHeaders += '\r\n';
637
+ socket.write(rawHeaders);
638
+
639
+ if (proxyHead && proxyHead.length) socket.write(proxyHead);
640
+ if (head && head.length) proxySocket.write(head);
641
+
642
+ proxySocket.pipe(socket);
643
+ socket.pipe(proxySocket);
644
+
645
+ proxySocket.on('error', () => socket.destroy());
646
+ socket.on('error', () => proxySocket.destroy());
647
+ });
648
+
649
+ proxyReq.on('error', (err) => {
650
+ log.warn?.(`[lan-bridge] WS upgrade to ${dshHost}:${dshPort} error: ${err.message}`);
651
+ socket.destroy();
652
+ });
653
+
654
+ proxyReq.end();
655
+ });
656
+
657
+ server.on('error', (err) => {
658
+ isListening = false;
659
+ log.warn?.(`[lan-bridge] Failed to bind 0.0.0.0:${port}: ${err.message}`);
660
+ });
661
+
662
+ try {
663
+ server.listen(port, '0.0.0.0', () => {
664
+ isListening = true;
665
+ log.log?.(`[lan-bridge] Listening on 0.0.0.0:${port} -> proxying to ${dshHost}:${dshPort}`);
666
+ });
667
+ } catch (err) {
668
+ log.warn?.(`[lan-bridge] Failed to start listener: ${err.message}`);
669
+ }
670
+
671
+ return {
672
+ server,
673
+ port,
674
+ get enabled() { return isListening && server.listening; },
675
+ close() {
676
+ try { server.close(); } catch {}
677
+ },
678
+ };
679
+ }
680
+
350
681
  /**
351
682
  * Start the agent connection loop.
352
683
  * @param {object} opts
@@ -498,8 +829,43 @@ export function startAgent(opts) {
498
829
  mintAuthCookie();
499
830
  }
500
831
 
832
+ function getActiveWebToken() {
833
+ if (webToken) return webToken;
834
+ try {
835
+ if (fs.existsSync('/var/log/dsh-web.log')) {
836
+ const content = fs.readFileSync('/var/log/dsh-web.log', 'utf8');
837
+ const matches = [...content.matchAll(/\?token=([A-Za-z0-9_-]+)/g)];
838
+ if (matches.length > 0) {
839
+ return matches[matches.length - 1][1];
840
+ }
841
+ }
842
+ } catch {}
843
+ return null;
844
+ }
845
+
501
846
  if (webToken) mintAuthCookie();
502
847
 
848
+ // ── LAN Bridge for local/intranet direct offload & timing-gap probing ──
849
+ const lanBridgePort = parseInt(process.env.LAN_BRIDGE_PORT || '3088', 10);
850
+ const lanBridgeEnabled = process.env.LAN_BRIDGE_ENABLED !== '0' && process.env.DSH_SERVICES_ONLY !== '1';
851
+ let lanBridge = null;
852
+ if (lanBridgeEnabled) {
853
+ try {
854
+ lanBridge = startLanBridge({
855
+ port: lanBridgePort,
856
+ dshHost,
857
+ dshPort,
858
+ getInstanceId: getEffectiveInstanceId,
859
+ getWebToken: getActiveWebToken,
860
+ getAuthCookie: () => authCookie,
861
+ mintAuthCookie,
862
+ log,
863
+ });
864
+ } catch (e) {
865
+ log.warn?.(`[agent] Failed to initialize LAN bridge: ${e.message}`);
866
+ }
867
+ }
868
+
503
869
  const streams = new Map();
504
870
  let registered = false;
505
871
  let reconnectTimer = null;
@@ -541,7 +907,13 @@ export function startAgent(opts) {
541
907
  reconnectAttempt = 0;
542
908
  relayUsername = msg.username || null;
543
909
  log.log?.(`[agent] Registered as ${msg.username} (session ${msg.sessionId || ''})`);
544
- send(ws, { type: 'system-info', info: collectSystemInfo(dshVersion) });
910
+ send(ws, {
911
+ type: 'system-info',
912
+ info: collectSystemInfo(dshVersion, {
913
+ port: lanBridgePort,
914
+ enabled: lanBridge ? lanBridge.enabled : false,
915
+ }),
916
+ });
545
917
  // 插件工作区:上报本实例声明的服务清单(relay 存实例维度,供 /p 路由校验)
546
918
  // DSH_SERVICES_ONLY=1:独立 services agent,relay 不注册主实例(服务旁挂到同机 dsh 实例)
547
919
  send(ws, servicesFrame());
@@ -551,6 +923,29 @@ export function startAgent(opts) {
551
923
 
552
924
  if (msg.type === 'heartbeat') return;
553
925
 
926
+ // 直连免密换票请求响应
927
+ if (msg.type === 'lan-ticket:request') {
928
+ const activeToken = getActiveWebToken();
929
+ if (!authCookie && activeToken) {
930
+ mintAuthCookie().catch(() => {});
931
+ }
932
+ send(ws, {
933
+ type: 'lan-ticket:response',
934
+ reqId: msg.reqId,
935
+ ok: true,
936
+ token: activeToken || '',
937
+ bridgePort: lanBridgePort,
938
+ bridgeEnabled: lanBridge ? lanBridge.enabled : false,
939
+ });
940
+ return;
941
+ }
942
+
943
+ // WebRTC P2P 穿透打洞信令交互
944
+ if (msg.type === 'webrtc:signal') {
945
+ handleWebRtcSignalMessage(ws, msg);
946
+ return;
947
+ }
948
+
554
949
  // 门户托管业务下发(仅宿主开启 remoteServices 时接受)
555
950
  if (msg.type === 'services:config') {
556
951
  if (!remoteServices) { log.warn?.('[agent] services:config ignored (remoteServices disabled)'); return; }
@@ -997,6 +1392,305 @@ export function startAgent(opts) {
997
1392
  streams.set(streamId, { dshWs, pending });
998
1393
  }
999
1394
 
1395
+ // ── WebRTC P2P Hole Punching Engine (werift) ──────────────────────
1396
+ let weriftModule = null;
1397
+ async function loadWerift() {
1398
+ if (weriftModule) return weriftModule;
1399
+ try {
1400
+ weriftModule = await import('werift');
1401
+ return weriftModule;
1402
+ } catch (e) {
1403
+ log.warn?.(`[agent] werift WebRTC module load failed: ${e.message}`);
1404
+ return null;
1405
+ }
1406
+ }
1407
+
1408
+ const activeRtcPeers = new Map(); // clientId -> { pc, dc, wsStreams }
1409
+
1410
+ async function handleWebRtcSignalMessage(ws, msg) {
1411
+ const { reqId, signalType, sdp, candidate, clientId = 'default' } = msg;
1412
+ if (signalType === 'offer') {
1413
+ const werift = await loadWerift();
1414
+ if (!werift || !werift.RTCPeerConnection) {
1415
+ send(ws, {
1416
+ type: 'webrtc:signal-response',
1417
+ reqId,
1418
+ ok: false,
1419
+ error: '宿主机未安装或加载 werift WebRTC 模块',
1420
+ });
1421
+ return;
1422
+ }
1423
+
1424
+ // 确保本地 Auth Cookie 就绪
1425
+ if (!authCookie && webToken) {
1426
+ try { await mintAuthCookie(); } catch {}
1427
+ }
1428
+
1429
+ try {
1430
+ const existing = activeRtcPeers.get(clientId);
1431
+ if (existing) {
1432
+ try { existing.pc.close(); } catch {}
1433
+ activeRtcPeers.delete(clientId);
1434
+ }
1435
+
1436
+ const pc = new werift.RTCPeerConnection({
1437
+ iceServers: [
1438
+ { urls: 'stun:stun.cloudflare.com:3478' },
1439
+ { urls: 'stun:stun.l.google.com:19302' },
1440
+ ],
1441
+ });
1442
+
1443
+ const peer = {
1444
+ clientId,
1445
+ pc,
1446
+ dc: null,
1447
+ wsStreams: new Map(),
1448
+ };
1449
+ activeRtcPeers.set(clientId, peer);
1450
+
1451
+ pc.ondatachannel = (event) => {
1452
+ const dc = event.channel;
1453
+ peer.dc = dc;
1454
+ log.log?.(`[agent-webrtc] DataChannel opened (${dc.label}) for client ${clientId}`);
1455
+
1456
+ dc.onmessage = (e) => {
1457
+ handleRtcDataChannelMessage(peer, dc, e.data);
1458
+ };
1459
+
1460
+ dc.onclose = () => {
1461
+ log.log?.(`[agent-webrtc] DataChannel closed for client ${clientId}`);
1462
+ for (const [, sWs] of peer.wsStreams) {
1463
+ try { sWs.close(); } catch {}
1464
+ }
1465
+ peer.wsStreams.clear();
1466
+ activeRtcPeers.delete(clientId);
1467
+ };
1468
+
1469
+ dc.onerror = (err) => {
1470
+ log.warn?.(`[agent-webrtc] DataChannel error: ${err.message}`);
1471
+ };
1472
+ };
1473
+
1474
+ await pc.setRemoteDescription({ type: 'offer', sdp });
1475
+ const answer = await pc.createAnswer();
1476
+ await pc.setLocalDescription(answer);
1477
+
1478
+ // 等待 ICE 候选收集(嵌入至 localDescription 中返回,单次 RTT 快速握手)
1479
+ await new Promise((resolve) => {
1480
+ if (pc.iceGatheringState === 'complete') return resolve();
1481
+ const timer = setTimeout(resolve, 800);
1482
+ pc.onicegatheringstatechange = () => {
1483
+ if (pc.iceGatheringState === 'complete') {
1484
+ clearTimeout(timer);
1485
+ resolve();
1486
+ }
1487
+ };
1488
+ });
1489
+
1490
+ send(ws, {
1491
+ type: 'webrtc:signal-response',
1492
+ reqId,
1493
+ ok: true,
1494
+ signalType: 'answer',
1495
+ sdp: pc.localDescription.sdp,
1496
+ });
1497
+ } catch (err) {
1498
+ log.warn?.(`[agent-webrtc] Offer 协商异常: ${err.message}`);
1499
+ send(ws, {
1500
+ type: 'webrtc:signal-response',
1501
+ reqId,
1502
+ ok: false,
1503
+ error: err.message,
1504
+ });
1505
+ }
1506
+ return;
1507
+ }
1508
+
1509
+ if (signalType === 'candidate') {
1510
+ const peer = activeRtcPeers.get(clientId);
1511
+ if (peer && peer.pc && candidate) {
1512
+ try { await peer.pc.addIceCandidate(candidate); } catch {}
1513
+ }
1514
+ send(ws, { type: 'webrtc:signal-response', reqId, ok: true });
1515
+ return;
1516
+ }
1517
+ }
1518
+
1519
+ function handleRtcDataChannelMessage(peer, dc, raw) {
1520
+ let msg;
1521
+ try {
1522
+ msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf8'));
1523
+ } catch {
1524
+ return;
1525
+ }
1526
+
1527
+ if (!msg || !msg.type) return;
1528
+
1529
+ // 1. P2P 测速与探测 (Ping / Pong)
1530
+ if (msg.type === 'ping') {
1531
+ try {
1532
+ dc.send(JSON.stringify({
1533
+ type: 'pong',
1534
+ reqId: msg.reqId,
1535
+ ts: msg.ts,
1536
+ agentTs: Date.now(),
1537
+ instanceId: getEffectiveInstanceId(),
1538
+ hostname: os.hostname(),
1539
+ }));
1540
+ } catch {}
1541
+ return;
1542
+ }
1543
+
1544
+ // 2. HTTP 虚拟流转发
1545
+ if (msg.type === 'http:request') {
1546
+ const { streamId, method = 'GET', url = '/', headers = {}, body } = msg;
1547
+ const targetHeaders = { ...headers };
1548
+ targetHeaders.host = `${dshHost}:${dshPort}`;
1549
+ targetHeaders.origin = `http://${dshHost}:${dshPort}`;
1550
+ if (authCookie) targetHeaders.cookie = authCookie;
1551
+ delete targetHeaders['content-length'];
1552
+
1553
+ const dshReq = http.request(`http://${dshHost}:${dshPort}${url}`, {
1554
+ method,
1555
+ headers: targetHeaders,
1556
+ }, (dshRes) => {
1557
+ const resHeaders = { ...dshRes.headers };
1558
+ const sc = resHeaders['set-cookie'];
1559
+ if (sc) {
1560
+ const list = Array.isArray(sc) ? sc : [sc];
1561
+ resHeaders['set-cookie'] = list.map(c => c.replace(/;\s*SameSite=Strict/i, '; SameSite=Lax'));
1562
+ }
1563
+
1564
+ try {
1565
+ dc.send(JSON.stringify({
1566
+ type: 'http:response',
1567
+ streamId,
1568
+ status: dshRes.statusCode,
1569
+ headers: resHeaders,
1570
+ }));
1571
+ } catch {}
1572
+
1573
+ dshRes.on('data', (chunk) => {
1574
+ try {
1575
+ dc.send(JSON.stringify({
1576
+ type: 'http:chunk',
1577
+ streamId,
1578
+ data: chunk.toString('base64'),
1579
+ }));
1580
+ } catch {}
1581
+ });
1582
+
1583
+ dshRes.on('end', () => {
1584
+ try {
1585
+ dc.send(JSON.stringify({ type: 'http:end', streamId }));
1586
+ } catch {}
1587
+ });
1588
+ });
1589
+
1590
+ dshReq.on('error', (err) => {
1591
+ try {
1592
+ dc.send(JSON.stringify({
1593
+ type: 'http:response',
1594
+ streamId,
1595
+ status: 502,
1596
+ headers: { 'content-type': 'text/plain' },
1597
+ }));
1598
+ dc.send(JSON.stringify({
1599
+ type: 'http:chunk',
1600
+ streamId,
1601
+ data: Buffer.from(`DSH WebRTC proxy error: ${err.message}`).toString('base64'),
1602
+ }));
1603
+ dc.send(JSON.stringify({ type: 'http:end', streamId }));
1604
+ } catch {}
1605
+ });
1606
+
1607
+ if (body) {
1608
+ dshReq.write(Buffer.from(body, 'base64'));
1609
+ }
1610
+ dshReq.end();
1611
+ return;
1612
+ }
1613
+
1614
+ // 3. WebSocket 虚拟流转发 (终端 / RPC)
1615
+ if (msg.type === 'ws:open') {
1616
+ const { streamId, url = '/api/remote.mux' } = msg;
1617
+ const wsHeaders = {
1618
+ host: `${dshHost}:${dshPort}`,
1619
+ origin: `http://${dshHost}:${dshPort}`,
1620
+ };
1621
+ if (authCookie) wsHeaders.cookie = authCookie;
1622
+
1623
+ const dshWs = new WebSocket(`ws://${dshHost}:${dshPort}${url}`, {
1624
+ headers: wsHeaders,
1625
+ });
1626
+
1627
+ peer.wsStreams.set(streamId, dshWs);
1628
+
1629
+ dshWs.on('open', () => {
1630
+ try {
1631
+ dc.send(JSON.stringify({ type: 'ws:opened', streamId }));
1632
+ } catch {}
1633
+ });
1634
+
1635
+ dshWs.on('message', (data, isBinary) => {
1636
+ try {
1637
+ dc.send(JSON.stringify({
1638
+ type: 'ws:message',
1639
+ streamId,
1640
+ data: isBinary ? data.toString('base64') : data.toString(),
1641
+ binary: !!isBinary,
1642
+ }));
1643
+ } catch {}
1644
+ });
1645
+
1646
+ dshWs.on('close', (code, reason) => {
1647
+ peer.wsStreams.delete(streamId);
1648
+ try {
1649
+ dc.send(JSON.stringify({
1650
+ type: 'ws:close',
1651
+ streamId,
1652
+ code,
1653
+ reason: reason?.toString(),
1654
+ }));
1655
+ } catch {}
1656
+ });
1657
+
1658
+ dshWs.on('error', (err) => {
1659
+ try {
1660
+ dc.send(JSON.stringify({
1661
+ type: 'ws:error',
1662
+ streamId,
1663
+ message: err.message,
1664
+ }));
1665
+ } catch {}
1666
+ });
1667
+ return;
1668
+ }
1669
+
1670
+ if (msg.type === 'ws:message') {
1671
+ const { streamId, data, binary } = msg;
1672
+ const dshWs = peer.wsStreams.get(streamId);
1673
+ if (dshWs && dshWs.readyState === WebSocket.OPEN) {
1674
+ if (binary) {
1675
+ dshWs.send(Buffer.from(data, 'base64'), { binary: true });
1676
+ } else {
1677
+ dshWs.send(data);
1678
+ }
1679
+ }
1680
+ return;
1681
+ }
1682
+
1683
+ if (msg.type === 'ws:close') {
1684
+ const { streamId, code, reason } = msg;
1685
+ const dshWs = peer.wsStreams.get(streamId);
1686
+ if (dshWs) {
1687
+ try { dshWs.close(code, reason); } catch {}
1688
+ peer.wsStreams.delete(streamId);
1689
+ }
1690
+ return;
1691
+ }
1692
+ }
1693
+
1000
1694
  connect();
1001
1695
 
1002
1696
  return {
@@ -1005,6 +1699,11 @@ export function startAgent(opts) {
1005
1699
  stop() {
1006
1700
  if (heartbeatInterval) clearInterval(heartbeatInterval);
1007
1701
  if (reconnectTimer) clearTimeout(reconnectTimer);
1702
+ if (lanBridge) lanBridge.close();
1703
+ for (const [, peer] of activeRtcPeers) {
1704
+ try { peer.pc.close(); } catch {}
1705
+ }
1706
+ activeRtcPeers.clear();
1008
1707
  },
1009
1708
  };
1010
1709
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dshost-plugin",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Official remote cloud relay plugin for DSHost (dshost.me): securely access your dsh Web UI from anywhere",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -40,6 +40,7 @@
40
40
  "node": ">=22.5.0"
41
41
  },
42
42
  "dependencies": {
43
+ "werift": "^0.26.0",
43
44
  "ws": "^8.18.0"
44
45
  },
45
46
  "dsh": {