dsh-remote-agent 0.0.0-stage → 0.4.1
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 +700 -0
- package/CONTRIBUTING.md +358 -0
- package/KNOWN-ISSUES.md +601 -0
- package/LICENSE +21 -0
- package/README.en.md +353 -0
- package/README.md +317 -2
- package/SECURITY.md +348 -0
- package/bin/dsh-remote-relay.js +257 -0
- package/bin/dsh-remote-setup.js +440 -0
- package/bin/setup.cmd +30 -0
- package/cordis.patch.yml +87 -0
- package/deploy/relay-vps/Caddyfile +24 -0
- package/deploy/relay-vps/README.md +132 -0
- package/deploy/relay-vps/dsh-remote-relay.service +47 -0
- package/deploy/relay-vps/nginx.conf +49 -0
- package/deploy/relay-vps/relay.env.example +20 -0
- package/docs/dispatch-automation-eval.md +140 -0
- package/docs/screenshots/panel-main.png +0 -0
- package/docs/screenshots/panel-task-detail.png +0 -0
- package/docs/verification-agent-e2e.md +425 -0
- package/docs/verification-repos-routing-real.md +521 -0
- package/docs/verification-resume-real.md +254 -0
- package/docs/worker-setup-pitfalls.md +266 -0
- package/lib/artifacts.js +451 -0
- package/lib/client.js +1752 -0
- package/lib/e2e.js +304 -0
- package/lib/glob.js +225 -0
- package/lib/hub/audit-log.js +161 -0
- package/lib/hub/hub-util.js +114 -0
- package/lib/hub/index.js +500 -0
- package/lib/hub/node-registry.js +943 -0
- package/lib/hub/pull-manager.js +781 -0
- package/lib/hub/registry-handshake.js +403 -0
- package/lib/hub/registry-ingest.js +346 -0
- package/lib/hub/relay-link.js +228 -0
- package/lib/hub/subagent-provider.js +424 -0
- package/lib/hub/templates.js +693 -0
- package/lib/hub/tools.js +1059 -0
- package/lib/hub/ui-routes.js +856 -0
- package/lib/hub/views.js +351 -0
- package/lib/identity.js +271 -0
- package/lib/index.js +272 -0
- package/lib/node/artifact-service.js +231 -0
- package/lib/node/diff.js +124 -0
- package/lib/node/exec.js +536 -0
- package/lib/node/hub-pin.js +71 -0
- package/lib/node/index.js +1022 -0
- package/lib/node/repos.js +134 -0
- package/lib/node/resource.js +63 -0
- package/lib/node/runner.js +792 -0
- package/lib/node/task-engine.js +712 -0
- package/lib/node/task-store.js +273 -0
- package/lib/node/transport.js +763 -0
- package/lib/pairing.js +186 -0
- package/lib/protocol.js +414 -0
- package/lib/relay-config.js +317 -0
- package/lib/relay.js +732 -0
- package/lib/setup-routes.js +313 -0
- package/lib/setup.js +522 -0
- package/lib/ws.js +466 -0
- package/package.json +103 -4
- package/profiles/README.md +573 -0
- package/profiles/hub.cordis.yml +84 -0
- package/profiles/worker.cordis.yml +60 -0
- package/scripts/check-doc-drift.mjs +802 -0
- package/scripts/check-packaging.mjs +177 -0
- package/scripts/diag-inject.mjs +243 -0
- package/scripts/e2e-live-check.mjs +181 -0
- package/scripts/relay-init.ps1 +286 -0
- package/scripts/relay-init.sh +199 -0
package/SECURITY.md
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
# 安全策略
|
|
2
|
+
|
|
3
|
+
这个插件**在两台机器之间建立执行通道** —— 主机可以在这台机器之外的电脑上
|
|
4
|
+
读写文件、执行命令。所以它的安全边界值得认真对待,而不是"装个插件而已"。
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 报告漏洞
|
|
9
|
+
|
|
10
|
+
**不要开公开 issue。** 请用 GitHub 的
|
|
11
|
+
[Private vulnerability reporting](https://github.com/zbzbhs/dsh-remote-agent/security/advisories/new),
|
|
12
|
+
或发邮件到仓库 owner 的 GitHub 邮箱。
|
|
13
|
+
|
|
14
|
+
请附:
|
|
15
|
+
|
|
16
|
+
- 受影响的版本(`node_modules/dsh-remote-agent/package.json` 的 `version`)
|
|
17
|
+
- 角色与传输方式(`config.role` / `transportMode`)
|
|
18
|
+
- 复现步骤(**不要**附带真实凭证或他人数据)
|
|
19
|
+
- 你判断的影响范围
|
|
20
|
+
|
|
21
|
+
我会在 **7 天内**确认收到,并在修复发布后致谢(除非你要求匿名)。
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## 威胁模型
|
|
26
|
+
|
|
27
|
+
### 部署形态
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
主机(hub) ──ws──▶ 副机(worker)
|
|
31
|
+
派任务 在 roots 内执行
|
|
32
|
+
收 handoff
|
|
33
|
+
按需 pull 成品
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**relay 形态**(双方都在 NAT 后,见 [profiles/README.md](profiles/README.md) 的降级链):
|
|
37
|
+
hub 与 node 都主动拨入中继,中继按 nodeId 双向转发。
|
|
38
|
+
|
|
39
|
+
**信任假设**:
|
|
40
|
+
|
|
41
|
+
| 假设 | 说明 |
|
|
42
|
+
|---|---|
|
|
43
|
+
| 主机是可信的 | 主机能派任意任务给副机。**不要把副机接到你不信任的主机。** |
|
|
44
|
+
| 副机是可信的 | 副机上报的 handoff 会进主机的模型上下文。**不要把主机接到你不信任的副机。** |
|
|
45
|
+
| 传输通道可能被窃听 | `direct` 模式的默认配置是**明文 ws://**。见下面「传输加密」。 |
|
|
46
|
+
| 中继机器是可信的(用 relay 时) | **提案 #1 起:业务帧端到端加密**——中继只见路由信封(`t`/`id`/`to`/`from`),goal、handoff 与成品内容全是密文。但中继仍是**流量路径**:它可以扣下握手帧让链路退回明文(副机会大声警告),也能拒绝转发(可用性攻击)。边界细节见下面「中继链路端到端加密」。TLS([deploy/relay-vps/](deploy/relay-vps/))仍建议叠加以隐藏元数据 |
|
|
47
|
+
| 副机能识别"它钉住的 hub" | P2-11 起:hub 有持久 ed25519 身份,副机首连自动钉住指纹(TOFU)或用 `pinnedHubFingerprint` 显式钉住,此后**假 hub / 假中继冒充会被当场拒绝**。边界:TOFU 防不了"首次连接就被截和"—— 指纹随配对码分发时依赖传递渠道可信(当面 / 加密聊天)。 |
|
|
48
|
+
|
|
49
|
+
**可用性边界**(不是安全边界):中继挂了任务照跑 —— 副机本地持久执行,
|
|
50
|
+
重连后 `task/sync` 对账。链路断不影响已派任务的完成,只影响"此刻能否派新的"。
|
|
51
|
+
|
|
52
|
+
**本插件不做的事**(有意为之):
|
|
53
|
+
|
|
54
|
+
- 不隔离副机的文件系统 —— 安全边界是 `roots` 配置,不是沙箱。
|
|
55
|
+
- 不限制副机能执行的命令 —— `mode: exec` 直接跑 shell。
|
|
56
|
+
- 不校验副机的身份(默认 `nodeToken` 为空)—— 见下面「认证」。
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## 部署检查清单
|
|
61
|
+
|
|
62
|
+
### 1. 认证:**默认不校验,公网必须配**
|
|
63
|
+
|
|
64
|
+
`direct` 模式下,主机侧的 `nodeToken` 为空时**不校验任何凭证** ——
|
|
65
|
+
任何能连到 `hubUrl` 的人都能注册成节点并接收任务。
|
|
66
|
+
|
|
67
|
+
```yaml
|
|
68
|
+
# 主机
|
|
69
|
+
- id: remote-agent
|
|
70
|
+
config:
|
|
71
|
+
role: hub
|
|
72
|
+
nodeToken: 'nt_<用 newNodeToken() 生成,或自选强随机>' # ← 必配
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```yaml
|
|
76
|
+
# 副机
|
|
77
|
+
- id: remote-agent
|
|
78
|
+
config:
|
|
79
|
+
role: worker
|
|
80
|
+
token: 'nt_<与主机相同>' # ← 必配
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**什么时候可以留空**:只在**完全可信的私网**里(例如 Tailscale 虚拟内网、
|
|
84
|
+
或者本机自测)。**任何情况下都不该在公网留空。**
|
|
85
|
+
|
|
86
|
+
> `relay` 模式有独立的 enroll token 机制,但主机的 `nodeToken` 仍应配置 ——
|
|
87
|
+
> 中继只负责会合,不替代节点认证。
|
|
88
|
+
|
|
89
|
+
### 2. 传输加密
|
|
90
|
+
|
|
91
|
+
| 场景 | 建议 |
|
|
92
|
+
|---|---|
|
|
93
|
+
| Tailscale / WireGuard 虚拟内网 | 明文 `ws://` 可接受(隧道已加密)|
|
|
94
|
+
| 同机 / 完全可信 LAN | 明文 `ws://` 可接受 |
|
|
95
|
+
| **公网直连** | **必须**在前面放 TLS 反向代理(nginx/caddy),用 `wss://` |
|
|
96
|
+
| 公网中继 | 中继应部署在 TLS 之后,用 `wss://`。可直接抄的模板:[deploy/relay-vps/](deploy/relay-vps/)(Caddyfile / nginx.conf,中继只听回环、公网只暴露 443)|
|
|
97
|
+
|
|
98
|
+
**本插件不自带 TLS。** 它假设"要么网络层已加密,要么你在可信网络里"。
|
|
99
|
+
relay 模式另有应用层端到端加密,见下面「中继链路端到端加密(提案 #1)」。
|
|
100
|
+
|
|
101
|
+
### 3. `roots` 是真正的安全边界
|
|
102
|
+
|
|
103
|
+
副机只允许任务在 `roots` 列出的目录内动作。**留空会退回保守默认**
|
|
104
|
+
(用户 home 下的工作目录),**不要依赖这个默认值** —— 显式写清。
|
|
105
|
+
|
|
106
|
+
```yaml
|
|
107
|
+
config:
|
|
108
|
+
roots:
|
|
109
|
+
- 'D:\work' # ← 只给任务需要的目录
|
|
110
|
+
- 'D:\scratch'
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**不要**把整个盘(`D:\`)或用户 home 加进去。
|
|
114
|
+
|
|
115
|
+
### 4. `uiPullRoot` 控制面板的写盘范围
|
|
116
|
+
|
|
117
|
+
Web 面板的「拉取」按钮是**唯一**由浏览器触发的写盘动作。
|
|
118
|
+
|
|
119
|
+
- 未配置(默认 `''`)→ 面板拉取**禁用**(返回 `403 pull-disabled`)。
|
|
120
|
+
- 配置了 → 只允许写到该目录内。路径校验是双道:先字符串层 `isInside`
|
|
121
|
+
(挡 `../` 与反斜杠),再 `realpath` 复查(挡符号链接与 8.3 短名)。
|
|
122
|
+
|
|
123
|
+
**注意**:`realpath` 必须在 `mkdir` 之后 —— 所以一个越界目录可能被**创建**
|
|
124
|
+
出来(空目录,不是数据泄漏)。这是已知取舍。
|
|
125
|
+
|
|
126
|
+
### 5. 副机的 `denyPaths`
|
|
127
|
+
|
|
128
|
+
在 `roots` 之内再排除敏感子目录(例如 `roots: ['D:\work']` 但
|
|
129
|
+
`denyPaths: ['D:\work\secrets']`)。相对与绝对形式都支持。
|
|
130
|
+
|
|
131
|
+
### 6. 配对码(`pair`)等同凭证,按凭证传阅
|
|
132
|
+
|
|
133
|
+
配对码 `dshra1://…` 内含 `nodeToken`。**它能注册一台新副机 —— 谁拿到它,
|
|
134
|
+
谁就能接入你的主机。**规矩:
|
|
135
|
+
|
|
136
|
+
- 只通过**当面或加密聊天**传给副机;**不要贴公开群、截图、工单、日志**。
|
|
137
|
+
- 轮换:副机遗失/外泄时,改主机 `nodeToken` + 逐台换码即可全部失效
|
|
138
|
+
(旧码里的旧 token 直接认证失败)。
|
|
139
|
+
- 面板「副机接入」生成的配对码有披露边界:**含 token 的完整码只回本机
|
|
140
|
+
回环请求**(`/api/remote-agent/ui/pair-info` 判定 `remoteAddress`)——
|
|
141
|
+
面板可能被 `lanBind` 暴露到局域网,能打开面板 ≠ 该拿到主机凭证。
|
|
142
|
+
非回环请求只会拿到**不含 token** 的码(仅够可信内网直连)+ 一条提示。
|
|
143
|
+
- `roots` **不在**配对码里:副机能操作哪些目录是副机自己的安全边界,
|
|
144
|
+
永远由副机配置声明 —— 主机侧的任何串都决定不了它。
|
|
145
|
+
|
|
146
|
+
### 7. hub 身份认证:副机也会验主机(默认开启)
|
|
147
|
+
|
|
148
|
+
协议此前只有单向认证:副机拿 token 向 hub 证明自己,但 hub 从不向副机证明
|
|
149
|
+
"我就是那个 hub"—— 假 hub 回一帧 welcome 副机就上线(token 是副机交出去
|
|
150
|
+
的东西,假 hub 根本不验),假中继更可以直接冒充 hub 下发 `mode: exec`
|
|
151
|
+
(副机任意代码执行)任务。P2-11 起补上了反向认证:
|
|
152
|
+
|
|
153
|
+
- **hub 侧**:首次启动生成持久 ed25519 身份密钥对,落盘
|
|
154
|
+
`<stateDir>/hub-identity.json`(默认 `~/.dsh-remote-agent/`,0600;可用
|
|
155
|
+
hub 配置项 `stateDir` 改目录)。direct 模式的身份证明放在 `welcome` 帧;
|
|
156
|
+
relay 模式 hub 在节点上线后**追发**一帧 `hub/identity`。
|
|
157
|
+
- **副机侧**:配置 `pinnedHubFingerprint`(`hubfp1-` 开头共 29 字符)显式
|
|
158
|
+
钉住;未配置时**首连自动钉住(TOFU)**,指纹写进
|
|
159
|
+
`<stateDir>/hub-pin.json`(0600)。配对码自带指纹(向导生成的码自动带上
|
|
160
|
+
`hubFingerprint` 字段)—— 副机粘一个码即完成"连接配置 + 指纹钉住",
|
|
161
|
+
零终端。
|
|
162
|
+
- **验证链**:指纹自洽(`sha256(报上来的公钥)` 必须等于报上来的指纹)→
|
|
163
|
+
匹配 pin → ed25519 验签 → nonce 回显(防重放旧 welcome)→ 时间戳
|
|
164
|
+
±5 分钟窗口。任何一环不过:拒绝上线、**停止重连**、日志大声报错。
|
|
165
|
+
钉住的指纹不会自动清除 —— 确认是主动换 hub 后删 `hub-pin.json`(或更新
|
|
166
|
+
`pinnedHubFingerprint`)再重启副机即可重新钉住。
|
|
167
|
+
- **relay 宽限窗口**:relay 模式的"在线"信号来自中继,hub 身份帧是追发的。
|
|
168
|
+
副机上线后 15 秒内等它:验过 → 正常干活;有 pin 但一直不来 → 拒绝
|
|
169
|
+
(身份帧可能被中间人剥掉);无 pin 且不来 → 按旧版兼容模式继续(记
|
|
170
|
+
warn)。宽限窗口内到达的任务一律回 `hub-identity-pending`,不会被拿去执行。
|
|
171
|
+
- **换拨入地址不算换 hub**:pin 文件记录了当时的 endpoint。你自己改了
|
|
172
|
+
`hubUrl` / `relayUrl`(本地配置行为,远程攻击者改不了)→ 副机提示 re-pin
|
|
173
|
+
并重新 TOFU,不会逼你手删文件。
|
|
174
|
+
|
|
175
|
+
**剩余边界**(如实说):① 能改副机本地配置或删 `hub-pin.json` 的人不在
|
|
176
|
+
威胁模型内(本地攻击者 = 游戏结束,任何方案都一样);② 提案 #1 后中继只
|
|
177
|
+
见密文,但**主动降级**仍是已知局限——中继扣下 E2E 握手帧可让链路退回明文
|
|
178
|
+
(副机 5 秒超时后按明文兼容旧中继继续,并大声警告);持有 enrollToken 的
|
|
179
|
+
中继运营者另可冒充**节点**重拨入(task/sync 会同步任务视图,历史 goal 泄露
|
|
180
|
+
给副机可见的中继),彻底关闭需副机侧身份钥,是另一个提案的体量;③ hub
|
|
181
|
+
身份文件被替换(磁盘损坏重建 / 被人动手脚)时,副机侧表现为指纹不匹配的
|
|
182
|
+
大声失败 —— 不会静默接受新身份,但恢复需要人工干预。
|
|
183
|
+
|
|
184
|
+
---
|
|
185
|
+
|
|
186
|
+
## 本插件的安全设计
|
|
187
|
+
|
|
188
|
+
### 审计日志(提案 #19)
|
|
189
|
+
|
|
190
|
+
hub 在 `<stateDir>/audit.jsonl` 追加记录**派发 / 取消 / 拉取**三类事件
|
|
191
|
+
(谁在何时让 hub 在哪台机器上跑了什么、哪些成品过了河落在哪)。
|
|
192
|
+
字段过统一打码层(双层):**键名**含 token/password/apiKey 类字样的写 `***`;
|
|
193
|
+
**值内**的凭证形态字符串(sk-/ghp-/github_pat_ 前缀 token、`Bearer <token>`、
|
|
194
|
+
`password=值` 等键值对、URL userinfo)同样打 `***` —— 任务书正文里内嵌的
|
|
195
|
+
凭证不落明文(真机验证揪出并修复,见
|
|
196
|
+
docs/verification-repos-routing-real.md §4③)。超长值 400 字符截断。
|
|
197
|
+
默认开启,体积 8MB × 3 份轮转封顶;`config.audit: false` 显式关闭。
|
|
198
|
+
写入失败只限频告警,**绝不影响任务链路**。
|
|
199
|
+
|
|
200
|
+
### 凭证不离开副机
|
|
201
|
+
|
|
202
|
+
协议里**不带模型 API key**。副机用自己的 DSH 配置与凭证 ——
|
|
203
|
+
主机只能派任务,拿不到副机的模型凭证。
|
|
204
|
+
|
|
205
|
+
### exec 两阶段审批:默认挂起,等不到人不跑(提案 #2)
|
|
206
|
+
|
|
207
|
+
`mode:"exec"` 的任务**默认 `approval:"manual"`**:hub 收到派单后挂起,
|
|
208
|
+
**副机完全不知道这条任务存在**,直到人在面板上点批准;挂起超过
|
|
209
|
+
`limits.approvalTimeoutMs`(默认 30 分钟)自动拒绝。设计原则是
|
|
210
|
+
**fail-closed**:超时、拒绝、取消都让任务终止,唯一"放行"的路径是人
|
|
211
|
+
明确点批准 —— 绝不存在"没人管就默认执行"。批准时节点恰好掉线 → 任务
|
|
212
|
+
立即转 failed 并给 502,绝不静默退回挂起(那会让"批了但没跑"变成无头案)。
|
|
213
|
+
审计留痕:`approval.pending / granted / rejected` 三类事件进 audit.jsonl。
|
|
214
|
+
想要旧行为显式传 `approval:"auto"`。
|
|
215
|
+
|
|
216
|
+
### 仓库清单上报:只报结构,不报凭证(提案 #13)
|
|
217
|
+
|
|
218
|
+
副机接入后自动扫描 `roots` 下的 git 仓库并上报到 hub:每仓库只含
|
|
219
|
+
**名字、远端 url、本机路径** 三个字段。两条硬边界:
|
|
220
|
+
|
|
221
|
+
- **url 里的凭证在副机本地剥掉**:`.git/config` 常见
|
|
222
|
+
`https://user:token@host/...` 的推送凭证,上报前统一打码
|
|
223
|
+
(见 `lib/node/repos.js` 的 `sanitizeRepoUrl`)——hub 与面板永远拿不到。
|
|
224
|
+
- **扫描零子进程**:只 `readdir` + 读 `.git/config` 文本,不 spawn git,
|
|
225
|
+
扫描行为完全可从源码审计。
|
|
226
|
+
|
|
227
|
+
不想暴露目录结构时 `config.reposIntervalMs: 0` 关掉上报(`repo:` 路由
|
|
228
|
+
随之不可用,其余功能不受影响)。注意:仓库路径本身也是主机目录结构的
|
|
229
|
+
一部分 —— 若 `roots` 给了整盘,这条披露与 roots 自身的披露同样成立。
|
|
230
|
+
|
|
231
|
+
### 响应体逐字段白名单,绝不 spread
|
|
232
|
+
|
|
233
|
+
`lib/hub/views.js` 是工具与 UI 共用的投影层,**逐字段构造**。
|
|
234
|
+
反例是 `{...registry.list()[i]}` —— 那会让将来新增的内部字段
|
|
235
|
+
(`conn`、`hello` 全量、`pendingPulls`…)自动泄漏到浏览器。
|
|
236
|
+
|
|
237
|
+
`handoffView` 尤其重要:handoff 由**副机上报**,字段集不受主机控制。
|
|
238
|
+
它刻意**不含** `workspace`(绝对路径会回显主机目录结构)、`logs`
|
|
239
|
+
(可能带环境变量)、`sessionId`(副机内部标识)。
|
|
240
|
+
|
|
241
|
+
### UI 路由自带围栏
|
|
242
|
+
|
|
243
|
+
DSH 的 `/api` 鉴权围栏是 `kind: 'prefix'`,而本插件的路由是 `kind: 'exact'`。
|
|
244
|
+
`@deepseek-ai/dsh-host-webserver` 的 `match()` **exact 表优先** ——
|
|
245
|
+
所以本插件的路由**不在** DSH 的 token/cookie 围栏内。
|
|
246
|
+
|
|
247
|
+
因此 `lib/hub/ui-routes.js` **自带 guard**,且**失败关闭**:
|
|
248
|
+
|
|
249
|
+
- 带 `Origin` 且与 `Host` 不一致 → 403
|
|
250
|
+
- `sec-fetch-site: cross-site` → 403
|
|
251
|
+
- `ctx.connection.requestRejection` 抛错 → **仍拒绝**(不是放行)
|
|
252
|
+
|
|
253
|
+
`check-doc-drift.mjs` 有断言锁住"失败关闭"这条语义。
|
|
254
|
+
|
|
255
|
+
### 只有 `role:final` 过河
|
|
256
|
+
|
|
257
|
+
任务结束只回 `manifest` + `handoff`;`intermediate`(日志、缓存、
|
|
258
|
+
中间构建文件)只在副机留存,仅上报元数据。这限制了"主机意外拉走一堆
|
|
259
|
+
不该传的东西"的风险面。
|
|
260
|
+
|
|
261
|
+
### 变更 diff 视图:内容(不只是元数据)会随 handoff 上报(提案 #21)
|
|
262
|
+
|
|
263
|
+
handoff 的 `diffs` 字段会带**工作区文本文件改动的 unified diff** ——
|
|
264
|
+
这是文件内容本身,不再是元数据。边界刻意收窄:
|
|
265
|
+
|
|
266
|
+
- 只覆盖 `changed` 里的 add/modify/delete,且**前像必须在任务开始时
|
|
267
|
+
被快照捕获**(≤256KB/文件、64 文件、总量 4MB、前 64 字节含 NUL 的
|
|
268
|
+
二进制不捕获)—— 大文件、二进制文件的 diff 缺席,changed 元数据仍在。
|
|
269
|
+
- 输出封顶:12 文件 / 单文件 16KB / 总量 96KB,超出部分缺席(truncated
|
|
270
|
+
标注),不做"全量备份"。
|
|
271
|
+
- diff 内容与 handoff 一起过 `handoffView` 白名单投影,进模型上下文与
|
|
272
|
+
面板的路径同受约束。
|
|
273
|
+
|
|
274
|
+
评估口径:能被 `role:final` 产物拉走的文件本来就能过河,diff 只是让
|
|
275
|
+
"改了什么"在卡面上直接可见;真正敏感的是**没有列为产物、但被任务改动
|
|
276
|
+
的文件**(例如顺带改掉的配置)现在会以 diff 形式出现在卡上 —— 这是有意
|
|
277
|
+
的透明化(证据链),不是泄漏路径变化。若工作区含不该离开副机的文件,
|
|
278
|
+
用 `denyPaths` 排除(快照与 diff 都遵守它)。
|
|
279
|
+
|
|
280
|
+
### 中继链路端到端加密(提案 #1)
|
|
281
|
+
|
|
282
|
+
relay 模式下,hub⇄副机的业务帧默认**端到端加密**(`relayE2E: false` 可关)。
|
|
283
|
+
中继转发的是密文:它只看得到路由信封(`t`/`id`/`to`/`from` 明文,路由逻辑
|
|
284
|
+
不变),goal、handoff、产物分块全在 AES-256-GCM 密文里。要点:
|
|
285
|
+
|
|
286
|
+
- **密钥协商**:双方各出一次性 X25519 → ECDH → HKDF-SHA256 派生会话密钥。
|
|
287
|
+
ed25519 身份钥**永不参与**对称密钥推导——会话钥泄露不影响身份链,
|
|
288
|
+
每次重连换新会话钥(前向隔离)。
|
|
289
|
+
- **认证**:hub 用持久 ed25519 身份签握手回执,签名绑定双方临时公钥与
|
|
290
|
+
nonce——中继偷换任一侧公钥或重放握手都会验签失败。副机的验证锚是
|
|
291
|
+
P2-11 钉住的 hub 指纹。
|
|
292
|
+
- **完整性**:每帧 AAD 绑定方向 + 帧类型 + 帧 id + 序号,序号严格递增,
|
|
293
|
+
重放/乱序/跨会话移植即解密失败丢弃。副机产物的 pullId/offset/sha256
|
|
294
|
+
等路由细节也在密文里(二进制帧整帧进密文)。
|
|
295
|
+
- **防伪造指令**:会话建立后,副机**拒绝一切明文业务帧**(task/start 等)
|
|
296
|
+
并回加密的 `e2e-required` 错误——这是挡住"中继运营者拿 enrollToken
|
|
297
|
+
冒充 hub 注入任务"的唯一防线(P2-11 身份链挡不住中继本身,因为它就是
|
|
298
|
+
传输路径)。
|
|
299
|
+
- **已知局限(如实说)**:① **主动降级**——中继扣下握手帧可让链路退回
|
|
300
|
+
明文(旧中继兼容需要),副机 5 秒超时后按明文继续并大声警告;彻底关闭
|
|
301
|
+
需副机侧身份钥,是另一个提案的体量。② 持有 enrollToken 的中继运营者
|
|
302
|
+
可冒充**节点**重新拨入,让 task/sync 把任务视图同步给他(历史 goal 泄露)。
|
|
303
|
+
③ 中继仍可做可用性攻击(拒转/延迟),这不是加密能解决的。
|
|
304
|
+
|
|
305
|
+
### 中继的 token 处理
|
|
306
|
+
|
|
307
|
+
- **一次性 token 不落盘**:由中继管理接口(`POST /api/relay/token`)铸造,
|
|
308
|
+
只存在中继进程内存里,用过(consumed)或过期(默认 15 分钟)即消失。
|
|
309
|
+
重放会 403 —— 适合"发一台机器出去"的场景。
|
|
310
|
+
- **长期凭证在中继机器上**:`/etc/dsh-remote-relay/env`(0600,root:root)与
|
|
311
|
+
`<data-dir>/tokens.json`(0600)。**都不要提交进版本库。**部署模板见
|
|
312
|
+
[deploy/relay-vps/](deploy/relay-vps/)。
|
|
313
|
+
- 副机的 `token` 也可以留空,让它读 `<stateDir>/token` 文件 ——
|
|
314
|
+
便于不把凭证写进 YAML。
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## 已知的未验证项
|
|
319
|
+
|
|
320
|
+
见 [KNOWN-ISSUES.md](KNOWN-ISSUES.md) 的 §7.1。要点:
|
|
321
|
+
|
|
322
|
+
- **Windows 8.3 短名绕过向量未验证**:`uiPullRoot` 的路径校验先做字符串层
|
|
323
|
+
再 `realpath`。8.3 短名(如 `PROGRA~1`)能否绕过字符串层、以及 `realpath`
|
|
324
|
+
是否总能解析它,**没有实测**。已加防御(双道校验),但缺对抗性验证。
|
|
325
|
+
- **POSIX 分支的平台实测覆盖不足**:Windows 全量验证,Linux/macOS 只跑过 CI。
|
|
326
|
+
- **P2-11 hub 身份链路的对抗性验证不足**:验签 / 钉住 / 重放 / 超窗有单元
|
|
327
|
+
与变异负向测试,真实双进程 e2e 走通了完整身份链路;但"真中继剥帧 /
|
|
328
|
+
真 MITM"场景没有实机红蓝对抗实测,relay 宽限窗口的超时分支只在测试
|
|
329
|
+
时钟下验证过。
|
|
330
|
+
|
|
331
|
+
如果你做了这两项的对抗性测试,欢迎按上面的方式报告结果。
|
|
332
|
+
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
## 不在范围内
|
|
336
|
+
|
|
337
|
+
以下**不算**本插件的漏洞(属于部署配置问题):
|
|
338
|
+
|
|
339
|
+
- 主机 `nodeToken` 留空且暴露在公网 → 设计如此,见检查清单第 1 条
|
|
340
|
+
- `roots` 配得太宽(例如整个盘)→ 配置问题
|
|
341
|
+
- 明文 `ws://` 走公网 → 见检查清单第 2 条
|
|
342
|
+
- 副机执行了主机派来的破坏性命令 → 这是功能,不是漏洞
|
|
343
|
+
(主副机互信是前提)
|
|
344
|
+
- 能改副机本地配置(删 `hub-pin.json`、改 `pinnedHubFingerprint`)的人
|
|
345
|
+
→ 本地攻击者不在威胁模型内,见检查清单第 7 条的剩余边界
|
|
346
|
+
|
|
347
|
+
**但如果**你能证明:绕过 `roots` / `denyPaths` / `uiPullRoot` 的边界、
|
|
348
|
+
未授权注册成节点、或从响应体里取到未白名单的字段 —— **那是真漏洞,请报告。**
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-remote-relay —— dsh-remote-agent 的中继 CLI(零外部依赖,ESM)。
|
|
4
|
+
*
|
|
5
|
+
* 用法:
|
|
6
|
+
* dsh-remote-relay [--port 8890] [--host 0.0.0.0] [--hub-token T] [--enroll-token T]
|
|
7
|
+
* [--data-dir DIR] [--print-tokens] [--help]
|
|
8
|
+
*
|
|
9
|
+
* 环境变量回退:DSH_RELAY_PORT / DSH_RELAY_HOST / DSH_RELAY_HUB_TOKEN / DSH_RELAY_ENROLL_TOKEN
|
|
10
|
+
* 数据目录回退:DSH_RELAY_DATA_DIR(默认 ~/.dsh-remote-agent-relay)
|
|
11
|
+
*
|
|
12
|
+
* 设计约束(与 v0.1 契约一致):
|
|
13
|
+
* - 明文 token 绝不进持久日志:ready 行只给指纹;stdout 不是 TTY(例如 systemd/journald)
|
|
14
|
+
* 时默认不打印明文,只给指纹行 + 提示行。
|
|
15
|
+
* - SIGINT / SIGTERM 优雅关闭:await relay.close() 之后 exit 0。
|
|
16
|
+
* - onEvent 观测钩子逐行 JSON 输出,交给 journald / 文件收集器。
|
|
17
|
+
*/
|
|
18
|
+
import os from 'node:os'
|
|
19
|
+
import process from 'node:process'
|
|
20
|
+
import { pathToFileURL } from 'node:url'
|
|
21
|
+
import { createRelay, tokenFingerprint } from '../lib/relay.js'
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_HOST,
|
|
24
|
+
DEFAULT_PORT,
|
|
25
|
+
formatReadyLine,
|
|
26
|
+
formatTokenFingerprintLine,
|
|
27
|
+
formatTokensLine,
|
|
28
|
+
loadOrCreateTokens,
|
|
29
|
+
parseArgs,
|
|
30
|
+
} from '../lib/relay-config.js'
|
|
31
|
+
|
|
32
|
+
const USAGE = `dsh-remote-relay —— dsh-remote-agent 中继服务
|
|
33
|
+
|
|
34
|
+
用法:
|
|
35
|
+
dsh-remote-relay [options]
|
|
36
|
+
|
|
37
|
+
选项:
|
|
38
|
+
--port <n> 监听端口(默认 ${DEFAULT_PORT},0 = 随机端口)
|
|
39
|
+
--host <addr> 监听地址(默认 ${DEFAULT_HOST})
|
|
40
|
+
--hub-token <token> hub 端凭证;不提供则读/建 <data-dir>/tokens.json
|
|
41
|
+
--enroll-token <tok> node 入网凭证;不提供则读/建 <data-dir>/tokens.json
|
|
42
|
+
--data-dir <dir> 凭证目录(默认 ~/.dsh-remote-agent-relay)
|
|
43
|
+
--print-tokens 只打印 token 后退出,不启动服务
|
|
44
|
+
--help, -h 显示本帮助
|
|
45
|
+
|
|
46
|
+
环境变量回退:
|
|
47
|
+
DSH_RELAY_PORT / DSH_RELAY_HOST / DSH_RELAY_HUB_TOKEN / DSH_RELAY_ENROLL_TOKEN
|
|
48
|
+
DSH_RELAY_DATA_DIR / DSH_RELAY_PRINT_TOKENS=1
|
|
49
|
+
|
|
50
|
+
输出(stdout,每行一条):
|
|
51
|
+
RELAY_READY url=ws://<host>:<port>/relay hub=<fp> enroll=<fp>
|
|
52
|
+
RELAY_TOKENS hub=<token> enroll=<token> ... (仅 --print-tokens / 交互式终端)
|
|
53
|
+
RELAY_TOKENS_SOURCE hub=<fp> enroll=<fp> ... (非交互式,只有指纹)
|
|
54
|
+
{"ts":...,"level":"info","msg":"node online","nodeId":"..."} (事件日志)
|
|
55
|
+
|
|
56
|
+
说明: ready 行里的 host 是「客户端可用的地址」——监听 0.0.0.0/:: 时自动换成
|
|
57
|
+
本机对外 IPv4(真实监听地址见 relay listening 事件里的 bind 字段)。
|
|
58
|
+
|
|
59
|
+
HTTP 端点:
|
|
60
|
+
GET /healthz 健康检查
|
|
61
|
+
GET /api/relay/nodes?token=... 节点目录(需 hub token)
|
|
62
|
+
POST /api/relay/token?token=... 铸造一次性入网 token(需 hub token)
|
|
63
|
+
`
|
|
64
|
+
|
|
65
|
+
/** 事件日志:一行 JSON,不落明文 token。 */
|
|
66
|
+
function makeEventSink(stream) {
|
|
67
|
+
return (evt) => {
|
|
68
|
+
if (!evt || typeof evt !== 'object') return
|
|
69
|
+
const { level = 'info', msg = '', time, ...rest } = evt
|
|
70
|
+
let line
|
|
71
|
+
try {
|
|
72
|
+
line = JSON.stringify({ ts: time ?? Date.now(), level, msg, ...rest })
|
|
73
|
+
} catch {
|
|
74
|
+
line = JSON.stringify({ ts: time ?? Date.now(), level, msg, note: 'unserializable-event' })
|
|
75
|
+
}
|
|
76
|
+
stream.write(`${line}\n`)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 通配地址对客户端没意义,换成主机的对外 IPv4;找不到就原样返回。 */
|
|
81
|
+
function displayHost(host) {
|
|
82
|
+
if (host !== '0.0.0.0' && host !== '::' && host !== '*') return host
|
|
83
|
+
try {
|
|
84
|
+
for (const list of Object.values(os.networkInterfaces())) {
|
|
85
|
+
for (const ni of list ?? []) {
|
|
86
|
+
if (ni && ni.family === 'IPv4' && ni.internal === false) return ni.address
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
/* 忽略:退回原 host */
|
|
91
|
+
}
|
|
92
|
+
return host
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function fail(message, code = 2) {
|
|
96
|
+
process.stderr.write(`dsh-remote-relay: ${message}\n`)
|
|
97
|
+
process.stderr.write('用 --help 查看用法。\n')
|
|
98
|
+
process.exit(code)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function main() {
|
|
102
|
+
let opts
|
|
103
|
+
try {
|
|
104
|
+
opts = parseArgs(process.argv.slice(2), process.env)
|
|
105
|
+
} catch (err) {
|
|
106
|
+
fail(err.message)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (opts.help) {
|
|
110
|
+
process.stdout.write(USAGE)
|
|
111
|
+
return 0
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const wantPlainTokens =
|
|
115
|
+
opts.printTokens || process.stdout.isTTY === true || process.env.DSH_RELAY_PRINT_TOKENS === '1'
|
|
116
|
+
|
|
117
|
+
/** 解析凭证:显式给全了就用显式的;否则读/建 tokens.json。 */
|
|
118
|
+
function resolveTokens() {
|
|
119
|
+
const bothGiven = opts.hubToken !== '' && opts.enrollToken !== ''
|
|
120
|
+
if (bothGiven) {
|
|
121
|
+
return {
|
|
122
|
+
hubToken: opts.hubToken,
|
|
123
|
+
enrollToken: opts.enrollToken,
|
|
124
|
+
created: false,
|
|
125
|
+
path: '',
|
|
126
|
+
source: 'args-or-env',
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const rec = loadOrCreateTokens(opts.dataDir)
|
|
130
|
+
return {
|
|
131
|
+
hubToken: opts.hubToken !== '' ? opts.hubToken : rec.hubToken,
|
|
132
|
+
enrollToken: opts.enrollToken !== '' ? opts.enrollToken : rec.enrollToken,
|
|
133
|
+
created: rec.created,
|
|
134
|
+
path: rec.path,
|
|
135
|
+
source: rec.created ? 'generated' : 'file',
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let creds
|
|
140
|
+
try {
|
|
141
|
+
creds = resolveTokens()
|
|
142
|
+
} catch (err) {
|
|
143
|
+
fail(err.message, 1)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (opts.printTokens) {
|
|
147
|
+
process.stdout.write(`${formatTokensLine(creds)}\n`)
|
|
148
|
+
return 0
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// 凭证来源始终给指纹(可安全进日志);明文只在交互式/显式要求时打。
|
|
152
|
+
process.stdout.write(`${formatTokenFingerprintLine(creds)}\n`)
|
|
153
|
+
if (creds.created || wantPlainTokens) {
|
|
154
|
+
process.stdout.write(`${formatTokensLine(creds)}\n`)
|
|
155
|
+
} else {
|
|
156
|
+
process.stdout.write(
|
|
157
|
+
`RELAY_TOKENS_HINT file=${creds.path} note=plaintext-hidden(run --print-tokens to display)\n`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const relay = createRelay({
|
|
162
|
+
port: opts.port,
|
|
163
|
+
host: opts.host,
|
|
164
|
+
hubToken: creds.hubToken,
|
|
165
|
+
enrollToken: creds.enrollToken,
|
|
166
|
+
onEvent: makeEventSink(process.stdout),
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
await new Promise((resolve, reject) => {
|
|
170
|
+
relay.server.once('error', reject)
|
|
171
|
+
relay.listen(() => {
|
|
172
|
+
relay.server.removeListener('error', reject)
|
|
173
|
+
resolve()
|
|
174
|
+
})
|
|
175
|
+
}).catch((err) => {
|
|
176
|
+
fail(`监听 ${opts.host}:${opts.port} 失败:${err.message}`, 1)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
const addr = relay.address()
|
|
180
|
+
const port = typeof addr === 'object' && addr ? addr.port : opts.port
|
|
181
|
+
const url = `ws://${displayHost(opts.host)}:${port}/relay`
|
|
182
|
+
process.stdout.write(
|
|
183
|
+
`${formatReadyLine({
|
|
184
|
+
url,
|
|
185
|
+
hubFingerprint: tokenFingerprint(creds.hubToken),
|
|
186
|
+
enrollFingerprint: tokenFingerprint(creds.enrollToken),
|
|
187
|
+
})}\n`,
|
|
188
|
+
)
|
|
189
|
+
process.stdout.write(
|
|
190
|
+
`${JSON.stringify({
|
|
191
|
+
ts: Date.now(),
|
|
192
|
+
level: 'info',
|
|
193
|
+
msg: 'relay listening',
|
|
194
|
+
bind: `${opts.host}:${port}`,
|
|
195
|
+
healthz: `http://127.0.0.1:${port}/healthz`,
|
|
196
|
+
})}\n`,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
let closing = false
|
|
200
|
+
const shutdown = async (signal) => {
|
|
201
|
+
if (closing) return
|
|
202
|
+
closing = true
|
|
203
|
+
process.stdout.write(`${JSON.stringify({ ts: Date.now(), level: 'info', msg: 'shutting down', signal })}\n`)
|
|
204
|
+
try {
|
|
205
|
+
await relay.close()
|
|
206
|
+
} catch (err) {
|
|
207
|
+
process.stderr.write(`dsh-remote-relay: 关闭失败:${err?.message ?? err}\n`)
|
|
208
|
+
process.exit(1)
|
|
209
|
+
}
|
|
210
|
+
process.exit(0)
|
|
211
|
+
}
|
|
212
|
+
process.on('SIGINT', () => {
|
|
213
|
+
void shutdown('SIGINT')
|
|
214
|
+
})
|
|
215
|
+
process.on('SIGTERM', () => {
|
|
216
|
+
void shutdown('SIGTERM')
|
|
217
|
+
})
|
|
218
|
+
process.on('unhandledRejection', (err) => {
|
|
219
|
+
process.stderr.write(`dsh-remote-relay: unhandledRejection: ${err?.stack ?? err}\n`)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
return null // 常驻:交给信号处理收尾
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* main-module 守卫(F2-1 修复)。
|
|
227
|
+
*
|
|
228
|
+
* 此前 `main()` 在模块顶层无条件调用,于是**仅仅 import 这个文件**就会:
|
|
229
|
+
* - 占用默认端口 8890
|
|
230
|
+
* - 读写 ~/.dsh-remote-agent-relay/tokens.json(创建目录并生成 token)
|
|
231
|
+
* - 注册进程级 SIGINT/SIGTERM/unhandledRejection 处理器 —— 后者会吞掉
|
|
232
|
+
* 宿主进程(例如测试运行器)本来要报告的 rejection
|
|
233
|
+
*
|
|
234
|
+
* 作为 CLI 直接运行时行为不变;被 import 时只导出纯函数,零副作用。
|
|
235
|
+
*/
|
|
236
|
+
function isMainModule() {
|
|
237
|
+
if (typeof process.argv[1] !== 'string' || process.argv[1] === '') return false
|
|
238
|
+
try {
|
|
239
|
+
return import.meta.url === pathToFileURL(process.argv[1]).href
|
|
240
|
+
} catch {
|
|
241
|
+
return false
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (isMainModule()) {
|
|
246
|
+
main().then(
|
|
247
|
+
(code) => {
|
|
248
|
+
if (code !== null) process.exit(code)
|
|
249
|
+
},
|
|
250
|
+
(err) => {
|
|
251
|
+
process.stderr.write(`dsh-remote-relay: 启动失败:${err?.stack ?? err}\n`)
|
|
252
|
+
process.exit(1)
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export { main }
|