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/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,154 @@
|
|
|
9
9
|
|
|
10
10
|
---
|
|
11
11
|
|
|
12
|
+
## [0.9.4] - 2026-10-20
|
|
13
|
+
|
|
14
|
+
> **0.9.3 审查的 19 项全部修完。** 0.9.3 把架构换对了(插件自己开 19387),
|
|
15
|
+
> 但落地时几乎每一项都只改了"多条路径中的一条" —— 每个新机制都带着一个
|
|
16
|
+
> 旧假设。本版逐条补齐。
|
|
17
|
+
>
|
|
18
|
+
> **0.9.3 有两个 Critical,请直接升级到 0.9.4**:
|
|
19
|
+
> 配对码端口取的是 DSH webserver 的端口(复制的码 100% 连不上);
|
|
20
|
+
> `nodeToken` 为空时独立监听成了 tailnet 内的无鉴权接入点。
|
|
21
|
+
>
|
|
22
|
+
> 架构(独立监听 / 配置分层 / 绑址优先链)见 0.9.3 条目,本版不改它。
|
|
23
|
+
|
|
24
|
+
### 本版修掉的 19 个问题(0.9.3 审查)
|
|
25
|
+
|
|
26
|
+
**Critical**
|
|
27
|
+
|
|
28
|
+
- **C1 配对码端口是错的** —— 前端把浏览器地址栏端口(DSH webserver 的
|
|
29
|
+
58626 之类)拼进配对码,而节点端点在 19387 → **复制的码 100% 连不上**。
|
|
30
|
+
现在 `/pair-info` 从 `listenRuntime` 取**实际绑定端口**,响应带 `portSource`
|
|
31
|
+
(`listen-server` / `fallback`),每个候选地址标 `usable`,可用项排最前。
|
|
32
|
+
- **C2 配对码的地址顺序与绑定顺序相反** —— `detectLanIps()` 自己实现了一套
|
|
33
|
+
分类(私网优先),而 `resolveListenHost()` 是 Tailscale 优先 → 首选地址
|
|
34
|
+
根本不是监听地址。现在 `detectLanIps()` 复用 `listCandidateAddresses()`,
|
|
35
|
+
判据只有一份。
|
|
36
|
+
|
|
37
|
+
**High**
|
|
38
|
+
|
|
39
|
+
- **H1 `nodeToken` 为空时端点暴露到网络** —— 现在 token 为空且没显式指定可达
|
|
40
|
+
`listenHost` 时**强制绑 `127.0.0.1`**,面板给黄色提示引导去「生成口令」。
|
|
41
|
+
- **H2 setup 表单只管 6 个字段,写盘却整块重写** —— 用户在 YAML 里手写的
|
|
42
|
+
`uiPullRoot` / `listenHost` 等被静默抹掉。新增 `extractPreservedConfig`
|
|
43
|
+
文本级切分,**只覆盖表单管辖的键**(`role/nodeId/transportMode/hubUrl/
|
|
44
|
+
relayUrl/roots`),其余原样保留。
|
|
45
|
+
- **H3 面板没有生成口令的入口** —— token 为空时只能看到"绑回环"却无路可修。
|
|
46
|
+
新增 `POST /setup/rotate-token`(**仅接受回环请求**),面板加「生成口令」
|
|
47
|
+
按钮(带二次确认),明文只回一次。
|
|
48
|
+
- **H4 CI 连红两次还发了 npm** —— `test/doc-drift-selfcheck.test.js` 的手工
|
|
49
|
+
复制清单漏了 `lib/listen-server.js`,副本里 `read()` 抛 ENOENT → **20 个
|
|
50
|
+
自检用例全挂**(不是单条断言失败)。这是同一规矩**第六次**被踩。
|
|
51
|
+
结构性修复:改成**自动扫 `lib/*.js` 全量复制**,手工清单只管目录与非 js 目标。
|
|
52
|
+
- **H5 架构换了,诊断层没换** —— reachability 快照、`/env-check`、面板横幅、
|
|
53
|
+
README 全停留在 `webserver` 语义 → **面板报假红**(独立监听 19387 明明在跑)
|
|
54
|
+
并教用户做无效操作(改 webserver host,改了也会被冲掉)。现在:
|
|
55
|
+
`reachability` 变**两层结构 + getter**(运行时绑定后动态求值),
|
|
56
|
+
新增 `listener` 四态(未就绪 / 就绪 / 已关闭 / 失败),横幅三态
|
|
57
|
+
(正常不显示 / 绑回环黄条 / 启动失败红条),README 安装段重写。
|
|
58
|
+
|
|
59
|
+
**Medium**
|
|
60
|
+
|
|
61
|
+
- **M1 `writeInstanceConfig(values)` 没传 `config`** —— 写默认 stateDir,而读
|
|
62
|
+
的一方按 `config.stateDir` 找,两边对不上。补上第二个参数。
|
|
63
|
+
- **M2 `listenHost` / `listenPort` / `relay` 不在持久化白名单** —— 写进
|
|
64
|
+
instance.json 会被静默丢弃。`PERSISTED_KEYS` 补齐,`relay` 按对象白名单
|
|
65
|
+
(`url` / `token`)处理;schema 也补上 `listenHost` / `listenPort` 声明
|
|
66
|
+
(此前靠 schemastery passthrough 生效,**无表单、无校验**)。
|
|
67
|
+
- **M3 instance.json 静默覆盖 patch 值** —— 现在 `mergeInstanceConfig` 返回
|
|
68
|
+
`shadowed: string[]`,`apply()` 里 `log('info', …)` 明示谁覆盖了谁;
|
|
69
|
+
`--doctor` 也会报告"instance.json 覆盖了 patch 的:…"。
|
|
70
|
+
- **M4 配对码端口取错** —— 用的是浏览器地址栏的端口(DSH 的 webserver 端口,
|
|
71
|
+
常见 58626),而副机该拨的是 19387。现在从 `listenRuntime` 取**实际绑定端口**,
|
|
72
|
+
响应带 `portSource`,并给每个候选地址标 `usable`(只有与本机绑定地址一致的
|
|
73
|
+
才算可用,可用项排最前)。
|
|
74
|
+
- **M5 CLI 写盘路径不写 instance.json** —— 向导/`--apply` 写完 patch 后,
|
|
75
|
+
重启 DSH 又会被重写。`offerWrite` / `applyWrite` 增加 `persist` 参数,
|
|
76
|
+
成功后同步写独立配置。
|
|
77
|
+
|
|
78
|
+
**Low / Info**
|
|
79
|
+
|
|
80
|
+
- **L1 `server.on('error')` 在 listen 成功后无声吞错** —— 加 `settled` 闸门:
|
|
81
|
+
首次 reject,之后转 `log('error', 'listen server error (runtime)')`。
|
|
82
|
+
- **L2 回环判据三份实现**(listen-server / env-check / 面板各写一份)——
|
|
83
|
+
统一导出 `isLoopbackHost()`,其余全部 import。
|
|
84
|
+
- **L3 `explicitPort` 判断看的是归一化后的值** —— 改成看**原始 config 是否
|
|
85
|
+
有该键**(否则"没填"和"填了默认值"无法区分)。
|
|
86
|
+
- **L6 候选地址顺序随网卡枚举变化** —— `listCandidateAddresses()` 按
|
|
87
|
+
`score`(Tailscale=0 / 私网=1 / 其它=2)+ 接口名 + 地址稳定排序,
|
|
88
|
+
避免同一台机器两次启动绑到不同 IP。
|
|
89
|
+
|
|
90
|
+
### 断言与门禁
|
|
91
|
+
|
|
92
|
+
- **85 改向**:要求 CLI `--doctor` 用 `listen-server` 的回环判据,
|
|
93
|
+
并**负向守卫**——不得再从 patch 里找 `- id: webserver` 判可达性
|
|
94
|
+
(扫剥注释后的代码,注释里的历史留档不算违规)。
|
|
95
|
+
- **79b 加固**:三处面向用户的文案必须同时满足"不建议 `0.0.0.0`"与
|
|
96
|
+
"包含不能用 `0.0.0.0` 的警告"。
|
|
97
|
+
- 自检不再依赖 import 的**排版**(`writePatchBlock` 的断言改为匹配符号
|
|
98
|
+
而非单行字面量)—— 断言的是依赖关系,不是格式。
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
packaging 4 项
|
|
102
|
+
doc-drift 123/123
|
|
103
|
+
diag:inject 11 项
|
|
104
|
+
tests 982(980 pass / 0 fail / 2 skip)
|
|
105
|
+
lint 干净(eslint 严格档,含 bin/ 与 test/)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 升级须知
|
|
109
|
+
|
|
110
|
+
- **不用再给 `webserver` 填 host**。之前填过的可以删掉(留着也无害,DSH 会
|
|
111
|
+
自己重写掉)。
|
|
112
|
+
- 想固定绑哪个地址:`listenHost: '100.x.x.x'`;端口冲突就换 `listenPort`。
|
|
113
|
+
- 防火墙要放行的是 **`listenPort`(默认 19387)**,不再是 DSH 的 Web 端口。
|
|
114
|
+
- **面板里点过一次「生成口令」的,副机要用新口令重新配对**(旧口令已换掉)。
|
|
115
|
+
|
|
116
|
+
## [0.9.3] - 2026-10-18
|
|
117
|
+
|
|
118
|
+
> **架构级重做:插件自己开端口,副机拨入不再依赖 DSH 的 `webserver`。**
|
|
119
|
+
>
|
|
120
|
+
> ⚠ **0.9.2 那段"去 patch 里给 `webserver` 填一个具体 host"的做法从本版起
|
|
121
|
+
> 作废** —— 不用填了,填了也会被 DSH 每次启动重写掉。
|
|
122
|
+
>
|
|
123
|
+
> 🔴 **本版有两个 Critical,已被 0.9.4 修复,请不要停留在本版**:
|
|
124
|
+
> 面板生成的配对码用了 DSH webserver 的端口(粘了连不上);
|
|
125
|
+
> `nodeToken` 为空时独立监听变成了 tailnet 内的无鉴权接入点。
|
|
126
|
+
> 另外本版发布时 **CI 是红的**(win + ubuntu 全红、本地实测 20 fail)。
|
|
127
|
+
|
|
128
|
+
### 为什么必须换架构(三个死结,均实证)
|
|
129
|
+
|
|
130
|
+
| 死结 | 现象 |
|
|
131
|
+
|---|---|
|
|
132
|
+
| **patch 被重写** | DSH 每次启动都重写 `cordis.patch.yml`,实测 3962B / 15 条 → **661B / 3 条**,写进去的 `webserver` 段被删光 |
|
|
133
|
+
| **webServer 无 rebind** | `webServer` 服务激活即 `listen`,没有改绑方法,插件拿不到控制权 |
|
|
134
|
+
| **硬拒通配地址** | 新版 `dsh-host-webserver` 在 schema 阶段就拒绝 `0.0.0.0`,绑了就 **DSH 启动崩溃**(已产生 10 份 crash 日志) |
|
|
135
|
+
|
|
136
|
+
结论:**副机端点不能再挂在 `ctx.webServer` 上。**
|
|
137
|
+
|
|
138
|
+
### 新方案
|
|
139
|
+
|
|
140
|
+
- **`lib/listen-server.js`** —— 用 `node:http` 自己开一个只服务节点端点的小
|
|
141
|
+
server:`GET <healthPath>` 回 200 JSON,`Upgrade: websocket` 复用 `ws.js`
|
|
142
|
+
的 `acceptUpgrade`,**路径精确匹配**,其它一律 404;关闭时追踪并断开所有连接。
|
|
143
|
+
- **绑址优先链**:`listenHost` > Tailscale IP(`100.64.0.0/10`)> RFC1918 私网
|
|
144
|
+
> 任意非回环 > `127.0.0.1`;**拒绝** `0.0.0.0` / `::` / `[::]`。
|
|
145
|
+
默认端口 **19387**,`listenPort: 0` = 关闭独立监听。
|
|
146
|
+
- **`lib/instance-config.js`** —— 配置分层:patch 只负责"让 loader 加载插件",
|
|
147
|
+
真配置存 `<stateDir>/instance.json`(**0600 + 原子写 + 字段白名单**)。
|
|
148
|
+
`mergeInstanceConfig` 合并时**独立配置优先**,`apply()` 里重新归一化。
|
|
149
|
+
- **安全边界**:token 校验仍是同一份逻辑,独立监听不降低门槛。
|
|
150
|
+
(`nodeToken` 为空的处理是本版**没做到位**的地方,见 0.9.4 的 H1。)
|
|
151
|
+
|
|
152
|
+
```
|
|
153
|
+
packaging 4 项
|
|
154
|
+
doc-drift 121/121
|
|
155
|
+
diag:inject 11 项
|
|
156
|
+
tests 975(953 pass / 20 fail / 2 skip) ← 发布时实际是红的
|
|
157
|
+
lint 干净
|
|
158
|
+
```
|
|
159
|
+
|
|
12
160
|
## [0.9.2] - 2026-10-18
|
|
13
161
|
|
|
14
162
|
> **紧急修复:0.9.0/0.9.1 会让新版 DSH 启动崩溃。**
|
package/CONTRIBUTING.md
CHANGED
|
@@ -351,7 +351,9 @@ Node 24 下会把目录当模块入口,必然失败。用 `npm test`(内部
|
|
|
351
351
|
3. **角色**(`config.role`)与**传输方式**(`transportMode`)
|
|
352
352
|
4. **现象 + 复现步骤**
|
|
353
353
|
5. **日志**:
|
|
354
|
-
- 主机侧:面板「日志尾部」区,或
|
|
354
|
+
- 主机侧:面板「日志尾部」区,或 `GET /api/remote-agent/healthz`
|
|
355
|
+
带 `x-dsh-token: <nodeToken>` 头(匿名只回 `{ "ok": true }`,
|
|
356
|
+
拿不到节点/任务计数 —— 0.9.3 起的分级策略)
|
|
355
357
|
- 副机侧:`%TEMP%\dsh-remote-agent-diag.log`(若有)
|
|
356
358
|
- 启动日志:`<DSH_HOME>/logs/` 与 `%APPDATA%\@deepseek-ai\dsh-desktop\logs`
|
|
357
359
|
|
package/KNOWN-ISSUES.md
CHANGED
|
@@ -572,6 +572,8 @@ $env:DSH_REMOTE_PWSH = 'C:\nope\pwsh.exe'
|
|
|
572
572
|
2. **registry 上下线实时性**:worker 在线时 `healthz` 报 `online:2`,
|
|
573
573
|
进程退出后数秒内回落到 `online:1` —— 真实 webServer 路由下的
|
|
574
574
|
在线状态检测是活的。
|
|
575
|
+
(0.9.3 起计数只回给带正确 `x-dsh-token` 的请求;匿名只回 `{ ok: true }`,
|
|
576
|
+
见 CHANGELOG 0.9.3 的 L4 条。)
|
|
575
577
|
3. **UI 围栏确认(安全结论,非缺陷)**:hub 的 webserver 除 `/healthz`
|
|
576
578
|
外**整台 401**(`/`、`/index.html`、`/ui/config`、`/ui/overview` 全中)
|
|
577
579
|
—— 没有桌面会话的请求一律被 `requestRejection` 拦掉。这意味着:
|
package/README.en.md
CHANGED
|
@@ -37,7 +37,7 @@ explicitly pull them.
|
|
|
37
37
|
| **Platforms** | Windows (fully verified) · Linux / macOS (CI covered) |
|
|
38
38
|
| **Dependencies** | **Zero external npm dependencies** —— `node:` built-ins only |
|
|
39
39
|
| **Node** | `^22.19.0 \|\| >=24.0.0` |
|
|
40
|
-
| **Tests** |
|
|
40
|
+
| **Tests** | 982 (980 pass / 2 skip) · 123 doc-drift assertions · eslint strict |
|
|
41
41
|
|
|
42
42
|
**Web panel**(sidebar → "远程同事" / Remote co-worker):node list with CPU /
|
|
43
43
|
memory badges, task dispatch, task states, result summaries, workspace changes,
|
|
@@ -47,13 +47,15 @@ change diffs (unified diffs of text-file edits), final artifacts, log tail,
|
|
|
47
47
|
> **Works out of the box**: restart DSH after install and the bundled patch
|
|
48
48
|
> defaults `role: auto` (no `hubUrl` ⇒ hub), which takes effect automatically.
|
|
49
49
|
>
|
|
50
|
-
> **
|
|
51
|
-
>
|
|
52
|
-
>
|
|
53
|
-
>
|
|
50
|
+
> **Workers connect to the plugin's own port — no DSH config to touch.**
|
|
51
|
+
> Since 0.9.3 the plugin runs its own listener (default **19387**) that workers
|
|
52
|
+
> dial directly; it does **not** go through `webserver`, so you never edit a
|
|
53
|
+
> `host` in `cordis.patch.yml`.
|
|
54
54
|
>
|
|
55
|
-
>
|
|
56
|
-
>
|
|
55
|
+
> The bind address is chosen automatically: `listenHost` (if set) > Tailscale
|
|
56
|
+
> IP > LAN IP > loopback. Set `listenHost: '100.x.x.x'` to pin it.
|
|
57
|
+
> **`0.0.0.0` is always rejected** (newer DSH refuses wildcard addresses —
|
|
58
|
+
> binding one crashes DSH on startup). See *Install* below.
|
|
57
59
|
|
|
58
60
|
<p align="center">
|
|
59
61
|
<img src="docs/screenshots/panel-main.png" width="720" alt="Web panel: nodes, dispatch form, task list">
|
|
@@ -244,6 +246,23 @@ in-package patch inserts it automatically):
|
|
|
244
246
|
role: hub
|
|
245
247
|
```
|
|
246
248
|
|
|
249
|
+
**No `webserver` tuning needed.** The plugin listens on its own port (default
|
|
250
|
+
`19387`) and workers dial that, so the DSH web server's bind address is
|
|
251
|
+
irrelevant. Optional overrides:
|
|
252
|
+
|
|
253
|
+
```yaml
|
|
254
|
+
config:
|
|
255
|
+
role: hub
|
|
256
|
+
listenHost: '100.x.x.x' # optional: blank = auto (Tailscale > LAN > loopback)
|
|
257
|
+
listenPort: 19387 # optional: default 19387; 0 = disable the listener
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
> **Security policy: no token, no network exposure.** With an empty
|
|
261
|
+
> `nodeToken` the listener binds `127.0.0.1` only (same-machine `both` mode
|
|
262
|
+
> still works) and the panel shows a **yellow** banner pointing at
|
|
263
|
+
> “生成口令”. Generate one and restart DSH to get the reachable address back.
|
|
264
|
+
> To override deliberately, set a concrete non-loopback `listenHost`.
|
|
265
|
+
|
|
247
266
|
### Worker
|
|
248
267
|
|
|
249
268
|
**Same package, different config** —— the key point is that **`inject: []` is
|
package/README.md
CHANGED
|
@@ -30,12 +30,12 @@
|
|
|
30
30
|
|
|
31
31
|
| | |
|
|
32
32
|
|---|---|
|
|
33
|
-
| **状态** | 0.9.
|
|
33
|
+
| **状态** | 0.9.4 —— 主机 + 副机端到端可用;`exec` 与 `agent` 双链路均已在两台真机上验证通过;CI 覆盖 Windows / macOS / Linux |
|
|
34
34
|
| **DSH** | 桌面版 / CLI,0.2.0-rc.2 验证 |
|
|
35
35
|
| **平台** | Windows(全量验证)· Linux / macOS(CI 覆盖)|
|
|
36
36
|
| **依赖** | **零外部 npm 依赖** —— 只用 `node:` 内置模块 |
|
|
37
37
|
| **Node** | `^22.19.0 \|\| >=24.0.0` |
|
|
38
|
-
| **测试** |
|
|
38
|
+
| **测试** | 982 项(980 pass / 2 skip)· 123 条文档漂移断言 · eslint 严格档 |
|
|
39
39
|
|
|
40
40
|
**Web 面板**(侧栏「远程同事」):节点列表(含 CPU/内存徽章)、派发任务、
|
|
41
41
|
任务状态、结果摘要、工作区变更、成品清单、日志尾巴、**环境体检**、
|
|
@@ -44,11 +44,13 @@
|
|
|
44
44
|
> **开箱即用**:装完重启 DSH 就能用。包内 patch 默认 `role: auto`(没配
|
|
45
45
|
> `hubUrl` 就是主机),自动生效。
|
|
46
46
|
>
|
|
47
|
-
>
|
|
48
|
-
>
|
|
49
|
-
>
|
|
47
|
+
> **副机连的是插件自己的端口,不用改 DSH 的任何配置。** 0.9.3 起本插件自己
|
|
48
|
+
> 开一个独立监听(默认 **19387**),副机直接拨这个端口 —— **不再依赖
|
|
49
|
+
> `webserver`**,也就不用去 `cordis.patch.yml` 里填 host。
|
|
50
50
|
>
|
|
51
|
-
>
|
|
51
|
+
> 绑哪个地址是自动选的:`listenHost`(如果你填了)> Tailscale IP >
|
|
52
|
+
> 局域网 IP > 回环。想指定就配 `listenHost: '100.x.x.x'`;
|
|
53
|
+
> **`0.0.0.0` 一律拒绝**(新版 DSH 硬拒通配地址,绑了会让 DSH 启动崩溃)。
|
|
52
54
|
> 详见下方「安装」。
|
|
53
55
|
|
|
54
56
|
<p align="center">
|
|
@@ -221,24 +223,30 @@ npm install -g dsh-remote-agent
|
|
|
221
223
|
- 包内 patch 默认 `role: auto`。**没配 `hubUrl` 就是主机**,自动生效。
|
|
222
224
|
- 打开 Web 面板「远程同事」,点「副机接入」生成配对码,发给副机。
|
|
223
225
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
226
|
+
**不需要改 DSH 的 `webserver`。** 0.9.3 起插件自己监听一个端口(默认 19387),
|
|
227
|
+
副机拨的是它,不是 DSH 的 Web 端口。历史上那套"去 patch 里给 webserver 填一个
|
|
228
|
+
具体 host"的做法**已经作废** —— 它还有个副作用:DSH 每次启动都会重写 patch,
|
|
229
|
+
填进去的 `webserver` 段会被删掉(实测 15 条配置剩 3 条)。
|
|
230
|
+
|
|
231
|
+
**想指定绑哪个地址**(多数情况不用,自动选的就是对的):
|
|
227
232
|
|
|
228
233
|
```yaml
|
|
229
|
-
- id:
|
|
234
|
+
- id: remote-agent
|
|
230
235
|
config:
|
|
231
|
-
|
|
232
|
-
|
|
236
|
+
listenHost: '100.x.x.x' # 可选:留空=自动(Tailscale > 局域网 > 回环)
|
|
237
|
+
listenPort: 19387 # 可选:默认 19387;填 0 = 关闭独立监听
|
|
233
238
|
```
|
|
234
239
|
|
|
240
|
+
**自动选的顺序**是 `listenHost`(填了就用它)→ Tailscale IP(`100.64.0.0/10`)
|
|
241
|
+
→ 局域网私网 IP → 任意非回环 → `127.0.0.1`。
|
|
242
|
+
|
|
235
243
|
**怎么查自己的地址**:
|
|
236
244
|
|
|
237
245
|
- 跨网(推荐):`tailscale ip -4` —— Tailscale IP 固定不变、跨网可达
|
|
238
246
|
- 同一局域网:`ipconfig` 看 WLAN/以太网那块网卡的 IPv4
|
|
239
247
|
|
|
240
|
-
> ⚠
|
|
241
|
-
>
|
|
248
|
+
> ⚠ **`listenHost` 不能写 `0.0.0.0`**(`::` 也不行)。新版
|
|
249
|
+
> `@deepseek-ai/dsh-host-webserver` 在 schema 校验阶段就硬拒绝通配地址:
|
|
242
250
|
>
|
|
243
251
|
> ```
|
|
244
252
|
> host "0.0.0.0" is an unspecified (wildcard) address, which is not supported:
|
|
@@ -246,11 +254,16 @@ npm install -g dsh-remote-agent
|
|
|
246
254
|
> bind one concrete IPv4 or IPv6 address of a local interface instead
|
|
247
255
|
> ```
|
|
248
256
|
>
|
|
249
|
-
>
|
|
250
|
-
>
|
|
257
|
+
> 本插件自己开端口,同样**主动拒绝**通配地址:填了会退回自动选择,而不是
|
|
258
|
+
> 把端点暴露到所有网卡。
|
|
251
259
|
>
|
|
252
|
-
>
|
|
253
|
-
>
|
|
260
|
+
> 面板的「环境体检」会检查独立监听有没有起来、绑在哪、副机能不能拨进来;
|
|
261
|
+
> 连不上时按提示修(每条 ✗ 都带具体怎么做)。
|
|
262
|
+
|
|
263
|
+
> **安全策略:`nodeToken` 为空时不暴露到网络。** 没有入网口令的开放在公网/
|
|
264
|
+
> 局域网里等于把 RCE 送人。这种情况下插件只绑 `127.0.0.1`(本机 both 模式
|
|
265
|
+
> 仍可用),面板会显示一条**黄色**提示告诉你去点「生成口令」。生成后重启
|
|
266
|
+
> DSH 即恢复可达地址 —— 想强行对外就显式填一个非回环的 `listenHost`。
|
|
254
267
|
|
|
255
268
|
### 副机(worker)—— 贴个配对码就行
|
|
256
269
|
|
package/bin/dsh-remote-setup.js
CHANGED
|
@@ -36,7 +36,9 @@ import { encodePairCode, decodePairCode } from '../lib/pairing.js'
|
|
|
36
36
|
// loopback 诊断** —— 而它正是本版本最重要的那条。现在两段都跑、结果合并。
|
|
37
37
|
// resolveRole 保证 auto 被推断成实际角色(与运行时同一份实现)。
|
|
38
38
|
import { resolveRole } from '../lib/index.js'
|
|
39
|
-
import { envChecks
|
|
39
|
+
import { envChecks } from '../lib/env-check.js'
|
|
40
|
+
import { resolveListenHost, isLoopbackHost } from '../lib/listen-server.js'
|
|
41
|
+
import { readInstanceConfig, writeInstanceConfig } from '../lib/instance-config.js'
|
|
40
42
|
import { IDENTITY_FILE_NAME, readHubIdentity } from '../lib/identity.js'
|
|
41
43
|
|
|
42
44
|
const DIM = '\x1b[2m'
|
|
@@ -114,8 +116,12 @@ function printYaml(name, yaml, outFile) {
|
|
|
114
116
|
/**
|
|
115
117
|
* 询问并把 YAML 写进本机 DSH 配置(备份 → 托管块合并 → 校验,失败自动回滚)。
|
|
116
118
|
* 写盘是零终端小白的"最后一公里":不用自己找配置文件、开编辑器、粘 YAML。
|
|
119
|
+
*
|
|
120
|
+
* @param {object} [persist] 同时写入 `<stateDir>/instance.json` 的字段(2026-10-20,M5)。
|
|
121
|
+
* patch 那层会被 DSH 每次启动重写冲掉,instance.json 才是"重启后配置还在"的保障。
|
|
122
|
+
* 面板配的走这条,CLI 配的也必须走 —— 否则同一台机器换个配置方式行为就不同。
|
|
117
123
|
*/
|
|
118
|
-
async function offerWrite(rl, values, yaml, roleLabel) {
|
|
124
|
+
async function offerWrite(rl, values, yaml, roleLabel, persist = null) {
|
|
119
125
|
if (values['no-write']) return false
|
|
120
126
|
const profile = String(values.profile ?? '').trim() || 'desktop'
|
|
121
127
|
const file = patchFilePath(profile)
|
|
@@ -129,15 +135,29 @@ async function offerWrite(rl, values, yaml, roleLabel) {
|
|
|
129
135
|
validate: (v) => (['y', 'yes', 'n', 'no', '1', '0'].includes(v.toLowerCase()) || '输 Y 或 N'),
|
|
130
136
|
})
|
|
131
137
|
if (!['y', 'yes', '1'].includes(yes.toLowerCase())) return false
|
|
132
|
-
return applyWrite({ file, blockYaml: yaml, force: values.force === true })
|
|
138
|
+
return applyWrite({ file, blockYaml: yaml, force: values.force === true, persist })
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
/** 非交互写盘执行 + 结果播报。返回 true = 成功。 */
|
|
136
|
-
function applyWrite({ file, blockYaml, force }) {
|
|
142
|
+
function applyWrite({ file, blockYaml, force, persist = null }) {
|
|
137
143
|
const r = writePatchBlock({ file, blockYaml, force })
|
|
138
144
|
if (r.ok) {
|
|
139
145
|
say(` ✓ 已${r.mode === 'replaced' ? '更新(原托管块替换)' : '写入(追加托管块)'} ${r.file}`)
|
|
140
146
|
say(` (原文件已备份:${r.backup})`)
|
|
147
|
+
// 独立配置这一层(2026-10-20):DSH 会重写 patch,只有这里存的能扛住
|
|
148
|
+
if (persist && typeof persist === 'object') {
|
|
149
|
+
try {
|
|
150
|
+
const prev = readInstanceConfig({}) ?? {}
|
|
151
|
+
const next = { ...prev }
|
|
152
|
+
for (const k of Object.keys(persist)) if (persist[k] === undefined) delete next[k]
|
|
153
|
+
Object.assign(next, persist)
|
|
154
|
+
const w = writeInstanceConfig(next, {})
|
|
155
|
+
if (w.ok) say(` ✓ 已写入独立配置 ${w.file}(DSH 重写 patch 也不会丢)`)
|
|
156
|
+
else hint(` ⚠ 独立配置写入失败(${w.error})—— 重启后角色配置可能被 DSH 冲掉`)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
hint(` ⚠ 独立配置写入异常(${String(err?.message ?? err)})—— 不影响本次 patch 写入`)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
141
161
|
hint(' 重启 DSH 后生效。')
|
|
142
162
|
} else {
|
|
143
163
|
say(` ✗ ${r.error}`)
|
|
@@ -177,8 +197,37 @@ async function runDoctor(positionals) {
|
|
|
177
197
|
pair: grab('pair'),
|
|
178
198
|
roots,
|
|
179
199
|
uiPullRoot: grab('uiPullRoot'),
|
|
200
|
+
// 独立监听(v0.9.3 起的主通道)。listenPort 必须是 number —— 0 有语义
|
|
201
|
+
// (= 关闭),所以不能用 `Number(x) || 19387` 那种写法把它吃掉。
|
|
202
|
+
listenHost: grab('listenHost'),
|
|
203
|
+
listenPort: (function () {
|
|
204
|
+
const v = grab('listenPort')
|
|
205
|
+
if (v === '') return 19387
|
|
206
|
+
const n = Number(v)
|
|
207
|
+
return Number.isFinite(n) && n >= 0 ? n : 19387
|
|
208
|
+
})(),
|
|
180
209
|
}
|
|
181
210
|
|
|
211
|
+
// ⚠ 独立配置优先(v0.9.3 审查 M2/M3):DSH 每次启动都会重写 patch,
|
|
212
|
+
// 真正生效的字段存在 `<stateDir>/instance.json` 里并**覆盖** patch 的值。
|
|
213
|
+
// 只读 patch 体检 = 报告一个已经不在生效的配置(比如用户在面板里改了
|
|
214
|
+
// listenPort,patch 里还是旧的 19387)。
|
|
215
|
+
//
|
|
216
|
+
// 先拍一张 patch 快照,合并之后再拿它比对 —— 否则"谁覆盖了谁"永远看不出
|
|
217
|
+
// (config 已被改写,比较恒等)。
|
|
218
|
+
const patchSnapshot = { ...config }
|
|
219
|
+
let instanceSrc = null
|
|
220
|
+
try {
|
|
221
|
+
const saved = readInstanceConfig({})
|
|
222
|
+
if (saved && typeof saved === 'object') {
|
|
223
|
+
for (const k of Object.keys(saved)) {
|
|
224
|
+
if (!(k in config)) continue
|
|
225
|
+
if (saved[k] !== undefined) config[k] = saved[k]
|
|
226
|
+
}
|
|
227
|
+
instanceSrc = saved
|
|
228
|
+
}
|
|
229
|
+
} catch { /* 读不到就当没有 */ }
|
|
230
|
+
|
|
182
231
|
// 角色解析用**与运行时同一份实现**(唯一真相):auto 在这里被推断成
|
|
183
232
|
// 实际角色,这样 doctor 报告的 role 与 hub 实际行为一致。
|
|
184
233
|
const resolvedRole = resolveRole(config) || ''
|
|
@@ -204,30 +253,60 @@ async function runDoctor(positionals) {
|
|
|
204
253
|
//
|
|
205
254
|
// 探测参数:CLI 能真探测的才传,拿不到的留 null(envChecks 会跳过该项,
|
|
206
255
|
// 不会误报)。
|
|
207
|
-
// ·
|
|
256
|
+
// · 独立监听:从 **生效配置**(instance.json 覆盖后)用与运行时同一份
|
|
257
|
+
// `resolveListenHost` 算出来,再用本机 TCP 探活判断"插件是否真在听"
|
|
208
258
|
// · roots 存在性真查文件系统
|
|
259
|
+
//
|
|
260
|
+
// ⚠ **绝不能再从 patch 里读 `- id: webserver`**(v0.9.3 审查 H5 的 CLI 侧):
|
|
261
|
+
// 0.9.3 起端点由插件自己的独立监听承载,webserver 的 host 与副机能否接入
|
|
262
|
+
// **毫无关系**。老代码读到 `host: 127.0.0.1` 就报"副机连不上,去改
|
|
263
|
+
// webserver",把用户引向一个改了也会被 DSH 重写冲掉、且修不好的方向。
|
|
209
264
|
try {
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
265
|
+
const listenPort = config.listenPort
|
|
266
|
+
const tokenBlank = config.nodeToken === ''
|
|
267
|
+
const explicitNonLoop = config.listenHost !== '' && !isLoopbackHost(config.listenHost)
|
|
268
|
+
// 与 applyHub 同一条安全策略:token 为空且没显式指定可达地址 → 强制回环
|
|
269
|
+
const pick = listenPort === 0
|
|
270
|
+
? { host: '', source: 'disabled' }
|
|
271
|
+
: (tokenBlank && !explicitNonLoop
|
|
272
|
+
? { host: '127.0.0.1', source: 'loopback-no-token' }
|
|
273
|
+
: resolveListenHost({ listenHost: config.listenHost }))
|
|
274
|
+
|
|
275
|
+
// 本机 TCP 探活:能连上 = 插件此刻确实在听(比"配置看起来对"强得多)
|
|
276
|
+
let alive = null
|
|
277
|
+
if (pick.host !== '') {
|
|
278
|
+
const probe = await probeTcp(isLoopbackHost(pick.host) ? '127.0.0.1' : pick.host, listenPort)
|
|
279
|
+
if (probe.ok) alive = true
|
|
280
|
+
else if (probe.error === 'ECONNREFUSED') alive = false
|
|
281
|
+
// 其它错误(超时/EHOSTUNREACH)→ 无法判定,保持 null
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const listener = {
|
|
285
|
+
// 连不上时**不要**断言"启动失败":DSH 可能根本没在跑(CLI 常在停机时用)
|
|
286
|
+
ok: pick.host === '' ? false : alive,
|
|
287
|
+
disabled: pick.host === '' ? '配置里 listenPort: 0(显式关闭独立监听)' : '',
|
|
288
|
+
host: pick.host,
|
|
289
|
+
port: listenPort,
|
|
290
|
+
source: pick.source,
|
|
291
|
+
error: alive === false ? `本机 ${listenPort} 端口没有在听(DSH 未运行?或端口被占)` : '',
|
|
292
|
+
}
|
|
293
|
+
|
|
217
294
|
const missingRoots = config.roots.filter((r) => {
|
|
218
295
|
try { return !fs.existsSync(r) } catch { return true }
|
|
219
296
|
})
|
|
220
297
|
const env = envChecks({
|
|
221
298
|
role: resolvedRole || 'hub',
|
|
222
299
|
config,
|
|
223
|
-
reachability:
|
|
300
|
+
reachability: pick.host === ''
|
|
224
301
|
? null
|
|
225
302
|
: {
|
|
226
|
-
bound:
|
|
227
|
-
origin:
|
|
228
|
-
loopback:
|
|
303
|
+
bound: pick.host,
|
|
304
|
+
origin: `${pick.host}:${listenPort}`,
|
|
305
|
+
loopback: isLoopbackHost(pick.host),
|
|
306
|
+
via: 'listen-server',
|
|
229
307
|
hint: '',
|
|
230
308
|
},
|
|
309
|
+
listener,
|
|
231
310
|
missingRoots,
|
|
232
311
|
})
|
|
233
312
|
// 合并:envChecks 的项追加到 CLI 结果后(格式一致:{ok, label, fix})
|
|
@@ -239,6 +318,20 @@ async function runDoctor(positionals) {
|
|
|
239
318
|
fix: it.fix,
|
|
240
319
|
})
|
|
241
320
|
}
|
|
321
|
+
|
|
322
|
+
// 独立配置来源提示:patch 与 instance.json 不一致时说明以谁为准
|
|
323
|
+
if (instanceSrc && typeof instanceSrc === 'object') {
|
|
324
|
+
const overridden = ['role', 'hubUrl', 'listenHost', 'listenPort']
|
|
325
|
+
.filter((k) => instanceSrc[k] !== undefined
|
|
326
|
+
&& String(instanceSrc[k]) !== String(patchSnapshot[k] === undefined ? '' : patchSnapshot[k]))
|
|
327
|
+
checks.push({
|
|
328
|
+
ok: true,
|
|
329
|
+
label: overridden.length === 0
|
|
330
|
+
? '独立配置 instance.json 已加载(与 patch 一致)'
|
|
331
|
+
: `独立配置 instance.json 覆盖了 patch 的:${overridden.join(' / ')}(DSH 重写 patch 也不会丢)`,
|
|
332
|
+
fix: '',
|
|
333
|
+
})
|
|
334
|
+
}
|
|
242
335
|
} catch (err) {
|
|
243
336
|
// envChecks 自身出错不能挡 CLI 体检(它只是补充)
|
|
244
337
|
checks.push({ ok: true, label: `(运行时态检查不可用:${String(err?.message ?? err)})`, fix: '' })
|