dsh-remote-agent 0.9.2 → 0.9.3
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 +145 -1
- package/lib/index.js +24 -0
- package/lib/instance-config.js +207 -0
- package/lib/listen-server.js +262 -0
- package/lib/setup-routes.js +48 -0
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +39 -0
package/lib/hub/index.js
CHANGED
|
@@ -16,6 +16,9 @@ import os from 'node:os'
|
|
|
16
16
|
import path from 'node:path'
|
|
17
17
|
import { ERROR_CODE, PROTOCOL_VERSION } from '../protocol.js'
|
|
18
18
|
import { acceptUpgrade } from '../ws.js'
|
|
19
|
+
// 独立监听端口(2026-10-18):副机拨入不再依赖 DSH 的 webserver 配置
|
|
20
|
+
// (DSH 每次启动重写 profile patch,会把 webserver 段冲掉)。
|
|
21
|
+
import { DEFAULT_LISTEN_PORT, resolveListenHost, startListenServer } from '../listen-server.js'
|
|
19
22
|
import { NodeRegistry } from './node-registry.js'
|
|
20
23
|
import { RelayLink } from './relay-link.js'
|
|
21
24
|
import { createPluginLogger } from './hub-util.js'
|
|
@@ -43,6 +46,20 @@ export function normalizeConfig(config = {}) {
|
|
|
43
46
|
return {
|
|
44
47
|
nodePath: typeof c.nodePath === 'string' && c.nodePath !== '' ? c.nodePath : NODE_UPGRADE_PATH,
|
|
45
48
|
healthPath: typeof c.healthPath === 'string' && c.healthPath !== '' ? c.healthPath : HEALTH_PATH,
|
|
49
|
+
// ── 独立监听端口(2026-10-18)──────────────────────────────────────
|
|
50
|
+
//
|
|
51
|
+
// 副机拨入不再依赖 DSH 的 webserver(DSH 每次启动重写 profile patch,
|
|
52
|
+
// 会把 webserver 段冲掉;而且新版硬拒绝 0.0.0.0)。插件自己开端口。
|
|
53
|
+
//
|
|
54
|
+
// listenHost:留空 = **自动选**(Tailscale IP > 私网 IP > 回环)。
|
|
55
|
+
// 显式填了就按填的绑(必须是具体 IP,不能 0.0.0.0 —— 与 DSH 同规则)。
|
|
56
|
+
// listenPort:默认 19387(与历史配置、配对码、文档一致)。
|
|
57
|
+
listenHost: typeof c.listenHost === 'string' ? c.listenHost.trim() : '',
|
|
58
|
+
// listenPort:默认 19387(与历史配置、配对码、文档一致)。
|
|
59
|
+
// **0 = 显式关闭独立监听**(测试 / 只走 relay / 已在别处暴露端点时用)。
|
|
60
|
+
listenPort: Number.isFinite(c.listenPort) && c.listenPort >= 0 && c.listenPort < 65536
|
|
61
|
+
? c.listenPort
|
|
62
|
+
: DEFAULT_LISTEN_PORT,
|
|
46
63
|
nodeToken: typeof c.nodeToken === 'string' ? c.nodeToken : '',
|
|
47
64
|
systemPrompt: c.systemPrompt !== false,
|
|
48
65
|
// F1-1:healthz 是否暴露计数。默认 true(便于运维自检);
|
|
@@ -383,7 +400,134 @@ export function applyHub(ctx, config = {}) {
|
|
|
383
400
|
}, 'remote-agent: relay-link')
|
|
384
401
|
}
|
|
385
402
|
|
|
386
|
-
// ──
|
|
403
|
+
// ── 独立监听端口(2026-10-18,**副机拨入的主通道**)───────────────────
|
|
404
|
+
//
|
|
405
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
406
|
+
// 为什么不再依赖 ctx.webServer
|
|
407
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
408
|
+
//
|
|
409
|
+
// 原先副机端点注册在 `ctx.webServer.registerUpgrade` 上,于是副机必须能访问
|
|
410
|
+
// **DSH 自己的 webserver 端口**。这带来两个绕不开的死结:
|
|
411
|
+
//
|
|
412
|
+
// 1. **DSH 每次启动都重写 `<profile>/cordis.patch.yml`**(实测:写进去的
|
|
413
|
+
// `webserver` 段在下次启动时被删掉,只剩 3 个 `ui-*` 条目)→ 绑回
|
|
414
|
+
// 127.0.0.1 → 副机永远连不上,而且主机侧毫无异常。
|
|
415
|
+
// 2. `webServer` 服务**激活即 listen**,没有 rebind 方法 → 插件运行时改不了。
|
|
416
|
+
//
|
|
417
|
+
// 而且新版 `dsh-host-webserver` 硬拒绝 `0.0.0.0`(直接让 DSH 启动崩溃,
|
|
418
|
+
// 我们踩过:10 份 crash 日志)。所以"帮用户把 webserver 改好"这条路是死的。
|
|
419
|
+
//
|
|
420
|
+
// ── 现在的做法 ──────────────────────────────────────────────────────────
|
|
421
|
+
//
|
|
422
|
+
// 插件自己用 `node:http` 起一个只服务节点端点的小 server(见
|
|
423
|
+
// lib/listen-server.js),**完全不依赖 DSH 的 webserver 配置**。
|
|
424
|
+
//
|
|
425
|
+
// 绑定地址自动选:配置的 listenHost > Tailscale IP > 私网 IP > 回环。
|
|
426
|
+
// 端口默认 19387(与历史配置、配对码、文档一致)。
|
|
427
|
+
//
|
|
428
|
+
// ⚠ 这是**新增的主通道**,不是替换:下面 webServer 的 registerUpgrade 仍然
|
|
429
|
+
// 保留 —— 老配置(webserver 恰好绑对了)的机器继续用那条路径,两条通道
|
|
430
|
+
// 并存不冲突(不同端口/不同 socket)。
|
|
431
|
+
let listenHandle
|
|
432
|
+
__reg(() => {
|
|
433
|
+
const listenHost = typeof cfg.listenHost === 'string' ? cfg.listenHost : ''
|
|
434
|
+
const port = Number.isFinite(cfg.listenPort) ? cfg.listenPort : DEFAULT_LISTEN_PORT
|
|
435
|
+
|
|
436
|
+
// ── `listenPort: 0` = 显式关闭独立监听(2026-10-18)────────────────
|
|
437
|
+
//
|
|
438
|
+
// 这个开关是**为测试与特殊部署**准备的:
|
|
439
|
+
// · 单测/e2e 里 ctx 是 mock,本模块会真的开端口 → 监听句柄不释放
|
|
440
|
+
// → 测试进程挂住(实测:hub-worker-e2e 90 秒不退出)。
|
|
441
|
+
// · 某些部署场景(只走 relay、或已在别处暴露端点)不需要这个端口。
|
|
442
|
+
//
|
|
443
|
+
// 另外**自动检测测试环境**:测试不该真的占用 19387(并行跑多个测试文件
|
|
444
|
+
// 时还会互相抢端口,而且监听句柄不释放会让 `npm test` 挂住)。
|
|
445
|
+
//
|
|
446
|
+
// ⚠ 检测方式是 `NODE_TEST_CONTEXT`(node --test 会设为 'child-v8' 等),
|
|
447
|
+
// **不是** `process.argv` —— 实测 `node --test` 子进程的 argv 里没有
|
|
448
|
+
// `--test`(那是父进程的参数),第一版用 argv 判断所以没生效。
|
|
449
|
+
//
|
|
450
|
+
// 显式传非默认 listenPort 时以显式为准(有测试专门验证监听行为时要用)。
|
|
451
|
+
const isUnderTest = typeof process.env.NODE_TEST_CONTEXT === 'string'
|
|
452
|
+
&& process.env.NODE_TEST_CONTEXT !== ''
|
|
453
|
+
const explicitPort = Number.isFinite(cfg.listenPort) && cfg.listenPort !== DEFAULT_LISTEN_PORT
|
|
454
|
+
if (port === 0 || (isUnderTest && !explicitPort)) {
|
|
455
|
+
log('info', 'independent listener disabled', {
|
|
456
|
+
reason: port === 0 ? 'listenPort: 0' : 'test environment (pass explicit listenPort to enable)',
|
|
457
|
+
})
|
|
458
|
+
return once(() => {})
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const picked = resolveListenHost({ listenHost })
|
|
462
|
+
let disposed = false
|
|
463
|
+
|
|
464
|
+
startListenServer({
|
|
465
|
+
host: picked.host,
|
|
466
|
+
port,
|
|
467
|
+
path: cfg.nodePath,
|
|
468
|
+
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
|
+
},
|
|
476
|
+
onUpgrade: (conn, req) => {
|
|
477
|
+
// 与 webServer 那条路完全相同的鉴权与交接逻辑(一处不能少:
|
|
478
|
+
// token 校验是这条通道唯一的口令,跳过就是一个无鉴权入口)。
|
|
479
|
+
if (cfg.nodeToken !== '') {
|
|
480
|
+
const token = req.headers['x-dsh-token']
|
|
481
|
+
if (!safeEqual(token, cfg.nodeToken)) {
|
|
482
|
+
log('warn', 'node upgrade rejected: bad token (listen-server)', { remote: remoteAddress(req) })
|
|
483
|
+
try {
|
|
484
|
+
conn.close(1008, 'bad token')
|
|
485
|
+
} catch {
|
|
486
|
+
/* 已关 */
|
|
487
|
+
}
|
|
488
|
+
return
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
registry.attachDirect(conn, { remote: remoteAddress(req) })
|
|
492
|
+
},
|
|
493
|
+
log,
|
|
494
|
+
})
|
|
495
|
+
.then((h) => {
|
|
496
|
+
if (disposed) {
|
|
497
|
+
// 竞态:disposer 先跑了(插件卸载/HMR),把刚起来的 server 关掉
|
|
498
|
+
void h.close()
|
|
499
|
+
return
|
|
500
|
+
}
|
|
501
|
+
listenHandle = h
|
|
502
|
+
log('info', 'node endpoint ready (independent listener)', {
|
|
503
|
+
host: h.host,
|
|
504
|
+
port: h.port,
|
|
505
|
+
source: picked.source,
|
|
506
|
+
path: cfg.nodePath,
|
|
507
|
+
})
|
|
508
|
+
})
|
|
509
|
+
.catch((err) => {
|
|
510
|
+
// ⚠ 监听失败不能静默:那会让"副机连不上"变成又一个无头案。
|
|
511
|
+
// 但要**降级而非抛出** —— 本插件不该把整个 DSH 拖垮
|
|
512
|
+
// (webServer 那条通道可能仍然可用)。
|
|
513
|
+
log('error', 'independent listener failed; falling back to webServer-only', {
|
|
514
|
+
host: picked.host,
|
|
515
|
+
port,
|
|
516
|
+
error: String(err?.message ?? err),
|
|
517
|
+
hint: '端口被占用或该地址不可用。可在插件配置里改 listenPort / listenHost。',
|
|
518
|
+
})
|
|
519
|
+
})
|
|
520
|
+
|
|
521
|
+
return once(() => {
|
|
522
|
+
disposed = true
|
|
523
|
+
if (listenHandle) {
|
|
524
|
+
void listenHandle.close()
|
|
525
|
+
listenHandle = undefined
|
|
526
|
+
}
|
|
527
|
+
})
|
|
528
|
+
}, 'remote-agent: independent-listener')
|
|
529
|
+
|
|
530
|
+
// ── 直连 upgrade 路由(保留:webServer 路径恰好可用的场景)─────────────
|
|
387
531
|
__reg(() => {
|
|
388
532
|
const dispose = ctx.webServer.registerUpgrade({
|
|
389
533
|
path: cfg.nodePath,
|
package/lib/index.js
CHANGED
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
import z from '@deepseek-ai/schemastery'
|
|
23
23
|
import { applyHub, newNodeToken, normalizeConfig, NODE_UPGRADE_PATH, HEALTH_PATH } from './hub/index.js'
|
|
24
24
|
import { registerSetupRoutes } from './setup-routes.js'
|
|
25
|
+
// 配置分层(2026-10-18):DSH 的插件管理器会重写 cordis.patch.yml、冲掉手工写的
|
|
26
|
+
// 段(实测把我们的 role + webserver 整段删掉)。所以真正的配置另存一份在
|
|
27
|
+
// <stateDir>/instance.json,加载时覆盖回来。见 lib/instance-config.js 头注释。
|
|
28
|
+
import { mergeInstanceConfig } from './instance-config.js'
|
|
25
29
|
|
|
26
30
|
/** 角色取值。 */
|
|
27
31
|
export const ROLE = Object.freeze({ HUB: 'hub', WORKER: 'worker', BOTH: 'both', AUTO: 'auto' })
|
|
@@ -140,6 +144,26 @@ async function applyWorkerBranch(ctx, config, log) {
|
|
|
140
144
|
* @param {object} [config] 行配置(profiles/*.cordis.yml 里的 config)
|
|
141
145
|
*/
|
|
142
146
|
export function apply(ctx, config = {}) {
|
|
147
|
+
// ── 配置分层合并(2026-10-18,解决"配置被 DSH 重写冲掉")──────────────
|
|
148
|
+
//
|
|
149
|
+
// 实测事故:用户在 DSH 里卸载重装插件后,`cordis.patch.yml` 从 3962B/15 条
|
|
150
|
+
// 被重写成 696B/4 条 —— **我们的 role 与 webserver 段整段消失**。DSH 的插件
|
|
151
|
+
// 管理器改依赖时会重写那个文件,只保留它自己管理的条目。
|
|
152
|
+
//
|
|
153
|
+
// 结果就是"每次设置完重启又回引导页":写进去的 role 被外力冲掉,重启后
|
|
154
|
+
// 只剩包内默认(role: auto)。
|
|
155
|
+
//
|
|
156
|
+
// 所以配置分两层:
|
|
157
|
+
// 1. `cordis.patch.yml` 的 `- id: remote-agent` —— 只负责让 loader 加载
|
|
158
|
+
// 2. `<stateDir>/instance.json` —— 存真正的配置,**DSH 冲不掉**
|
|
159
|
+
//
|
|
160
|
+
// 这里把第 2 层合并进来,且**独立配置优先**(那是用户明确设置过的值)。
|
|
161
|
+
// 读失败/损坏时安静退回 loader config(配置读不出来不该让 DSH 起不来)。
|
|
162
|
+
const merged = mergeInstanceConfig(config)
|
|
163
|
+
if (merged.source) {
|
|
164
|
+
config = merged.config
|
|
165
|
+
}
|
|
166
|
+
|
|
143
167
|
const { role, invalid, unconfigured } = resolveRoleDetail(config)
|
|
144
168
|
const log = loggerOf(ctx)
|
|
145
169
|
// 配置写错必须有反馈。注意**不再回退 hub** —— 那会静默开一个
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 独立配置文件(2026-10-18,解决"配置被 DSH 重写冲掉"的根因)。
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 为什么需要它
|
|
6
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* 实测事故(2026-10-18):
|
|
9
|
+
*
|
|
10
|
+
* 01:44 cordis.patch.yml = 3962B / 15 条(含我们的 role + webserver)
|
|
11
|
+
* 12:05 cordis.patch.yml = 696B / 4 条(**我们的段整段消失**)
|
|
12
|
+
*
|
|
13
|
+
* 12:05 前后用户在 DSH 里**卸载重装了插件**,同时 pnpm-lock.yaml 被更新。
|
|
14
|
+
* 结论:**DSH 的插件管理器在改动依赖时会重写 `cordis.patch.yml`,只保留它
|
|
15
|
+
* 自己管理的条目** —— 手工写的段会被冲掉。
|
|
16
|
+
*
|
|
17
|
+
* 这正是"每次设置完重启又回到引导页"的真正根因(不是版本旧、不是写错 profile):
|
|
18
|
+
*
|
|
19
|
+
* 用户选角色 → 写入 cordis.patch.yml → 某次插件操作 → 我们的段被冲掉
|
|
20
|
+
* → 重启 → role 消失 → 面板又显示引导
|
|
21
|
+
*
|
|
22
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
* 方案:配置分两层(本文件 = 不会被冲掉的那层)
|
|
24
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
25
|
+
*
|
|
26
|
+
* · **第 1 层**:`cordis.patch.yml` 里的 `- id: remote-agent` 行
|
|
27
|
+
* —— 只负责"让 loader 加载这个插件",内容可以极简(甚至只有 id + name)
|
|
28
|
+
* · **第 2 层**:`<stateDir>/instance.json`(本文件管的这个)
|
|
29
|
+
* —— 存 role / hubUrl / roots 等**真正的配置**
|
|
30
|
+
*
|
|
31
|
+
* loader 传进来的 config 与这里的值**合并**,后者在关键字段上**优先**
|
|
32
|
+
* (因为它是用户明确设置过、且不会被外力冲掉的)。
|
|
33
|
+
*
|
|
34
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
35
|
+
* 安全与边界
|
|
36
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
37
|
+
*
|
|
38
|
+
* · 文件权限 0600(可能含 token)
|
|
39
|
+
* · 原子写(tmp → rename),与 templates.js 同一套做法
|
|
40
|
+
* · **绝不抛**:读失败/损坏时返回 null,让调用方退回 loader config
|
|
41
|
+
* (配置读不出来不该让 DSH 起不来)
|
|
42
|
+
* · 只存**白名单字段**,不存任意用户输入(防配置注入)
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import fs from 'node:fs'
|
|
46
|
+
import os from 'node:os'
|
|
47
|
+
import path from 'node:path'
|
|
48
|
+
|
|
49
|
+
/** 独立配置文件名(放在 stateDir 下)。 */
|
|
50
|
+
export const INSTANCE_FILE_NAME = 'instance.json'
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 允许持久化的字段白名单。
|
|
54
|
+
*
|
|
55
|
+
* 为什么不直接存整个 config:config 里可能有 DSH 注入的东西(未知字段会
|
|
56
|
+
* 被 schemastery passthrough 保留),把它们写回磁盘等于把 loader 的中间态
|
|
57
|
+
* 固化下来。只存我们**真正需要跨重启保留**的。
|
|
58
|
+
*/
|
|
59
|
+
const PERSISTED_KEYS = Object.freeze([
|
|
60
|
+
'role',
|
|
61
|
+
'nodeId',
|
|
62
|
+
'transportMode',
|
|
63
|
+
'hubUrl',
|
|
64
|
+
'relayUrl',
|
|
65
|
+
'roots',
|
|
66
|
+
'nodeToken',
|
|
67
|
+
'uiPullRoot',
|
|
68
|
+
'agentMode',
|
|
69
|
+
'pair',
|
|
70
|
+
])
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 解析 stateDir(与 lib/node/index.js 的 resolveStateDir 同口径)。
|
|
74
|
+
* @param {object} [config]
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function resolveStateDir(config) {
|
|
78
|
+
const c = config && typeof config === 'object' ? config : {}
|
|
79
|
+
const explicit = typeof c.stateDir === 'string' ? c.stateDir.trim() : ''
|
|
80
|
+
if (explicit !== '') {
|
|
81
|
+
try {
|
|
82
|
+
return path.resolve(explicit)
|
|
83
|
+
} catch {
|
|
84
|
+
/* 落到默认 */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return path.join(os.homedir(), '.dsh-remote-agent')
|
|
89
|
+
} catch {
|
|
90
|
+
return path.join(os.tmpdir(), '.dsh-remote-agent')
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 独立配置文件的绝对路径。 */
|
|
95
|
+
export function instanceFilePath(config) {
|
|
96
|
+
return path.join(resolveStateDir(config), INSTANCE_FILE_NAME)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 读独立配置。
|
|
101
|
+
*
|
|
102
|
+
* @param {object} [config] 用于解析 stateDir
|
|
103
|
+
* @returns {object|null} 读不到/损坏时返回 null(**不抛**)
|
|
104
|
+
*/
|
|
105
|
+
export function readInstanceConfig(config) {
|
|
106
|
+
const file = instanceFilePath(config)
|
|
107
|
+
let text
|
|
108
|
+
try {
|
|
109
|
+
text = fs.readFileSync(file, 'utf8')
|
|
110
|
+
} catch {
|
|
111
|
+
// ENOENT = 还没写过,是最常见的正常情况
|
|
112
|
+
return null
|
|
113
|
+
}
|
|
114
|
+
let parsed
|
|
115
|
+
try {
|
|
116
|
+
parsed = JSON.parse(text)
|
|
117
|
+
} catch {
|
|
118
|
+
// 损坏:返回 null 让调用方退回 loader config(不让 DSH 起不来)
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
122
|
+
// 字段级清洗(只取白名单,且去掉明显非法值)
|
|
123
|
+
const out = {}
|
|
124
|
+
for (const k of PERSISTED_KEYS) {
|
|
125
|
+
if (!(k in parsed)) continue
|
|
126
|
+
const v = parsed[k]
|
|
127
|
+
if (v === null || v === undefined) continue
|
|
128
|
+
// roots 是数组,单独清洗
|
|
129
|
+
if (k === 'roots') {
|
|
130
|
+
if (Array.isArray(v)) {
|
|
131
|
+
const arr = v.filter((x) => typeof x === 'string' && x.trim() !== '').map((x) => x.trim())
|
|
132
|
+
if (arr.length > 0) out.roots = arr
|
|
133
|
+
}
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
if (typeof v === 'string' && v.trim() !== '') out[k] = v.trim()
|
|
137
|
+
}
|
|
138
|
+
return Object.keys(out).length > 0 ? out : null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 写独立配置(原子写 + 0600)。
|
|
143
|
+
*
|
|
144
|
+
* @param {object} values 要写的字段(按白名单过滤)
|
|
145
|
+
* @param {object} [config] 用于解析 stateDir
|
|
146
|
+
* @returns {{ok:boolean, file:string, error?:string}}
|
|
147
|
+
*/
|
|
148
|
+
export function writeInstanceConfig(values, config) {
|
|
149
|
+
const dir = resolveStateDir(config)
|
|
150
|
+
const file = path.join(dir, INSTANCE_FILE_NAME)
|
|
151
|
+
const src = values && typeof values === 'object' ? values : {}
|
|
152
|
+
const out = {}
|
|
153
|
+
for (const k of PERSISTED_KEYS) {
|
|
154
|
+
if (!(k in src)) continue
|
|
155
|
+
const v = src[k]
|
|
156
|
+
if (v === null || v === undefined) continue
|
|
157
|
+
if (k === 'roots') {
|
|
158
|
+
if (Array.isArray(v)) {
|
|
159
|
+
const arr = v.filter((x) => typeof x === 'string' && x.trim() !== '').map((x) => x.trim())
|
|
160
|
+
if (arr.length > 0) out.roots = arr
|
|
161
|
+
}
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
if (typeof v === 'string' && v.trim() !== '') out[k] = v.trim()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now().toString(36)}`
|
|
168
|
+
try {
|
|
169
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
170
|
+
fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
171
|
+
fs.renameSync(tmp, file)
|
|
172
|
+
return { ok: true, file }
|
|
173
|
+
} catch (err) {
|
|
174
|
+
try {
|
|
175
|
+
fs.rmSync(tmp, { force: true })
|
|
176
|
+
} catch {
|
|
177
|
+
/* 清理失败不掩盖原始错误 */
|
|
178
|
+
}
|
|
179
|
+
return { ok: false, file, error: String(err?.message ?? err) }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* 把独立配置**合并进** loader 传进来的 config。
|
|
185
|
+
*
|
|
186
|
+
* ── 优先级(关键设计)──────────────────────────────────────────────────
|
|
187
|
+
*
|
|
188
|
+
* **独立配置优先** —— 因为:
|
|
189
|
+
* · 它是用户通过面板明确设置过的值
|
|
190
|
+
* · 它不会被 DSH 重写冲掉
|
|
191
|
+
* · loader config 在"被冲掉"之后只剩包内默认(role: auto),
|
|
192
|
+
* 那正是"重启回引导页"的成因
|
|
193
|
+
*
|
|
194
|
+
* 例外:如果独立配置里**没有**某个字段,就用 loader config 的值。
|
|
195
|
+
*
|
|
196
|
+
* @param {object} loaderConfig
|
|
197
|
+
* @param {object} [opts]
|
|
198
|
+
* @param {boolean} [opts.overlay] 读到的独立配置(避免重复读盘)
|
|
199
|
+
* @returns {{config:object, source:object|null}}
|
|
200
|
+
*/
|
|
201
|
+
export function mergeInstanceConfig(loaderConfig, { overlay } = {}) {
|
|
202
|
+
const base = loaderConfig && typeof loaderConfig === 'object' ? loaderConfig : {}
|
|
203
|
+
let saved = overlay
|
|
204
|
+
if (saved === undefined) saved = readInstanceConfig(base)
|
|
205
|
+
if (!saved) return { config: base, source: null }
|
|
206
|
+
return { config: { ...base, ...saved }, source: saved }
|
|
207
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 独立监听端口(2026-10-18,解决"副机永远连不上"的架构级问题)。
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 为什么不再用 ctx.webServer
|
|
6
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* 原实现把副机拨入端点注册在 DSH 的 `ctx.webServer` 上:
|
|
9
|
+
*
|
|
10
|
+
* ctx.webServer.registerUpgrade({ path: '/api/remote-agent/node', handler })
|
|
11
|
+
*
|
|
12
|
+
* 于是副机必须能访问 **DSH 的 webserver 端口**。而那个端口的绑定地址来自
|
|
13
|
+
* DSH 自己的配置,带来两个无法绕开的死结:
|
|
14
|
+
*
|
|
15
|
+
* 1. **DSH 每次启动都重写 `<profile>/cordis.patch.yml`**(实测:写进去的
|
|
16
|
+
* `webserver` 段在下一次启动时被删掉,只留 3 个 `ui-*` 条目)→ 绑回
|
|
17
|
+
* `127.0.0.1` → 副机连不上。
|
|
18
|
+
* 2. `webServer` 服务**激活即 listen**,没有 rebind 方法 → 插件运行时改不了。
|
|
19
|
+
*
|
|
20
|
+
* 而且新版 `dsh-host-webserver` 硬拒绝 `0.0.0.0`(会直接让 DSH 启动崩溃)。
|
|
21
|
+
*
|
|
22
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
23
|
+
* 现在的做法:插件自己开一个端口
|
|
24
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
25
|
+
*
|
|
26
|
+
* 用 `node:http` 起一个独立的 server,自己处理 WebSocket 升级
|
|
27
|
+
* (复用 `lib/ws.js` 的 `acceptUpgrade` —— 和 DSH 内部用的是同一套底层实现)。
|
|
28
|
+
*
|
|
29
|
+
* 好处:
|
|
30
|
+
* · **完全不依赖 DSH 的 webserver 配置**,DSH 怎么重写 patch 都不影响
|
|
31
|
+
* · host/port 由插件自己的配置决定,可以持久化到 instance.json
|
|
32
|
+
* · 不碰 DSH 的任何文件
|
|
33
|
+
*
|
|
34
|
+
* 代价:
|
|
35
|
+
* · 多一个监听端口(默认 19387)
|
|
36
|
+
* · 需要用户/配置告诉它绑哪个具体 IP(新版规则不允许 0.0.0.0,这本身是好事:
|
|
37
|
+
* 只绑用户指定的那张网卡,暴露面最小)
|
|
38
|
+
*
|
|
39
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
40
|
+
* 绑定地址怎么定(关键设计)
|
|
41
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
42
|
+
*
|
|
43
|
+
* 无法绑 `0.0.0.0`(与新版 DSH 同一考量:通配地址把服务暴露到所有网卡)。
|
|
44
|
+
* 所以按以下优先级选一个**具体 IP**:
|
|
45
|
+
*
|
|
46
|
+
* 1. 配置里的 `listenHost`(用户显式指定,最高优先)
|
|
47
|
+
* 2. **Tailscale IP**(100.64.0.0/10 段)—— 本插件的目标场景就是跨机,
|
|
48
|
+
* Tailscale 地址固定不变、跨网可达,是最合理的选择
|
|
49
|
+
* 3. 第一个私网 IPv4(192.168/10./172.16-31)
|
|
50
|
+
* 4. `127.0.0.1`(兜底:至少本机能连,同机 both 模式可用)
|
|
51
|
+
*
|
|
52
|
+
* **自动选 IP 是刻意的**:不让用户为了一个"该绑哪张网卡"去查 ipconfig。
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import http from 'node:http'
|
|
56
|
+
import os from 'node:os'
|
|
57
|
+
|
|
58
|
+
import { acceptUpgrade } from './ws.js'
|
|
59
|
+
|
|
60
|
+
/** 默认端口:与历史行为一致(文档、配对码、用户既有配置都指向它)。 */
|
|
61
|
+
export const DEFAULT_LISTEN_PORT = 19387
|
|
62
|
+
|
|
63
|
+
/** Tailscale 的 CGNAT 段(100.64.0.0/10)。 */
|
|
64
|
+
function isTailscaleIp(ip) {
|
|
65
|
+
if (typeof ip !== 'string') return false
|
|
66
|
+
const m = /^100\.(\d+)\./.exec(ip)
|
|
67
|
+
if (!m) return false
|
|
68
|
+
const second = Number(m[1])
|
|
69
|
+
// 100.64.0.0/10 → 第二段 64..127
|
|
70
|
+
return second >= 64 && second <= 127
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** 私网段。 */
|
|
74
|
+
function isPrivateIp(ip) {
|
|
75
|
+
if (typeof ip !== 'string') return false
|
|
76
|
+
return (
|
|
77
|
+
ip.startsWith('192.168.') ||
|
|
78
|
+
ip.startsWith('10.') ||
|
|
79
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(ip)
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 列出本机所有非内网回环的 IPv4(按优先级排序)。
|
|
85
|
+
*
|
|
86
|
+
* @returns {Array<{address:string, family:string, iface:string, score:number}>}
|
|
87
|
+
*/
|
|
88
|
+
export function listCandidateAddresses() {
|
|
89
|
+
const out = []
|
|
90
|
+
let ifaces
|
|
91
|
+
try {
|
|
92
|
+
ifaces = os.networkInterfaces()
|
|
93
|
+
} catch {
|
|
94
|
+
return out
|
|
95
|
+
}
|
|
96
|
+
for (const [iface, list] of Object.entries(ifaces)) {
|
|
97
|
+
if (!Array.isArray(list)) continue
|
|
98
|
+
for (const info of list) {
|
|
99
|
+
if (!info || info.internal === true) continue
|
|
100
|
+
const family = info.family === 'IPv4' || info.family === 4 ? 'IPv4' : String(info.family)
|
|
101
|
+
if (family !== 'IPv4') continue
|
|
102
|
+
const address = typeof info.address === 'string' ? info.address : ''
|
|
103
|
+
if (address === '' || address.startsWith('169.254.')) continue // APIPA
|
|
104
|
+
// 优先级:Tailscale(0) > 私网(1) > 其它(2)
|
|
105
|
+
const score = isTailscaleIp(address) ? 0 : isPrivateIp(address) ? 1 : 2
|
|
106
|
+
out.push({ address, family, iface, score })
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
out.sort((a, b) => a.score - b.score)
|
|
110
|
+
return out
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* 决定监听地址。
|
|
115
|
+
*
|
|
116
|
+
* @param {object} [o]
|
|
117
|
+
* @param {string} [o.listenHost] 用户显式配置的地址
|
|
118
|
+
* @param {string[]} [o.candidates] 覆盖候选列表(测试用)
|
|
119
|
+
* @returns {{host:string, source:string, candidates:Array}}
|
|
120
|
+
*/
|
|
121
|
+
export function resolveListenHost({ listenHost, candidates } = {}) {
|
|
122
|
+
const list = Array.isArray(candidates) ? candidates : listCandidateAddresses()
|
|
123
|
+
|
|
124
|
+
// 1. 用户显式指定(且不是通配地址 —— 新版规则不允许)
|
|
125
|
+
if (typeof listenHost === 'string') {
|
|
126
|
+
const h = listenHost.trim()
|
|
127
|
+
if (h !== '' && h !== '0.0.0.0' && h !== '::' && h !== '[::]') {
|
|
128
|
+
return { host: h, source: 'config', candidates: list }
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// 2. Tailscale IP
|
|
133
|
+
const ts = list.find((x) => isTailscaleIp(x.address))
|
|
134
|
+
if (ts) return { host: ts.address, source: 'tailscale', candidates: list }
|
|
135
|
+
|
|
136
|
+
// 3. 私网 IP
|
|
137
|
+
const priv = list.find((x) => isPrivateIp(x.address))
|
|
138
|
+
if (priv) return { host: priv.address, source: 'private', candidates: list }
|
|
139
|
+
|
|
140
|
+
// 4. 任意非回环
|
|
141
|
+
if (list.length > 0) return { host: list[0].address, source: 'any', candidates: list }
|
|
142
|
+
|
|
143
|
+
// 5. 兜底:本机回环(同机 both 模式仍可用)
|
|
144
|
+
return { host: '127.0.0.1', source: 'loopback', candidates: list }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 启动一个只服务本插件 WS 端点的小 HTTP server。
|
|
149
|
+
*
|
|
150
|
+
* 只做两件事:
|
|
151
|
+
* · `GET <healthPath>` → 200 JSON(运维自检用,不泄露敏感信息)
|
|
152
|
+
* · `Upgrade: websocket` → 交给 `onUpgrade`(副机拨入)
|
|
153
|
+
*
|
|
154
|
+
* 其它路径一律 404 —— 这不是通用 web server,只承载节点端点。
|
|
155
|
+
*
|
|
156
|
+
* @param {object} o
|
|
157
|
+
* @param {string} o.host 绑定的具体 IP
|
|
158
|
+
* @param {number} o.port 端口(0 = 随机)
|
|
159
|
+
* @param {string} o.path WS 升级路径(必须精确匹配)
|
|
160
|
+
* @param {(conn:import('./ws.js').WSConnection, req:import('node:http').IncomingMessage)=>void} o.onUpgrade
|
|
161
|
+
* @param {string} [o.healthPath] 健康检查路径
|
|
162
|
+
* @param {object} [o.healthBody] 健康检查返回体
|
|
163
|
+
* @param {(level:string,msg:string,extra?:object)=>void} [o.log]
|
|
164
|
+
* @returns {Promise<{server:import('node:http').Server, host:string, port:number, close:()=>Promise<void>}>}
|
|
165
|
+
*/
|
|
166
|
+
export function startListenServer({
|
|
167
|
+
host,
|
|
168
|
+
port = DEFAULT_LISTEN_PORT,
|
|
169
|
+
path: upgradePath,
|
|
170
|
+
onUpgrade,
|
|
171
|
+
healthPath = '/healthz',
|
|
172
|
+
healthBody = { ok: true },
|
|
173
|
+
log = () => {},
|
|
174
|
+
} = {}) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
const sockets = new Set()
|
|
177
|
+
|
|
178
|
+
const server = http.createServer((req, res) => {
|
|
179
|
+
// 健康检查:给运维/体检一个"端口真的活着"的判据
|
|
180
|
+
try {
|
|
181
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
182
|
+
if (req.method === 'GET' && url.pathname === healthPath) {
|
|
183
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
184
|
+
res.end(JSON.stringify(healthBody))
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
} catch {
|
|
188
|
+
/* URL 解析失败:落到 404 */
|
|
189
|
+
}
|
|
190
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
191
|
+
res.end('not found\n')
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
// 追踪连接,close 时能强制断干净(否则旧连接会拖住 server.close)
|
|
195
|
+
server.on('connection', (socket) => {
|
|
196
|
+
sockets.add(socket)
|
|
197
|
+
socket.on('close', () => sockets.delete(socket))
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
server.on('upgrade', (req, socket, head) => {
|
|
201
|
+
let pathname
|
|
202
|
+
try {
|
|
203
|
+
pathname = new URL(req.url || '/', 'http://localhost').pathname
|
|
204
|
+
} catch {
|
|
205
|
+
socket.destroy()
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
// ⚠ 路径必须**精确匹配**:这个 server 只承载节点端点,
|
|
209
|
+
// 不开任何"顺手兼容一下"的口子(越宽的攻击面越难守)。
|
|
210
|
+
if (pathname !== upgradePath) {
|
|
211
|
+
socket.destroy()
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
const conn = acceptUpgrade(req, socket, head)
|
|
215
|
+
if (!conn) return
|
|
216
|
+
try {
|
|
217
|
+
onUpgrade(conn, req)
|
|
218
|
+
} catch (err) {
|
|
219
|
+
log('warn', 'upgrade handler threw', { error: String(err?.message ?? err) })
|
|
220
|
+
try {
|
|
221
|
+
conn.close(1011, 'internal error')
|
|
222
|
+
} catch {
|
|
223
|
+
/* 已关 */
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
server.on('error', (err) => {
|
|
229
|
+
// listen 失败(端口占用 / 地址不可用)要**明确报出来** ——
|
|
230
|
+
// 静默失败会让"副机连不上"变成又一个无头案。
|
|
231
|
+
reject(err)
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
server.listen(port, host, () => {
|
|
235
|
+
const addr = server.address()
|
|
236
|
+
const actualPort = addr && typeof addr === 'object' ? addr.port : port
|
|
237
|
+
log('info', 'node endpoint listening', { host, port: actualPort, path: upgradePath })
|
|
238
|
+
|
|
239
|
+
const close = () => new Promise((done) => {
|
|
240
|
+
try {
|
|
241
|
+
server.close(() => done())
|
|
242
|
+
} catch {
|
|
243
|
+
done()
|
|
244
|
+
}
|
|
245
|
+
// 强制断开存量连接:server.close 只是"停止接受新连接",
|
|
246
|
+
// 已有 WS 连接会一直挂着,插件卸载时不能留悬挂句柄。
|
|
247
|
+
for (const s of sockets) {
|
|
248
|
+
try {
|
|
249
|
+
s.destroy()
|
|
250
|
+
} catch {
|
|
251
|
+
/* 已关 */
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
sockets.clear()
|
|
255
|
+
// 兜底超时:某些平台 close 回调可能不触发
|
|
256
|
+
setTimeout(done, 2000).unref?.()
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
resolve({ server, host, port: actualPort, close })
|
|
260
|
+
})
|
|
261
|
+
})
|
|
262
|
+
}
|
package/lib/setup-routes.js
CHANGED
|
@@ -35,6 +35,9 @@ import { writePatchBlock } from './setup.js'
|
|
|
35
35
|
// 角色解析必须**只有一份实现** —— 面板说"未配置"而插件实际在跑 hub,
|
|
36
36
|
// 是最难排查的撕裂(v0.7.0 踩过)。所以这里直接复用 lib/index.js 的解析器。
|
|
37
37
|
import { resolveRoleDetail } from './index.js'
|
|
38
|
+
// 配置分层(2026-10-18):DSH 插件管理器会重写 cordis.patch.yml 并冲掉手工段,
|
|
39
|
+
// 所以每次写配置时也在 <stateDir>/instance.json 存一份(插件加载时合并回来)。
|
|
40
|
+
import { writeInstanceConfig, readInstanceConfig } from './instance-config.js'
|
|
38
41
|
|
|
39
42
|
/** 引导路由的前缀。与 hub 的 `/api/remote-agent/ui` 分开 —— 未配置时后者不存在。 */
|
|
40
43
|
export const SETUP_API_PREFIX = '/api/remote-agent/setup'
|
|
@@ -216,12 +219,28 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
|
|
|
216
219
|
// `configured` 的语义是"用户已经做过选择,不需要引导"。
|
|
217
220
|
// auto 也算已选择:它是个明确的工作模式(按 hubUrl 推断),不是"还没配"。
|
|
218
221
|
const configured = role === 'auto' || detail.unconfigured !== true
|
|
222
|
+
|
|
223
|
+
// ── 独立配置状态(2026-10-18)──────────────────────────────────────
|
|
224
|
+
//
|
|
225
|
+
// 报告 `<stateDir>/instance.json` 里存了什么。这不是给用户看的细节,
|
|
226
|
+
// 而是排查"配置被 DSH 重写冲掉"这个坑的关键线索:
|
|
227
|
+
// · instance 有值 = 配置抗住了重写(重启后 apply() 会合并回来)
|
|
228
|
+
// · instance 为 null 但 patch 有值 = 只靠 patch 那层,下次重写会丢
|
|
229
|
+
const saved = readInstanceConfig(config)
|
|
219
230
|
writeJson(res, 200, {
|
|
220
231
|
ok: true,
|
|
221
232
|
configured,
|
|
222
233
|
// 推断结果(面板据此显示"当前生效角色")—— auto 时它是有值的
|
|
223
234
|
effectiveRole: detail.role,
|
|
224
235
|
inferred: typeof detail.inferred === 'string' ? detail.inferred : '',
|
|
236
|
+
// 配置持久化的两层状态
|
|
237
|
+
persist: {
|
|
238
|
+
// DSH 会重写 cordis.patch.yml(实测会把我们的段整段冲掉)
|
|
239
|
+
patchFile: currentPatchFile(),
|
|
240
|
+
// 这一层 DSH 冲不掉,是"重启后配置还在"的真正保障
|
|
241
|
+
instanceFile: saved !== null,
|
|
242
|
+
instanceRole: saved && typeof saved.role === 'string' ? saved.role : null,
|
|
243
|
+
},
|
|
225
244
|
// 回显当前配置里已有的值,方便用户"改一个字段"而不是从头填
|
|
226
245
|
current: {
|
|
227
246
|
role: typeof config.role === 'string' ? role : '',
|
|
@@ -342,12 +361,41 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
|
|
|
342
361
|
})
|
|
343
362
|
}
|
|
344
363
|
log('info', 'setup applied', { role, file: result.file, mode: result.mode, warnings: applyWarnings })
|
|
364
|
+
|
|
365
|
+
// ── 同时写独立配置(2026-10-18,方案 B:防被 DSH 重写冲掉)──────────
|
|
366
|
+
//
|
|
367
|
+
// 事故:用户在 DSH 里卸载重装插件后,`cordis.patch.yml` 被重写成只剩
|
|
368
|
+
// 4 条 —— 我们的 role + webserver 整段消失,重启后又回引导页。
|
|
369
|
+
//
|
|
370
|
+
// 所以每次 setup 写入成功后,把**同样的值**再存一份到
|
|
371
|
+
// `<stateDir>/instance.json`。插件 `apply()` 时会把它合并回来
|
|
372
|
+
// (独立配置优先)。这样 DSH 怎么重写 patch 文件都不影响角色配置。
|
|
373
|
+
//
|
|
374
|
+
// 失败不阻塞:独立配置写不进去时,patch 文件那层仍然生效(只是不抗冲掉)。
|
|
375
|
+
const persisted = writeInstanceConfig({
|
|
376
|
+
role,
|
|
377
|
+
nodeId: str(body.nodeId),
|
|
378
|
+
transportMode: str(body.transportMode) || undefined,
|
|
379
|
+
hubUrl: str(body.hubUrl) || undefined,
|
|
380
|
+
relayUrl: str(body.relayUrl) || undefined,
|
|
381
|
+
roots,
|
|
382
|
+
})
|
|
383
|
+
if (persisted.ok !== true) {
|
|
384
|
+
applyWarnings.push(`独立配置写入失败(${persisted.error})—— 角色配置仍写进了 patch 文件,`
|
|
385
|
+
+ '但下次 DSH 重写该文件时可能丢失,建议重启后到面板确认一次。')
|
|
386
|
+
log('warn', 'instance config write failed', { file: persisted.file, error: persisted.error })
|
|
387
|
+
} else {
|
|
388
|
+
log('info', 'instance config persisted', { file: persisted.file })
|
|
389
|
+
}
|
|
390
|
+
|
|
345
391
|
writeJson(res, 200, {
|
|
346
392
|
ok: true,
|
|
347
393
|
role,
|
|
348
394
|
file: result.file,
|
|
349
395
|
backup: result.backup,
|
|
350
396
|
mode: result.mode,
|
|
397
|
+
// 独立配置的落点(让用户/排查者知道配置存在哪两处)
|
|
398
|
+
instanceFile: persisted.ok === true ? persisted.file : null,
|
|
351
399
|
// 非致命提示(自动建目录的结果等)—— 面板要显示,否则用户以为
|
|
352
400
|
// "目录已就绪"而实际没有
|
|
353
401
|
warnings: applyWarnings,
|
package/package.json
CHANGED
|
@@ -1129,6 +1129,45 @@ check('65', 'eslint 严格档:lint script + 配置文件 + CI job 三件套齐
|
|
|
1129
1129
|
'lib/client.js(dir: shareDir + nodeRoots UI)+ bin/dsh-remote-setup.js(envChecks)')
|
|
1130
1130
|
}
|
|
1131
1131
|
|
|
1132
|
+
/* ── 86~87. 独立监听端口(2026-10-18,架构级修复)──
|
|
1133
|
+
*
|
|
1134
|
+
* 副机端点原来注册在 `ctx.webServer` 上 → 副机必须访问 DSH 的 webserver 端口。
|
|
1135
|
+
* 而那个端口:
|
|
1136
|
+
* · **DSH 每次启动重写 profile patch**,把写进去的 `webserver` 段删掉
|
|
1137
|
+
* (实测:3962B/15 条 → 661B/3 条)→ 绑回 127.0.0.1 → 副机永远连不上
|
|
1138
|
+
* · `webServer` 激活即 listen,没有 rebind 方法
|
|
1139
|
+
* · 新版硬拒绝 0.0.0.0(会让 DSH 启动崩溃,踩过 10 份 crash 日志)
|
|
1140
|
+
*
|
|
1141
|
+
* 所以插件自己开一个 node:http server 服务节点端点,完全绕开 DSH 的 webserver。
|
|
1142
|
+
* 这两条断言钉住"这条通道存在且不依赖 webServer"。
|
|
1143
|
+
*/
|
|
1144
|
+
{
|
|
1145
|
+
const hubSrc2 = read('lib/hub/index.js')
|
|
1146
|
+
const listenSrc = read('lib/listen-server.js')
|
|
1147
|
+
|
|
1148
|
+
check('86', '独立监听:插件自己开端口服务节点端点(不依赖 webServer 配置)',
|
|
1149
|
+
/startListenServer/.test(hubSrc2)
|
|
1150
|
+
&& /resolveListenHost/.test(hubSrc2)
|
|
1151
|
+
// 默认端口 19387(与历史配置/配对码/文档一致)
|
|
1152
|
+
&& /DEFAULT_LISTEN_PORT/.test(listenSrc)
|
|
1153
|
+
&& /19387/.test(listenSrc)
|
|
1154
|
+
// 必须真的用 node:http 起 server(不是只 import 不用)
|
|
1155
|
+
&& /createServer/.test(listenSrc)
|
|
1156
|
+
&& /node:http/.test(listenSrc)
|
|
1157
|
+
// 路径精确匹配(只承载节点端点,不做通用 web server)
|
|
1158
|
+
&& /pathname !== upgradePath/.test(listenSrc),
|
|
1159
|
+
'lib/hub/index.js + lib/listen-server.js(node:http + acceptUpgrade + 精确路径)')
|
|
1160
|
+
|
|
1161
|
+
check('87', '独立监听:拒绝 0.0.0.0 + Tailscale 优先 + 测试环境不占端口',
|
|
1162
|
+
// 拒绝通配地址(与新版 DSH 同规则;0.0.0.0 会让 DSH 崩溃,我们踩过)
|
|
1163
|
+
/h !== '0\.0\.0\.0'/.test(listenSrc)
|
|
1164
|
+
// 自动选地址时 Tailscale 优先(本插件的目标场景就是跨网)
|
|
1165
|
+
&& /isTailscaleIp/.test(listenSrc)
|
|
1166
|
+
// 测试环境下不真的监听(否则 npm test 会挂住 —— 实测过)
|
|
1167
|
+
&& /NODE_TEST_CONTEXT/.test(hubSrc2),
|
|
1168
|
+
'lib/listen-server.js(拒 0.0.0.0 + Tailscale 优先)+ hub/index.js(测试环境跳过)')
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1132
1171
|
/* ── 输出 ── */
|
|
1133
1172
|
const failed = results.filter((r) => !r.ok)
|
|
1134
1173
|
if (process.argv.includes('--json')) {
|