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.
- package/lib/core.js +160 -13
- 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
|
|
1458
|
-
|
|
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,
|
|
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
|
-
|
|
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