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 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: 'services',
492
- servicesOnly: process.env.DSH_SERVICES_ONLY === '1' || undefined,
493
- services: svcList.map(({ id, name, desc, icon, auth, entry, spa }) => ({ id, name, desc, icon, auth, entry, spa })),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dshost-plugin",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
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",