dsh-remote-agent 0.9.3 → 0.9.4

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/hub/index.js CHANGED
@@ -18,7 +18,12 @@ import { ERROR_CODE, PROTOCOL_VERSION } from '../protocol.js'
18
18
  import { acceptUpgrade } from '../ws.js'
19
19
  // 独立监听端口(2026-10-18):副机拨入不再依赖 DSH 的 webserver 配置
20
20
  // (DSH 每次启动重写 profile patch,会把 webserver 段冲掉)。
21
- import { DEFAULT_LISTEN_PORT, resolveListenHost, startListenServer } from '../listen-server.js'
21
+ import {
22
+ DEFAULT_LISTEN_PORT,
23
+ isLoopbackHost,
24
+ resolveListenHost,
25
+ startListenServer,
26
+ } from '../listen-server.js'
22
27
  import { NodeRegistry } from './node-registry.js'
23
28
  import { RelayLink } from './relay-link.js'
24
29
  import { createPluginLogger } from './hub-util.js'
@@ -303,47 +308,106 @@ export function applyHub(ctx, config = {}) {
303
308
  //
304
309
  // ⚠ 自检本身**绝不能**让 hub 起不来:`getOrigin()` 在 listen 之前会抛,
305
310
  // 服务缺失时也不存在。整段包 try/catch,拿不到就记 `unknown`。
306
- let reachability = { bound: null, origin: null, loopback: null, hint: '' }
307
- try {
308
- const ws = ctx.webServer
309
- const origin = typeof ws?.getOrigin === 'function' ? ws.getOrigin() : null
310
- if (typeof origin === 'string' && origin !== '') {
311
- let hostname = null
312
- try {
313
- hostname = new URL(origin).hostname
314
- } catch {
315
- /* 非 URL 形态:保持 null */
311
+ //
312
+ // ═══════════════════════════════════════════════════════════════════════════
313
+ // 2026-10-20 重写:旧结论只反映 webServer,会**假红**(0.9.3 起独立监听才是主通道)
314
+ // ═══════════════════════════════════════════════════════════════════════════
315
+ //
316
+ // 0.9.3 把节点端点搬到独立监听(默认 19387)后,这段自检没跟着改,结果是:
317
+ // 副机明明能连(19387 在听),面板却因为 DSH 的 webserver 绑 127.0.0.1 而
318
+ // 报「副机用任何地址都连不上」,并把用户引向一个已被证伪的修法(改 webserver)。
319
+ //
320
+ // 现在拆成两层:
321
+ // · `webServerReach` —— 旧通道的结论(保留给"独立监听没起来"的降级场景)
322
+ // · `currentReachability()` —— **对外唯一入口**:独立监听优先,实时读取
323
+ //
324
+ // 为什么是函数而不是快照:独立监听是异步启动的(Promise),注册 ui-routes 时
325
+ // 往往还没结果。传快照会让面板永远显示启动瞬间的状态。
326
+ const listenRuntime = {
327
+ ok: null, // null=未出结果 / true=在听 / false=失败
328
+ disabled: '', // 非空 = 显式关闭的原因(listenPort:0 / 测试环境 / token 空)
329
+ host: '',
330
+ port: 0,
331
+ source: '',
332
+ error: '',
333
+ }
334
+
335
+ /** 旧通道(DSH webserver)的可达性结论。 */
336
+ function computeWebServerReachability() {
337
+ try {
338
+ const ws = ctx.webServer
339
+ const origin = typeof ws?.getOrigin === 'function' ? ws.getOrigin() : null
340
+ if (typeof origin === 'string' && origin !== '') {
341
+ let hostname = null
342
+ try {
343
+ hostname = new URL(origin).hostname
344
+ } catch {
345
+ /* 非 URL 形态:保持 null */
346
+ }
347
+ const h = typeof hostname === 'string' ? hostname.toLowerCase() : ''
348
+ const loopback = isLoopbackHost(h)
349
+ return {
350
+ bound: hostname,
351
+ origin,
352
+ loopback,
353
+ hint: loopback
354
+ ? 'DSH 的 Web 服务绑在 loopback。如果下面「独立监听」正常,这不影响副机接入 —— '
355
+ + '本插件已不再依赖它(2026-10-18 架构变更)。'
356
+ : '',
357
+ via: 'webserver',
358
+ }
316
359
  }
317
- const h = typeof hostname === 'string' ? hostname.toLowerCase() : ''
318
- // IPv6 在 URL.hostname 里带方括号
319
- const loopback = h === '127.0.0.1' || h === 'localhost' || h === '[::1]' || h === '::1'
320
- reachability = {
321
- bound: hostname,
322
- origin,
360
+ return { bound: null, origin: null, loopback: null, hint: 'webServer.getOrigin() 不可用', via: 'webserver' }
361
+ } catch (err) {
362
+ // getOrigin() 在 listen 完成前会抛(文档明说);服务缺失也会抛。
363
+ log('info', 'reachability self-check unavailable', { error: String(err?.message ?? err) })
364
+ return {
365
+ bound: null,
366
+ origin: null,
367
+ loopback: null,
368
+ hint: `webServer 可达性自检失败:${String(err?.message ?? err)}`,
369
+ via: 'webserver',
370
+ }
371
+ }
372
+ }
373
+
374
+ const webServerReach = computeWebServerReachability()
375
+
376
+ /**
377
+ * 对外统一的可达性结论:**独立监听优先**。
378
+ * @returns {{bound:string|null, origin:string|null, loopback:boolean|null, hint:string, via:string, port?:number}}
379
+ */
380
+ function currentReachability() {
381
+ if (listenRuntime.ok === true && listenRuntime.host !== '') {
382
+ const loopback = isLoopbackHost(listenRuntime.host)
383
+ return {
384
+ bound: listenRuntime.host,
385
+ origin: `${listenRuntime.host}:${listenRuntime.port}`,
323
386
  loopback,
324
387
  hint: loopback
325
- // ⚠ 不能建议 0.0.0.0(2026-10-18 事故):新版 dsh-host-webserver 在
326
- // schema 校验阶段就拒绝通配地址 → webserver 无法激活 → webServer
327
- // 服务缺失 → DSH 启动崩溃。必须绑一个**具体的本机接口地址**。
328
- ? 'webServer 绑在 loopback,副机无法接入。在 profile 的 cordis.patch.yml 里把 webserver 的 host 改成一个具体的本机 IP(推荐 Tailscale IP:tailscale ip -4),然后重启 DSH。不能用 0.0.0.0 —— 新版 DSH 会拒绝并启动失败。'
388
+ ? '独立监听绑在 loopback —— 副机无法接入。原因通常是 nodeToken 为空(安全策略:'
389
+ + '无口令的端点不暴露到网络)。到面板「副机接入」生成口令后重启 DSH 即可。'
329
390
  : '',
391
+ via: 'listen-server',
392
+ port: listenRuntime.port,
393
+ source: listenRuntime.source,
330
394
  }
331
- if (loopback) {
332
- log('warn', 'hub 绑在 loopback:副机将无法接入', {
333
- origin,
334
- bound: hostname,
335
- fix: "把 webserver 的 host 改成具体的本机 IP(如 Tailscale IP),不能用 0.0.0.0",
336
- })
337
- } else {
338
- log('info', 'hub reachable from other hosts', { origin, bound: hostname })
339
- }
340
- } else {
341
- reachability = { bound: null, origin: null, loopback: null, hint: 'webServer.getOrigin() 不可用,无法判断可达性' }
342
395
  }
343
- } catch (err) {
344
- // getOrigin() 在 listen 完成前会抛(文档明说);服务缺失也会抛。
345
- reachability = { bound: null, origin: null, loopback: null, hint: `可达性自检失败:${String(err?.message ?? err)}` }
346
- log('info', 'reachability self-check unavailable', { error: String(err?.message ?? err) })
396
+ // 独立监听没起来(失败/关闭)→ 退回旧通道结论,并把原因带上
397
+ const reason = listenRuntime.error !== ''
398
+ ? `独立监听启动失败:${listenRuntime.error}`
399
+ : (listenRuntime.disabled !== '' ? `独立监听已关闭:${listenRuntime.disabled}` : '')
400
+ return {
401
+ ...webServerReach,
402
+ hint: [reason, webServerReach.hint].filter((s) => s !== '').join(' '),
403
+ }
404
+ }
405
+
406
+ // 启动期的一条日志(面板读实时值,日志只在启动时说一次)
407
+ if (webServerReach.loopback === true) {
408
+ log('info', 'DSH webserver 只绑 loopback(不影响独立监听通道)', {
409
+ origin: webServerReach.origin,
410
+ })
347
411
  }
348
412
 
349
413
  const registry = new NodeRegistry({ log, limits: cfg.limits, identity: hubIdentity, audit })
@@ -428,6 +492,41 @@ export function applyHub(ctx, config = {}) {
428
492
  // ⚠ 这是**新增的主通道**,不是替换:下面 webServer 的 registerUpgrade 仍然
429
493
  // 保留 —— 老配置(webserver 恰好绑对了)的机器继续用那条路径,两条通道
430
494
  // 并存不冲突(不同端口/不同 socket)。
495
+
496
+ // ── 健康检查响应:按调用者身份分级(v0.9.3 审查 L4)──────────────────
497
+ //
498
+ // 该端点**无鉴权**,而原实现直接把 `plugin: dsh-remote-agent / role: hub /
499
+ // protocol` 回给任何人 —— 那是给扫描器送指纹:认出"这台机器装了会执行
500
+ // 远程命令的插件"正是值得挑的目标。
501
+ //
502
+ // · 匿名(或口令不对) → 只回 `{ ok: true }`
503
+ // · 带正确 `x-dsh-token` → 完整信息(healthDetail=false 时不含计数)
504
+ //
505
+ // ⚠ 必须定义在**两个使用点之前**:独立监听块在本函数体内同步执行,
506
+ // 用 `const` 声明的函数在后面会踩 TDZ(`ReferenceError: Cannot access
507
+ // 'healthPayload' before initialization`)—— 与 0.9.x 那次 H1 同族。
508
+ // 两条通道(webServer / 独立监听)共用这一份,避免"改一条漏一条"。
509
+ const healthPayload = (req, via = '') => {
510
+ const authed = cfg.nodeToken !== '' && safeEqual(req.headers['x-dsh-token'], cfg.nodeToken)
511
+ if (!authed) return { ok: true }
512
+ const base = {
513
+ ok: true,
514
+ plugin: 'dsh-remote-agent',
515
+ role: 'hub',
516
+ protocol: PROTOCOL_VERSION,
517
+ }
518
+ if (via !== '') base.via = via
519
+ if (!cfg.healthDetail) return base
520
+ const nodes = registry.list()
521
+ return {
522
+ ...base,
523
+ nodes: nodes.length,
524
+ online: nodes.filter((n) => n.online).length,
525
+ tasks: registry.listTasks().length,
526
+ activePulls: pulls.activeCount,
527
+ }
528
+ }
529
+
431
530
  let listenHandle
432
531
  __reg(() => {
433
532
  const listenHost = typeof cfg.listenHost === 'string' ? cfg.listenHost : ''
@@ -450,15 +549,52 @@ export function applyHub(ctx, config = {}) {
450
549
  // 显式传非默认 listenPort 时以显式为准(有测试专门验证监听行为时要用)。
451
550
  const isUnderTest = typeof process.env.NODE_TEST_CONTEXT === 'string'
452
551
  && process.env.NODE_TEST_CONTEXT !== ''
453
- const explicitPort = Number.isFinite(cfg.listenPort) && cfg.listenPort !== DEFAULT_LISTEN_PORT
552
+ // L3(2026-10-20):原来用"值 != 默认端口"推断"是否显式配置",
553
+ // 于是用户显式写 19387 与"没写"无法区分(测试里想显式测 19387 会被跳过)。
554
+ // 改成看**原始 config 有没有这个键**。
555
+ const explicitPort = config !== null && typeof config === 'object'
556
+ && Object.prototype.hasOwnProperty.call(config, 'listenPort')
557
+ && Number.isFinite(config.listenPort)
558
+ && config.listenPort !== DEFAULT_LISTEN_PORT
454
559
  if (port === 0 || (isUnderTest && !explicitPort)) {
560
+ listenRuntime.disabled = port === 0 ? 'listenPort: 0' : 'test environment'
455
561
  log('info', 'independent listener disabled', {
456
562
  reason: port === 0 ? 'listenPort: 0' : 'test environment (pass explicit listenPort to enable)',
457
563
  })
458
564
  return once(() => {})
459
565
  }
460
566
 
461
- const picked = resolveListenHost({ listenHost })
567
+ // ── 安全:无口令的端点不暴露到网络(H1,2026-10-20)───────────────
568
+ //
569
+ // 0.9.3 把端点从 DSH 的 webserver(默认绑 127.0.0.1)搬到了独立监听,
570
+ // 而独立监听**主动挑最可达的地址**(Tailscale 优先)。于是"nodeToken 为空"
571
+ // 从"只有本机能连"变成"整个 tailnet 无口令可连" —— 任何人都能
572
+ // `attachDirect` 注册成节点、收到派发的任务内容。
573
+ //
574
+ // 规则:
575
+ // · listenHost 没显式配(自动选)+ token 空 → **强制回环**
576
+ // (用户没有表达"要对外"的意图,默认必须安全)
577
+ // · listenHost 显式配成非回环 + token 空 → 尊重用户,但明确警告
578
+ const tokenBlank = cfg.nodeToken === ''
579
+ const explicitNonLoopback = listenHost !== '' && !isLoopbackHost(listenHost)
580
+ const forceLoopback = tokenBlank && !explicitNonLoopback
581
+
582
+ const picked = forceLoopback
583
+ ? { host: '127.0.0.1', source: 'loopback-no-token', candidates: [] }
584
+ : resolveListenHost({ listenHost })
585
+
586
+ if (forceLoopback) {
587
+ log('warn', '独立监听退回 loopback:nodeToken 为空,端点不暴露到网络', {
588
+ hint: '副机要接入,请先在面板「副机接入」生成口令(nodeToken),再重启 DSH。'
589
+ + '(0.9.3 之前端点跟 DSH webserver 一起绑 loopback,不存在这个暴露面。)',
590
+ })
591
+ } else if (tokenBlank && explicitNonLoopback) {
592
+ log('warn', 'nodeToken 为空但 listenHost 显式指向非回环地址 —— 端点无口令可连', {
593
+ host: picked.host,
594
+ hint: '仅限完全可信网络。建议尽快设置 nodeToken。',
595
+ })
596
+ }
597
+
462
598
  let disposed = false
463
599
 
464
600
  startListenServer({
@@ -466,13 +602,8 @@ export function applyHub(ctx, config = {}) {
466
602
  port,
467
603
  path: cfg.nodePath,
468
604
  healthPath: cfg.healthPath,
469
- healthBody: {
470
- ok: true,
471
- plugin: 'dsh-remote-agent',
472
- role: 'hub',
473
- protocol: PROTOCOL_VERSION,
474
- via: 'listen-server',
475
- },
605
+ // 函数形态:按调用者身份分级(匿名只见 `{ ok: true }`)
606
+ healthBody: (req) => healthPayload(req, 'listen-server'),
476
607
  onUpgrade: (conn, req) => {
477
608
  // 与 webServer 那条路完全相同的鉴权与交接逻辑(一处不能少:
478
609
  // token 校验是这条通道唯一的口令,跳过就是一个无鉴权入口)。
@@ -499,6 +630,12 @@ export function applyHub(ctx, config = {}) {
499
630
  return
500
631
  }
501
632
  listenHandle = h
633
+ // 运行时状态:面板的配对码与体检都读它(C1/M4)
634
+ listenRuntime.ok = true
635
+ listenRuntime.host = h.host
636
+ listenRuntime.port = h.port
637
+ listenRuntime.source = picked.source
638
+ listenRuntime.error = ''
502
639
  log('info', 'node endpoint ready (independent listener)', {
503
640
  host: h.host,
504
641
  port: h.port,
@@ -510,6 +647,11 @@ export function applyHub(ctx, config = {}) {
510
647
  // ⚠ 监听失败不能静默:那会让"副机连不上"变成又一个无头案。
511
648
  // 但要**降级而非抛出** —— 本插件不该把整个 DSH 拖垮
512
649
  // (webServer 那条通道可能仍然可用)。
650
+ listenRuntime.ok = false
651
+ listenRuntime.host = picked.host
652
+ listenRuntime.port = port
653
+ listenRuntime.source = picked.source
654
+ listenRuntime.error = String(err?.message ?? err)
513
655
  log('error', 'independent listener failed; falling back to webServer-only', {
514
656
  host: picked.host,
515
657
  port,
@@ -561,36 +703,16 @@ export function applyHub(ctx, config = {}) {
561
703
 
562
704
  // ── 健康检查(只读,不含凭证) ─────────────────────────────────
563
705
  //
564
- // F1-1:该路由无鉴权,暴露的计数可被用来探测"本机在跑多少远程任务"。
565
- // 凭证扫描已确认 14 项全 false(无 token/apiKey/绝对路径/hostname/nodeId),
566
- // 所以这不是泄漏,只是可被动的存在性探测。
567
- // 处置:加 `healthDetail` 配置(默认 true,便于运维自检);设为 false 时
568
- // 只返回 { ok, plugin, role, protocol },不暴露任何计数。
706
+ // 响应体由上面定义的 `healthPayload()` 产出(匿名只回 `{ ok: true }`,
707
+ // 带正确 `x-dsh-token` 才给插件名/角色/计数)。这里只负责注册路由。
569
708
  __reg(() => {
570
709
  const dispose = ctx.webServer.register({
571
710
  kind: 'exact',
572
711
  path: cfg.healthPath,
573
712
  handler: (req, res) => {
574
713
  try {
575
- const base = {
576
- ok: true,
577
- plugin: 'dsh-remote-agent',
578
- role: 'hub',
579
- protocol: PROTOCOL_VERSION,
580
- }
581
- let body = base
582
- if (cfg.healthDetail) {
583
- const nodes = registry.list()
584
- body = {
585
- ...base,
586
- nodes: nodes.length,
587
- online: nodes.filter((n) => n.online).length,
588
- tasks: registry.listTasks().length,
589
- activePulls: pulls.activeCount,
590
- }
591
- }
592
714
  res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
593
- res.end(JSON.stringify(body))
715
+ res.end(JSON.stringify(healthPayload(req)))
594
716
  } catch (err) {
595
717
  log('warn', 'healthz failed', { error: String(err?.message ?? err) })
596
718
  try {
@@ -623,9 +745,16 @@ export function applyHub(ctx, config = {}) {
623
745
  // 本行生效配置的只读快照,供面板「主机设置」区展示。
624
746
  // 传**原始** config(而非归一化后的 cfg),因为面板要如实反映用户写了什么。
625
747
  config,
626
- // 可达性自检结论(2026-10-17):绑 loopback 时副机永远连不上,
627
- // 面板要渲染成红色横幅 —— 这个失败在主机侧是完全静默的。
628
- reachability,
748
+ // 可达性:**必须传 getter**(2026-10-20)。
749
+ // 独立监听是异步启动的,注册路由时它往往还没结果 —— 传快照会让面板
750
+ // 永远显示启动瞬间的旧结论(0.9.3 的假红就是这么来的)。
751
+ // 静态的 `reachability` 保留给老调用方/测试,getter 优先。
752
+ reachability: currentReachability(),
753
+ getReachability: currentReachability,
754
+ // 独立监听的实时状态(ok/host/port/source/error/disabled)。
755
+ // 配对码的端口必须来自这里 —— 浏览器地址栏的端口是 DSH webserver 的,
756
+ // 与节点端点无关(C1)。
757
+ listenRuntime,
629
758
  }).map(once)
630
759
  } catch (err) {
631
760
  log('error', 'ui route registration threw', { error: String(err?.message ?? err) })
@@ -312,11 +312,31 @@ export function registerUiRoutes({
312
312
  // 可达性自检结论(2026-10-17):{ bound, origin, loopback, hint }
313
313
  // 绑 loopback 时副机永远连不上,而这个失败在主机侧完全静默 ——
314
314
  // 面板必须渲染成红色横幅,否则用户只能去猜"地址填错了?token 错了?"
315
+ //
316
+ // ⚠ 2026-10-20:必须用 **getter**,不能用快照。
317
+ // 独立监听是异步启动的,注册路由的这一刻它通常还没有结果;传快照会让面板
318
+ // 永远显示启动瞬间的旧结论(0.9.3 的"假红"就是这么产生的)。
315
319
  reachability = null,
320
+ getReachability = null,
321
+ // 独立监听的实时状态:{ ok, disabled, host, port, source, error }
322
+ listenRuntime = null,
316
323
  }) {
317
324
  const disposers = []
318
325
  const guard = makeGuard(ctx, log)
319
326
 
327
+ /** 取当前可达性结论:getter 优先,静态快照兜底(兼容老调用方/测试)。 */
328
+ const reachOf = () => {
329
+ if (typeof getReachability === 'function') {
330
+ try {
331
+ const r = getReachability()
332
+ if (r) return r
333
+ } catch {
334
+ /* 取失败退回快照 */
335
+ }
336
+ }
337
+ return reachability
338
+ }
339
+
320
340
  // 注册计数:`applyHub` 结束时会打一行日志汇总。
321
341
  // 为什么用 log 而不是写文件:上一轮排查用的文件诊断在清理时被删了,
322
342
  // 而**路由注册失败是静默致命的**(面板只看到 404,启动日志里什么都没有)。
@@ -440,12 +460,29 @@ export function registerUiRoutes({
440
460
  dispatch: true,
441
461
  // 可达性自检(2026-10-17):`loopback === true` 时副机永远连不上,
442
462
  // 面板渲染红色横幅。逐字段投影,不 spread。
443
- reachability: reachability
463
+ // 2026-10-20:结论来自 `reachOf()`(独立监听优先),并带上 via/port,
464
+ // 让面板能说清"是哪条通道在听"。
465
+ reachability: (() => {
466
+ const r = reachOf()
467
+ if (!r) return null
468
+ return {
469
+ bound: typeof r.bound === 'string' ? r.bound : null,
470
+ origin: typeof r.origin === 'string' ? r.origin : null,
471
+ loopback: r.loopback === true,
472
+ hint: typeof r.hint === 'string' ? r.hint : '',
473
+ via: typeof r.via === 'string' ? r.via : 'webserver',
474
+ port: Number.isFinite(r.port) ? r.port : null,
475
+ }
476
+ })(),
477
+ // 独立监听的原始状态:面板要区分"在听 / 失败 / 关闭"三种情形
478
+ listener: listenRuntime
444
479
  ? {
445
- bound: typeof reachability.bound === 'string' ? reachability.bound : null,
446
- origin: typeof reachability.origin === 'string' ? reachability.origin : null,
447
- loopback: reachability.loopback === true,
448
- hint: typeof reachability.hint === 'string' ? reachability.hint : '',
480
+ ok: listenRuntime.ok,
481
+ disabled: typeof listenRuntime.disabled === 'string' ? listenRuntime.disabled : '',
482
+ host: typeof listenRuntime.host === 'string' ? listenRuntime.host : '',
483
+ port: Number.isFinite(listenRuntime.port) ? listenRuntime.port : null,
484
+ source: typeof listenRuntime.source === 'string' ? listenRuntime.source : '',
485
+ error: typeof listenRuntime.error === 'string' ? listenRuntime.error : '',
449
486
  }
450
487
  : null,
451
488
  },
@@ -695,7 +732,19 @@ export function registerUiRoutes({
695
732
  // 用 resolveRole 更稳妥(覆盖 both 与显式 hub 两种)。
696
733
  role: resolveRole(config) || 'hub',
697
734
  config,
698
- reachability,
735
+ // 实时取(独立监听优先),不能再用注册时的快照
736
+ reachability: reachOf(),
737
+ // 独立监听的原始状态:体检要能单独报"主通道起没起来"(M4)
738
+ listener: listenRuntime
739
+ ? {
740
+ ok: listenRuntime.ok,
741
+ disabled: typeof listenRuntime.disabled === 'string' ? listenRuntime.disabled : '',
742
+ host: typeof listenRuntime.host === 'string' ? listenRuntime.host : '',
743
+ port: Number.isFinite(listenRuntime.port) ? listenRuntime.port : null,
744
+ source: typeof listenRuntime.source === 'string' ? listenRuntime.source : '',
745
+ error: typeof listenRuntime.error === 'string' ? listenRuntime.error : '',
746
+ }
747
+ : null,
699
748
  nodesOnline: nodes.filter((n) => n.online).length,
700
749
  })
701
750
  } catch (err) {
@@ -802,48 +851,100 @@ export function registerUiRoutes({
802
851
 
803
852
  // ── 副机接入:生成配对码(面板 GUI 化的落点)──────────────────
804
853
  //
805
- // 端口不查内部状态:面板本身就由 hub 的 webserver 服务,浏览器侧把
806
- // window.location.port 传回来即是节点拨入端口(节点端点与面板同端口)。
854
+ // ═══════════════════════════════════════════════════════════════════════════
855
+ // 2026-10-20 修(C1):端口**必须**取独立监听的实时状态
856
+ // ═══════════════════════════════════════════════════════════════════════════
857
+ //
858
+ // 原实现:面板传 `window.location.port` 回来,注释写着"节点端点与面板同端口"。
859
+ // 那个前提在 0.9.3 已被推翻 —— 节点端点搬到了独立监听(默认 19387),
860
+ // 而 DSH webserver 的端口是**每次启动随机**的(实测见过 56594、58626)。
861
+ //
862
+ // 后果:面板生成的每一个配对码都指向"DSH 面板端口 + 节点路径"——
863
+ // 那个端口既没有节点端点、又绑在 127.0.0.1 → 副机 100% 连不上。
864
+ // 用户看到的是"配对码贴了没反应",而两端都没有报错。
865
+ //
866
+ // 现在:端口从 `listenRuntime` 取(真实监听成功后的端口)。浏览器传来的
867
+ // `port` 参数**不再采信**,只作为"独立监听没起来"时的兜底参考。
807
868
  //
808
869
  // ⚠ token 披露边界(与 /config 的"只回 set 布尔"同一精神):
809
870
  // 含 token 的完整配对码**只对本机回环请求**生成 —— 面板可能被 lanBind
810
871
  // 暴露到局域网,局域网内的另一台机器能打开面板 ≠ 应该拿到主机凭证。
811
- // 非回环请求得到不含 token 的码(对可信内网直连仍然够用)+ 一条提示。
812
872
  route(`${UI_API_PREFIX}/pair-info`, (req, res) => {
813
873
  if (req.method !== 'GET') return writeJson(res, 405, { error: 'method' })
814
874
  const url = new URL(req.url, 'http://localhost')
815
- const portRaw = url.searchParams.get('port') ?? ''
816
- const port = /^\d+$/.test(portRaw) && +portRaw > 0 && +portRaw < 65536 ? +portRaw : 19387
817
875
  const ra = String(req.socket?.remoteAddress ?? '')
818
876
  const loopback = ra === '127.0.0.1' || ra === '::1' || ra === '::ffff:127.0.0.1'
819
877
  const tokenSet = typeof config.nodeToken === 'string' && config.nodeToken !== ''
820
878
  const token = loopback && tokenSet ? config.nodeToken : ''
821
- const codes = detectLanIps()
879
+
880
+ // 真实端口:独立监听优先;没起来则退回浏览器端口(老部署),再兜底 19387
881
+ const portRaw = url.searchParams.get('port') ?? ''
882
+ const browserPort = /^\d+$/.test(portRaw) && +portRaw > 0 && +portRaw < 65536 ? +portRaw : null
883
+ const listenOk = listenRuntime && listenRuntime.ok === true
884
+ const port = listenOk && Number.isFinite(listenRuntime.port) && listenRuntime.port > 0
885
+ ? listenRuntime.port
886
+ : (browserPort ?? 19387)
887
+ const portSource = listenOk && Number.isFinite(listenRuntime.port) ? 'listen-server' : 'fallback'
888
+
889
+ // 独立监听在听时,**只有它绑的那个地址**是可用的(新架构只绑一个具体 IP)。
890
+ // 其余候选地址照旧列出来(老配置可能走 webServer 通道),但明确标 usable,
891
+ // 免得用户复制了一条连不上的码还以为是自己的问题(C2)。
892
+ const boundHost = listenOk && typeof listenRuntime.host === 'string' ? listenRuntime.host : ''
893
+ const candidates = detectLanIps()
894
+ const codes = candidates
822
895
  .map((ip) => {
823
896
  const hubUrl = buildHubUrl(ip.address, port)
824
897
  const pairCode = encodePairCode({ hubUrl, token })
898
+ const usable = boundHost === '' ? portSource === 'fallback' : ip.address === boundHost
825
899
  return {
826
900
  address: ip.address,
827
901
  name: ip.name,
828
902
  private: ip.private === true,
829
903
  hubUrl,
830
904
  pairCode,
905
+ usable,
831
906
  includesToken: token !== '' && pairCode !== '',
832
907
  }
833
908
  })
834
909
  .filter((c) => c.pairCode !== '')
910
+ // 可用的排最前(面板默认展示第一条,用户多半直接复制)
911
+ .sort((a, b) => Number(b.usable) - Number(a.usable))
912
+
913
+ // 独立监听没在听时,给出**可执行**的原因,而不是让用户对着空列表猜
914
+ const listenerState = !listenRuntime
915
+ ? 'unknown'
916
+ : (listenRuntime.ok === true ? 'listening' : (listenRuntime.disabled !== '' ? 'disabled' : 'failed'))
917
+ const listenerError = listenRuntime && typeof listenRuntime.error === 'string' ? listenRuntime.error : ''
918
+ const listenerHint = listenerState === 'listening'
919
+ ? ''
920
+ : listenRuntime && listenRuntime.disabled !== ''
921
+ ? `独立监听已关闭(${listenRuntime.disabled})—— 副机只能走 DSH webserver 通道`
922
+ : (listenerError !== ''
923
+ ? `独立监听启动失败:${listenerError}(改 listenPort / listenHost 后重启)`
924
+ : '独立监听尚未就绪(刚启动?稍后刷新)')
925
+
835
926
  writeJson(res, 200, {
836
927
  ok: true,
837
928
  port,
929
+ portSource,
838
930
  tokenSet,
839
931
  scope: token !== '' ? 'full' : 'tokenless',
840
932
  codes,
933
+ // 面板据此决定是否禁用"生成/复制"并显示原因
934
+ listener: {
935
+ state: listenerState,
936
+ host: boundHost,
937
+ port: listenOk ? listenRuntime.port : null,
938
+ source: listenRuntime && typeof listenRuntime.source === 'string' ? listenRuntime.source : '',
939
+ error: listenerError,
940
+ hint: listenerHint,
941
+ },
841
942
  firewallHint: `netsh advfirewall firewall add rule name="dsh-remote-agent" dir=in action=allow protocol=TCP localport=${port}`,
842
943
  note: token !== ''
843
944
  ? ''
844
945
  : (tokenSet
845
946
  ? '当前不是从主机本机打开的面板 —— 配对码不含 token;完整版请在主机本机打开面板生成,或用 dsh-remote-setup 向导'
846
- : '主机未设置 nodeToken —— 配对码不含 token,仅限完全可信内网;公网部署前必须先设置'),
947
+ : '主机未设置 nodeToken —— 无口令的端点不会暴露到网络(安全策略),副机连不上。请先生成口令。'),
847
948
  })
848
949
  })
849
950
 
package/lib/index.js CHANGED
@@ -166,6 +166,17 @@ export function apply(ctx, config = {}) {
166
166
 
167
167
  const { role, invalid, unconfigured } = resolveRoleDetail(config)
168
168
  const log = loggerOf(ctx)
169
+
170
+ // M3(2026-10-20):独立配置优先,会**压住** loader(patch/插件 UI)里的同名字段。
171
+ // 这是设计(它才抗得住 DSH 重写),但必须留痕 —— 否则用户"改了 patch 没生效"
172
+ // 时完全无从判断。只记字段名,不记值(token 等敏感字段不能进日志)。
173
+ if (Array.isArray(merged.shadowed) && merged.shadowed.length > 0) {
174
+ log('info', 'instance.json overrides loader config (patch edits ignored for these keys)', {
175
+ keys: merged.shadowed,
176
+ fix: '要让 patch 生效,请删掉 instance.json 里对应的键(或用面板改)',
177
+ })
178
+ }
179
+
169
180
  // 配置写错必须有反馈。注意**不再回退 hub** —— 那会静默开一个
170
181
  // nodeToken 为空、不校验凭证的直连端点(见 resolveRoleDetail 的说明)。
171
182
  // 现在当成"未配置"处理:什么都不注册,面板显示引导让用户重选。
@@ -326,6 +337,22 @@ export const Config = z.object({
326
337
  uiPullRoot: z.string()
327
338
  .default('')
328
339
  .description('Web 面板拉取产物时唯一允许落地的目录(绝对路径)。留空则面板拉取按钮禁用。'),
340
+
341
+ // ── 独立监听(2026-10-18 引入,2026-10-20 补进 schema)───────────────
342
+ //
343
+ // ⚠ 这两个字段 0.9.3 就已在用,但**没有在这里声明** —— 靠 schemastery 的
344
+ // passthrough 才勉强能读到。后果:插件配置页里根本没有这两项(无表单)、
345
+ // 没有类型校验(`listenPort: "19387"` 会静默回弹默认值)、也无法在 UI 上改。
346
+ // 现在正式声明:能配、能校验、能在面板/插件页里看到。
347
+ listenHost: z.string()
348
+ .default('')
349
+ .description('独立监听绑定的具体本机 IP(留空=自动选:Tailscale > 私网 > 回环)。'
350
+ + '不能填 0.0.0.0 —— 通配地址会把服务暴露到所有网卡,且新版 DSH 同规则拒绝。'),
351
+ // 取值范围由 normalizeConfig 归一(0 <= port < 65536),这里只声明类型与默认值。
352
+ listenPort: z.number()
353
+ .default(19387)
354
+ .description('独立监听端口(副机从这里拨入)。0 = 关闭独立监听,只走 DSH 自己的 Web 服务通道。'),
355
+
329
356
  uiEnabled: z.boolean()
330
357
  .default(true)
331
358
  .description('是否注册 Web 面板的控制路由。'),