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/index.js
CHANGED
|
@@ -138,6 +138,31 @@ async function applyWorkerBranch(ctx, config, log) {
|
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* 每个"注册宿主"上当前这一代的清理函数。
|
|
143
|
+
*
|
|
144
|
+
* 键选 **`ctx.webServer` 服务实例**,不是 `ctx`:
|
|
145
|
+
* 撞车的是**宿主的路由表**(`webServer.upgrades` 这张 Map)。同一个 ctx 当然
|
|
146
|
+
* 共用同一张表,但 DSH 热重载时也可能给每代一个新的子 ctx —— 那时按 ctx 记
|
|
147
|
+
* 就会漏掉,照样撞车。按服务实例记才对应"真正会冲突的那个东西"。
|
|
148
|
+
*
|
|
149
|
+
* 读不到 `ctx.webServer`(副机 profile 的 `inject: []` 一读就抛)时退回 ctx,
|
|
150
|
+
* 这条兜底只是让字典有键,不影响正确性。
|
|
151
|
+
*
|
|
152
|
+
* 用 WeakMap:既不阻止 GC,也不会把多个 profile / 多个 host 实例串在一起。
|
|
153
|
+
*/
|
|
154
|
+
const GENERATIONS = new WeakMap()
|
|
155
|
+
|
|
156
|
+
/** 取"注册宿主"作为字典键。读服务可能抛(无 inject),所以必须 try。 */
|
|
157
|
+
function generationKey(ctx) {
|
|
158
|
+
try {
|
|
159
|
+
const ws = ctx && ctx.webServer
|
|
160
|
+
return ws !== undefined && ws !== null ? ws : ctx
|
|
161
|
+
} catch {
|
|
162
|
+
return ctx
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
/**
|
|
142
167
|
* DSH 插件入口。
|
|
143
168
|
* @param {object} ctx Cordis 上下文
|
|
@@ -166,6 +191,17 @@ export function apply(ctx, config = {}) {
|
|
|
166
191
|
|
|
167
192
|
const { role, invalid, unconfigured } = resolveRoleDetail(config)
|
|
168
193
|
const log = loggerOf(ctx)
|
|
194
|
+
|
|
195
|
+
// M3(2026-10-20):独立配置优先,会**压住** loader(patch/插件 UI)里的同名字段。
|
|
196
|
+
// 这是设计(它才抗得住 DSH 重写),但必须留痕 —— 否则用户"改了 patch 没生效"
|
|
197
|
+
// 时完全无从判断。只记字段名,不记值(token 等敏感字段不能进日志)。
|
|
198
|
+
if (Array.isArray(merged.shadowed) && merged.shadowed.length > 0) {
|
|
199
|
+
log('info', 'instance.json overrides loader config (patch edits ignored for these keys)', {
|
|
200
|
+
keys: merged.shadowed,
|
|
201
|
+
fix: '要让 patch 生效,请删掉 instance.json 里对应的键(或用面板改)',
|
|
202
|
+
})
|
|
203
|
+
}
|
|
204
|
+
|
|
169
205
|
// 配置写错必须有反馈。注意**不再回退 hub** —— 那会静默开一个
|
|
170
206
|
// nodeToken 为空、不校验凭证的直连端点(见 resolveRoleDetail 的说明)。
|
|
171
207
|
// 现在当成"未配置"处理:什么都不注册,面板显示引导让用户重选。
|
|
@@ -177,6 +213,64 @@ export function apply(ctx, config = {}) {
|
|
|
177
213
|
}
|
|
178
214
|
const handles = { role, hub: undefined, worker: undefined, unconfigured: unconfigured === true }
|
|
179
215
|
|
|
216
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
217
|
+
// 可重入:同一个 ctx 上只允许存在**一代**注册(0.9.5 修 duplicate 激活失败)
|
|
218
|
+
// ══════════════════════════════════════════════════════════════════════════
|
|
219
|
+
//
|
|
220
|
+
// 0.9.4 装上后 DSH 报:
|
|
221
|
+
//
|
|
222
|
+
// webserver: duplicate upgrade route "/api/remote-agent/node"
|
|
223
|
+
// 1 entry did not activate remote-agent (dsh-remote-agent)
|
|
224
|
+
//
|
|
225
|
+
// 根因不是"注册了两次"写错,而是**我们的注册不可回收**:
|
|
226
|
+
//
|
|
227
|
+
// · DSH 的 `webServer.registerUpgrade(path)` 是**按 path 查表**的,
|
|
228
|
+
// 同路径第二次注册直接抛(这是它的组合级契约,合理):
|
|
229
|
+
// if (this.upgrades.has(route.path)) throw new Error('webserver: duplicate upgrade route ...')
|
|
230
|
+
// · 我们的注册全部收集在 `__disposers` 里、由 `applyHub()` 的返回值
|
|
231
|
+
// `disposeAll` 交给调用方保管 —— 但 **DSH 不认识这个返回值**,
|
|
232
|
+
// 热重载/安装后 reload 时它只会重新 `apply`,不会回头调我们的 disposer。
|
|
233
|
+
// · 于是第二代在第一代的残留上注册 → 撞车 → 整个 entry 激活失败。
|
|
234
|
+
//
|
|
235
|
+
// 为什么以前没暴露:只有"同一个 ctx 上重新 apply"才触发。DSH 在
|
|
236
|
+
// 插件安装/升级后会热重载,这正是用户装 0.9.4 时踩到的那一步。
|
|
237
|
+
//
|
|
238
|
+
// 修法:**不指望宿主帮我们清理**。按 ctx 记住上一代的 dispose,重新
|
|
239
|
+
// apply 时先自己摘干净再装。这样无论宿主是否调用 disposer,都不会重复注册。
|
|
240
|
+
const genKey = generationKey(ctx)
|
|
241
|
+
const prevDispose = GENERATIONS.get(genKey)
|
|
242
|
+
if (typeof prevDispose === 'function') {
|
|
243
|
+
try {
|
|
244
|
+
prevDispose()
|
|
245
|
+
log('info', 're-apply on the same host: previous generation disposed first')
|
|
246
|
+
} catch (err) {
|
|
247
|
+
log('warn', 'previous generation dispose failed (continuing)', {
|
|
248
|
+
error: String(err?.message ?? err),
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
let released = false
|
|
254
|
+
const dispose = () => {
|
|
255
|
+
if (released) return
|
|
256
|
+
released = true
|
|
257
|
+
// 只摘自己:新一代可能已经顶替了这一格,别把它的注册也拆了
|
|
258
|
+
if (GENERATIONS.get(genKey) === dispose) GENERATIONS.delete(genKey)
|
|
259
|
+
// 顺序:hub 的路由/监听 → setup 路由 → worker 拨号。
|
|
260
|
+
// 每一步独立兜底:一个失败不能让后面的漏摘(否则又变成"摘一半")。
|
|
261
|
+
try { handles.hub?.disposeAll?.() } catch (err) {
|
|
262
|
+
log('warn', 'hub dispose failed', { error: String(err?.message ?? err) })
|
|
263
|
+
}
|
|
264
|
+
if (Array.isArray(handles.setup)) {
|
|
265
|
+
for (const d of handles.setup) {
|
|
266
|
+
try { if (typeof d === 'function') d() } catch { /* 已摘 */ }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
try { if (typeof handles.worker === 'function') handles.worker() } catch { /* 已摘 */ }
|
|
270
|
+
}
|
|
271
|
+
GENERATIONS.set(genKey, dispose)
|
|
272
|
+
handles.dispose = dispose
|
|
273
|
+
|
|
180
274
|
// ── 未配置:什么都不注册 ────────────────────────────────────────────────
|
|
181
275
|
//
|
|
182
276
|
// 为什么要有这个分支:首次安装时**插件无法知道这台机器是主机还是副机** ——
|
|
@@ -241,6 +335,12 @@ export function apply(ctx, config = {}) {
|
|
|
241
335
|
// 动态 import 是异步的;不 await —— apply 本身是同步契约。
|
|
242
336
|
// 失败已在 applyWorkerBranch 内部记日志,不让 host 因副机缺件而起不来。
|
|
243
337
|
void applyWorkerBranch(ctx, config, log).then((h) => {
|
|
338
|
+
// 竞态:这一代已经被 dispose(热重载/卸载)了,把刚挂上的 worker 立刻摘掉。
|
|
339
|
+
// 否则它会留在后台继续拨号 —— 一份回收不掉的僵尸连接。
|
|
340
|
+
if (released) {
|
|
341
|
+
try { if (typeof h === 'function') h() } catch { /* 已摘 */ }
|
|
342
|
+
return
|
|
343
|
+
}
|
|
244
344
|
handles.worker = h
|
|
245
345
|
})
|
|
246
346
|
}
|
|
@@ -326,6 +426,22 @@ export const Config = z.object({
|
|
|
326
426
|
uiPullRoot: z.string()
|
|
327
427
|
.default('')
|
|
328
428
|
.description('Web 面板拉取产物时唯一允许落地的目录(绝对路径)。留空则面板拉取按钮禁用。'),
|
|
429
|
+
|
|
430
|
+
// ── 独立监听(2026-10-18 引入,2026-10-20 补进 schema)───────────────
|
|
431
|
+
//
|
|
432
|
+
// ⚠ 这两个字段 0.9.3 就已在用,但**没有在这里声明** —— 靠 schemastery 的
|
|
433
|
+
// passthrough 才勉强能读到。后果:插件配置页里根本没有这两项(无表单)、
|
|
434
|
+
// 没有类型校验(`listenPort: "19387"` 会静默回弹默认值)、也无法在 UI 上改。
|
|
435
|
+
// 现在正式声明:能配、能校验、能在面板/插件页里看到。
|
|
436
|
+
listenHost: z.string()
|
|
437
|
+
.default('')
|
|
438
|
+
.description('独立监听绑定的具体本机 IP(留空=自动选:Tailscale > 私网 > 回环)。'
|
|
439
|
+
+ '不能填 0.0.0.0 —— 通配地址会把服务暴露到所有网卡,且新版 DSH 同规则拒绝。'),
|
|
440
|
+
// 取值范围由 normalizeConfig 归一(0 <= port < 65536),这里只声明类型与默认值。
|
|
441
|
+
listenPort: z.number()
|
|
442
|
+
.default(19387)
|
|
443
|
+
.description('独立监听端口(副机从这里拨入)。0 = 关闭独立监听,只走 DSH 自己的 Web 服务通道。'),
|
|
444
|
+
|
|
329
445
|
uiEnabled: z.boolean()
|
|
330
446
|
.default(true)
|
|
331
447
|
.description('是否注册 Web 面板的控制路由。'),
|
package/lib/instance-config.js
CHANGED
|
@@ -55,6 +55,11 @@ export const INSTANCE_FILE_NAME = 'instance.json'
|
|
|
55
55
|
* 为什么不直接存整个 config:config 里可能有 DSH 注入的东西(未知字段会
|
|
56
56
|
* 被 schemastery passthrough 保留),把它们写回磁盘等于把 loader 的中间态
|
|
57
57
|
* 固化下来。只存我们**真正需要跨重启保留**的。
|
|
58
|
+
*
|
|
59
|
+
* ⚠ 2026-10-20 补齐(v0.9.3 审查 H2/M2):此前少了 `listenHost` /
|
|
60
|
+
* `listenPort` / `relay` —— 于是架构承诺"配置不会再被 DSH 冲掉",
|
|
61
|
+
* 而 0.9.3 新增的两个关键字段恰好不在保护范围内,`relay`(跨 NAT 必需)
|
|
62
|
+
* 更是从来没有落过盘。用户配了它们,一次重启就静默回默认。
|
|
58
63
|
*/
|
|
59
64
|
const PERSISTED_KEYS = Object.freeze([
|
|
60
65
|
'role',
|
|
@@ -67,8 +72,18 @@ const PERSISTED_KEYS = Object.freeze([
|
|
|
67
72
|
'uiPullRoot',
|
|
68
73
|
'agentMode',
|
|
69
74
|
'pair',
|
|
75
|
+
// 0.9.3 独立监听的绑定意图(自动选地址会挑 Tailscale,用户可能想钉死局域网)
|
|
76
|
+
'listenHost',
|
|
77
|
+
'listenPort',
|
|
78
|
+
// hub 侧中继凭证(跨 NAT 场景的唯一通路)
|
|
79
|
+
'relay',
|
|
70
80
|
])
|
|
71
81
|
|
|
82
|
+
/** 值是对象的白名单字段(当前只有 relay:{ url, token })。 */
|
|
83
|
+
const OBJECT_KEYS = Object.freeze({
|
|
84
|
+
relay: ['url', 'token'],
|
|
85
|
+
})
|
|
86
|
+
|
|
72
87
|
/**
|
|
73
88
|
* 解析 stateDir(与 lib/node/index.js 的 resolveStateDir 同口径)。
|
|
74
89
|
* @param {object} [config]
|
|
@@ -96,6 +111,55 @@ export function instanceFilePath(config) {
|
|
|
96
111
|
return path.join(resolveStateDir(config), INSTANCE_FILE_NAME)
|
|
97
112
|
}
|
|
98
113
|
|
|
114
|
+
/**
|
|
115
|
+
* 字段级清洗(读与写共用,保证两侧对称)。
|
|
116
|
+
*
|
|
117
|
+
* 2026-10-20 扩展:支持 number(listenPort)与白名单对象(relay)。
|
|
118
|
+
* 早先只接受 string,于是 `listenPort: 19388` 这种合法配置会被静默丢弃。
|
|
119
|
+
*
|
|
120
|
+
* @param {Map<string, unknown>} src
|
|
121
|
+
* @returns {object|null} 清洗后的对象;没有任何有效字段时返回 null
|
|
122
|
+
*/
|
|
123
|
+
function sanitizeFields(src) {
|
|
124
|
+
const out = {}
|
|
125
|
+
for (const k of PERSISTED_KEYS) {
|
|
126
|
+
if (!src.has(k)) continue
|
|
127
|
+
const v = src.get(k)
|
|
128
|
+
if (v === null || v === undefined) continue
|
|
129
|
+
|
|
130
|
+
// roots:数组,去空去重前的简单清洗(真正的规范化在 normalizeRoots)
|
|
131
|
+
if (k === 'roots') {
|
|
132
|
+
if (Array.isArray(v)) {
|
|
133
|
+
const arr = v.filter((x) => typeof x === 'string' && x.trim() !== '').map((x) => x.trim())
|
|
134
|
+
if (arr.length > 0) out.roots = arr
|
|
135
|
+
}
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// relay:{ url, token } —— 只取白名单子键,且至少有一个非空
|
|
140
|
+
if (OBJECT_KEYS[k]) {
|
|
141
|
+
if (!v || typeof v !== 'object' || Array.isArray(v)) continue
|
|
142
|
+
const sub = {}
|
|
143
|
+
for (const sk of OBJECT_KEYS[k]) {
|
|
144
|
+
const sv = v[sk]
|
|
145
|
+
if (typeof sv === 'string' && sv.trim() !== '') sub[sk] = sv.trim()
|
|
146
|
+
}
|
|
147
|
+
if (Object.keys(sub).length > 0) out[k] = sub
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// listenPort:数字(0 合法 —— 表示显式关闭独立监听)
|
|
152
|
+
if (k === 'listenPort') {
|
|
153
|
+
const n = typeof v === 'number' ? v : (typeof v === 'string' && /^\d+$/.test(v.trim()) ? Number(v.trim()) : NaN)
|
|
154
|
+
if (Number.isInteger(n) && n >= 0 && n < 65536) out.listenPort = n
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (typeof v === 'string' && v.trim() !== '') out[k] = v.trim()
|
|
159
|
+
}
|
|
160
|
+
return Object.keys(out).length > 0 ? out : null
|
|
161
|
+
}
|
|
162
|
+
|
|
99
163
|
/**
|
|
100
164
|
* 读独立配置。
|
|
101
165
|
*
|
|
@@ -119,50 +183,22 @@ export function readInstanceConfig(config) {
|
|
|
119
183
|
return null
|
|
120
184
|
}
|
|
121
185
|
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
|
|
186
|
+
return sanitizeFields(new Map(Object.entries(parsed)))
|
|
139
187
|
}
|
|
140
188
|
|
|
141
189
|
/**
|
|
142
190
|
* 写独立配置(原子写 + 0600)。
|
|
143
191
|
*
|
|
144
192
|
* @param {object} values 要写的字段(按白名单过滤)
|
|
145
|
-
* @param {object} [config] 用于解析 stateDir
|
|
193
|
+
* @param {object} [config] 用于解析 stateDir —— **必须传**,否则写默认目录、
|
|
194
|
+
* 而读的一方按 `config.stateDir` 找,两边对不上(v0.9.3 审查 M1 的成因)
|
|
146
195
|
* @returns {{ok:boolean, file:string, error?:string}}
|
|
147
196
|
*/
|
|
148
197
|
export function writeInstanceConfig(values, config) {
|
|
149
198
|
const dir = resolveStateDir(config)
|
|
150
199
|
const file = path.join(dir, INSTANCE_FILE_NAME)
|
|
151
200
|
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
|
-
}
|
|
201
|
+
const out = sanitizeFields(new Map(Object.entries(src))) ?? {}
|
|
166
202
|
|
|
167
203
|
const tmp = `${file}.tmp-${process.pid}-${Date.now().toString(36)}`
|
|
168
204
|
try {
|
|
@@ -193,15 +229,33 @@ export function writeInstanceConfig(values, config) {
|
|
|
193
229
|
*
|
|
194
230
|
* 例外:如果独立配置里**没有**某个字段,就用 loader config 的值。
|
|
195
231
|
*
|
|
232
|
+
* ⚠ 这个优先级有一个副作用(v0.9.3 审查 M3):用户手改 `cordis.patch.yml`
|
|
233
|
+
* 或 DSH 插件 UI 之后,**同名字段永远不生效**,而且没有任何提示 ——
|
|
234
|
+
* 与项目反复强调的"面板说 A、插件实际做 B 是最难排查的撕裂"同型。
|
|
235
|
+
* 所以这里把"被覆盖了哪些字段"回报给调用方,由它记日志/在面板展示。
|
|
236
|
+
*
|
|
196
237
|
* @param {object} loaderConfig
|
|
197
238
|
* @param {object} [opts]
|
|
198
|
-
* @param {
|
|
199
|
-
* @returns {{config:object, source:object|null}}
|
|
239
|
+
* @param {object} [opts.overlay] 读到的独立配置(避免重复读盘)
|
|
240
|
+
* @returns {{config:object, source:object|null, shadowed:string[]}}
|
|
241
|
+
* `shadowed` = 独立配置压住 loader 值的字段名(值本身不回报,避免日志泄密)
|
|
200
242
|
*/
|
|
201
243
|
export function mergeInstanceConfig(loaderConfig, { overlay } = {}) {
|
|
202
244
|
const base = loaderConfig && typeof loaderConfig === 'object' ? loaderConfig : {}
|
|
203
245
|
let saved = overlay
|
|
204
246
|
if (saved === undefined) saved = readInstanceConfig(base)
|
|
205
|
-
if (!saved) return { config: base, source: null }
|
|
206
|
-
|
|
247
|
+
if (!saved) return { config: base, source: null, shadowed: [] }
|
|
248
|
+
|
|
249
|
+
const shadowed = []
|
|
250
|
+
for (const k of Object.keys(saved)) {
|
|
251
|
+
if (!(k in base)) continue // loader 没有 → 不算"覆盖",是补全
|
|
252
|
+
const a = base[k]
|
|
253
|
+
const b = saved[k]
|
|
254
|
+
// 只在**值真的不同**时记 —— 否则每次启动都刷一堆无意义的"覆盖"
|
|
255
|
+
const differ = (typeof a === 'object' || typeof b === 'object')
|
|
256
|
+
? JSON.stringify(a) !== JSON.stringify(b)
|
|
257
|
+
: a !== b
|
|
258
|
+
if (differ) shadowed.push(k)
|
|
259
|
+
}
|
|
260
|
+
return { config: { ...base, ...saved }, source: saved, shadowed }
|
|
207
261
|
}
|
package/lib/listen-server.js
CHANGED
|
@@ -80,6 +80,30 @@ function isPrivateIp(ip) {
|
|
|
80
80
|
)
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* 判定一个主机名/字面量地址是不是回环。
|
|
85
|
+
*
|
|
86
|
+
* ⚠ 这是**唯一的**回环判据(2026-10-20 收口)。此前 `lib/env-check.js` 的
|
|
87
|
+
* `isLoopbackOrigin`、`lib/hub/index.js` 的内联判断、`lib/client.js` 的前端
|
|
88
|
+
* 判断各写一份 —— 三份判据迟早分叉(C2 就是"两套地址分类"分叉的直接后果)。
|
|
89
|
+
* 新增调用方一律用这个,别再内联。
|
|
90
|
+
*
|
|
91
|
+
* @param {string} host 形如 `127.0.0.1` / `localhost` / `::1` / `[::1]`
|
|
92
|
+
* @returns {boolean}
|
|
93
|
+
*/
|
|
94
|
+
export function isLoopbackHost(host) {
|
|
95
|
+
if (typeof host !== 'string') return false
|
|
96
|
+
const h = host.trim().toLowerCase()
|
|
97
|
+
return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h === '[::1]'
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 一个地址的"可信程度"(0 最高):Tailscale > RFC1918 > 其它。 */
|
|
101
|
+
function scoreOf(address) {
|
|
102
|
+
if (isTailscaleIp(address)) return 0
|
|
103
|
+
if (isPrivateIp(address)) return 1
|
|
104
|
+
return 2
|
|
105
|
+
}
|
|
106
|
+
|
|
83
107
|
/**
|
|
84
108
|
* 列出本机所有非内网回环的 IPv4(按优先级排序)。
|
|
85
109
|
*
|
|
@@ -102,11 +126,15 @@ export function listCandidateAddresses() {
|
|
|
102
126
|
const address = typeof info.address === 'string' ? info.address : ''
|
|
103
127
|
if (address === '' || address.startsWith('169.254.')) continue // APIPA
|
|
104
128
|
// 优先级:Tailscale(0) > 私网(1) > 其它(2)
|
|
105
|
-
|
|
106
|
-
out.push({ address, family, iface, score })
|
|
129
|
+
out.push({ address, family, iface, score: scoreOf(address) })
|
|
107
130
|
}
|
|
108
131
|
}
|
|
109
|
-
|
|
132
|
+
// 稳定排序(L6,2026-10-20):同 score 时不能依赖 `os.networkInterfaces()`
|
|
133
|
+
// 的返回顺序 —— 那是平台相关的,同一台机器两次启动可能不同 → 绑定地址漂移。
|
|
134
|
+
// 用「接口名 → 地址」做次级键,保证同一台机器每次选出同一个地址。
|
|
135
|
+
out.sort((a, b) => (a.score - b.score)
|
|
136
|
+
|| String(a.iface).localeCompare(String(b.iface))
|
|
137
|
+
|| String(a.address).localeCompare(String(b.address)))
|
|
110
138
|
return out
|
|
111
139
|
}
|
|
112
140
|
|
|
@@ -159,7 +187,8 @@ export function resolveListenHost({ listenHost, candidates } = {}) {
|
|
|
159
187
|
* @param {string} o.path WS 升级路径(必须精确匹配)
|
|
160
188
|
* @param {(conn:import('./ws.js').WSConnection, req:import('node:http').IncomingMessage)=>void} o.onUpgrade
|
|
161
189
|
* @param {string} [o.healthPath] 健康检查路径
|
|
162
|
-
* @param {object} [o.healthBody]
|
|
190
|
+
* @param {object|((req:import('node:http').IncomingMessage)=>object)} [o.healthBody]
|
|
191
|
+
* 健康检查返回体;传函数可按调用者身份分级(匿名只回 `{ ok: true }`)
|
|
163
192
|
* @param {(level:string,msg:string,extra?:object)=>void} [o.log]
|
|
164
193
|
* @returns {Promise<{server:import('node:http').Server, host:string, port:number, close:()=>Promise<void>}>}
|
|
165
194
|
*/
|
|
@@ -180,8 +209,22 @@ export function startListenServer({
|
|
|
180
209
|
try {
|
|
181
210
|
const url = new URL(req.url || '/', 'http://localhost')
|
|
182
211
|
if (req.method === 'GET' && url.pathname === healthPath) {
|
|
212
|
+
// healthBody 可以是对象,也可以是 `(req) => object`。
|
|
213
|
+
// 后者用于**按调用者身份分级**:匿名只回 `{ ok: true }`,
|
|
214
|
+
// 带正确口令才给插件名/角色/计数(v0.9.3 审查 L4 —— 这个端点
|
|
215
|
+
// 本来是无鉴权的,回 "plugin: dsh-remote-agent, role: hub"
|
|
216
|
+
// 等于给扫描器送指纹)。
|
|
217
|
+
let payload = healthBody
|
|
218
|
+
if (typeof healthBody === 'function') {
|
|
219
|
+
try {
|
|
220
|
+
payload = healthBody(req)
|
|
221
|
+
} catch (err) {
|
|
222
|
+
log('warn', 'health body threw', { error: String(err?.message ?? err) })
|
|
223
|
+
payload = { ok: false }
|
|
224
|
+
}
|
|
225
|
+
}
|
|
183
226
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
184
|
-
res.end(JSON.stringify(
|
|
227
|
+
res.end(JSON.stringify(payload === undefined ? { ok: true } : payload))
|
|
185
228
|
return
|
|
186
229
|
}
|
|
187
230
|
} catch {
|
|
@@ -225,13 +268,29 @@ export function startListenServer({
|
|
|
225
268
|
}
|
|
226
269
|
})
|
|
227
270
|
|
|
271
|
+
// listen 失败要 reject(调用方会 log 并降级);listen **之后**的错误则
|
|
272
|
+
// 只能用日志 —— 那个 Promise 早已 resolve,再 reject 是空操作。
|
|
273
|
+
// 2026-10-20 修(L1):原实现无条件 reject,导致运行期 socket 错误全被吞掉。
|
|
274
|
+
let settled = false
|
|
275
|
+
|
|
228
276
|
server.on('error', (err) => {
|
|
229
277
|
// listen 失败(端口占用 / 地址不可用)要**明确报出来** ——
|
|
230
278
|
// 静默失败会让"副机连不上"变成又一个无头案。
|
|
231
|
-
|
|
279
|
+
if (!settled) {
|
|
280
|
+
settled = true
|
|
281
|
+
reject(err)
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
log('error', 'listen server error (runtime)', {
|
|
285
|
+
host,
|
|
286
|
+
port,
|
|
287
|
+
error: String(err?.message ?? err),
|
|
288
|
+
code: String(err?.code ?? ''),
|
|
289
|
+
})
|
|
232
290
|
})
|
|
233
291
|
|
|
234
292
|
server.listen(port, host, () => {
|
|
293
|
+
settled = true
|
|
235
294
|
const addr = server.address()
|
|
236
295
|
const actualPort = addr && typeof addr === 'object' ? addr.port : port
|
|
237
296
|
log('info', 'node endpoint listening', { host, port: actualPort, path: upgradePath })
|
package/lib/setup-routes.js
CHANGED
|
@@ -31,7 +31,14 @@ import fs from 'node:fs'
|
|
|
31
31
|
import path from 'node:path'
|
|
32
32
|
import os from 'node:os'
|
|
33
33
|
import { ERROR_CODE, RemoteAgentError } from './protocol.js'
|
|
34
|
-
import {
|
|
34
|
+
import {
|
|
35
|
+
FORM_OWNED_KEYS,
|
|
36
|
+
extractPreservedConfig,
|
|
37
|
+
newNodeToken,
|
|
38
|
+
readManagedBlockBody,
|
|
39
|
+
upsertTokenInBlock,
|
|
40
|
+
writePatchBlock,
|
|
41
|
+
} from './setup.js'
|
|
35
42
|
// 角色解析必须**只有一份实现** —— 面板说"未配置"而插件实际在跑 hub,
|
|
36
43
|
// 是最难排查的撕裂(v0.7.0 踩过)。所以这里直接复用 lib/index.js 的解析器。
|
|
37
44
|
import { resolveRoleDetail } from './index.js'
|
|
@@ -39,6 +46,16 @@ import { resolveRoleDetail } from './index.js'
|
|
|
39
46
|
// 所以每次写配置时也在 <stateDir>/instance.json 存一份(插件加载时合并回来)。
|
|
40
47
|
import { writeInstanceConfig, readInstanceConfig } from './instance-config.js'
|
|
41
48
|
|
|
49
|
+
/**
|
|
50
|
+
* 面板表单**管辖**的顶层 config 键由 `lib/setup.js` 的 `FORM_OWNED_KEYS` 定义
|
|
51
|
+
* (CLI 向导与测试共用同一份,避免两处各写一份再分叉 —— 本版审查的 C2/L2
|
|
52
|
+
* 就是这么来的)。所以这里直接用那份常量,不再本地复制。
|
|
53
|
+
*
|
|
54
|
+
* 其余的一切(nodeToken / uiPullRoot / relay / listenHost / listenPort /
|
|
55
|
+
* agentMode…)在重写托管块时必须**原样保留** —— 否则用户在面板点一次保存,
|
|
56
|
+
* 凭证与设置就被静默抹掉(已实证)。
|
|
57
|
+
*/
|
|
58
|
+
|
|
42
59
|
/** 引导路由的前缀。与 hub 的 `/api/remote-agent/ui` 分开 —— 未配置时后者不存在。 */
|
|
43
60
|
export const SETUP_API_PREFIX = '/api/remote-agent/setup'
|
|
44
61
|
|
|
@@ -249,6 +266,10 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
|
|
|
249
266
|
hubUrl: typeof config.hubUrl === 'string' ? config.hubUrl : '',
|
|
250
267
|
relayUrl: typeof config.relayUrl === 'string' ? config.relayUrl : '',
|
|
251
268
|
roots: Array.isArray(config.roots) ? config.roots : [],
|
|
269
|
+
// H3(2026-10-20):面板必须知道"有没有口令"。此前完全不回显 → 用户
|
|
270
|
+
// 走面板配完 hub 永远是空口令,而独立监听因此只能绑回环、副机连不上。
|
|
271
|
+
// ⚠ 只回布尔,绝不回 token 本身(与 /config 的披露边界一致)。
|
|
272
|
+
tokenSet: typeof config.nodeToken === 'string' && config.nodeToken.trim() !== '',
|
|
252
273
|
},
|
|
253
274
|
// 写入目标(让用户知道改的是哪个文件)
|
|
254
275
|
patchFile: currentPatchFile(),
|
|
@@ -349,7 +370,28 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
|
|
|
349
370
|
|
|
350
371
|
const blockYaml = `${lines.join('\n')}\n`
|
|
351
372
|
const file = currentPatchFile()
|
|
352
|
-
|
|
373
|
+
|
|
374
|
+
// ── 保留本次表单不管辖的字段(H2,2026-10-20)────────────────────
|
|
375
|
+
//
|
|
376
|
+
// 托管块是**整体替换**的:新块进来,旧块整段消失。而本表单只提交
|
|
377
|
+
// role/nodeId/transportMode/hubUrl/relayUrl/roots —— 于是用户在面板点一次
|
|
378
|
+
// 「保存」,托管块里的 nodeToken / uiPullRoot / relay / listenHost /
|
|
379
|
+
// listenPort 全部被静默删掉(已实证)。
|
|
380
|
+
//
|
|
381
|
+
// 这里把旧块里"不归表单管"的行原样搬到新块末尾(文本级搬运,保留用户的
|
|
382
|
+
// 格式与顺序;不解析 YAML,避免零依赖被打破)。
|
|
383
|
+
let preservedLines = []
|
|
384
|
+
try {
|
|
385
|
+
const existing = readManagedBlockBody(fs.readFileSync(file, 'utf8'))
|
|
386
|
+
preservedLines = extractPreservedConfig(existing, FORM_OWNED_KEYS)
|
|
387
|
+
} catch {
|
|
388
|
+
// 首次写入(文件不存在)—— 没有可保留的
|
|
389
|
+
}
|
|
390
|
+
const blockWithPreserved = preservedLines.length > 0
|
|
391
|
+
? `${blockYaml.replace(/\s+$/, '')}\n${preservedLines.join('\n')}\n`
|
|
392
|
+
: blockYaml
|
|
393
|
+
|
|
394
|
+
const result = writePatchBlock({ file, blockYaml: blockWithPreserved, force: body.force === true })
|
|
353
395
|
if (result.ok !== true) {
|
|
354
396
|
// 冲突(有手写条目)是可预期的用户可修复状态,单独给个 code 让面板
|
|
355
397
|
// 能显示"替换手写配置"按钮,而不是把整段错误文案丢给用户。
|
|
@@ -371,15 +413,24 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
|
|
|
371
413
|
// `<stateDir>/instance.json`。插件 `apply()` 时会把它合并回来
|
|
372
414
|
// (独立配置优先)。这样 DSH 怎么重写 patch 文件都不影响角色配置。
|
|
373
415
|
//
|
|
374
|
-
//
|
|
416
|
+
// ⚠ 2026-10-20 修两处:
|
|
417
|
+
// · **必须传 config**(第二个参数)。此前漏了 → 写默认目录、读自定义
|
|
418
|
+
// stateDir → 配了 stateDir 的机器持久化完全失效(M1,已复现)。
|
|
419
|
+
// · 先铺上已有值再覆盖表单字段,否则每次保存都会把 nodeToken / relay /
|
|
420
|
+
// listenHost 这类"表单不管辖"的字段从 instance.json 里抹掉(H2 的另一半)。
|
|
421
|
+
const prev = readInstanceConfig(config) ?? {}
|
|
422
|
+
const nextPersist = { ...prev }
|
|
423
|
+
// 表单管辖的键以本次提交为准(这样"清空"也能真正生效)
|
|
424
|
+
for (const k of FORM_OWNED_KEYS) delete nextPersist[k]
|
|
375
425
|
const persisted = writeInstanceConfig({
|
|
426
|
+
...nextPersist,
|
|
376
427
|
role,
|
|
377
428
|
nodeId: str(body.nodeId),
|
|
378
429
|
transportMode: str(body.transportMode) || undefined,
|
|
379
430
|
hubUrl: str(body.hubUrl) || undefined,
|
|
380
431
|
relayUrl: str(body.relayUrl) || undefined,
|
|
381
432
|
roots,
|
|
382
|
-
})
|
|
433
|
+
}, config)
|
|
383
434
|
if (persisted.ok !== true) {
|
|
384
435
|
applyWarnings.push(`独立配置写入失败(${persisted.error})—— 角色配置仍写进了 patch 文件,`
|
|
385
436
|
+ '但下次 DSH 重写该文件时可能丢失,建议重启后到面板确认一次。')
|
|
@@ -403,5 +454,90 @@ export function registerSetupRoutes(ctx, config = {}, log = () => {}) {
|
|
|
403
454
|
})
|
|
404
455
|
})
|
|
405
456
|
|
|
457
|
+
// ── 生成 / 轮换入网口令(nodeToken)──────────────────────────────
|
|
458
|
+
//
|
|
459
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
460
|
+
// 为什么必须有这条(v0.9.3 审查 H3,2026-10-20)
|
|
461
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
462
|
+
//
|
|
463
|
+
// CLI 向导(`dsh-remote-setup --role hub`)会生成 `nt_` 开头的口令,
|
|
464
|
+
// **面板这条路却完全没有** —— YAML 里不写 nodeToken、instance.json 里也不写。
|
|
465
|
+
//
|
|
466
|
+
// 后果链(实测):
|
|
467
|
+
// 面板配 hub → nodeToken 空 → 独立监听按安全策略只能绑回环
|
|
468
|
+
// → 副机连不上 → 体检报"需要口令" → 而面板没有任何生成入口
|
|
469
|
+
// → 用户只能去手改 patch → 被 DSH 重写冲掉 → 死循环
|
|
470
|
+
//
|
|
471
|
+
// 安全边界:
|
|
472
|
+
// · 只对**回环请求**生成并回显明文(与 pair-info 的 token 披露边界一致)
|
|
473
|
+
// · 明文只回这一次,之后任何接口都不再吐 token(`/state` 只给 tokenSet)
|
|
474
|
+
// · 走的是同一套 patch 写盘(备份 / 校验 / 回滚)
|
|
475
|
+
route(`${SETUP_API_PREFIX}/rotate-token`, async (req, res) => {
|
|
476
|
+
if (req.method !== 'POST') return writeJson(res, 405, { error: 'method' })
|
|
477
|
+
|
|
478
|
+
const ra = String(req.socket?.remoteAddress ?? '')
|
|
479
|
+
const loopback = ra === '127.0.0.1' || ra === '::1' || ra === '::ffff:127.0.0.1'
|
|
480
|
+
if (!loopback) {
|
|
481
|
+
return writeJson(res, 403, {
|
|
482
|
+
error: 'loopback-only',
|
|
483
|
+
message: '生成口令只能在主机本机打开的面板里操作(避免局域网内其他人拿到凭证)',
|
|
484
|
+
})
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const token = newNodeToken()
|
|
488
|
+
const file = currentPatchFile()
|
|
489
|
+
|
|
490
|
+
// 只改 nodeToken 这一行,其余字段(roots / uiPullRoot / relay / listen*)原样不动
|
|
491
|
+
let blockBody = ''
|
|
492
|
+
try {
|
|
493
|
+
blockBody = readManagedBlockBody(fs.readFileSync(file, 'utf8'))
|
|
494
|
+
} catch {
|
|
495
|
+
// 还没有 patch 文件:生成一个最小的托管块,role 取当前生效角色
|
|
496
|
+
}
|
|
497
|
+
if (blockBody.trim() === '') {
|
|
498
|
+
const detail = resolveRoleDetail(config)
|
|
499
|
+
const r = detail.role === 'worker' ? 'worker' : 'hub'
|
|
500
|
+
const inject = r === 'worker'
|
|
501
|
+
? "['tools', 'agentLoop', 'sessions']"
|
|
502
|
+
: "['webServer', 'tools', 'systemPrompt']"
|
|
503
|
+
blockBody = `- id: remote-agent\n name: dsh-remote-agent\n inject: ${inject}\n config:\n role: ${r}\n`
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
const result = writePatchBlock({
|
|
507
|
+
file,
|
|
508
|
+
blockYaml: upsertTokenInBlock(blockBody, token),
|
|
509
|
+
force: false,
|
|
510
|
+
})
|
|
511
|
+
if (result.ok !== true) {
|
|
512
|
+
const conflict = /手写的 remote-agent 条目/.test(String(result.error ?? ''))
|
|
513
|
+
return writeJson(res, conflict ? 409 : 400, {
|
|
514
|
+
error: conflict ? 'handwritten-conflict' : 'write-failed',
|
|
515
|
+
message: result.error,
|
|
516
|
+
file: result.file,
|
|
517
|
+
})
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// instance.json 也存一份(这一层 DSH 冲不掉)—— 同样保留已有字段
|
|
521
|
+
const prev = readInstanceConfig(config) ?? {}
|
|
522
|
+
const persisted = writeInstanceConfig({ ...prev, nodeToken: token }, config)
|
|
523
|
+
|
|
524
|
+
log('info', 'node token rotated', {
|
|
525
|
+
file: result.file,
|
|
526
|
+
instance: persisted.ok === true ? persisted.file : null,
|
|
527
|
+
instanceError: persisted.ok === true ? undefined : persisted.error,
|
|
528
|
+
})
|
|
529
|
+
|
|
530
|
+
writeJson(res, 200, {
|
|
531
|
+
ok: true,
|
|
532
|
+
// 明文只此一次:面板要提示用户"复制保存",之后不再回显
|
|
533
|
+
token,
|
|
534
|
+
file: result.file,
|
|
535
|
+
backup: result.backup,
|
|
536
|
+
instanceFile: persisted.ok === true ? persisted.file : null,
|
|
537
|
+
note: '口令已写入。**重启 DSH 后生效**;副机需要用新口令重新配对。'
|
|
538
|
+
+ '这串口令只显示这一次,请立即复制保存。',
|
|
539
|
+
})
|
|
540
|
+
})
|
|
541
|
+
|
|
406
542
|
return disposers
|
|
407
543
|
}
|