dshost-plugin 0.1.21 → 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 -25
  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,36 +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:39.106.35.187:3478' },
1460
- { urls: 'stun:111.206.174.2:3478' },
1461
- { urls: 'stun:111.206.174.3:3478' },
1462
- { urls: 'stun:47.100.127.239:3478' },
1463
- { urls: 'stun:stun.qq.com:3478' },
1464
- { urls: 'stun:stun.miwifi.com:3478' },
1465
- { urls: 'stun:stun.cloudflare.com:3478' },
1466
- {
1467
- urls: [
1468
- 'turn:39.106.35.187:3478?transport=udp',
1469
- 'turn:39.106.35.187:3478?transport=tcp',
1470
- ],
1471
- username: 'dshost',
1472
- credential: 'dshost_p2p_secret',
1473
- },
1474
- ],
1475
- });
1545
+ const iceServers = await getIceServers(ws);
1546
+ const pc = new werift.RTCPeerConnection({ iceServers });
1476
1547
 
1477
1548
  const peer = {
1478
1549
  clientId,
1479
1550
  pc,
1480
1551
  dc: null,
1552
+ ws,
1481
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 },
1482
1558
  };
1483
1559
  activeRtcPeers.set(clientId, peer);
1484
1560
 
@@ -1491,13 +1567,36 @@ export function startAgent(opts) {
1491
1567
  handleRtcDataChannelMessage(peer, dc, e.data);
1492
1568
  };
1493
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
+
1494
1583
  dc.onclose = () => {
1495
1584
  log.log?.(`[agent-webrtc] DataChannel closed for client ${clientId}`);
1496
1585
  for (const [, sWs] of peer.wsStreams) {
1497
1586
  try { sWs.close(); } catch {}
1498
1587
  }
1499
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);
1500
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
+ }
1501
1600
  };
1502
1601
 
1503
1602
  dc.onerror = (err) => {
@@ -1510,9 +1609,11 @@ export function startAgent(opts) {
1510
1609
  await pc.setLocalDescription(answer);
1511
1610
 
1512
1611
  // 等待 ICE 候选收集(嵌入至 localDescription 中返回,单次 RTT 快速握手)
1612
+ // 2500ms:TURN-only 路径经常超过原来的 1200ms,窗口太短会拿到没有
1613
+ // relay candidate 的 answer,握手必然失败。
1513
1614
  await new Promise((resolve) => {
1514
1615
  if (pc.iceGatheringState === 'complete') return resolve();
1515
- const timer = setTimeout(resolve, 1200);
1616
+ const timer = setTimeout(resolve, 2500);
1516
1617
  pc.onicegatheringstatechange = () => {
1517
1618
  if (pc.iceGatheringState === 'complete') {
1518
1619
  clearTimeout(timer);
@@ -1560,6 +1661,19 @@ export function startAgent(opts) {
1560
1661
 
1561
1662
  if (!msg || !msg.type) return;
1562
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
+
1563
1677
  // 1. P2P 测速与探测 (Ping / Pong)
1564
1678
  if (msg.type === 'ping') {
1565
1679
  try {
@@ -1583,6 +1697,8 @@ export function startAgent(opts) {
1583
1697
  targetHeaders.origin = `http://${dshHost}:${dshPort}`;
1584
1698
  if (authCookie) targetHeaders.cookie = authCookie;
1585
1699
  delete targetHeaders['content-length'];
1700
+ // 压缩后的字节没法塞进构造型 Response 让浏览器解压,直接要明文。
1701
+ targetHeaders['accept-encoding'] = 'identity';
1586
1702
 
1587
1703
  const dshReq = http.request(`http://${dshHost}:${dshPort}${url}`, {
1588
1704
  method,
@@ -1605,6 +1721,7 @@ export function startAgent(opts) {
1605
1721
  } catch {}
1606
1722
 
1607
1723
  dshRes.on('data', (chunk) => {
1724
+ bump(peer, 'down', chunk.length);
1608
1725
  try {
1609
1726
  dc.send(JSON.stringify({
1610
1727
  type: 'http:chunk',
@@ -1615,13 +1732,17 @@ export function startAgent(opts) {
1615
1732
  });
1616
1733
 
1617
1734
  dshRes.on('end', () => {
1735
+ peer.httpReqs.delete(streamId);
1618
1736
  try {
1619
1737
  dc.send(JSON.stringify({ type: 'http:end', streamId }));
1620
1738
  } catch {}
1621
1739
  });
1622
1740
  });
1623
1741
 
1742
+ peer.httpReqs.set(streamId, dshReq);
1743
+
1624
1744
  dshReq.on('error', (err) => {
1745
+ peer.httpReqs.delete(streamId);
1625
1746
  try {
1626
1747
  dc.send(JSON.stringify({
1627
1748
  type: 'http:response',
@@ -1639,12 +1760,24 @@ export function startAgent(opts) {
1639
1760
  });
1640
1761
 
1641
1762
  if (body) {
1642
- dshReq.write(Buffer.from(body, 'base64'));
1763
+ const buf = Buffer.from(body, 'base64');
1764
+ bump(peer, 'up', buf.length);
1765
+ dshReq.write(buf);
1643
1766
  }
1644
1767
  dshReq.end();
1645
1768
  return;
1646
1769
  }
1647
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
+
1648
1781
  // 3. WebSocket 虚拟流转发 (终端 / RPC)
1649
1782
  if (msg.type === 'ws:open') {
1650
1783
  const { streamId, url = '/api/remote.mux' } = msg;
@@ -1667,6 +1800,7 @@ export function startAgent(opts) {
1667
1800
  });
1668
1801
 
1669
1802
  dshWs.on('message', (data, isBinary) => {
1803
+ bump(peer, 'down', isBinary ? data.length : Buffer.byteLength(data, 'utf8'));
1670
1804
  try {
1671
1805
  dc.send(JSON.stringify({
1672
1806
  type: 'ws:message',
@@ -1703,6 +1837,7 @@ export function startAgent(opts) {
1703
1837
 
1704
1838
  if (msg.type === 'ws:message') {
1705
1839
  const { streamId, data, binary } = msg;
1840
+ bump(peer, 'up', binary ? Buffer.from(data, 'base64').length : Buffer.byteLength(data, 'utf8'));
1706
1841
  const dshWs = peer.wsStreams.get(streamId);
1707
1842
  if (dshWs && dshWs.readyState === WebSocket.OPEN) {
1708
1843
  if (binary) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dshost-plugin",
3
- "version": "0.1.21",
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",