dsh-remote-agent 0.0.0-stage → 0.4.0

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.
Files changed (70) hide show
  1. package/CHANGELOG.md +700 -0
  2. package/CONTRIBUTING.md +358 -0
  3. package/KNOWN-ISSUES.md +601 -0
  4. package/LICENSE +21 -0
  5. package/README.en.md +353 -0
  6. package/README.md +317 -2
  7. package/SECURITY.md +348 -0
  8. package/bin/dsh-remote-relay.js +257 -0
  9. package/bin/dsh-remote-setup.js +440 -0
  10. package/bin//345/217/214/345/207/273/350/277/220/350/241/214-/351/205/215/347/275/256/345/220/221/345/257/274.cmd +28 -0
  11. package/cordis.patch.yml +87 -0
  12. package/deploy/relay-vps/Caddyfile +24 -0
  13. package/deploy/relay-vps/README.md +132 -0
  14. package/deploy/relay-vps/dsh-remote-relay.service +47 -0
  15. package/deploy/relay-vps/nginx.conf +49 -0
  16. package/deploy/relay-vps/relay.env.example +20 -0
  17. package/docs/dispatch-automation-eval.md +140 -0
  18. package/docs/screenshots/panel-main.png +0 -0
  19. package/docs/screenshots/panel-task-detail.png +0 -0
  20. package/docs/verification-agent-e2e.md +425 -0
  21. package/docs/verification-repos-routing-real.md +521 -0
  22. package/docs/verification-resume-real.md +254 -0
  23. package/docs/worker-setup-pitfalls.md +266 -0
  24. package/lib/artifacts.js +451 -0
  25. package/lib/client.js +1752 -0
  26. package/lib/e2e.js +304 -0
  27. package/lib/glob.js +225 -0
  28. package/lib/hub/audit-log.js +161 -0
  29. package/lib/hub/hub-util.js +114 -0
  30. package/lib/hub/index.js +500 -0
  31. package/lib/hub/node-registry.js +943 -0
  32. package/lib/hub/pull-manager.js +781 -0
  33. package/lib/hub/registry-handshake.js +403 -0
  34. package/lib/hub/registry-ingest.js +346 -0
  35. package/lib/hub/relay-link.js +228 -0
  36. package/lib/hub/subagent-provider.js +424 -0
  37. package/lib/hub/templates.js +693 -0
  38. package/lib/hub/tools.js +1059 -0
  39. package/lib/hub/ui-routes.js +856 -0
  40. package/lib/hub/views.js +351 -0
  41. package/lib/identity.js +271 -0
  42. package/lib/index.js +272 -0
  43. package/lib/node/artifact-service.js +231 -0
  44. package/lib/node/diff.js +124 -0
  45. package/lib/node/exec.js +536 -0
  46. package/lib/node/hub-pin.js +71 -0
  47. package/lib/node/index.js +1022 -0
  48. package/lib/node/repos.js +134 -0
  49. package/lib/node/resource.js +63 -0
  50. package/lib/node/runner.js +792 -0
  51. package/lib/node/task-engine.js +712 -0
  52. package/lib/node/task-store.js +273 -0
  53. package/lib/node/transport.js +763 -0
  54. package/lib/pairing.js +186 -0
  55. package/lib/protocol.js +414 -0
  56. package/lib/relay-config.js +317 -0
  57. package/lib/relay.js +732 -0
  58. package/lib/setup-routes.js +313 -0
  59. package/lib/setup.js +522 -0
  60. package/lib/ws.js +466 -0
  61. package/package.json +103 -4
  62. package/profiles/README.md +573 -0
  63. package/profiles/hub.cordis.yml +84 -0
  64. package/profiles/worker.cordis.yml +60 -0
  65. package/scripts/check-doc-drift.mjs +785 -0
  66. package/scripts/check-packaging.mjs +130 -0
  67. package/scripts/diag-inject.mjs +243 -0
  68. package/scripts/e2e-live-check.mjs +181 -0
  69. package/scripts/relay-init.ps1 +286 -0
  70. package/scripts/relay-init.sh +199 -0
