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/CHANGELOG.md +148 -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 +200 -71
- package/lib/hub/ui-routes.js +114 -13
- package/lib/index.js +27 -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/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
|
}
|