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.
- package/lib/core.js +702 -3
- 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, {
|
|
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.
|
|
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": {
|