dshost-plugin 0.1.13 → 0.1.15

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
@@ -375,6 +375,63 @@ export function startAgent(opts) {
375
375
 
376
376
  const svcList = normalizeServices(services, log);
377
377
  const svcMap = new Map(svcList.map((s) => [s.id, s]));
378
+ // 单业务配置管理(门户托管):宿主显式开启 remoteServices 才接受 relay 下发的
379
+ // 'services:config';下发项与本机声明走同一 normalizeServices 白名单,且只允许
380
+ // auth=open、不接受任何凭据字段;与本机声明同 id 的下发项一律拒绝(本机为准)。
381
+ const remoteServices = opts.remoteServices === true || process.env.DSH_REMOTE_SERVICES === '1';
382
+ let managedList = [];
383
+ let lastApplied = null;
384
+ function servicesFrame() {
385
+ const pub = ({ id, name, desc, icon, auth, entry, spa, category }) => ({ id, name, desc, icon, auth, entry, spa, category });
386
+ return {
387
+ type: 'services',
388
+ servicesOnly: process.env.DSH_SERVICES_ONLY === '1' || undefined,
389
+ capabilities: remoteServices && process.env.DSH_SERVICES_ONLY !== '1' ? { remoteConfig: 1 } : undefined,
390
+ services: [
391
+ ...svcList.map((s) => ({ ...pub(s), origin: 'local' })),
392
+ ...managedList.map((s) => ({ ...pub(s), origin: 'managed' })),
393
+ ],
394
+ applied: lastApplied || undefined,
395
+ };
396
+ }
397
+ function applyManagedConfig(raw) {
398
+ const results = [];
399
+ const accepted = [];
400
+ const localIds = new Set(svcList.map((s) => s.id));
401
+ for (const item of Array.isArray(raw) ? raw.slice(0, 32) : []) {
402
+ const id = String(item?.id || '');
403
+ if (localIds.has(id)) { results.push({ id, ok: false, error: '与本机声明的服务同名,以本机为准' }); continue; }
404
+ const warn = [];
405
+ const [svc] = normalizeServices([{
406
+ id, name: item?.name, desc: item?.desc, icon: item?.icon, upstream: item?.upstream,
407
+ entry: item?.entry, spa: item?.spa === true, auth: 'open',
408
+ }], { warn: (m) => warn.push(m) });
409
+ if (!svc) { results.push({ id, ok: false, error: (warn[0] || 'invalid').replace(/^\[agent\] /, '') }); continue; }
410
+ svc.category = typeof item?.category === 'string' ? item.category.slice(0, 16) : '';
411
+ accepted.push(svc);
412
+ results.push({ id, ok: true });
413
+ }
414
+ for (const s of managedList) svcMap.delete(s.id);
415
+ managedList = accepted;
416
+ for (const s of managedList) svcMap.set(s.id, s);
417
+ lastApplied = results;
418
+ log.log?.(`[agent] managed services applied: ${results.map((r) => `${r.id}:${r.ok ? 'ok' : 'rejected'}`).join(', ') || '(none)'}`);
419
+ }
420
+ function probeUpstream(ws, msg) {
421
+ const t0 = Date.now();
422
+ const reply = (r) => send(ws, { type: 'services:probe-result', reqId: msg.reqId, ms: Date.now() - t0, ...r });
423
+ const up = parseUpstream(msg.upstream);
424
+ if (!up) { reply({ ok: false, error: '上游地址格式错误' }); return; }
425
+ if (!upstreamAllowed(up.host)) { reply({ ok: false, error: `${up.host} 不在实例白名单内(回环 / 10.144.144.0/24 / AGENT_UPSTREAM_ALLOW)` }); return; }
426
+ const entry = typeof msg.entry === 'string' && msg.entry.startsWith('/') ? msg.entry : '/';
427
+ const req = http.request({ host: up.host, port: up.port, path: entry, method: 'GET', timeout: 5000, headers: { host: `${up.host}:${up.port}` } }, (res) => {
428
+ res.resume();
429
+ reply({ ok: res.statusCode < 500, status: res.statusCode });
430
+ });
431
+ req.on('timeout', () => { req.destroy(new Error('连接超时(5s)')); });
432
+ req.on('error', (e) => reply({ ok: false, error: e.code === 'ECONNREFUSED' ? '连接被拒绝(端口没有服务在监听)' : e.message }));
433
+ req.end();
434
+ }
378
435
  let relayUsername = null; // set on 'registered'; subject of SSO trust headers
379
436
  if (svcList.length) {
380
437
  log.log?.(`[agent] plugin services: ${svcList.map((s) => `${s.id}->${s.host}:${s.port}${s.auth === 'sso' ? ' (sso)' : ''}`).join(', ')}`);
@@ -458,7 +515,19 @@ export function startAgent(opts) {
458
515
  log.log?.(`[agent] Connecting to ${relayUrl} ...`);
459
516
  const ws = new WebSocket(relayUrl);
460
517
 
518
+ // 握手超时看门狗:如果 WS open 事件在 20s 内未到达(TCP 连上但 upgrade
519
+ // 响应丢失——WiFi 恢复期/代理链路抖动时常见),主动 terminate 触发 close
520
+ // → 重连,避免永久挂死在 CONNECTING 状态(旧逻辑的致命缺陷:startHeartbeat
521
+ // 只在 registered 后启动,握手挂死时无任何看门狗看管新连接)。
522
+ const handshakeTimer = setTimeout(() => {
523
+ if (ws.readyState === WebSocket.CONNECTING) {
524
+ log.log?.('[agent] handshake timeout (20s, no WS open), forcing reconnect');
525
+ try { ws.terminate(); } catch (eT) {}
526
+ }
527
+ }, 20000);
528
+
461
529
  ws.on('open', () => {
530
+ clearTimeout(handshakeTimer);
462
531
  log.log?.('[agent] Connected to relay');
463
532
  send(ws, { type: 'register', token, version });
464
533
  });
@@ -475,17 +544,26 @@ export function startAgent(opts) {
475
544
  send(ws, { type: 'system-info', info: collectSystemInfo(dshVersion) });
476
545
  // 插件工作区:上报本实例声明的服务清单(relay 存实例维度,供 /p 路由校验)
477
546
  // DSH_SERVICES_ONLY=1:独立 services agent,relay 不注册主实例(服务旁挂到同机 dsh 实例)
478
- send(ws, {
479
- type: 'services',
480
- servicesOnly: process.env.DSH_SERVICES_ONLY === '1' || undefined,
481
- services: svcList.map(({ id, name, desc, icon, auth, entry, spa }) => ({ id, name, desc, icon, auth, entry, spa })),
482
- });
547
+ send(ws, servicesFrame());
483
548
  startHeartbeat(ws);
484
549
  return;
485
550
  }
486
551
 
487
552
  if (msg.type === 'heartbeat') return;
488
553
 
554
+ // 门户托管业务下发(仅宿主开启 remoteServices 时接受)
555
+ if (msg.type === 'services:config') {
556
+ if (!remoteServices) { log.warn?.('[agent] services:config ignored (remoteServices disabled)'); return; }
557
+ applyManagedConfig(msg.services);
558
+ send(ws, servicesFrame());
559
+ return;
560
+ }
561
+ if (msg.type === 'services:probe') {
562
+ if (!remoteServices) { send(ws, { type: 'services:probe-result', reqId: msg.reqId, ok: false, error: 'remoteServices disabled' }); return; }
563
+ probeUpstream(ws, msg);
564
+ return;
565
+ }
566
+
489
567
  if (msg.type === 'http:request') {
490
568
  // [Artifact Viewer] 是 dsh UI 注入脚本的专属旁路;插件流不经过它。
491
569
  if (!msg.target && serveArtifactIfRequested(ws, msg)) return;
@@ -545,6 +623,8 @@ export function startAgent(opts) {
545
623
  });
546
624
 
547
625
  ws.on('close', (code, reason) => {
626
+ clearTimeout(handshakeTimer);
627
+ if (heartbeatInterval) { clearInterval(heartbeatInterval); heartbeatInterval = null; }
548
628
  log.log?.(`[agent] Disconnected (${code}): ${reason}`);
549
629
  registered = false;
550
630
  for (const [, stream] of streams) {
@@ -560,6 +640,7 @@ export function startAgent(opts) {
560
640
  });
561
641
 
562
642
  ws.on('error', (err) => {
643
+ clearTimeout(handshakeTimer);
563
644
  log.error?.(`[agent] WS Error: ${err.message}`);
564
645
  ws.close();
565
646
  });
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.13",
3
+ "version": "0.1.15",
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",