dshost-plugin 0.1.14 → 0.1.16
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 +464 -8
- package/lib/index.js +3 -0
- package/package.json +1 -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
|
|
@@ -375,6 +706,63 @@ export function startAgent(opts) {
|
|
|
375
706
|
|
|
376
707
|
const svcList = normalizeServices(services, log);
|
|
377
708
|
const svcMap = new Map(svcList.map((s) => [s.id, s]));
|
|
709
|
+
// 单业务配置管理(门户托管):宿主显式开启 remoteServices 才接受 relay 下发的
|
|
710
|
+
// 'services:config';下发项与本机声明走同一 normalizeServices 白名单,且只允许
|
|
711
|
+
// auth=open、不接受任何凭据字段;与本机声明同 id 的下发项一律拒绝(本机为准)。
|
|
712
|
+
const remoteServices = opts.remoteServices === true || process.env.DSH_REMOTE_SERVICES === '1';
|
|
713
|
+
let managedList = [];
|
|
714
|
+
let lastApplied = null;
|
|
715
|
+
function servicesFrame() {
|
|
716
|
+
const pub = ({ id, name, desc, icon, auth, entry, spa, category }) => ({ id, name, desc, icon, auth, entry, spa, category });
|
|
717
|
+
return {
|
|
718
|
+
type: 'services',
|
|
719
|
+
servicesOnly: process.env.DSH_SERVICES_ONLY === '1' || undefined,
|
|
720
|
+
capabilities: remoteServices && process.env.DSH_SERVICES_ONLY !== '1' ? { remoteConfig: 1 } : undefined,
|
|
721
|
+
services: [
|
|
722
|
+
...svcList.map((s) => ({ ...pub(s), origin: 'local' })),
|
|
723
|
+
...managedList.map((s) => ({ ...pub(s), origin: 'managed' })),
|
|
724
|
+
],
|
|
725
|
+
applied: lastApplied || undefined,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
function applyManagedConfig(raw) {
|
|
729
|
+
const results = [];
|
|
730
|
+
const accepted = [];
|
|
731
|
+
const localIds = new Set(svcList.map((s) => s.id));
|
|
732
|
+
for (const item of Array.isArray(raw) ? raw.slice(0, 32) : []) {
|
|
733
|
+
const id = String(item?.id || '');
|
|
734
|
+
if (localIds.has(id)) { results.push({ id, ok: false, error: '与本机声明的服务同名,以本机为准' }); continue; }
|
|
735
|
+
const warn = [];
|
|
736
|
+
const [svc] = normalizeServices([{
|
|
737
|
+
id, name: item?.name, desc: item?.desc, icon: item?.icon, upstream: item?.upstream,
|
|
738
|
+
entry: item?.entry, spa: item?.spa === true, auth: 'open',
|
|
739
|
+
}], { warn: (m) => warn.push(m) });
|
|
740
|
+
if (!svc) { results.push({ id, ok: false, error: (warn[0] || 'invalid').replace(/^\[agent\] /, '') }); continue; }
|
|
741
|
+
svc.category = typeof item?.category === 'string' ? item.category.slice(0, 16) : '';
|
|
742
|
+
accepted.push(svc);
|
|
743
|
+
results.push({ id, ok: true });
|
|
744
|
+
}
|
|
745
|
+
for (const s of managedList) svcMap.delete(s.id);
|
|
746
|
+
managedList = accepted;
|
|
747
|
+
for (const s of managedList) svcMap.set(s.id, s);
|
|
748
|
+
lastApplied = results;
|
|
749
|
+
log.log?.(`[agent] managed services applied: ${results.map((r) => `${r.id}:${r.ok ? 'ok' : 'rejected'}`).join(', ') || '(none)'}`);
|
|
750
|
+
}
|
|
751
|
+
function probeUpstream(ws, msg) {
|
|
752
|
+
const t0 = Date.now();
|
|
753
|
+
const reply = (r) => send(ws, { type: 'services:probe-result', reqId: msg.reqId, ms: Date.now() - t0, ...r });
|
|
754
|
+
const up = parseUpstream(msg.upstream);
|
|
755
|
+
if (!up) { reply({ ok: false, error: '上游地址格式错误' }); return; }
|
|
756
|
+
if (!upstreamAllowed(up.host)) { reply({ ok: false, error: `${up.host} 不在实例白名单内(回环 / 10.144.144.0/24 / AGENT_UPSTREAM_ALLOW)` }); return; }
|
|
757
|
+
const entry = typeof msg.entry === 'string' && msg.entry.startsWith('/') ? msg.entry : '/';
|
|
758
|
+
const req = http.request({ host: up.host, port: up.port, path: entry, method: 'GET', timeout: 5000, headers: { host: `${up.host}:${up.port}` } }, (res) => {
|
|
759
|
+
res.resume();
|
|
760
|
+
reply({ ok: res.statusCode < 500, status: res.statusCode });
|
|
761
|
+
});
|
|
762
|
+
req.on('timeout', () => { req.destroy(new Error('连接超时(5s)')); });
|
|
763
|
+
req.on('error', (e) => reply({ ok: false, error: e.code === 'ECONNREFUSED' ? '连接被拒绝(端口没有服务在监听)' : e.message }));
|
|
764
|
+
req.end();
|
|
765
|
+
}
|
|
378
766
|
let relayUsername = null; // set on 'registered'; subject of SSO trust headers
|
|
379
767
|
if (svcList.length) {
|
|
380
768
|
log.log?.(`[agent] plugin services: ${svcList.map((s) => `${s.id}->${s.host}:${s.port}${s.auth === 'sso' ? ' (sso)' : ''}`).join(', ')}`);
|
|
@@ -441,8 +829,43 @@ export function startAgent(opts) {
|
|
|
441
829
|
mintAuthCookie();
|
|
442
830
|
}
|
|
443
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
|
+
|
|
444
846
|
if (webToken) mintAuthCookie();
|
|
445
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
|
+
|
|
446
869
|
const streams = new Map();
|
|
447
870
|
let registered = false;
|
|
448
871
|
let reconnectTimer = null;
|
|
@@ -484,20 +907,52 @@ export function startAgent(opts) {
|
|
|
484
907
|
reconnectAttempt = 0;
|
|
485
908
|
relayUsername = msg.username || null;
|
|
486
909
|
log.log?.(`[agent] Registered as ${msg.username} (session ${msg.sessionId || ''})`);
|
|
487
|
-
send(ws, { type: 'system-info', info: collectSystemInfo(dshVersion) });
|
|
488
|
-
// 插件工作区:上报本实例声明的服务清单(relay 存实例维度,供 /p 路由校验)
|
|
489
|
-
// DSH_SERVICES_ONLY=1:独立 services agent,relay 不注册主实例(服务旁挂到同机 dsh 实例)
|
|
490
910
|
send(ws, {
|
|
491
|
-
type: '
|
|
492
|
-
|
|
493
|
-
|
|
911
|
+
type: 'system-info',
|
|
912
|
+
info: collectSystemInfo(dshVersion, {
|
|
913
|
+
port: lanBridgePort,
|
|
914
|
+
enabled: lanBridge ? lanBridge.enabled : false,
|
|
915
|
+
}),
|
|
494
916
|
});
|
|
917
|
+
// 插件工作区:上报本实例声明的服务清单(relay 存实例维度,供 /p 路由校验)
|
|
918
|
+
// DSH_SERVICES_ONLY=1:独立 services agent,relay 不注册主实例(服务旁挂到同机 dsh 实例)
|
|
919
|
+
send(ws, servicesFrame());
|
|
495
920
|
startHeartbeat(ws);
|
|
496
921
|
return;
|
|
497
922
|
}
|
|
498
923
|
|
|
499
924
|
if (msg.type === 'heartbeat') return;
|
|
500
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
|
+
// 门户托管业务下发(仅宿主开启 remoteServices 时接受)
|
|
944
|
+
if (msg.type === 'services:config') {
|
|
945
|
+
if (!remoteServices) { log.warn?.('[agent] services:config ignored (remoteServices disabled)'); return; }
|
|
946
|
+
applyManagedConfig(msg.services);
|
|
947
|
+
send(ws, servicesFrame());
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
if (msg.type === 'services:probe') {
|
|
951
|
+
if (!remoteServices) { send(ws, { type: 'services:probe-result', reqId: msg.reqId, ok: false, error: 'remoteServices disabled' }); return; }
|
|
952
|
+
probeUpstream(ws, msg);
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
|
|
501
956
|
if (msg.type === 'http:request') {
|
|
502
957
|
// [Artifact Viewer] 是 dsh UI 注入脚本的专属旁路;插件流不经过它。
|
|
503
958
|
if (!msg.target && serveArtifactIfRequested(ws, msg)) return;
|
|
@@ -939,6 +1394,7 @@ export function startAgent(opts) {
|
|
|
939
1394
|
stop() {
|
|
940
1395
|
if (heartbeatInterval) clearInterval(heartbeatInterval);
|
|
941
1396
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
1397
|
+
if (lanBridge) lanBridge.close();
|
|
942
1398
|
},
|
|
943
1399
|
};
|
|
944
1400
|
}
|
package/lib/index.js
CHANGED
|
@@ -20,6 +20,8 @@ export function apply(ctx, config) {
|
|
|
20
20
|
const dshPort = parseInt(config.dshPort || process.env.DSH_PORT || String(DSH_PORT), 10);
|
|
21
21
|
// 插件工作区服务清单(cordis.patch.yml 的 config.services 数组)
|
|
22
22
|
const services = Array.isArray(config.services) ? config.services : [];
|
|
23
|
+
// 单业务配置管理:允许 dshost.me 门户下发托管业务(显式开启;上游白名单仍在本机强制)
|
|
24
|
+
const remoteServices = config.remoteServices === true;
|
|
23
25
|
|
|
24
26
|
console.log('[dsh-remote] Plugin loaded, token:', token ? '***' : 'none');
|
|
25
27
|
if (services.length) console.log('[dsh-remote] Plugin services:', services.map((s) => s && s.id).filter(Boolean).join(', '));
|
|
@@ -41,6 +43,7 @@ export function apply(ctx, config) {
|
|
|
41
43
|
dshHost,
|
|
42
44
|
dshPort,
|
|
43
45
|
services,
|
|
46
|
+
remoteServices,
|
|
44
47
|
});
|
|
45
48
|
|
|
46
49
|
// dsh >= 0.1.2 web auth: the web UI is gated behind a per-process one-time
|
package/package.json
CHANGED