dshost-plugin 0.1.20 → 0.1.22

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 +160 -13
  2. package/package.json +1 -1
package/lib/core.js CHANGED
@@ -940,6 +940,21 @@ export function startAgent(opts) {
940
940
  return;
941
941
  }
942
942
 
943
+ // WebRTC ICE 配置(relay 现签 TURN 临时凭证)
944
+ if (msg.type === 'webrtc:config') {
945
+ const cb = pendingWebrtcConfig.get(msg.reqId);
946
+ if (cb) { pendingWebrtcConfig.delete(msg.reqId); cb(msg); }
947
+ return;
948
+ }
949
+
950
+ // relay 吊销某个 P2P 连接(被顶替 / 登出 / 封禁 / 超配额)
951
+ if (msg.type === 'webrtc:revoke') {
952
+ if (closePeer(msg.clientId)) {
953
+ log.log?.(`[agent-webrtc] revoked client ${msg.clientId}`);
954
+ }
955
+ return;
956
+ }
957
+
943
958
  // WebRTC P2P 穿透打洞信令交互
944
959
  if (msg.type === 'webrtc:signal') {
945
960
  handleWebRtcSignalMessage(ws, msg);
@@ -1428,6 +1443,82 @@ export function startAgent(opts) {
1428
1443
 
1429
1444
  const activeRtcPeers = new Map(); // clientId -> { pc, dc, wsStreams }
1430
1445
 
1446
+ // ── ICE 配置 ──────────────────────────────────────────────────────
1447
+ // TURN 凭证由 relay 现签(relay 持有 TURN_SECRET,agent 不该有)。缓存到
1448
+ // 剩余 10 分钟时刷新;relay 不可达则退化为仅 STUN,P2P 可能失败但不会崩。
1449
+ const STUN_ONLY = [
1450
+ { urls: 'stun:39.106.35.187:3478' },
1451
+ { urls: 'stun:111.206.174.2:3478' },
1452
+ { urls: 'stun:111.206.174.3:3478' },
1453
+ { urls: 'stun:47.100.127.239:3478' },
1454
+ { urls: 'stun:stun.qq.com:3478' },
1455
+ { urls: 'stun:stun.miwifi.com:3478' },
1456
+ { urls: 'stun:stun.cloudflare.com:3478' },
1457
+ ];
1458
+ let iceCache = { servers: null, expiresAt: 0 };
1459
+ const pendingWebrtcConfig = new Map(); // reqId -> resolve
1460
+
1461
+ function requestWebrtcConfig(ws) {
1462
+ const reqId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1463
+ return new Promise((resolve) => {
1464
+ const timer = setTimeout(() => {
1465
+ pendingWebrtcConfig.delete(reqId);
1466
+ resolve(null);
1467
+ }, 5000);
1468
+ pendingWebrtcConfig.set(reqId, (msg) => {
1469
+ clearTimeout(timer);
1470
+ resolve(msg);
1471
+ });
1472
+ send(ws, { type: 'webrtc:config-request', reqId });
1473
+ });
1474
+ }
1475
+
1476
+ async function getIceServers(ws) {
1477
+ const now = Date.now();
1478
+ if (iceCache.servers && iceCache.expiresAt - now > 600_000) return iceCache.servers;
1479
+ const cfg = await requestWebrtcConfig(ws);
1480
+ if (cfg && Array.isArray(cfg.iceServers) && cfg.iceServers.length) {
1481
+ const ttlMs = (Number(cfg.ttl) || 3600) * 1000;
1482
+ iceCache = { servers: cfg.iceServers, expiresAt: now + ttlMs };
1483
+ return iceCache.servers;
1484
+ }
1485
+ return STUN_ONLY;
1486
+ }
1487
+
1488
+ // ── P2P 用量统计(每 60s 汇总上报,供 relay 记账与配额)─────────────
1489
+ function bump(peer, dir, n) {
1490
+ if (!peer || !peer.usage || !n) return;
1491
+ if (dir === 'up') peer.usage.up += n;
1492
+ else peer.usage.down += n;
1493
+ }
1494
+
1495
+ function flushUsage(peer) {
1496
+ if (!peer || !peer.usage || !peer.ws || peer.ws.readyState !== 1) return;
1497
+ const { up, down } = peer.usage;
1498
+ if (!up && !down) return;
1499
+ peer.usage.up = 0;
1500
+ peer.usage.down = 0;
1501
+ try {
1502
+ send(peer.ws, {
1503
+ type: 'webrtc:usage',
1504
+ clientId: peer.clientId,
1505
+ bytesUp: up,
1506
+ bytesDown: down,
1507
+ streams: peer.wsStreams.size,
1508
+ windowMs: 60_000,
1509
+ });
1510
+ } catch {}
1511
+ }
1512
+
1513
+ function closePeer(clientId) {
1514
+ const peer = activeRtcPeers.get(clientId);
1515
+ if (!peer) return false;
1516
+ flushUsage(peer);
1517
+ try { peer.pc.close(); } catch {}
1518
+ activeRtcPeers.delete(clientId);
1519
+ return true;
1520
+ }
1521
+
1431
1522
  async function handleWebRtcSignalMessage(ws, msg) {
1432
1523
  const { reqId, signalType, sdp, candidate, clientId = 'default' } = msg;
1433
1524
  if (signalType === 'offer') {
@@ -1449,24 +1540,21 @@ export function startAgent(opts) {
1449
1540
 
1450
1541
  try {
1451
1542
  const existing = activeRtcPeers.get(clientId);
1452
- if (existing) {
1453
- try { existing.pc.close(); } catch {}
1454
- activeRtcPeers.delete(clientId);
1455
- }
1543
+ if (existing) closePeer(clientId);
1456
1544
 
1457
- const pc = new werift.RTCPeerConnection({
1458
- iceServers: [
1459
- { urls: 'stun:stun.cloudflare.com:3478' },
1460
- { urls: 'stun:stun.qq.com:3478' },
1461
- { urls: 'stun:stun.miwifi.com:3478' },
1462
- ],
1463
- });
1545
+ const iceServers = await getIceServers(ws);
1546
+ const pc = new werift.RTCPeerConnection({ iceServers });
1464
1547
 
1465
1548
  const peer = {
1466
1549
  clientId,
1467
1550
  pc,
1468
1551
  dc: null,
1552
+ ws,
1469
1553
  wsStreams: new Map(),
1554
+ httpReqs: new Map(),
1555
+ nonce: typeof msg.peerNonce === 'string' ? msg.peerNonce : '',
1556
+ authed: !msg.peerNonce,
1557
+ usage: { up: 0, down: 0, timer: null },
1470
1558
  };
1471
1559
  activeRtcPeers.set(clientId, peer);
1472
1560
 
@@ -1479,13 +1567,36 @@ export function startAgent(opts) {
1479
1567
  handleRtcDataChannelMessage(peer, dc, e.data);
1480
1568
  };
1481
1569
 
1570
+ // 首帧必须是 auth。5s 内未认证就直接断开,避免半开连接长期挂着。
1571
+ if (!peer.authed) {
1572
+ peer.authTimer = setTimeout(() => {
1573
+ if (!peer.authed) {
1574
+ log.warn?.(`[agent-webrtc] client ${clientId} failed to authenticate, closing`);
1575
+ closePeer(clientId);
1576
+ }
1577
+ }, 5000);
1578
+ }
1579
+
1580
+ // 用量每 60s 汇总一次;DC 关闭时再冲一次尾账。
1581
+ peer.usage.timer = setInterval(() => flushUsage(peer), 60_000);
1582
+
1482
1583
  dc.onclose = () => {
1483
1584
  log.log?.(`[agent-webrtc] DataChannel closed for client ${clientId}`);
1484
1585
  for (const [, sWs] of peer.wsStreams) {
1485
1586
  try { sWs.close(); } catch {}
1486
1587
  }
1487
1588
  peer.wsStreams.clear();
1589
+ for (const [, req] of peer.httpReqs) {
1590
+ try { req.destroy(); } catch {}
1591
+ }
1592
+ peer.httpReqs.clear();
1593
+ flushUsage(peer);
1594
+ if (peer.usage.timer) clearInterval(peer.usage.timer);
1595
+ if (peer.authTimer) clearTimeout(peer.authTimer);
1488
1596
  activeRtcPeers.delete(clientId);
1597
+ if (peer.authed && peer.ws && peer.ws.readyState === 1) {
1598
+ try { send(peer.ws, { type: 'webrtc:peer-state', clientId, state: 'closed' }); } catch {}
1599
+ }
1489
1600
  };
1490
1601
 
1491
1602
  dc.onerror = (err) => {
@@ -1498,9 +1609,11 @@ export function startAgent(opts) {
1498
1609
  await pc.setLocalDescription(answer);
1499
1610
 
1500
1611
  // 等待 ICE 候选收集(嵌入至 localDescription 中返回,单次 RTT 快速握手)
1612
+ // 2500ms:TURN-only 路径经常超过原来的 1200ms,窗口太短会拿到没有
1613
+ // relay candidate 的 answer,握手必然失败。
1501
1614
  await new Promise((resolve) => {
1502
1615
  if (pc.iceGatheringState === 'complete') return resolve();
1503
- const timer = setTimeout(resolve, 800);
1616
+ const timer = setTimeout(resolve, 2500);
1504
1617
  pc.onicegatheringstatechange = () => {
1505
1618
  if (pc.iceGatheringState === 'complete') {
1506
1619
  clearTimeout(timer);
@@ -1548,6 +1661,19 @@ export function startAgent(opts) {
1548
1661
 
1549
1662
  if (!msg || !msg.type) return;
1550
1663
 
1664
+ // 0. 认证门禁:未认证前只接受 auth,其余消息一律丢弃。
1665
+ if (!peer.authed) {
1666
+ if (msg.type === 'auth' && peer.nonce && msg.nonce === peer.nonce) {
1667
+ peer.authed = true;
1668
+ if (peer.authTimer) { clearTimeout(peer.authTimer); peer.authTimer = null; }
1669
+ log.log?.(`[agent-webrtc] client ${peer.clientId} authenticated`);
1670
+ if (peer.ws && peer.ws.readyState === 1) {
1671
+ try { send(peer.ws, { type: 'webrtc:peer-state', clientId: peer.clientId, state: 'open' }); } catch {}
1672
+ }
1673
+ }
1674
+ return;
1675
+ }
1676
+
1551
1677
  // 1. P2P 测速与探测 (Ping / Pong)
1552
1678
  if (msg.type === 'ping') {
1553
1679
  try {
@@ -1571,6 +1697,8 @@ export function startAgent(opts) {
1571
1697
  targetHeaders.origin = `http://${dshHost}:${dshPort}`;
1572
1698
  if (authCookie) targetHeaders.cookie = authCookie;
1573
1699
  delete targetHeaders['content-length'];
1700
+ // 压缩后的字节没法塞进构造型 Response 让浏览器解压,直接要明文。
1701
+ targetHeaders['accept-encoding'] = 'identity';
1574
1702
 
1575
1703
  const dshReq = http.request(`http://${dshHost}:${dshPort}${url}`, {
1576
1704
  method,
@@ -1593,6 +1721,7 @@ export function startAgent(opts) {
1593
1721
  } catch {}
1594
1722
 
1595
1723
  dshRes.on('data', (chunk) => {
1724
+ bump(peer, 'down', chunk.length);
1596
1725
  try {
1597
1726
  dc.send(JSON.stringify({
1598
1727
  type: 'http:chunk',
@@ -1603,13 +1732,17 @@ export function startAgent(opts) {
1603
1732
  });
1604
1733
 
1605
1734
  dshRes.on('end', () => {
1735
+ peer.httpReqs.delete(streamId);
1606
1736
  try {
1607
1737
  dc.send(JSON.stringify({ type: 'http:end', streamId }));
1608
1738
  } catch {}
1609
1739
  });
1610
1740
  });
1611
1741
 
1742
+ peer.httpReqs.set(streamId, dshReq);
1743
+
1612
1744
  dshReq.on('error', (err) => {
1745
+ peer.httpReqs.delete(streamId);
1613
1746
  try {
1614
1747
  dc.send(JSON.stringify({
1615
1748
  type: 'http:response',
@@ -1627,12 +1760,24 @@ export function startAgent(opts) {
1627
1760
  });
1628
1761
 
1629
1762
  if (body) {
1630
- dshReq.write(Buffer.from(body, 'base64'));
1763
+ const buf = Buffer.from(body, 'base64');
1764
+ bump(peer, 'up', buf.length);
1765
+ dshReq.write(buf);
1631
1766
  }
1632
1767
  dshReq.end();
1633
1768
  return;
1634
1769
  }
1635
1770
 
1771
+ // 2b. 浏览器中断 HTTP 流(AbortSignal)
1772
+ if (msg.type === 'http:abort') {
1773
+ const req = peer.httpReqs.get(msg.streamId);
1774
+ if (req) {
1775
+ try { req.destroy(); } catch {}
1776
+ peer.httpReqs.delete(msg.streamId);
1777
+ }
1778
+ return;
1779
+ }
1780
+
1636
1781
  // 3. WebSocket 虚拟流转发 (终端 / RPC)
1637
1782
  if (msg.type === 'ws:open') {
1638
1783
  const { streamId, url = '/api/remote.mux' } = msg;
@@ -1655,6 +1800,7 @@ export function startAgent(opts) {
1655
1800
  });
1656
1801
 
1657
1802
  dshWs.on('message', (data, isBinary) => {
1803
+ bump(peer, 'down', isBinary ? data.length : Buffer.byteLength(data, 'utf8'));
1658
1804
  try {
1659
1805
  dc.send(JSON.stringify({
1660
1806
  type: 'ws:message',
@@ -1691,6 +1837,7 @@ export function startAgent(opts) {
1691
1837
 
1692
1838
  if (msg.type === 'ws:message') {
1693
1839
  const { streamId, data, binary } = msg;
1840
+ bump(peer, 'up', binary ? Buffer.from(data, 'base64').length : Buffer.byteLength(data, 'utf8'));
1694
1841
  const dshWs = peer.wsStreams.get(streamId);
1695
1842
  if (dshWs && dshWs.readyState === WebSocket.OPEN) {
1696
1843
  if (binary) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dshost-plugin",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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",