dsh-remote-agent 0.9.4 → 0.9.6
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 +190 -0
- package/README.en.md +1 -1
- package/README.md +2 -2
- package/lib/client.js +162 -10
- package/lib/explain-error.js +472 -0
- package/lib/hub/index.js +75 -26
- package/lib/hub/node-registry.js +78 -0
- package/lib/hub/registry-handshake.js +44 -0
- package/lib/hub/ui-routes.js +28 -0
- package/lib/index.js +89 -0
- package/lib/node/index.js +40 -7
- package/package.json +1 -1
- package/scripts/check-doc-drift.mjs +66 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 错误翻译层(2026-10-20,用户体验提升 ④)。
|
|
3
|
+
*
|
|
4
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
5
|
+
* 为什么需要它
|
|
6
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
7
|
+
*
|
|
8
|
+
* 实测用户反馈:**出了问题不知道原因**。而原始错误长这样:
|
|
9
|
+
*
|
|
10
|
+
* connect timeout after 15000ms
|
|
11
|
+
* ECONNREFUSED 100.86.48.54:19387
|
|
12
|
+
* HTTP 401 {"error":"unauthorized"}
|
|
13
|
+
* task t-mvc2qkfcgo32w terminal: timeout after 40000ms
|
|
14
|
+
* EPERM: operation not permitted, rename 'C:\Users\...\templates.json.tmp-...'
|
|
15
|
+
*
|
|
16
|
+
* 用户看到这些只能猜。而本项目**已经**在 `lib/setup.js` 里有一份
|
|
17
|
+
* `friendlyConnectError` —— 但它只覆盖 5 类网络错误,且没用到协议错误码。
|
|
18
|
+
*
|
|
19
|
+
* 本模块把翻译**统一到一处**:
|
|
20
|
+
* · 网络层(Node errno)
|
|
21
|
+
* · 协议层(ERROR_CODE 的 17 个码)
|
|
22
|
+
* · 面板层(HTTP 状态码)
|
|
23
|
+
* · 文件系统(EPERM/ENOENT 等)
|
|
24
|
+
*
|
|
25
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
26
|
+
* 设计原则
|
|
27
|
+
* ═══════════════════════════════════════════════════════════════════════════
|
|
28
|
+
*
|
|
29
|
+
* · **每条都带"下一步做什么"** —— 只说"连接失败"等于没说
|
|
30
|
+
* · **保留原始信息**(`raw` 字段)—— 翻译不能丢排查线索
|
|
31
|
+
* · **绝不抛**:任何输入都返回一个结构,最差也回退到原始 message
|
|
32
|
+
* · **不猜**:拿不准的不编造原因,如实说"未知,请附上原始错误"
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { ERROR_CODE } from './protocol.js'
|
|
36
|
+
|
|
37
|
+
/** 统一的翻译结果。 */
|
|
38
|
+
function result({ title, detail = '', hint = '', raw = '', level = 'error' }) {
|
|
39
|
+
return { title, detail, hint, raw, level }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 网络层错误(Node errno)→ 人话。
|
|
44
|
+
*
|
|
45
|
+
* @param {string} code errno,如 ECONNREFUSED
|
|
46
|
+
* @param {object} [o]
|
|
47
|
+
* @param {string} [o.where] 目标地址(host:port)
|
|
48
|
+
* @param {boolean} [o.isHub] 出错方是不是主机侧(影响措辞)
|
|
49
|
+
*/
|
|
50
|
+
function translateErrno(code, { where = '', isHub = false } = {}) {
|
|
51
|
+
const at = where !== '' ? where : (isHub ? '副机' : '主机')
|
|
52
|
+
const other = isHub ? '副机' : '主机'
|
|
53
|
+
|
|
54
|
+
switch (code) {
|
|
55
|
+
case 'ECONNREFUSED':
|
|
56
|
+
return result({
|
|
57
|
+
title: `${at} 上没有服务在监听`,
|
|
58
|
+
detail: '连接被主动拒绝 —— 对端机器在,但那个端口没程序在听。',
|
|
59
|
+
hint: isHub
|
|
60
|
+
? `检查:① 副机的 DSH 是否在跑;② 副机配置的 hubUrl 端口是否与主机的 listenPort 一致(默认 19387);③ 副机是否已设 role: worker。`
|
|
61
|
+
: `检查:① 主机的 DSH 是否在跑;② 主机插件是否成功监听(看面板「环境体检」);③ IP/端口是否写对。`,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
case 'ETIMEDOUT':
|
|
65
|
+
case 'ETIMEDOUT_':
|
|
66
|
+
return result({
|
|
67
|
+
title: `${at} 连不上(超时)`,
|
|
68
|
+
detail: '数据包发出去了但没回应 —— 通常是网络不通或防火墙拦截。',
|
|
69
|
+
hint: '检查:① 两台机器能否互 ping(Tailscale 用 tailscale status 看是否 online);'
|
|
70
|
+
+ '② 主机防火墙是否放行该端口;③ 是否在同一局域网但路由器开了 AP 隔离。',
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
case 'EHOSTUNREACH':
|
|
74
|
+
case 'ENETUNREACH':
|
|
75
|
+
return result({
|
|
76
|
+
title: `到 ${at} 的网络不可达`,
|
|
77
|
+
detail: '本机没有到那个地址的路由 —— 地址可能不在任何已连接的网段。',
|
|
78
|
+
hint: `检查:① Tailscale 是否已连接(tailscale status);② 是否两台机器在同一局域网;`
|
|
79
|
+
+ `③ IP 是否写错(Tailscale IP 形如 100.x.x.x)。`,
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
case 'ENOTFOUND':
|
|
83
|
+
case 'EAI_AGAIN':
|
|
84
|
+
return result({
|
|
85
|
+
title: `解析不了 ${at} 的主机名`,
|
|
86
|
+
detail: 'DNS 查不到这个名字。',
|
|
87
|
+
hint: 'hubUrl 里建议直接写 **IP**(如 100.86.48.54 或 192.168.1.10),不要写主机名。',
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
case 'EACCES':
|
|
91
|
+
case 'EPERM':
|
|
92
|
+
return result({
|
|
93
|
+
title: '系统拒绝了这次操作(权限不足)',
|
|
94
|
+
detail: '不是网络问题 —— 是操作系统层面的权限拦截。',
|
|
95
|
+
hint: '如果是文件操作:检查目录权限、文件是否被别的程序占用;'
|
|
96
|
+
+ '如果是端口:换一个端口(<1024 需要管理员)。',
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
case 'ECONNRESET':
|
|
100
|
+
return result({
|
|
101
|
+
title: `${at} 主动断开了连接`,
|
|
102
|
+
detail: '连接建立后又被对方关闭 —— 可能是对端重启、崩溃,或协议不匹配。',
|
|
103
|
+
hint: `检查:① ${other}的 DSH 是否刚重启过(重连通常会自动恢复);`
|
|
104
|
+
+ '② 两端插件版本是否一致(版本差太多会握手失败)。',
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
case 'EPIPE':
|
|
108
|
+
return result({
|
|
109
|
+
title: '连接已断开,写入失败',
|
|
110
|
+
detail: '对端已经关闭,还在往管道里写。',
|
|
111
|
+
hint: '通常会自动重连。若持续出现,检查对端进程是否在崩溃重启。',
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
case 'ENOENT':
|
|
115
|
+
return result({
|
|
116
|
+
title: '找不到这个文件或目录',
|
|
117
|
+
detail: '路径不存在 —— 可能是拼写错误,或那个目录被删了。',
|
|
118
|
+
hint: '检查配置里的路径(尤其 roots / uiPullRoot)是否真实存在。',
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
case 'EADDRINUSE':
|
|
122
|
+
return result({
|
|
123
|
+
title: '端口已被占用',
|
|
124
|
+
detail: '这个端口已经有别的程序在监听了。',
|
|
125
|
+
hint: '换一个端口(配置里改 listenPort),或先关掉占用它的程序。'
|
|
126
|
+
+ '查占用:netstat -ano | findstr :19387',
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
case 'EADDRNOTAVAIL':
|
|
130
|
+
return result({
|
|
131
|
+
title: '这个 IP 不属于本机',
|
|
132
|
+
detail: '想绑的地址在本机网卡上不存在(IP 变了,或写错了)。',
|
|
133
|
+
hint: '检查配置里的 listenHost。留空则自动选择(推荐)—— '
|
|
134
|
+
+ '会自动挑 Tailscale IP 或局域网 IP。',
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
default:
|
|
138
|
+
return null
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 协议错误码 → 人话。
|
|
144
|
+
*
|
|
145
|
+
* @param {string} code ERROR_CODE 的值
|
|
146
|
+
* @param {object} [o]
|
|
147
|
+
* @param {boolean} [o.isHub] 保留参数(协议错误码的文案对两端通用,暂未分支)
|
|
148
|
+
*/
|
|
149
|
+
function translateProtocolCode(code, { isHub: _isHub = false } = {}) {
|
|
150
|
+
switch (code) {
|
|
151
|
+
case ERROR_CODE.PROTOCOL_MISMATCH:
|
|
152
|
+
return result({
|
|
153
|
+
title: '两端插件版本不兼容',
|
|
154
|
+
detail: '协议版本号对不上 —— 一端太旧或太新。',
|
|
155
|
+
hint: '把**两台机器**的 dsh-remote-agent 升级到同一版本(npm i -g dsh-remote-agent@latest)。',
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
case ERROR_CODE.UNAUTHORIZED:
|
|
159
|
+
return result({
|
|
160
|
+
title: '鉴权失败(token 不对)',
|
|
161
|
+
detail: '主机拒绝了副机的凭证。',
|
|
162
|
+
hint: '重新生成配对码:主机面板「副机接入」→ 复制新的 dshra1:// 整行 → '
|
|
163
|
+
+ '在副机重新粘贴 → 重启副机 DSH。',
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
case ERROR_CODE.DUPLICATE_NODE:
|
|
167
|
+
return result({
|
|
168
|
+
title: '节点 ID 重复',
|
|
169
|
+
detail: '已经有一台同 ID 的副机在线 —— 两台机器用了同一个 nodeId。',
|
|
170
|
+
hint: '在副机面板「本机标识」里改一个不同的节点 ID(或留空自动用主机名),然后重启。',
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
case ERROR_CODE.HUB_IDENTITY_MISMATCH:
|
|
174
|
+
return result({
|
|
175
|
+
title: '主机身份指纹变了',
|
|
176
|
+
detail: '副机之前钉住过这台主机,但现在的指纹对不上 —— 可能是换了主机,或主机身份文件被重建。',
|
|
177
|
+
hint: '如果确实是同一台主机(身份文件被删过):在副机删掉 '
|
|
178
|
+
+ '~/.dsh-remote-agent/hub-pin.json 后重启。如果是换了主机:重新生成配对码。',
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
case ERROR_CODE.HUB_IDENTITY_PENDING:
|
|
182
|
+
return result({
|
|
183
|
+
title: '主机身份待确认',
|
|
184
|
+
detail: '首次连接,副机在等主机出示身份证明。',
|
|
185
|
+
hint: '正常流程会自动完成。若卡住:检查主机是否是较新版本(旧版没有身份签名)。',
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
case ERROR_CODE.E2E_RESET:
|
|
189
|
+
return result({
|
|
190
|
+
title: '加密会话已重置',
|
|
191
|
+
detail: '主机重启过,之前的密钥失效了。',
|
|
192
|
+
hint: '自动重新握手,通常无需操作。若持续出现,检查两端版本是否一致。',
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
case ERROR_CODE.E2E_REQUIRED:
|
|
196
|
+
return result({
|
|
197
|
+
title: '要求加密但握手没完成',
|
|
198
|
+
detail: '业务帧必须以密文发送,但当前连接还没有加密会话。',
|
|
199
|
+
hint: '通常是中继模式下的临时状态。若持续:重启两端 DSH。',
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
case ERROR_CODE.WORKSPACE_DENIED:
|
|
203
|
+
return result({
|
|
204
|
+
title: '工作区被拒绝(路径越界)',
|
|
205
|
+
detail: '请求的路径不在该副机允许的 roots 范围内。',
|
|
206
|
+
hint: '在副机配置里把该目录加进 roots(必须是绝对路径、必须真实存在),然后重启副机。',
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
case ERROR_CODE.ARTIFACT_DENIED:
|
|
210
|
+
return result({
|
|
211
|
+
title: '产物被拒绝',
|
|
212
|
+
detail: '该文件不在可拉取的产物清单里(可能被判为 intermediate 或 other)。',
|
|
213
|
+
hint: '只有 role:final 的文件能拉。用 remote_manifest 看清单;'
|
|
214
|
+
+ '如需放宽,用 artifactRules 配置调整分类规则。',
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
case ERROR_CODE.ARTIFACT_MISSING:
|
|
218
|
+
return result({
|
|
219
|
+
title: '产物文件不见了',
|
|
220
|
+
detail: '清单里有,但副机上那个文件已经被删或改名。',
|
|
221
|
+
hint: '重新跑一次任务,或让副机确认该文件还在原路径。',
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
case ERROR_CODE.ARTIFACT_TOO_LARGE:
|
|
225
|
+
return result({
|
|
226
|
+
title: '产物超过大小上限',
|
|
227
|
+
detail: '单个文件太大,超出协议允许的单帧/单文件上限。',
|
|
228
|
+
hint: '在副机上压缩后再拉,或直接用面板的「工作区共享(SMB)」映射目录访问。',
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
case ERROR_CODE.HASH_MISMATCH:
|
|
232
|
+
return result({
|
|
233
|
+
title: '文件校验失败(内容不一致)',
|
|
234
|
+
detail: '传输过程中内容变了 —— 通常是文件在传的时候被修改,或断点续传的偏移算错了。',
|
|
235
|
+
hint: '重试一次(会自动从头传)。若持续:确认副机上那个文件没有被其它程序正在写。',
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
case ERROR_CODE.FS_TIMEOUT:
|
|
239
|
+
return result({
|
|
240
|
+
title: '文件操作超时',
|
|
241
|
+
detail: '副机在时限内没返回结果 —— 可能它正忙,或那个目录特别大。',
|
|
242
|
+
hint: '稍后重试。若是 grep 大目录,缩小 path 范围或用更精确的 pattern。',
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
case ERROR_CODE.TASK_UNKNOWN:
|
|
246
|
+
return result({
|
|
247
|
+
title: '找不到这个任务',
|
|
248
|
+
detail: '任务 ID 不存在 —— 可能已被清理,或副机重启后丢了记录。',
|
|
249
|
+
hint: '用 remote_nodes 看当前任务列表。若任务确实该在,检查副机是否重启过。',
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
case ERROR_CODE.TASK_DUPLICATE:
|
|
253
|
+
return result({
|
|
254
|
+
title: '任务 ID 重复',
|
|
255
|
+
detail: '这个 taskId 已经存在了。',
|
|
256
|
+
hint: '通常是重试逻辑的问题 —— 换一个新的 taskId,或先查 remote_status。',
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
case ERROR_CODE.INTERNAL:
|
|
260
|
+
return result({
|
|
261
|
+
title: '内部错误',
|
|
262
|
+
detail: '插件内部出了意外。',
|
|
263
|
+
hint: '这通常是 bug。请附上原始错误信息提 issue:'
|
|
264
|
+
+ 'https://github.com/zbzbhs/dsh-remote-agent/issues',
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
default:
|
|
268
|
+
return null
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** HTTP 状态码 → 人话(面板/路由层)。 */
|
|
273
|
+
function translateHttp(status) {
|
|
274
|
+
const n = Number(status)
|
|
275
|
+
if (!Number.isFinite(n)) return null
|
|
276
|
+
switch (n) {
|
|
277
|
+
case 401:
|
|
278
|
+
return result({
|
|
279
|
+
title: '面板鉴权失败',
|
|
280
|
+
detail: 'DSH 的连接围栏拒绝了这次请求(Host/Origin 校验或 cookie 失效)。',
|
|
281
|
+
hint: '刷新页面。若持续:确认你是从 DSH 桌面窗口打开面板,而不是别的浏览器标签。',
|
|
282
|
+
})
|
|
283
|
+
case 403:
|
|
284
|
+
return result({
|
|
285
|
+
title: '没有权限',
|
|
286
|
+
detail: '服务端明确拒绝了这次操作。',
|
|
287
|
+
hint: '检查当前 DSH 会话的权限设置。',
|
|
288
|
+
})
|
|
289
|
+
case 404:
|
|
290
|
+
return result({
|
|
291
|
+
title: '接口不存在',
|
|
292
|
+
detail: '请求的路径没注册 —— 通常是**插件没被加载**,或版本太旧没有这个接口。',
|
|
293
|
+
hint: '检查:① 插件是否已安装并启用;② 版本是否过旧(面板底部有版本号)。',
|
|
294
|
+
})
|
|
295
|
+
case 405:
|
|
296
|
+
return result({
|
|
297
|
+
title: '请求方法不对',
|
|
298
|
+
detail: '这个接口不接受该 HTTP 方法。',
|
|
299
|
+
hint: '通常是面板与插件版本不匹配 —— 升级插件后刷新页面。',
|
|
300
|
+
})
|
|
301
|
+
case 409:
|
|
302
|
+
return result({
|
|
303
|
+
title: '冲突',
|
|
304
|
+
detail: '目标状态已被别人改过(例如同时两个人改配置)。',
|
|
305
|
+
hint: '刷新页面看最新状态,再重试。',
|
|
306
|
+
})
|
|
307
|
+
case 500:
|
|
308
|
+
return result({
|
|
309
|
+
title: '服务端出错',
|
|
310
|
+
detail: '插件内部抛了异常。',
|
|
311
|
+
hint: '看 DSH 日志里的 [remote-agent] 行。这通常是 bug,欢迎提 issue。',
|
|
312
|
+
})
|
|
313
|
+
case 502:
|
|
314
|
+
case 503:
|
|
315
|
+
case 504:
|
|
316
|
+
return result({
|
|
317
|
+
title: '副机暂时不可用',
|
|
318
|
+
detail: '主机连不上副机,或副机正在重启。',
|
|
319
|
+
hint: '稍后重试。检查副机是否在线(面板「节点」区)。',
|
|
320
|
+
})
|
|
321
|
+
default:
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 主入口:把任意错误翻译成人话。
|
|
328
|
+
*
|
|
329
|
+
* 识别顺序:errno 码 → 协议错误码 → HTTP 状态码 → 已知文案特征 → 兜底。
|
|
330
|
+
*
|
|
331
|
+
* @param {any} err 错误对象 / 字符串 / {code, message}
|
|
332
|
+
* @param {object} [o]
|
|
333
|
+
* @param {string} [o.where] 目标地址(host:port),用于措辞
|
|
334
|
+
* @param {boolean} [o.isHub] 出错方是不是主机侧
|
|
335
|
+
* @param {string} [o.context] 上下文("连接副机" / "拉取产物" 等),用于兜底文案
|
|
336
|
+
* @returns {{title:string, detail:string, hint:string, raw:string, level:string}}
|
|
337
|
+
*/
|
|
338
|
+
export function explainError(err, { where = '', isHub = false, context = '' } = {}) {
|
|
339
|
+
// 提取原始信息(尽量不丢线索)
|
|
340
|
+
//
|
|
341
|
+
// ⚠ 这里必须**逐层 try**(2026-10-20 测试抓到的真 bug):
|
|
342
|
+
// `String(err)` 会调用 err.toString(),而它可能是被用户/第三方对象覆盖的
|
|
343
|
+
// 会抛错的实现。原写法 `String(err.message ?? err)` 在 message 为 undefined
|
|
344
|
+
// 时退化成 `String(err)` → 整个翻译层崩掉。
|
|
345
|
+
// 翻译层的职责是"绝不抛",所以每个可能抛的取值都单独兜住。
|
|
346
|
+
let raw = ''
|
|
347
|
+
let code = ''
|
|
348
|
+
let message = ''
|
|
349
|
+
const safeString = (v) => {
|
|
350
|
+
try {
|
|
351
|
+
if (typeof v === 'string') return v
|
|
352
|
+
if (v === null || v === undefined) return ''
|
|
353
|
+
return String(v)
|
|
354
|
+
} catch {
|
|
355
|
+
return '(无法转成字符串)'
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
if (typeof err === 'string') {
|
|
360
|
+
raw = err
|
|
361
|
+
message = err
|
|
362
|
+
} else if (err && typeof err === 'object') {
|
|
363
|
+
// message 优先;取不到再退回整个对象(但用 safeString 包住)
|
|
364
|
+
let m = ''
|
|
365
|
+
try {
|
|
366
|
+
m = typeof err.message === 'string' ? err.message : ''
|
|
367
|
+
} catch {
|
|
368
|
+
/* message getter 抛错 */
|
|
369
|
+
}
|
|
370
|
+
raw = m !== '' ? m : safeString(err)
|
|
371
|
+
message = m
|
|
372
|
+
try {
|
|
373
|
+
code = typeof err.code === 'string' ? err.code : ''
|
|
374
|
+
} catch {
|
|
375
|
+
/* code getter 抛错 */
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} catch {
|
|
379
|
+
raw = '(无法读取错误对象)'
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// 1. errno(Node 网络/文件系统错误)
|
|
383
|
+
if (code !== '') {
|
|
384
|
+
const t = translateErrno(code, { where, isHub })
|
|
385
|
+
if (t) return { ...t, raw }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 2. 协议错误码
|
|
389
|
+
if (code !== '') {
|
|
390
|
+
const t = translateProtocolCode(code, { isHub })
|
|
391
|
+
if (t) return { ...t, raw }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 3. 从 message 里捞协议错误码(很多地方只把 code 拼进文案)
|
|
395
|
+
for (const c of Object.values(ERROR_CODE)) {
|
|
396
|
+
if (typeof c === 'string' && c !== '' && message.includes(c)) {
|
|
397
|
+
const t = translateProtocolCode(c, { isHub })
|
|
398
|
+
if (t) return { ...t, raw }
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 4. HTTP 状态码(message 形如 "HTTP 401" 或 "401 Unauthorized")
|
|
403
|
+
const httpMatch = /\b(4\d\d|5\d\d)\b/.exec(message)
|
|
404
|
+
if (httpMatch) {
|
|
405
|
+
const t = translateHttp(httpMatch[1])
|
|
406
|
+
if (t) return { ...t, raw }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// 5. 常见文案特征(Node/我们自己的实现里的固定串)
|
|
410
|
+
if (/timeout after \d+ms/i.test(message) || /timed out after \d+ms/i.test(message)) {
|
|
411
|
+
const ms = /after (\d+)ms/i.exec(message)
|
|
412
|
+
const sec = ms ? Math.round(Number(ms[1]) / 1000) : null
|
|
413
|
+
return {
|
|
414
|
+
...result({
|
|
415
|
+
title: '操作超时',
|
|
416
|
+
detail: sec !== null ? `等了 ${sec} 秒没收到回应。` : '等待回应超时。',
|
|
417
|
+
hint: isHub
|
|
418
|
+
? '检查副机是否在线、是否正忙。若是长任务,用更长的 timeoutMs 重派。'
|
|
419
|
+
: '检查主机是否在线、网络是否稳定。',
|
|
420
|
+
}),
|
|
421
|
+
raw,
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (/handshake timeout/i.test(message)) {
|
|
426
|
+
return {
|
|
427
|
+
...result({
|
|
428
|
+
title: '握手超时',
|
|
429
|
+
detail: 'TCP 连上了,但 WebSocket/协议握手没在时限内完成。',
|
|
430
|
+
hint: '通常是**两端版本差异太大**,或对端那个端口上跑的不是本插件。'
|
|
431
|
+
+ '确认:① 端口号是否写对(默认 19387);② 两端插件版本是否一致。',
|
|
432
|
+
}),
|
|
433
|
+
raw,
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
if (/not lossless JSON|invalid output/i.test(message)) {
|
|
438
|
+
return {
|
|
439
|
+
...result({
|
|
440
|
+
title: '数据格式异常',
|
|
441
|
+
detail: '插件返回的数据里有无法序列化的值(这通常是 bug)。',
|
|
442
|
+
hint: '请附上原始错误提 issue。',
|
|
443
|
+
}),
|
|
444
|
+
raw,
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// 6. 兜底:不编造原因,如实说
|
|
449
|
+
return {
|
|
450
|
+
...result({
|
|
451
|
+
title: context !== '' ? `${context}失败` : '出错了',
|
|
452
|
+
detail: message !== '' ? message : '没有更多信息。',
|
|
453
|
+
hint: '如果看不懂这条错误,请把**原始错误**整段附上提 issue:'
|
|
454
|
+
+ 'https://github.com/zbzbhs/dsh-remote-agent/issues',
|
|
455
|
+
level: 'warn',
|
|
456
|
+
}),
|
|
457
|
+
raw,
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 把翻译结果渲染成一行可读文本(日志/CLI 用;面板自己排版)。
|
|
463
|
+
*
|
|
464
|
+
* @param {ReturnType<typeof explainError>} t
|
|
465
|
+
* @returns {string}
|
|
466
|
+
*/
|
|
467
|
+
export function formatExplained(t) {
|
|
468
|
+
const lines = [t.title]
|
|
469
|
+
if (t.detail) lines.push(` ${t.detail}`)
|
|
470
|
+
if (t.hint) lines.push(` → ${t.hint}`)
|
|
471
|
+
return lines.join('\n')
|
|
472
|
+
}
|
package/lib/hub/index.js
CHANGED
|
@@ -597,32 +597,65 @@ export function applyHub(ctx, config = {}) {
|
|
|
597
597
|
|
|
598
598
|
let disposed = false
|
|
599
599
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
600
|
+
/**
|
|
601
|
+
* 起监听,并对 **EADDRINUSE 做有限重试**。
|
|
602
|
+
*
|
|
603
|
+
* 为什么需要:0.9.5 起 apply 可重入(同一 ctx 重新 apply 时先摘上一代)。
|
|
604
|
+
* 摘上一代会 `server.close()`,而**紧接着用同一个端口重新 listen** 在
|
|
605
|
+
* Windows 上经常还会拿到 EADDRINUSE(close 是异步完成的,端口没这么快
|
|
606
|
+
* 释放)。没有重试的话,"热重载"就会把端点变成永久不可用 —— 看起来和
|
|
607
|
+
* "插件坏了"一样,但其实是可自愈的。
|
|
608
|
+
*
|
|
609
|
+
* 只重试 4 次(200/400/600ms):真的被别的进程占了就该报错,不能无限拖。
|
|
610
|
+
*/
|
|
611
|
+
const startWithRetry = async (attempt = 1) => {
|
|
612
|
+
try {
|
|
613
|
+
return await startListenServer({
|
|
614
|
+
host: picked.host,
|
|
615
|
+
port,
|
|
616
|
+
path: cfg.nodePath,
|
|
617
|
+
healthPath: cfg.healthPath,
|
|
618
|
+
// 函数形态:按调用者身份分级(匿名只见 `{ ok: true }`)
|
|
619
|
+
healthBody: (req) => healthPayload(req, 'listen-server'),
|
|
620
|
+
onUpgrade: (conn, req) => {
|
|
621
|
+
// 与 webServer 那条路完全相同的鉴权与交接逻辑(一处不能少:
|
|
622
|
+
// token 校验是这条通道唯一的口令,跳过就是一个无鉴权入口)。
|
|
623
|
+
if (cfg.nodeToken !== '') {
|
|
624
|
+
const token = req.headers['x-dsh-token']
|
|
625
|
+
if (!safeEqual(token, cfg.nodeToken)) {
|
|
626
|
+
log('warn', 'node upgrade rejected: bad token (listen-server)', { remote: remoteAddress(req) })
|
|
627
|
+
try {
|
|
628
|
+
conn.close(1008, 'bad token')
|
|
629
|
+
} catch {
|
|
630
|
+
/* 已关 */
|
|
631
|
+
}
|
|
632
|
+
return
|
|
633
|
+
}
|
|
618
634
|
}
|
|
619
|
-
|
|
620
|
-
}
|
|
635
|
+
registry.attachDirect(conn, { remote: remoteAddress(req) })
|
|
636
|
+
},
|
|
637
|
+
log,
|
|
638
|
+
})
|
|
639
|
+
} catch (err) {
|
|
640
|
+
const code = String(err?.code ?? '')
|
|
641
|
+
const inUse = code === 'EADDRINUSE' || /EADDRINUSE/.test(String(err?.message ?? ''))
|
|
642
|
+
if (inUse && attempt < 4 && !disposed) {
|
|
643
|
+
log('warn', 'listen: port still held, retrying (previous generation closing)', {
|
|
644
|
+
host: picked.host,
|
|
645
|
+
port,
|
|
646
|
+
attempt,
|
|
647
|
+
})
|
|
648
|
+
await new Promise((r) => {
|
|
649
|
+
const t = setTimeout(r, 200 * attempt)
|
|
650
|
+
if (t.unref) t.unref()
|
|
651
|
+
})
|
|
652
|
+
return startWithRetry(attempt + 1)
|
|
621
653
|
}
|
|
622
|
-
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
|
|
654
|
+
throw err
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
startWithRetry()
|
|
626
659
|
.then((h) => {
|
|
627
660
|
if (disposed) {
|
|
628
661
|
// 竞态:disposer 先跑了(插件卸载/HMR),把刚起来的 server 关掉
|
|
@@ -851,8 +884,24 @@ export function applyHub(ctx, config = {}) {
|
|
|
851
884
|
return {
|
|
852
885
|
registry,
|
|
853
886
|
pulls,
|
|
854
|
-
|
|
855
|
-
|
|
887
|
+
/**
|
|
888
|
+
* 由调用方保管;DSH 卸载插件 / 我们自己在重入前清理时用。
|
|
889
|
+
*
|
|
890
|
+
* ⚠ **必须逆序**(最后注册的先摘)。`__reg` 的顺序是「运行时 effect 在最先、
|
|
891
|
+
* 路由/工具/监听在其后」,而运行时那个 disposer 会 `registry.dispose()` /
|
|
892
|
+
* `pulls.dispose()` —— 先拆它再去拆工具,工具的 disposer 就可能碰到已销毁的
|
|
893
|
+
* registry(0.9.5 修重入时发现这里原来是正序跑的,与 cordis 的"逆序释放"
|
|
894
|
+
* 语义和本文件的注释都不一致)。
|
|
895
|
+
*/
|
|
896
|
+
disposeAll: () => {
|
|
897
|
+
for (let i = __disposers.length - 1; i >= 0; i -= 1) {
|
|
898
|
+
try {
|
|
899
|
+
__disposers[i]()
|
|
900
|
+
} catch {
|
|
901
|
+
/* 已清 */
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
},
|
|
856
905
|
}
|
|
857
906
|
}
|
|
858
907
|
|
package/lib/hub/node-registry.js
CHANGED
|
@@ -35,6 +35,8 @@ import {
|
|
|
35
35
|
// 视图投影共享层:registry 给工具用的任务视图与 UI 路由共用同一份白名单。
|
|
36
36
|
import { taskView as taskProjection } from './views.js'
|
|
37
37
|
import { errText } from './hub-util.js'
|
|
38
|
+
// 统一错误翻译层(2026-10-20,体验提升 ①):连接失败时给面板"人话 + 下一步"。
|
|
39
|
+
import { explainError } from '../explain-error.js'
|
|
38
40
|
import { withIngest } from './registry-ingest.js'
|
|
39
41
|
import { withHandshake } from './registry-handshake.js'
|
|
40
42
|
|
|
@@ -78,6 +80,19 @@ class NodeRegistryCore {
|
|
|
78
80
|
this.audit = audit && typeof audit.record === 'function' ? audit : null
|
|
79
81
|
/** nodeId → rec */
|
|
80
82
|
this.nodes = new Map()
|
|
83
|
+
/**
|
|
84
|
+
* 连接尝试历史(2026-10-20,体验提升 ①)。
|
|
85
|
+
*
|
|
86
|
+
* 问题:副机连不上时,主机只显示「0 在线」,**没有任何原因**。
|
|
87
|
+
* 实测用户为此排查了两天(重启回引导页、19387 不监听、SMB 密码…)。
|
|
88
|
+
*
|
|
89
|
+
* 这里记**每一次连接尝试的结果**(成功/失败 + 原因 + 时间 + 远端地址),
|
|
90
|
+
* 面板据此显示"最后尝试 2 分钟前 —— 握手超时 → 检查…"。
|
|
91
|
+
*
|
|
92
|
+
* 上限 50 条(环形):这是给人看的诊断线索,不是审计日志
|
|
93
|
+
* (审计是 audit.jsonl,那是给合规/追溯用的)。
|
|
94
|
+
*/
|
|
95
|
+
this.connectionLog = []
|
|
81
96
|
/** 等 hello 的连接 */
|
|
82
97
|
this.pending = new Set()
|
|
83
98
|
/** taskId → NodeJS.Timeout(两阶段审批的自动拒绝定时器,提案 #2) */
|
|
@@ -972,6 +987,69 @@ class NodeRegistryCore {
|
|
|
972
987
|
}))
|
|
973
988
|
}
|
|
974
989
|
|
|
990
|
+
// ─────────────────────────── 连接尝试历史 ───────────────────────────
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* 记一次连接尝试(2026-10-20,体验提升 ①)。
|
|
994
|
+
*
|
|
995
|
+
* @param {object} o
|
|
996
|
+
* @param {'ok'|'fail'} o.result
|
|
997
|
+
* @param {string} [o.code] 失败码(协议错误码 / errno / 'hello-timeout' 等)
|
|
998
|
+
* @param {string} [o.message] 原始错误文案
|
|
999
|
+
* @param {string} [o.nodeId] 若已知
|
|
1000
|
+
* @param {string} [o.remote] 远端地址(IP:port 或 IP)
|
|
1001
|
+
* @param {string} [o.transport] 'direct' | 'relay'
|
|
1002
|
+
*/
|
|
1003
|
+
noteConnectionAttempt({ result, code = '', message = '', nodeId = '', remote = '', transport = '' } = {}) {
|
|
1004
|
+
try {
|
|
1005
|
+
const entry = {
|
|
1006
|
+
at: Date.now(),
|
|
1007
|
+
result: result === 'ok' ? 'ok' : 'fail',
|
|
1008
|
+
code: typeof code === 'string' ? code : '',
|
|
1009
|
+
message: typeof message === 'string' ? message.slice(0, 300) : '',
|
|
1010
|
+
nodeId: typeof nodeId === 'string' ? nodeId : '',
|
|
1011
|
+
remote: typeof remote === 'string' ? remote : '',
|
|
1012
|
+
transport: typeof transport === 'string' ? transport : '',
|
|
1013
|
+
}
|
|
1014
|
+
// 失败时附上人话解释(面板直接用,不用再翻译一遍)
|
|
1015
|
+
if (entry.result === 'fail') {
|
|
1016
|
+
try {
|
|
1017
|
+
const ex = explainError({ code: entry.code, message: entry.message }, { isHub: true, context: '副机接入' })
|
|
1018
|
+
entry.title = ex.title
|
|
1019
|
+
entry.detail = ex.detail
|
|
1020
|
+
entry.hint = ex.hint
|
|
1021
|
+
} catch {
|
|
1022
|
+
/* 翻译失败不影响记录 */
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
this.connectionLog.push(entry)
|
|
1026
|
+
// 环形上限 50:这是诊断线索,不是审计
|
|
1027
|
+
if (this.connectionLog.length > 50) this.connectionLog.splice(0, this.connectionLog.length - 50)
|
|
1028
|
+
} catch {
|
|
1029
|
+
/* 诊断记录绝不能影响连接流程本身 */
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
/**
|
|
1034
|
+
* 取连接尝试历史(新的在前)+ 每台已知节点的最后状态。
|
|
1035
|
+
*
|
|
1036
|
+
* @param {number} [limit]
|
|
1037
|
+
*/
|
|
1038
|
+
connectionReport(limit = 20) {
|
|
1039
|
+
const recent = this.connectionLog.slice(-Math.max(1, limit)).reverse()
|
|
1040
|
+
// 按 nodeId 归并:每台机器最后一次尝试是什么结果
|
|
1041
|
+
const byNode = new Map()
|
|
1042
|
+
for (const e of this.connectionLog) {
|
|
1043
|
+
if (e.nodeId !== '') byNode.set(e.nodeId, e)
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
recent,
|
|
1047
|
+
byNode: [...byNode.entries()].map(([k, v]) => ({ nodeId: k, ...v })),
|
|
1048
|
+
// 有没有过任何成功的连接 —— 面板据此区分"从没连上过"与"连上又断了"
|
|
1049
|
+
everConnected: this.connectionLog.some((e) => e.result === 'ok'),
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
975
1053
|
// ─────────────────────────── 生命周期 ───────────────────────────
|
|
976
1054
|
|
|
977
1055
|
markOffline(nodeId, reason) {
|