dsh-remote-agent 0.9.2 → 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 +335 -62
- package/lib/hub/ui-routes.js +114 -13
- package/lib/index.js +51 -0
- package/lib/instance-config.js +261 -0
- package/lib/listen-server.js +321 -0
- package/lib/setup-routes.js +186 -2
- package/lib/setup.js +172 -14
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +51 -3
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,8 +144,39 @@ 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)
|
|
169
|
+
|
|
170
|
+
// M3(2026-10-20):独立配置优先,会**压住** loader(patch/插件 UI)里的同名字段。
|
|
171
|
+
// 这是设计(它才抗得住 DSH 重写),但必须留痕 —— 否则用户"改了 patch 没生效"
|
|
172
|
+
// 时完全无从判断。只记字段名,不记值(token 等敏感字段不能进日志)。
|
|
173
|
+
if (Array.isArray(merged.shadowed) && merged.shadowed.length > 0) {
|
|
174
|
+
log('info', 'instance.json overrides loader config (patch edits ignored for these keys)', {
|
|
175
|
+
keys: merged.shadowed,
|
|
176
|
+
fix: '要让 patch 生效,请删掉 instance.json 里对应的键(或用面板改)',
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
|
|
145
180
|
// 配置写错必须有反馈。注意**不再回退 hub** —— 那会静默开一个
|
|
146
181
|
// nodeToken 为空、不校验凭证的直连端点(见 resolveRoleDetail 的说明)。
|
|
147
182
|
// 现在当成"未配置"处理:什么都不注册,面板显示引导让用户重选。
|
|
@@ -302,6 +337,22 @@ export const Config = z.object({
|
|
|
302
337
|
uiPullRoot: z.string()
|
|
303
338
|
.default('')
|
|
304
339
|
.description('Web 面板拉取产物时唯一允许落地的目录(绝对路径)。留空则面板拉取按钮禁用。'),
|
|
340
|
+
|
|
341
|
+
// ── 独立监听(2026-10-18 引入,2026-10-20 补进 schema)───────────────
|
|
342
|
+
//
|
|
343
|
+
// ⚠ 这两个字段 0.9.3 就已在用,但**没有在这里声明** —— 靠 schemastery 的
|
|
344
|
+
// passthrough 才勉强能读到。后果:插件配置页里根本没有这两项(无表单)、
|
|
345
|
+
// 没有类型校验(`listenPort: "19387"` 会静默回弹默认值)、也无法在 UI 上改。
|
|
346
|
+
// 现在正式声明:能配、能校验、能在面板/插件页里看到。
|
|
347
|
+
listenHost: z.string()
|
|
348
|
+
.default('')
|
|
349
|
+
.description('独立监听绑定的具体本机 IP(留空=自动选:Tailscale > 私网 > 回环)。'
|
|
350
|
+
+ '不能填 0.0.0.0 —— 通配地址会把服务暴露到所有网卡,且新版 DSH 同规则拒绝。'),
|
|
351
|
+
// 取值范围由 normalizeConfig 归一(0 <= port < 65536),这里只声明类型与默认值。
|
|
352
|
+
listenPort: z.number()
|
|
353
|
+
.default(19387)
|
|
354
|
+
.description('独立监听端口(副机从这里拨入)。0 = 关闭独立监听,只走 DSH 自己的 Web 服务通道。'),
|
|
355
|
+
|
|
305
356
|
uiEnabled: z.boolean()
|
|
306
357
|
.default(true)
|
|
307
358
|
.description('是否注册 Web 面板的控制路由。'),
|
|
@@ -0,0 +1,261 @@
|
|
|
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
|
+
* ⚠ 2026-10-20 补齐(v0.9.3 审查 H2/M2):此前少了 `listenHost` /
|
|
60
|
+
* `listenPort` / `relay` —— 于是架构承诺"配置不会再被 DSH 冲掉",
|
|
61
|
+
* 而 0.9.3 新增的两个关键字段恰好不在保护范围内,`relay`(跨 NAT 必需)
|
|
62
|
+
* 更是从来没有落过盘。用户配了它们,一次重启就静默回默认。
|
|
63
|
+
*/
|
|
64
|
+
const PERSISTED_KEYS = Object.freeze([
|
|
65
|
+
'role',
|
|
66
|
+
'nodeId',
|
|
67
|
+
'transportMode',
|
|
68
|
+
'hubUrl',
|
|
69
|
+
'relayUrl',
|
|
70
|
+
'roots',
|
|
71
|
+
'nodeToken',
|
|
72
|
+
'uiPullRoot',
|
|
73
|
+
'agentMode',
|
|
74
|
+
'pair',
|
|
75
|
+
// 0.9.3 独立监听的绑定意图(自动选地址会挑 Tailscale,用户可能想钉死局域网)
|
|
76
|
+
'listenHost',
|
|
77
|
+
'listenPort',
|
|
78
|
+
// hub 侧中继凭证(跨 NAT 场景的唯一通路)
|
|
79
|
+
'relay',
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
/** 值是对象的白名单字段(当前只有 relay:{ url, token })。 */
|
|
83
|
+
const OBJECT_KEYS = Object.freeze({
|
|
84
|
+
relay: ['url', 'token'],
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 解析 stateDir(与 lib/node/index.js 的 resolveStateDir 同口径)。
|
|
89
|
+
* @param {object} [config]
|
|
90
|
+
* @returns {string}
|
|
91
|
+
*/
|
|
92
|
+
export function resolveStateDir(config) {
|
|
93
|
+
const c = config && typeof config === 'object' ? config : {}
|
|
94
|
+
const explicit = typeof c.stateDir === 'string' ? c.stateDir.trim() : ''
|
|
95
|
+
if (explicit !== '') {
|
|
96
|
+
try {
|
|
97
|
+
return path.resolve(explicit)
|
|
98
|
+
} catch {
|
|
99
|
+
/* 落到默认 */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
return path.join(os.homedir(), '.dsh-remote-agent')
|
|
104
|
+
} catch {
|
|
105
|
+
return path.join(os.tmpdir(), '.dsh-remote-agent')
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 独立配置文件的绝对路径。 */
|
|
110
|
+
export function instanceFilePath(config) {
|
|
111
|
+
return path.join(resolveStateDir(config), INSTANCE_FILE_NAME)
|
|
112
|
+
}
|
|
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
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 读独立配置。
|
|
165
|
+
*
|
|
166
|
+
* @param {object} [config] 用于解析 stateDir
|
|
167
|
+
* @returns {object|null} 读不到/损坏时返回 null(**不抛**)
|
|
168
|
+
*/
|
|
169
|
+
export function readInstanceConfig(config) {
|
|
170
|
+
const file = instanceFilePath(config)
|
|
171
|
+
let text
|
|
172
|
+
try {
|
|
173
|
+
text = fs.readFileSync(file, 'utf8')
|
|
174
|
+
} catch {
|
|
175
|
+
// ENOENT = 还没写过,是最常见的正常情况
|
|
176
|
+
return null
|
|
177
|
+
}
|
|
178
|
+
let parsed
|
|
179
|
+
try {
|
|
180
|
+
parsed = JSON.parse(text)
|
|
181
|
+
} catch {
|
|
182
|
+
// 损坏:返回 null 让调用方退回 loader config(不让 DSH 起不来)
|
|
183
|
+
return null
|
|
184
|
+
}
|
|
185
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
186
|
+
return sanitizeFields(new Map(Object.entries(parsed)))
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 写独立配置(原子写 + 0600)。
|
|
191
|
+
*
|
|
192
|
+
* @param {object} values 要写的字段(按白名单过滤)
|
|
193
|
+
* @param {object} [config] 用于解析 stateDir —— **必须传**,否则写默认目录、
|
|
194
|
+
* 而读的一方按 `config.stateDir` 找,两边对不上(v0.9.3 审查 M1 的成因)
|
|
195
|
+
* @returns {{ok:boolean, file:string, error?:string}}
|
|
196
|
+
*/
|
|
197
|
+
export function writeInstanceConfig(values, config) {
|
|
198
|
+
const dir = resolveStateDir(config)
|
|
199
|
+
const file = path.join(dir, INSTANCE_FILE_NAME)
|
|
200
|
+
const src = values && typeof values === 'object' ? values : {}
|
|
201
|
+
const out = sanitizeFields(new Map(Object.entries(src))) ?? {}
|
|
202
|
+
|
|
203
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now().toString(36)}`
|
|
204
|
+
try {
|
|
205
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
206
|
+
fs.writeFileSync(tmp, `${JSON.stringify(out, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
207
|
+
fs.renameSync(tmp, file)
|
|
208
|
+
return { ok: true, file }
|
|
209
|
+
} catch (err) {
|
|
210
|
+
try {
|
|
211
|
+
fs.rmSync(tmp, { force: true })
|
|
212
|
+
} catch {
|
|
213
|
+
/* 清理失败不掩盖原始错误 */
|
|
214
|
+
}
|
|
215
|
+
return { ok: false, file, error: String(err?.message ?? err) }
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 把独立配置**合并进** loader 传进来的 config。
|
|
221
|
+
*
|
|
222
|
+
* ── 优先级(关键设计)──────────────────────────────────────────────────
|
|
223
|
+
*
|
|
224
|
+
* **独立配置优先** —— 因为:
|
|
225
|
+
* · 它是用户通过面板明确设置过的值
|
|
226
|
+
* · 它不会被 DSH 重写冲掉
|
|
227
|
+
* · loader config 在"被冲掉"之后只剩包内默认(role: auto),
|
|
228
|
+
* 那正是"重启回引导页"的成因
|
|
229
|
+
*
|
|
230
|
+
* 例外:如果独立配置里**没有**某个字段,就用 loader config 的值。
|
|
231
|
+
*
|
|
232
|
+
* ⚠ 这个优先级有一个副作用(v0.9.3 审查 M3):用户手改 `cordis.patch.yml`
|
|
233
|
+
* 或 DSH 插件 UI 之后,**同名字段永远不生效**,而且没有任何提示 ——
|
|
234
|
+
* 与项目反复强调的"面板说 A、插件实际做 B 是最难排查的撕裂"同型。
|
|
235
|
+
* 所以这里把"被覆盖了哪些字段"回报给调用方,由它记日志/在面板展示。
|
|
236
|
+
*
|
|
237
|
+
* @param {object} loaderConfig
|
|
238
|
+
* @param {object} [opts]
|
|
239
|
+
* @param {object} [opts.overlay] 读到的独立配置(避免重复读盘)
|
|
240
|
+
* @returns {{config:object, source:object|null, shadowed:string[]}}
|
|
241
|
+
* `shadowed` = 独立配置压住 loader 值的字段名(值本身不回报,避免日志泄密)
|
|
242
|
+
*/
|
|
243
|
+
export function mergeInstanceConfig(loaderConfig, { overlay } = {}) {
|
|
244
|
+
const base = loaderConfig && typeof loaderConfig === 'object' ? loaderConfig : {}
|
|
245
|
+
let saved = overlay
|
|
246
|
+
if (saved === undefined) saved = readInstanceConfig(base)
|
|
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 }
|
|
261
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
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
|
+
* 判定一个主机名/字面量地址是不是回环。
|
|
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
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 列出本机所有非内网回环的 IPv4(按优先级排序)。
|
|
109
|
+
*
|
|
110
|
+
* @returns {Array<{address:string, family:string, iface:string, score:number}>}
|
|
111
|
+
*/
|
|
112
|
+
export function listCandidateAddresses() {
|
|
113
|
+
const out = []
|
|
114
|
+
let ifaces
|
|
115
|
+
try {
|
|
116
|
+
ifaces = os.networkInterfaces()
|
|
117
|
+
} catch {
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
for (const [iface, list] of Object.entries(ifaces)) {
|
|
121
|
+
if (!Array.isArray(list)) continue
|
|
122
|
+
for (const info of list) {
|
|
123
|
+
if (!info || info.internal === true) continue
|
|
124
|
+
const family = info.family === 'IPv4' || info.family === 4 ? 'IPv4' : String(info.family)
|
|
125
|
+
if (family !== 'IPv4') continue
|
|
126
|
+
const address = typeof info.address === 'string' ? info.address : ''
|
|
127
|
+
if (address === '' || address.startsWith('169.254.')) continue // APIPA
|
|
128
|
+
// 优先级:Tailscale(0) > 私网(1) > 其它(2)
|
|
129
|
+
out.push({ address, family, iface, score: scoreOf(address) })
|
|
130
|
+
}
|
|
131
|
+
}
|
|
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)))
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 决定监听地址。
|
|
143
|
+
*
|
|
144
|
+
* @param {object} [o]
|
|
145
|
+
* @param {string} [o.listenHost] 用户显式配置的地址
|
|
146
|
+
* @param {string[]} [o.candidates] 覆盖候选列表(测试用)
|
|
147
|
+
* @returns {{host:string, source:string, candidates:Array}}
|
|
148
|
+
*/
|
|
149
|
+
export function resolveListenHost({ listenHost, candidates } = {}) {
|
|
150
|
+
const list = Array.isArray(candidates) ? candidates : listCandidateAddresses()
|
|
151
|
+
|
|
152
|
+
// 1. 用户显式指定(且不是通配地址 —— 新版规则不允许)
|
|
153
|
+
if (typeof listenHost === 'string') {
|
|
154
|
+
const h = listenHost.trim()
|
|
155
|
+
if (h !== '' && h !== '0.0.0.0' && h !== '::' && h !== '[::]') {
|
|
156
|
+
return { host: h, source: 'config', candidates: list }
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// 2. Tailscale IP
|
|
161
|
+
const ts = list.find((x) => isTailscaleIp(x.address))
|
|
162
|
+
if (ts) return { host: ts.address, source: 'tailscale', candidates: list }
|
|
163
|
+
|
|
164
|
+
// 3. 私网 IP
|
|
165
|
+
const priv = list.find((x) => isPrivateIp(x.address))
|
|
166
|
+
if (priv) return { host: priv.address, source: 'private', candidates: list }
|
|
167
|
+
|
|
168
|
+
// 4. 任意非回环
|
|
169
|
+
if (list.length > 0) return { host: list[0].address, source: 'any', candidates: list }
|
|
170
|
+
|
|
171
|
+
// 5. 兜底:本机回环(同机 both 模式仍可用)
|
|
172
|
+
return { host: '127.0.0.1', source: 'loopback', candidates: list }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 启动一个只服务本插件 WS 端点的小 HTTP server。
|
|
177
|
+
*
|
|
178
|
+
* 只做两件事:
|
|
179
|
+
* · `GET <healthPath>` → 200 JSON(运维自检用,不泄露敏感信息)
|
|
180
|
+
* · `Upgrade: websocket` → 交给 `onUpgrade`(副机拨入)
|
|
181
|
+
*
|
|
182
|
+
* 其它路径一律 404 —— 这不是通用 web server,只承载节点端点。
|
|
183
|
+
*
|
|
184
|
+
* @param {object} o
|
|
185
|
+
* @param {string} o.host 绑定的具体 IP
|
|
186
|
+
* @param {number} o.port 端口(0 = 随机)
|
|
187
|
+
* @param {string} o.path WS 升级路径(必须精确匹配)
|
|
188
|
+
* @param {(conn:import('./ws.js').WSConnection, req:import('node:http').IncomingMessage)=>void} o.onUpgrade
|
|
189
|
+
* @param {string} [o.healthPath] 健康检查路径
|
|
190
|
+
* @param {object|((req:import('node:http').IncomingMessage)=>object)} [o.healthBody]
|
|
191
|
+
* 健康检查返回体;传函数可按调用者身份分级(匿名只回 `{ ok: true }`)
|
|
192
|
+
* @param {(level:string,msg:string,extra?:object)=>void} [o.log]
|
|
193
|
+
* @returns {Promise<{server:import('node:http').Server, host:string, port:number, close:()=>Promise<void>}>}
|
|
194
|
+
*/
|
|
195
|
+
export function startListenServer({
|
|
196
|
+
host,
|
|
197
|
+
port = DEFAULT_LISTEN_PORT,
|
|
198
|
+
path: upgradePath,
|
|
199
|
+
onUpgrade,
|
|
200
|
+
healthPath = '/healthz',
|
|
201
|
+
healthBody = { ok: true },
|
|
202
|
+
log = () => {},
|
|
203
|
+
} = {}) {
|
|
204
|
+
return new Promise((resolve, reject) => {
|
|
205
|
+
const sockets = new Set()
|
|
206
|
+
|
|
207
|
+
const server = http.createServer((req, res) => {
|
|
208
|
+
// 健康检查:给运维/体检一个"端口真的活着"的判据
|
|
209
|
+
try {
|
|
210
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
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
|
+
}
|
|
226
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
227
|
+
res.end(JSON.stringify(payload === undefined ? { ok: true } : payload))
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
/* URL 解析失败:落到 404 */
|
|
232
|
+
}
|
|
233
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' })
|
|
234
|
+
res.end('not found\n')
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
// 追踪连接,close 时能强制断干净(否则旧连接会拖住 server.close)
|
|
238
|
+
server.on('connection', (socket) => {
|
|
239
|
+
sockets.add(socket)
|
|
240
|
+
socket.on('close', () => sockets.delete(socket))
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
server.on('upgrade', (req, socket, head) => {
|
|
244
|
+
let pathname
|
|
245
|
+
try {
|
|
246
|
+
pathname = new URL(req.url || '/', 'http://localhost').pathname
|
|
247
|
+
} catch {
|
|
248
|
+
socket.destroy()
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
// ⚠ 路径必须**精确匹配**:这个 server 只承载节点端点,
|
|
252
|
+
// 不开任何"顺手兼容一下"的口子(越宽的攻击面越难守)。
|
|
253
|
+
if (pathname !== upgradePath) {
|
|
254
|
+
socket.destroy()
|
|
255
|
+
return
|
|
256
|
+
}
|
|
257
|
+
const conn = acceptUpgrade(req, socket, head)
|
|
258
|
+
if (!conn) return
|
|
259
|
+
try {
|
|
260
|
+
onUpgrade(conn, req)
|
|
261
|
+
} catch (err) {
|
|
262
|
+
log('warn', 'upgrade handler threw', { error: String(err?.message ?? err) })
|
|
263
|
+
try {
|
|
264
|
+
conn.close(1011, 'internal error')
|
|
265
|
+
} catch {
|
|
266
|
+
/* 已关 */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// listen 失败要 reject(调用方会 log 并降级);listen **之后**的错误则
|
|
272
|
+
// 只能用日志 —— 那个 Promise 早已 resolve,再 reject 是空操作。
|
|
273
|
+
// 2026-10-20 修(L1):原实现无条件 reject,导致运行期 socket 错误全被吞掉。
|
|
274
|
+
let settled = false
|
|
275
|
+
|
|
276
|
+
server.on('error', (err) => {
|
|
277
|
+
// listen 失败(端口占用 / 地址不可用)要**明确报出来** ——
|
|
278
|
+
// 静默失败会让"副机连不上"变成又一个无头案。
|
|
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
|
+
})
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
server.listen(port, host, () => {
|
|
293
|
+
settled = true
|
|
294
|
+
const addr = server.address()
|
|
295
|
+
const actualPort = addr && typeof addr === 'object' ? addr.port : port
|
|
296
|
+
log('info', 'node endpoint listening', { host, port: actualPort, path: upgradePath })
|
|
297
|
+
|
|
298
|
+
const close = () => new Promise((done) => {
|
|
299
|
+
try {
|
|
300
|
+
server.close(() => done())
|
|
301
|
+
} catch {
|
|
302
|
+
done()
|
|
303
|
+
}
|
|
304
|
+
// 强制断开存量连接:server.close 只是"停止接受新连接",
|
|
305
|
+
// 已有 WS 连接会一直挂着,插件卸载时不能留悬挂句柄。
|
|
306
|
+
for (const s of sockets) {
|
|
307
|
+
try {
|
|
308
|
+
s.destroy()
|
|
309
|
+
} catch {
|
|
310
|
+
/* 已关 */
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
sockets.clear()
|
|
314
|
+
// 兜底超时:某些平台 close 回调可能不触发
|
|
315
|
+
setTimeout(done, 2000).unref?.()
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
resolve({ server, host, port: actualPort, close })
|
|
319
|
+
})
|
|
320
|
+
})
|
|
321
|
+
}
|