package/lib/relay.js ADDED
@@ -0,0 +1,732 @@
1
+ /**
2
+ * 中继服务(零依赖)。
3
+ *
4
+ * 定位:只做寻址与转发,不理解业务帧。三件事:
5
+ * 1. hub 拨入 → 登记为控制端
6
+ * 2. node 拨入 → 登记 nodeId,广播 node/online 给 hub
7
+ * 3. 双向转发:node 的上行帧推给全部 hub;hub 的下行帧按 `to` 投给对应 node
8
+ *
9
+ * 刻意不做的事(v0.1):
10
+ * - 不缓冲离线消息(任务状态真身在副机,重连后 node 自己 task/sync)
11
+ * - 不解析业务帧(二进制帧只读头部路由,净荷不解码)
12
+ * - 不持久化任务(中继是管子,不是数据库)
13
+ *
14
+ * 鉴权:hubToken / enrollToken 分离。node 用 enrollToken 入网,hub 用 hubToken 接管。
15
+ * 二者都支持一次性 token(consumed 后失效),便于"发一台机器出去"。
16
+ */
17
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
18
+ import http from 'node:http'
19
+ import {
20
+ ERROR_CODE,
21
+ PROTOCOL_VERSION,
22
+ RELAY_FRAME,
23
+ decodeBinaryFrame,
24
+ decodeFrame,
25
+ encodeBinaryFrame,
26
+ encodeFrame,
27
+ } from './protocol.js'
28
+ import { acceptUpgrade } from './ws.js'
29
+
30
+ /** 恒定时间字符串比较,避免 token 逐字节泄露。 */
31
+ function safeEqual(a, b) {
32
+ const ba = Buffer.from(String(a), 'utf8')
33
+ const bb = Buffer.from(String(b), 'utf8')
34
+ if (ba.length !== bb.length) return false
35
+ return timingSafeEqual(ba, bb)
36
+ }
37
+
38
+ export function newRelayToken(prefix = 'rt') {
39
+ return `${prefix}_${randomBytes(24).toString('base64url')}`
40
+ }
41
+
42
+ /**
43
+ * 启动中继。
44
+ * @param {object} o
45
+ * @param {number} [o.port=8890]
46
+ * @param {string} [o.host='0.0.0.0']
47
+ * @param {string} o.hubToken hub 端凭证
48
+ * @param {string} o.enrollToken node 入网凭证
49
+ * @param {boolean} [o.allowOneTimeTokens=true] 是否接受注册表里的一次性 token
50
+ * @param {number} [o.maxNodes=64]
51
+ * @param {(evt:object)=>void} [o.onEvent] 观测钩子(日志)
52
+ */
53
+ /**
54
+ * 安全解析 request-target。
55
+ *
56
+ * 为什么必须包住:Node 对畸形 request-target(如 `GET http://[/ HTTP/1.1`)抛
57
+ * `TypeError: Invalid URL`。在 `http.createServer` 回调里抛会被 http 层兜住,
58
+ * 但在 `server.on('upgrade')` 回调里抛是 EventEmitter 的普通事件 —— 直接冒泡成
59
+ * uncaughtException,进程退出。实测:一条 TCP 连接、一个畸形请求行、**零 token**
60
+ * 就能打死中继;而中继是 NAT 场景双方唯一的会合点,CLI 默认监听 0.0.0.0,
61
+ * 暴露即等于可被单包打掉,副机与 hub 全体断连。
62
+ */
63
+ function safeParseUrl(req, fallback = '/') {
64
+ try {
65
+ return new URL(req?.url ?? fallback, 'http://localhost')
66
+ } catch {
67
+ return undefined
68
+ }
69
+ }
70
+
71
+ /**
72
+ * 中继连接的写缓冲预算。
73
+ *
74
+ * 比 ws.js 默认的 64 MiB 小得多:中继只是转发点,任何时刻都不该攒住这么多字节 ——
75
+ * 它攒住的每一字节都是"下游节点读不动"的堆积,而不是它自己的有用工作。
76
+ * 16 MiB 足够吸收正常的网络抖动,又能在节点真停滞时快速断连而非拖到 OOM。
77
+ */
78
+ const RELAY_QUEUE_BUDGET = 16 * 1024 * 1024
79
+
80
+ export function createRelay(o = {}) {
81
+ const {
82
+ port = 8890,
83
+ host = '0.0.0.0',
84
+ hubToken,
85
+ enrollToken,
86
+ allowOneTimeTokens = true,
87
+ maxNodes = 64,
88
+ // 空闲淘汰(见 sweepIdleConnections 的说明)。
89
+ // idleMs 取 3 分钟:比常见的 NAT 表项超时(5–30 分钟)短,
90
+ // 又远大于正常心跳间隔,不会误杀健康连接。
91
+ idleMs = 3 * 60 * 1000,
92
+ sweepMs = 60 * 1000,
93
+ onEvent,
94
+ } = o
95
+
96
+ if (!hubToken || !enrollToken) throw new Error('relay requires hubToken and enrollToken')
97
+
98
+ const log = (level, msg, extra) => {
99
+ if (typeof onEvent === 'function') onEvent({ level, msg, ...extra, time: Date.now() })
100
+ }
101
+
102
+ /** nodeId → { conn, hello, since, bytesUp, bytesDown } */
103
+ const nodes = new Map()
104
+ /** hub 连接集合 */
105
+ const hubs = new Set()
106
+ /**
107
+ * hub 连接 → 最后活跃时间(ms)。
108
+ *
109
+ * 为什么单独一张表而不是挂在连接上:`hubs` 里放的是 ws.js 的连接对象,
110
+ * 那是另一个模块的类 —— 往它身上挂业务字段会耦合两个模块的内部形状。
111
+ * 用 WeakMap 做旁路映射,连接被回收时条目自动消失。
112
+ */
113
+ const hubSeen = new WeakMap()
114
+ /** 空闲淘汰扫描的定时器(在 listen 里起、close 里清)。 */
115
+ let sweepTimer
116
+ /** 一次性 token:token → { nodeId?, expiresAt, consumed } */
117
+ const oneTime = new Map()
118
+ const stats = { upFrames: 0, downFrames: 0, upBytes: 0, downBytes: 0, rejected: 0, backpressureDrops: 0 }
119
+
120
+ function sweepTokens(now = Date.now()) {
121
+ for (const [tok, rec] of oneTime) {
122
+ if (rec.consumed || rec.expiresAt <= now) oneTime.delete(tok)
123
+ }
124
+ }
125
+
126
+ /**
127
+ * 空闲连接淘汰扫描。
128
+ *
129
+ * ═════════════════════════════════════════════════════════════════════════
130
+ * 为什么必须有(2026-10-15 独立评审发现)
131
+ * ═════════════════════════════════════════════════════════════════════════
132
+ *
133
+ * `nodes` 的清理**只发生在 `conn.on('close')`** 里。而 TCP 连接的 close
134
+ * 依赖于 FIN/RST 真的到达 —— 以下场景**不会**触发 close:
135
+ *
136
+ * - NAT 表项静默超时(家庭路由器 5–30 分钟,常见)
137
+ * - 副机断电 / 强制关机 / 断网
138
+ * - 中间设备悄悄丢连接
139
+ *
140
+ * 于是 `nodes.get(nodeId)` 里的死条目**永久占用 `maxNodes` 配额**,
141
+ * 配额满了之后新节点连进来直接 503 —— 中继就此缓慢"饱和而死"。
142
+ *
143
+ * ── 策略 ────────────────────────────────────────────────────────────────
144
+ * 周期性(默认 60s)检查每个 node 与 hub 的最后活跃时间:
145
+ * - 超过 idleMs(默认 3 分钟)没动静 → 发一个 **WebSocket ping** 探测
146
+ * - 再等一个周期仍无 pong/帧 → 判定死连接,主动 destroy 并清条目
147
+ *
148
+ * 为什么用 ping 而不是直接断:正常空闲的节点也不发帧(任务间隙),
149
+ * 直接按"多久没帧"断会误杀健康节点。ping/pong 是 WS 协议自带的活性探测,
150
+ * ws.js 会自动回 pong(见其 OP.PING 分支)。
151
+ *
152
+ * 为什么阈值是 3 分钟 + 60s 扫描:比任何合理的 NAT 超时都短,
153
+ * 又不至于把正常空闲误判成死(健康连接会被 ping 唤醒回 pong)。
154
+ *
155
+ * @returns {{nodes: number, hubs: number}} 本轮淘汰的连接数
156
+ */
157
+ function sweepIdleConnections(now = Date.now()) {
158
+ let deadNodes = 0
159
+ let deadHubs = 0
160
+
161
+ for (const [nodeId, rec] of nodes) {
162
+ const idle = now - rec.lastSeen
163
+ if (idle < idleMs) continue
164
+ if (!rec.pingSentAt) {
165
+ // 第一次发现空闲:发 ping 探测,记下时间,下轮再判
166
+ rec.pingSentAt = now
167
+ try {
168
+ rec.conn.ping()
169
+ } catch {
170
+ /* 连接可能已经坏了,下一轮会兜住 */
171
+ }
172
+ continue
173
+ } if (now - rec.pingSentAt < idleMs) continue
174
+ // ping 发出后整个 idleMs 仍无任何回应 → 判定死亡
175
+ try {
176
+ rec.conn.destroy(new Error('idle timeout'))
177
+ } catch {
178
+ /* 已销毁 */
179
+ }
180
+ nodes.delete(nodeId)
181
+ broadcastHubs(RELAY_FRAME.NODE_OFFLINE, { nodeId, reason: 'idle-timeout' })
182
+ deadNodes += 1
183
+ log('warn', 'node evicted: idle timeout', { nodeId, idleMs })
184
+ }
185
+
186
+ for (const h of [...hubs]) {
187
+ const seen = hubSeen.get(h) ?? now
188
+ if (now - seen < idleMs) continue
189
+ try {
190
+ h.destroy(new Error('idle timeout'))
191
+ } catch {
192
+ /* 已销毁 */
193
+ }
194
+ hubs.delete(h)
195
+ hubSeen.delete(h)
196
+ deadHubs += 1
197
+ log('warn', 'hub evicted: idle timeout', { idleMs })
198
+ }
199
+
200
+ return { nodes: deadNodes, hubs: deadHubs }
201
+ }
202
+
203
+ /** 铸造一个一次性入网 token(由 hub 通过管理接口调用)。 */
204
+ function mintToken({ nodeId, ttlMs = 15 * 60 * 1000 } = {}) {
205
+ const token = newRelayToken('en')
206
+ oneTime.set(token, { nodeId, expiresAt: Date.now() + ttlMs, consumed: false })
207
+ return token
208
+ }
209
+
210
+ function checkNodeToken(token) {
211
+ if (enrollToken && safeEqual(token, enrollToken)) return { ok: true, kind: 'static' }
212
+ if (!allowOneTimeTokens) return { ok: false }
213
+ sweepTokens()
214
+ const rec = oneTime.get(token)
215
+ if (!rec) return { ok: false }
216
+ rec.consumed = true
217
+ return { ok: true, kind: 'one-time', nodeId: rec.nodeId }
218
+ }
219
+
220
+ function sendJson(conn, type, data, opts) {
221
+ conn.send(encodeFrame({ v: PROTOCOL_VERSION, t: type, id: randomBytes(8).toString('hex'), d: data ?? {}, ...opts }))
222
+ }
223
+
224
+ function broadcastHubs(type, data, opts) {
225
+ const text = encodeFrame({ v: PROTOCOL_VERSION, t: type, id: randomBytes(8).toString('hex'), d: data ?? {}, ...opts })
226
+ for (const h of [...hubs]) {
227
+ if (h.closed) continue
228
+ // ⚠ hub 方向也必须过背压 —— 见 applyHubBackpressure 的说明。
229
+ if (!applyHubBackpressure(h)) continue
230
+ h.send(text)
231
+ }
232
+ }
233
+
234
+ function nodeSummary(rec) {
235
+ return {
236
+ nodeId: rec.hello.nodeId,
237
+ // protocol 必须带上(t3 审计查出):relay 模式下 hub 从来看不到副机的
238
+ // hello 原文,只能看这份摘要。而 hub 侧 node-registry.js 的
239
+ // `summary.protocol ?? PROTOCOL_VERSION` 在缺字段时会**静默填默认值** ——
240
+ // 于是「双方协议版本一致」成了被假设而非被验证的事。带上它,
241
+ // hub 才能真正做版本协商。
242
+ protocol: rec.hello.protocol,
243
+ os: rec.hello.os,
244
+ arch: rec.hello.arch,
245
+ hostname: rec.hello.hostname,
246
+ dshVersion: rec.hello.dshVersion,
247
+ shells: rec.hello.shells,
248
+ roots: rec.hello.roots,
249
+ caps: rec.hello.caps,
250
+ profile: rec.hello.profile,
251
+ agentMode: rec.hello.agentMode,
252
+ // P2-11:连接级 nonce 透传 —— hub 签身份帧时用它,副机才能核对
253
+ // "这是对我本次 hello 的回应",而不是被重放的旧帧。
254
+ nonce: rec.hello.nonce,
255
+ since: rec.since,
256
+ lastSeen: rec.lastSeen,
257
+ bytesUp: rec.bytesUp,
258
+ bytesDown: rec.bytesDown,
259
+ }
260
+ }
261
+
262
+ function nodeList() {
263
+ return [...nodes.values()].map(nodeSummary)
264
+ }
265
+
266
+ /** 文本帧下行(控制帧走这里)。 */
267
+ function forwardToNode(nodeId, text, bytes) {
268
+ const rec = nodes.get(nodeId)
269
+ if (!rec || rec.conn.closed) return false
270
+ if (!applyBackpressure(rec)) return false
271
+ rec.conn.send(text)
272
+ rec.bytesDown += bytes ?? text.length
273
+ stats.downFrames += 1
274
+ stats.downBytes += bytes ?? text.length
275
+ return true
276
+ }
277
+
278
+ /**
279
+ * 二进制帧下行。
280
+ *
281
+ * 必须走 sendBinary:早期版本把 encodeBinaryFrame 的 Buffer 交给 forwardToNode,
282
+ * 而后者调 conn.send(text),ws.js 的 send() 会做 Buffer.from(String(text),'utf8') ——
283
+ * 净荷里所有非 UTF-8 字节被替换成 U+FFFD(0xff/0xfe/0x80 → EF BF BD),
284
+ * 整帧损坏(实测 65 字节发出、73 字节收到)。
285
+ */
286
+ function forwardBinaryToNode(nodeId, buf) {
287
+ const rec = nodes.get(nodeId)
288
+ if (!rec || rec.conn.closed) return false
289
+ if (!applyBackpressure(rec)) return false
290
+ rec.conn.sendBinary(buf)
291
+ rec.bytesDown += buf.length
292
+ stats.downFrames += 1
293
+ stats.downBytes += buf.length
294
+ return true
295
+ }
296
+
297
+ /**
298
+ * 下行背压(C1-1 修复)。
299
+ *
300
+ * 此前 forwardToNode/forwardBinaryToNode 从不检查写缓冲,relay.js 全文
301
+ * 没有 drain / bufferedBytes / writableLength 的任何引用;ws.js 声明的
302
+ * maxQueuedBytes 也从未被读。实测:一个停滞的节点(不读 socket)会让
303
+ * writableLength 涨到 339.8 MiB、relay RSS 419 MiB —— 单个停滞节点
304
+ * 就能把中继 OOM 打掉。
305
+ *
306
+ * 策略选择:**超限即断连**,而不是 await drain 或静默丢帧。
307
+ * - await drain 会阻塞整个转发循环,一个慢节点拖死所有其他节点
308
+ * - 静默丢帧会让对端拿到残缺的流而毫无察觉(产物分块丢一块就废了整份)
309
+ * - 断连是可观测的,且节点会自动重连、靠 task/sync 重建视图,
310
+ * 代价只是那一条连接重来,不影响其他节点
311
+ *
312
+ * 阈值取 maxQueuedBytes(默认 64 MiB),并在日志与 stats 里留下痕迹。
313
+ */
314
+ function applyBackpressure(rec) {
315
+ if (!rec.conn.overBudget) return true
316
+ stats.backpressureDrops += 1
317
+ log('warn', 'node over write budget, dropping connection', {
318
+ nodeId: rec.nodeId,
319
+ bufferedBytes: rec.conn.bufferedBytes,
320
+ budget: rec.conn.maxQueuedBytes,
321
+ })
322
+ try {
323
+ rec.conn.destroy(new Error('write budget exceeded'))
324
+ } catch {
325
+ /* 已销毁 */
326
+ }
327
+ return false
328
+ }
329
+
330
+ /**
331
+ * hub 方向的背压:与 `applyBackpressure` 同样的策略,但作用对象是**裸连接**。
332
+ *
333
+ * ═════════════════════════════════════════════════════════════════════════
334
+ * 为什么必须补这一个(2026-10-15 独立评审发现)
335
+ * ═════════════════════════════════════════════════════════════════════════
336
+ *
337
+ * 原实现只在 `forwardToNode` / `forwardBinaryToNode`(node 方向)查预算,
338
+ * 而 `broadcastHubs` / `broadcastHubsBinary`(hub 方向)直接 `h.send()` ——
339
+ * **背压只修了一半**。
340
+ *
341
+ * 风险对称:一个不读 socket 的 hub 连接(卡死的浏览器页、暂停的调试器、
342
+ * 被挂起的宿主进程)会让 `writableLength` 无限增长。中继是**单进程常驻**
343
+ * 服务,这正是本文件上面那段注释描述的 OOM 场景,只是换了个方向。
344
+ *
345
+ * 与 node 方向的差别:hub 连接没有 `rec` 包装,所以这里直接接连接对象;
346
+ * 阈值用连接自己的 `maxQueuedBytes`(ws.js 的构造参数,默认 64 MiB)。
347
+ *
348
+ * @param {object} h hub 连接(ws.js 的 WSConnection)
349
+ * @returns {boolean} true = 可以写;false = 已断连,调用方应跳过
350
+ */
351
+ function applyHubBackpressure(h) {
352
+ if (!h.overBudget) return true
353
+ stats.backpressureDrops += 1
354
+ log('warn', 'hub over write budget, dropping connection', {
355
+ bufferedBytes: h.bufferedBytes,
356
+ budget: h.maxQueuedBytes,
357
+ })
358
+ try {
359
+ h.destroy(new Error('write budget exceeded'))
360
+ } catch {
361
+ /* 已销毁 */
362
+ }
363
+ return false
364
+ }
365
+
366
+ const server = http.createServer((req, res) => {
367
+ // 畸形 request-target 一律 400,绝不让 TypeError 冒泡成 uncaughtException
368
+ const url = safeParseUrl(req)
369
+ if (!url) {
370
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8' })
371
+ res.end(JSON.stringify({ error: 'bad-request-target' }))
372
+ return
373
+ }
374
+
375
+ // 健康检查 + 节点目录(只读,给运维看)
376
+ if (url.pathname === '/healthz') {
377
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
378
+ res.end(JSON.stringify({ ok: true, protocol: PROTOCOL_VERSION, nodes: nodes.size, hubs: hubs.size, stats }))
379
+ return
380
+ }
381
+ if (url.pathname === '/api/relay/nodes') {
382
+ if (req.method !== 'GET') {
383
+ res.writeHead(405).end()
384
+ return
385
+ }
386
+ const token = url.searchParams.get('token') ?? req.headers['x-relay-token']
387
+ if (!token || !safeEqual(token, hubToken)) {
388
+ res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'forbidden' }))
389
+ return
390
+ }
391
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
392
+ res.end(JSON.stringify({ nodes: nodeList() }))
393
+ return
394
+ }
395
+ if (url.pathname === '/api/relay/token') {
396
+ if (req.method !== 'POST') {
397
+ res.writeHead(405).end()
398
+ return
399
+ }
400
+ const token = url.searchParams.get('token') ?? req.headers['x-relay-token']
401
+ if (!token || !safeEqual(token, hubToken)) {
402
+ res.writeHead(403, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'forbidden' }))
403
+ return
404
+ }
405
+ let body = ''
406
+ req.on('data', (c) => {
407
+ body += c
408
+ })
409
+ req.on('end', () => {
410
+ let parsed
411
+ try {
412
+ parsed = body ? JSON.parse(body) : {}
413
+ } catch {
414
+ parsed = {}
415
+ }
416
+ const tok = mintToken({ nodeId: parsed.nodeId, ttlMs: parsed.ttlMs })
417
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
418
+ res.end(JSON.stringify({ token: tok, expiresInMs: parsed.ttlMs ?? 15 * 60 * 1000 }))
419
+ })
420
+ return
421
+ }
422
+
423
+ res.writeHead(404, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'not-found' }))
424
+ })
425
+
426
+ // 兜底:畸形请求行可能在到达任何 handler 之前就被 http 解析器拒绝
427
+ // (HPE_INVALID_* 等)。没有这个监听器时,这类连接会被直接销毁并可能
428
+ // 触发 'clientError' 的默认行为;显式处理保证一律 400 + 关闭,不升级为进程级错误。
429
+ server.on('clientError', (err, socket) => {
430
+ stats.rejected += 1
431
+ log('warn', 'client error', { code: err?.code ?? 'unknown' })
432
+ try {
433
+ if (socket.writable && !socket.destroyed) {
434
+ socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n')
435
+ } else {
436
+ socket.destroy()
437
+ }
438
+ } catch {
439
+ try {
440
+ socket.destroy()
441
+ } catch {
442
+ /* 已销毁 */
443
+ }
444
+ }
445
+ })
446
+
447
+ server.on('upgrade', (req, socket, head) => {
448
+ // 这一处是 blocker 的真正入口:upgrade 回调抛错 = uncaughtException = 进程退出
449
+ const url = safeParseUrl(req)
450
+ if (!url) {
451
+ stats.rejected += 1
452
+ log('warn', 'upgrade rejected: malformed request-target')
453
+ try {
454
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n')
455
+ } catch {
456
+ /* socket 可能已断 */
457
+ }
458
+ socket.destroy()
459
+ return
460
+ }
461
+ const role = url.searchParams.get('role')
462
+ const token = url.searchParams.get('token') ?? req.headers['x-relay-token']
463
+ const wantNodeId = url.searchParams.get('nodeId') ?? ''
464
+
465
+ if (url.pathname !== '/relay') {
466
+ socket.destroy()
467
+ return
468
+ }
469
+
470
+ if (role === 'hub') {
471
+ if (!token || !safeEqual(token, hubToken)) {
472
+ stats.rejected += 1
473
+ log('warn', 'hub rejected: bad token')
474
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
475
+ socket.destroy()
476
+ return
477
+ }
478
+ const conn = acceptUpgrade(req, socket, head, { name: 'relay-hub', maxQueuedBytes: RELAY_QUEUE_BUDGET })
479
+ if (!conn) return
480
+ hubs.add(conn)
481
+ log('info', 'hub connected', { hubs: hubs.size })
482
+ sendJson(conn, RELAY_FRAME.HELLO_HUB, { protocol: PROTOCOL_VERSION, nodes: nodeList() })
483
+ conn.on('message', (payload, isBinary) => {
484
+ if (isBinary) {
485
+ const dec = decodeBinaryFrame(payload)
486
+ if (!dec.ok) {
487
+ log('warn', 'hub binary frame undecodable', { error: dec.error })
488
+ return
489
+ }
490
+ const to = dec.header.to
491
+ if (typeof to !== 'string') return
492
+ const out = encodeBinaryFrame(dec.header, dec.payload)
493
+ forwardBinaryToNode(to, out)
494
+ return
495
+ }
496
+ const dec = decodeFrame(payload)
497
+ if (!dec.ok) {
498
+ sendJson(conn, RELAY_FRAME.ERROR, { code: dec.error })
499
+ return
500
+ }
501
+ const frame = dec.frame
502
+ if (frame.t === RELAY_FRAME.NODE_LIST) {
503
+ sendJson(conn, RELAY_FRAME.NODE_LIST, { nodes: nodeList() })
504
+ return
505
+ }
506
+ const to = frame.to ?? frame.d?.to
507
+ if (typeof to === 'string' && to !== '') {
508
+ const ok = forwardToNode(to, encodeFrame(frame), undefined)
509
+ if (!ok) {
510
+ sendJson(conn, RELAY_FRAME.ERROR, { code: 'node-offline', nodeId: to, replyTo: frame.id })
511
+ }
512
+ return
513
+ }
514
+ // 无 to 的下行帧 → 广播(用于广播式指令)
515
+ broadcastHubs(frame.t, frame.d, { r: frame.id })
516
+ })
517
+ conn.on('close', () => {
518
+ hubs.delete(conn)
519
+ hubSeen.delete(conn)
520
+ log('info', 'hub disconnected', { hubs: hubs.size })
521
+ })
522
+ conn.on('error', (err) => log('warn', 'hub error', { error: err.message }))
523
+ // 记录活跃时间,供空闲淘汰扫描判断(见 startIdleSweep)
524
+ conn.on('message', () => hubSeen.set(conn, Date.now()))
525
+ conn.on('pong', () => hubSeen.set(conn, Date.now()))
526
+ hubSeen.set(conn, Date.now())
527
+ return
528
+ }
529
+
530
+ if (role === 'node') {
531
+ const check = checkNodeToken(token)
532
+ if (!check.ok) {
533
+ stats.rejected += 1
534
+ log('warn', 'node rejected: bad token', { nodeId: wantNodeId })
535
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n')
536
+ socket.destroy()
537
+ return
538
+ }
539
+ if (nodes.size >= maxNodes) {
540
+ stats.rejected += 1
541
+ log('warn', 'node rejected: relay full')
542
+ socket.write('HTTP/1.1 503 Service Unavailable\r\n\r\n')
543
+ socket.destroy()
544
+ return
545
+ }
546
+ const conn = acceptUpgrade(req, socket, head, { name: 'relay-node', maxQueuedBytes: RELAY_QUEUE_BUDGET })
547
+ if (!conn) return
548
+
549
+ let boundId = ''
550
+ conn.on('message', (payload, isBinary) => {
551
+ if (isBinary) {
552
+ const dec = decodeBinaryFrame(payload)
553
+ if (!dec.ok) return
554
+ if (boundId === '') return
555
+ const out = encodeBinaryFrame({ ...dec.header, from: boundId }, dec.payload)
556
+ broadcastHubsBinary(out)
557
+ const rec = nodes.get(boundId)
558
+ if (rec) {
559
+ rec.bytesUp += out.length
560
+ rec.lastSeen = Date.now()
561
+ // 收到任何帧都算"活着":清掉 ping 标记,否则下一轮扫描
562
+ // 会因为 pingSentAt 仍在而误判为死亡(见 sweepIdleConnections)。
563
+ rec.pingSentAt = undefined
564
+ }
565
+ stats.upFrames += 1
566
+ stats.upBytes += out.length
567
+ return
568
+ }
569
+
570
+ const dec = decodeFrame(payload)
571
+ if (!dec.ok) {
572
+ sendJson(conn, RELAY_FRAME.ERROR, { code: dec.error })
573
+ return
574
+ }
575
+ const frame = dec.frame
576
+
577
+ if (frame.t === RELAY_FRAME.HELLO_NODE) {
578
+ // ⚠ 一条连接只能 hello 一次。
579
+ //
580
+ // 原实现没有这个守卫 —— 同一连接二次 HELLO_NODE 换一个 nodeId 时,
581
+ // `nodes.set(newId, rec)` 会**覆盖** boundId,而旧 id 的条目**留在
582
+ // nodes 里永不清除**(close 处理器只看当时的 boundId)。后果:
583
+ // - 幽灵条目永久占用 `maxNodes` 配额
584
+ // - 配额满了之后新节点直接 503
585
+ // - 反复 hello 可以在单条连接上堆任意多幽灵条目
586
+ // 拒绝第二次 hello 是最简单且语义正确的处理(重连应该开新连接)。
587
+ if (boundId !== '') {
588
+ sendJson(conn, RELAY_FRAME.ERROR, { code: 'already-helloed' })
589
+ conn.close(1008, 'already-helloed')
590
+ log('warn', 'node re-hello on same connection rejected', { boundId })
591
+ return
592
+ }
593
+ const hello = frame.d ?? {}
594
+ const nodeId = String(hello.nodeId ?? '')
595
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(nodeId)) {
596
+ sendJson(conn, RELAY_FRAME.ERROR, { code: 'node-id-invalid' })
597
+ conn.close(1008, 'node-id-invalid')
598
+ return
599
+ }
600
+ if (check.nodeId && check.nodeId !== nodeId) {
601
+ sendJson(conn, RELAY_FRAME.ERROR, { code: ERROR_CODE.UNAUTHORIZED, message: 'token bound to another nodeId' })
602
+ conn.close(1008, ERROR_CODE.UNAUTHORIZED)
603
+ return
604
+ }
605
+ const existing = nodes.get(nodeId)
606
+ if (existing && !existing.conn.closed) {
607
+ // 同 id 第二实例:拒掉新的,保住已经在跑的那个(任务不能被打断)
608
+ sendJson(conn, RELAY_FRAME.ERROR, { code: ERROR_CODE.DUPLICATE_NODE, nodeId })
609
+ conn.close(1008, ERROR_CODE.DUPLICATE_NODE)
610
+ log('warn', 'duplicate node rejected', { nodeId })
611
+ return
612
+ }
613
+ boundId = nodeId
614
+ const rec = { conn, hello, since: Date.now(), lastSeen: Date.now(), bytesUp: 0, bytesDown: 0 }
615
+ nodes.set(nodeId, rec)
616
+ sendJson(conn, RELAY_FRAME.HELLO_NODE, { protocol: PROTOCOL_VERSION, nodeId, accepted: true })
617
+ broadcastHubs(RELAY_FRAME.NODE_ONLINE, { node: nodeSummary(rec) })
618
+ log('info', 'node online', { nodeId, os: hello.os })
619
+ return
620
+ }
621
+
622
+ if (boundId === '') {
623
+ sendJson(conn, RELAY_FRAME.ERROR, { code: ERROR_CODE.UNAUTHORIZED, message: 'hello/node required first' })
624
+ return
625
+ }
626
+
627
+ const rec = nodes.get(boundId)
628
+ if (rec) {
629
+ rec.lastSeen = Date.now()
630
+ rec.pingSentAt = undefined // 同上:有帧即活着,清掉 ping 标记
631
+ rec.bytesUp += payload.length
632
+ }
633
+ stats.upFrames += 1
634
+ stats.upBytes += payload.length
635
+ // 上行:原样推给所有 hub,附带来源
636
+ const out = encodeFrame({ ...frame, from: boundId })
637
+ for (const h of hubs) {
638
+ if (!h.closed) h.send(out)
639
+ }
640
+ })
641
+
642
+ conn.on('close', () => {
643
+ if (boundId === '') return
644
+ const rec = nodes.get(boundId)
645
+ if (rec && rec.conn === conn) {
646
+ nodes.delete(boundId)
647
+ broadcastHubs(RELAY_FRAME.NODE_OFFLINE, { nodeId: boundId, reason: 'disconnected' })
648
+ log('info', 'node offline', { nodeId: boundId })
649
+ }
650
+ })
651
+ // pong 也是"活着"的证据 —— 空闲淘汰发 ping 后,健康节点回的正是 pong。
652
+ // ws.js 会在收到 PONG 时 emit('pong')(见其 OP.PONG 分支)。
653
+ conn.on('pong', () => {
654
+ const rec = nodes.get(boundId)
655
+ if (rec) {
656
+ rec.lastSeen = Date.now()
657
+ rec.pingSentAt = undefined
658
+ }
659
+ })
660
+ conn.on('error', (err) => log('warn', 'node error', { nodeId: boundId, error: err.message }))
661
+ return
662
+ }
663
+
664
+ stats.rejected += 1
665
+ socket.write('HTTP/1.1 400 Bad Request\r\n\r\n')
666
+ socket.destroy()
667
+ })
668
+
669
+ function broadcastHubsBinary(buf) {
670
+ for (const h of [...hubs]) {
671
+ if (h.closed) continue
672
+ // 二进制方向同样要过背压 —— 产物分块正是最容易把缓冲撑爆的流量。
673
+ if (!applyHubBackpressure(h)) continue
674
+ h.sendBinary(buf)
675
+ }
676
+ }
677
+
678
+ /** 主动断开一个节点(hub 侧吊销)。 */
679
+ function kickNode(nodeId, reason = 'revoked') {
680
+ const rec = nodes.get(nodeId)
681
+ if (!rec) return false
682
+ sendJson(rec.conn, RELAY_FRAME.ERROR, { code: 'revoked', reason })
683
+ rec.conn.close(1008, reason)
684
+ return true
685
+ }
686
+
687
+ return {
688
+ server,
689
+ nodes,
690
+ hubs,
691
+ stats,
692
+ mintToken,
693
+ nodeList,
694
+ kickNode,
695
+ listen: (cb) => {
696
+ // 起空闲淘汰扫描(见 sweepIdleConnections 的说明)。
697
+ // `unref()` 是必须的 —— 否则这个定时器会吊住 Node 事件循环,
698
+ // 让进程无法自然退出(测试里的 `close()` 之后尤其明显)。
699
+ if (idleMs > 0 && sweepMs > 0 && !sweepTimer) {
700
+ sweepTimer = setInterval(() => {
701
+ try {
702
+ sweepIdleConnections()
703
+ } catch (err) {
704
+ // 扫描内部已经逐个 try/catch,这里是兜底:绝不让它变成 uncaughtException
705
+ log('warn', 'idle sweep failed', { error: String(err?.message ?? err) })
706
+ }
707
+ }, sweepMs)
708
+ if (typeof sweepTimer.unref === 'function') sweepTimer.unref()
709
+ }
710
+ return server.listen(port, host, cb)
711
+ },
712
+ close: () => new Promise((resolve) => {
713
+ if (sweepTimer) {
714
+ clearInterval(sweepTimer)
715
+ sweepTimer = undefined
716
+ }
717
+ for (const n of nodes.values()) n.conn.close(1001, 'relay shutting down')
718
+ for (const h of hubs) h.close(1001, 'relay shutting down')
719
+ nodes.clear()
720
+ hubs.clear()
721
+ server.close(() => resolve())
722
+ }),
723
+ /** 主动跑一次空闲淘汰(测试用,避免依赖真实定时器)。 */
724
+ sweepIdle: (now) => sweepIdleConnections(now),
725
+ address: () => server.address(),
726
+ }
727
+ }
728
+
729
+ /** 给一次性 token 生成指纹,用于日志里不泄露 token 本体。 */
730
+ export function tokenFingerprint(token) {
731
+ return createHash('sha256').update(String(token)).digest('hex').slice(0, 12)
732
+ }