dsh-remote-agent 0.9.3 → 0.9.5
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/CHANGELOG.md +228 -0
- package/CONTRIBUTING.md +3 -1
- package/KNOWN-ISSUES.md +2 -0
- package/README.en.md +26 -7
- package/README.md +31 -18
- package/bin/dsh-remote-setup.js +109 -16
- package/lib/client.js +159 -50
- package/lib/env-check.js +121 -33
- package/lib/hub/index.js +273 -95
- package/lib/hub/ui-routes.js +114 -13
- package/lib/index.js +116 -0
- package/lib/instance-config.js +90 -36
- package/lib/listen-server.js +65 -6
- package/lib/setup-routes.js +140 -4
- package/lib/setup.js +172 -14
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +12 -3
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 {
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
reachability
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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,48 +549,113 @@ 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
600
|
+
/**
|
|
601
|
+
* 起监听,并对 **EADDRINUSE 做有限重试**。
|
|
602
|
+
*
|
|
603
|
+
* 为什么需要:0.9.5 起 apply 可重入(同一 ctx 重新 apply 时先摘上一代)。
|
|
604
|
+
* 摘上一代会 `server.close()`,而**紧接着用同一个端口重新 listen** 在
|
|
605
|
+
* Windows 上经常还会拿到 EADDRINUSE(close 是异步完成的,端口没这么快
|
|
606
|
+
* 释放)。没有重试的话,"热重载"就会把端点变成永久不可用 —— 看起来和
|
|
607
|
+
* "插件坏了"一样,但其实是可自愈的。
|
|
608
|
+
*
|
|
609
|
+
* 只重试 4 次(200/400/600ms):真的被别的进程占了就该报错,不能无限拖。
|
|
610
|
+
*/
|
|
611
|
+
const startWithRetry = async (attempt = 1) => {
|
|
612
|
+
try {
|
|
613
|
+
return await startListenServer({
|
|
614
|
+
host: picked.host,
|
|
615
|
+
port,
|
|
616
|
+
path: cfg.nodePath,
|
|
617
|
+
healthPath: cfg.healthPath,
|
|
618
|
+
// 函数形态:按调用者身份分级(匿名只见 `{ ok: true }`)
|
|
619
|
+
healthBody: (req) => healthPayload(req, 'listen-server'),
|
|
620
|
+
onUpgrade: (conn, req) => {
|
|
621
|
+
// 与 webServer 那条路完全相同的鉴权与交接逻辑(一处不能少:
|
|
622
|
+
// token 校验是这条通道唯一的口令,跳过就是一个无鉴权入口)。
|
|
623
|
+
if (cfg.nodeToken !== '') {
|
|
624
|
+
const token = req.headers['x-dsh-token']
|
|
625
|
+
if (!safeEqual(token, cfg.nodeToken)) {
|
|
626
|
+
log('warn', 'node upgrade rejected: bad token (listen-server)', { remote: remoteAddress(req) })
|
|
627
|
+
try {
|
|
628
|
+
conn.close(1008, 'bad token')
|
|
629
|
+
} catch {
|
|
630
|
+
/* 已关 */
|
|
631
|
+
}
|
|
632
|
+
return
|
|
633
|
+
}
|
|
487
634
|
}
|
|
488
|
-
|
|
489
|
-
}
|
|
635
|
+
registry.attachDirect(conn, { remote: remoteAddress(req) })
|
|
636
|
+
},
|
|
637
|
+
log,
|
|
638
|
+
})
|
|
639
|
+
} catch (err) {
|
|
640
|
+
const code = String(err?.code ?? '')
|
|
641
|
+
const inUse = code === 'EADDRINUSE' || /EADDRINUSE/.test(String(err?.message ?? ''))
|
|
642
|
+
if (inUse && attempt < 4 && !disposed) {
|
|
643
|
+
log('warn', 'listen: port still held, retrying (previous generation closing)', {
|
|
644
|
+
host: picked.host,
|
|
645
|
+
port,
|
|
646
|
+
attempt,
|
|
647
|
+
})
|
|
648
|
+
await new Promise((r) => {
|
|
649
|
+
const t = setTimeout(r, 200 * attempt)
|
|
650
|
+
if (t.unref) t.unref()
|
|
651
|
+
})
|
|
652
|
+
return startWithRetry(attempt + 1)
|
|
490
653
|
}
|
|
491
|
-
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
|
|
654
|
+
throw err
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
startWithRetry()
|
|
495
659
|
.then((h) => {
|
|
496
660
|
if (disposed) {
|
|
497
661
|
// 竞态:disposer 先跑了(插件卸载/HMR),把刚起来的 server 关掉
|
|
@@ -499,6 +663,12 @@ export function applyHub(ctx, config = {}) {
|
|
|
499
663
|
return
|
|
500
664
|
}
|
|
501
665
|
listenHandle = h
|
|
666
|
+
// 运行时状态:面板的配对码与体检都读它(C1/M4)
|
|
667
|
+
listenRuntime.ok = true
|
|
668
|
+
listenRuntime.host = h.host
|
|
669
|
+
listenRuntime.port = h.port
|
|
670
|
+
listenRuntime.source = picked.source
|
|
671
|
+
listenRuntime.error = ''
|
|
502
672
|
log('info', 'node endpoint ready (independent listener)', {
|
|
503
673
|
host: h.host,
|
|
504
674
|
port: h.port,
|
|
@@ -510,6 +680,11 @@ export function applyHub(ctx, config = {}) {
|
|
|
510
680
|
// ⚠ 监听失败不能静默:那会让"副机连不上"变成又一个无头案。
|
|
511
681
|
// 但要**降级而非抛出** —— 本插件不该把整个 DSH 拖垮
|
|
512
682
|
// (webServer 那条通道可能仍然可用)。
|
|
683
|
+
listenRuntime.ok = false
|
|
684
|
+
listenRuntime.host = picked.host
|
|
685
|
+
listenRuntime.port = port
|
|
686
|
+
listenRuntime.source = picked.source
|
|
687
|
+
listenRuntime.error = String(err?.message ?? err)
|
|
513
688
|
log('error', 'independent listener failed; falling back to webServer-only', {
|
|
514
689
|
host: picked.host,
|
|
515
690
|
port,
|
|
@@ -561,36 +736,16 @@ export function applyHub(ctx, config = {}) {
|
|
|
561
736
|
|
|
562
737
|
// ── 健康检查(只读,不含凭证) ─────────────────────────────────
|
|
563
738
|
//
|
|
564
|
-
//
|
|
565
|
-
//
|
|
566
|
-
// 所以这不是泄漏,只是可被动的存在性探测。
|
|
567
|
-
// 处置:加 `healthDetail` 配置(默认 true,便于运维自检);设为 false 时
|
|
568
|
-
// 只返回 { ok, plugin, role, protocol },不暴露任何计数。
|
|
739
|
+
// 响应体由上面定义的 `healthPayload()` 产出(匿名只回 `{ ok: true }`,
|
|
740
|
+
// 带正确 `x-dsh-token` 才给插件名/角色/计数)。这里只负责注册路由。
|
|
569
741
|
__reg(() => {
|
|
570
742
|
const dispose = ctx.webServer.register({
|
|
571
743
|
kind: 'exact',
|
|
572
744
|
path: cfg.healthPath,
|
|
573
745
|
handler: (req, res) => {
|
|
574
746
|
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
747
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
|
|
593
|
-
res.end(JSON.stringify(
|
|
748
|
+
res.end(JSON.stringify(healthPayload(req)))
|
|
594
749
|
} catch (err) {
|
|
595
750
|
log('warn', 'healthz failed', { error: String(err?.message ?? err) })
|
|
596
751
|
try {
|
|
@@ -623,9 +778,16 @@ export function applyHub(ctx, config = {}) {
|
|
|
623
778
|
// 本行生效配置的只读快照,供面板「主机设置」区展示。
|
|
624
779
|
// 传**原始** config(而非归一化后的 cfg),因为面板要如实反映用户写了什么。
|
|
625
780
|
config,
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
|
|
781
|
+
// 可达性:**必须传 getter**(2026-10-20)。
|
|
782
|
+
// 独立监听是异步启动的,注册路由时它往往还没结果 —— 传快照会让面板
|
|
783
|
+
// 永远显示启动瞬间的旧结论(0.9.3 的假红就是这么来的)。
|
|
784
|
+
// 静态的 `reachability` 保留给老调用方/测试,getter 优先。
|
|
785
|
+
reachability: currentReachability(),
|
|
786
|
+
getReachability: currentReachability,
|
|
787
|
+
// 独立监听的实时状态(ok/host/port/source/error/disabled)。
|
|
788
|
+
// 配对码的端口必须来自这里 —— 浏览器地址栏的端口是 DSH webserver 的,
|
|
789
|
+
// 与节点端点无关(C1)。
|
|
790
|
+
listenRuntime,
|
|
629
791
|
}).map(once)
|
|
630
792
|
} catch (err) {
|
|
631
793
|
log('error', 'ui route registration threw', { error: String(err?.message ?? err) })
|
|
@@ -722,8 +884,24 @@ export function applyHub(ctx, config = {}) {
|
|
|
722
884
|
return {
|
|
723
885
|
registry,
|
|
724
886
|
pulls,
|
|
725
|
-
|
|
726
|
-
|
|
887
|
+
/**
|
|
888
|
+
* 由调用方保管;DSH 卸载插件 / 我们自己在重入前清理时用。
|
|
889
|
+
*
|
|
890
|
+
* ⚠ **必须逆序**(最后注册的先摘)。`__reg` 的顺序是「运行时 effect 在最先、
|
|
891
|
+
* 路由/工具/监听在其后」,而运行时那个 disposer 会 `registry.dispose()` /
|
|
892
|
+
* `pulls.dispose()` —— 先拆它再去拆工具,工具的 disposer 就可能碰到已销毁的
|
|
893
|
+
* registry(0.9.5 修重入时发现这里原来是正序跑的,与 cordis 的"逆序释放"
|
|
894
|
+
* 语义和本文件的注释都不一致)。
|
|
895
|
+
*/
|
|
896
|
+
disposeAll: () => {
|
|
897
|
+
for (let i = __disposers.length - 1; i >= 0; i -= 1) {
|
|
898
|
+
try {
|
|
899
|
+
__disposers[i]()
|
|
900
|
+
} catch {
|
|
901
|
+
/* 已清 */
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
},
|
|
727
905
|
}
|
|
728
906
|
}
|
|
729
907
|
|
package/lib/hub/ui-routes.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
806
|
-
//
|
|
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
|
-
|
|
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 ——
|
|
947
|
+
: '主机未设置 nodeToken —— 无口令的端点不会暴露到网络(安全策略),副机连不上。请先生成口令。'),
|
|
847
948
|
})
|
|
848
949
|
})
|
|
849
950
|
|