@shendeguize/remote-dsh-center 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 (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +197 -0
  3. package/README.md +174 -0
  4. package/package.json +48 -0
  5. package/scripts/install.mjs +208 -0
  6. package/src/api.js +725 -0
  7. package/src/cli.js +1445 -0
  8. package/src/config-sync.js +157 -0
  9. package/src/daemon.js +362 -0
  10. package/src/defaults.js +89 -0
  11. package/src/dsh-workspace.js +467 -0
  12. package/src/launcher.js +627 -0
  13. package/src/lib/bundle.js +82 -0
  14. package/src/lib/bus.js +109 -0
  15. package/src/lib/capture.js +53 -0
  16. package/src/lib/clock.js +18 -0
  17. package/src/lib/entry.js +27 -0
  18. package/src/lib/errors.js +88 -0
  19. package/src/lib/logfile.js +65 -0
  20. package/src/lib/machine.js +63 -0
  21. package/src/lib/origin-guard.js +64 -0
  22. package/src/lib/pool.js +88 -0
  23. package/src/lib/proto.js +457 -0
  24. package/src/lib/semver.js +103 -0
  25. package/src/lib/shq.js +112 -0
  26. package/src/lib/ssh.js +647 -0
  27. package/src/lib/validate.js +363 -0
  28. package/src/monitor.js +145 -0
  29. package/src/patchsync.js +310 -0
  30. package/src/ports.js +93 -0
  31. package/src/prober.js +185 -0
  32. package/src/server.js +449 -0
  33. package/src/settings-file.js +550 -0
  34. package/src/ssh-config.js +152 -0
  35. package/src/store.js +772 -0
  36. package/src/tunnel.js +589 -0
  37. package/src/updater.js +450 -0
  38. package/src/web/actions.js +409 -0
  39. package/src/web/api.js +262 -0
  40. package/src/web/app.js +347 -0
  41. package/src/web/components/config-sync-dialog.js +469 -0
  42. package/src/web/components/confirm-dialog.js +61 -0
  43. package/src/web/components/defaults-card.js +216 -0
  44. package/src/web/components/event-panel.js +98 -0
  45. package/src/web/components/host-drawer.js +1039 -0
  46. package/src/web/components/host-table.js +317 -0
  47. package/src/web/components/hub.js +143 -0
  48. package/src/web/components/iframe-pane.js +377 -0
  49. package/src/web/components/manager-card.js +65 -0
  50. package/src/web/components/setup-wizard.js +726 -0
  51. package/src/web/components/tabbar.js +577 -0
  52. package/src/web/components/toast-region.js +107 -0
  53. package/src/web/favicon.svg +7 -0
  54. package/src/web/form.js +220 -0
  55. package/src/web/host-presentation.js +73 -0
  56. package/src/web/host-rules.js +76 -0
  57. package/src/web/index.html +17 -0
  58. package/src/web/router.js +118 -0
  59. package/src/web/setup-schema.js +203 -0
  60. package/src/web/sse.js +118 -0
  61. package/src/web/store.js +405 -0
  62. package/src/web/style.css +813 -0
  63. package/src/web/utils.js +210 -0
@@ -0,0 +1,363 @@
1
+ /**
2
+ * 零依赖手写 schema 校验器(11 §4.3)+ 四份 schema。
3
+ * 组合子风格:每个 schema 是 (value, path, errs) => void,往 errs 推人类可读的错误路径。
4
+ */
5
+
6
+ import { DshError } from './errors.js';
7
+ import { PHASES } from './machine.js';
8
+ import { isWorkdirPath, SAFE_HOST_RE } from './shq.js';
9
+
10
+ const ENV_KEY_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
+ const SETTINGS_CHECKSUM_RE = /^cksum-v1:(0|[1-9][0-9]{0,9}):(0|[1-9][0-9]{0,6})$/u;
12
+ const SETTINGS_CHECKSUM_MAX_BYTES = 512 * 1024;
13
+
14
+ function typeName(v) {
15
+ if (v === null) return 'null';
16
+ if (Array.isArray(v)) return 'array';
17
+ return typeof v;
18
+ }
19
+
20
+ function fail(errs, path, msg) {
21
+ errs.push(`${path || '<root>'}: ${msg}`);
22
+ }
23
+
24
+ export const V = {
25
+ /**
26
+ * @param {Record<string, Function>} shape
27
+ * @param {{extra?:boolean, optional?:string[]}} [opts] extra=false 时未知键报错(config 顶层收紧)
28
+ */
29
+ obj(shape, { extra = false, optional = [] } = {}) {
30
+ const optionalSet = new Set(optional);
31
+ return (value, path, errs) => {
32
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
33
+ return fail(errs, path, `expected object, got ${typeName(value)}`);
34
+ }
35
+ for (const [key, schema] of Object.entries(shape)) {
36
+ const child = path ? `${path}.${key}` : key;
37
+ if (!Object.hasOwn(value, key)) {
38
+ if (!optionalSet.has(key)) fail(errs, child, 'required');
39
+ continue;
40
+ }
41
+ schema(value[key], child, errs);
42
+ }
43
+ if (!extra) {
44
+ for (const key of Object.keys(value)) {
45
+ if (!Object.hasOwn(shape, key)) fail(errs, path ? `${path}.${key}` : key, 'unknown key');
46
+ }
47
+ }
48
+ };
49
+ },
50
+
51
+ str({ pattern, min, max } = {}) {
52
+ return (value, path, errs) => {
53
+ if (typeof value !== 'string') return fail(errs, path, `expected string, got ${typeName(value)}`);
54
+ if (min !== undefined && value.length < min) fail(errs, path, `expected length >= ${min}`);
55
+ if (max !== undefined && value.length > max) fail(errs, path, `expected length <= ${max}`);
56
+ if (pattern && !pattern.test(value)) fail(errs, path, `expected match ${pattern}`);
57
+ };
58
+ },
59
+
60
+ int({ min, max } = {}) {
61
+ return (value, path, errs) => {
62
+ if (typeof value !== 'number' || !Number.isInteger(value)) {
63
+ return fail(errs, path, `expected int, got ${typeName(value)}`);
64
+ }
65
+ if (min !== undefined && value < min) fail(errs, path, `expected int ${min}..${max ?? '∞'}`);
66
+ if (max !== undefined && value > max) fail(errs, path, `expected int ${min ?? '-∞'}..${max}`);
67
+ };
68
+ },
69
+
70
+ bool() {
71
+ return (value, path, errs) => {
72
+ if (typeof value !== 'boolean') fail(errs, path, `expected boolean, got ${typeName(value)}`);
73
+ };
74
+ },
75
+
76
+ enum_(vals) {
77
+ return (value, path, errs) => {
78
+ if (!vals.includes(value)) fail(errs, path, `expected one of ${vals.join('|')}, got ${JSON.stringify(value)}`);
79
+ };
80
+ },
81
+
82
+ arr(item, { max, min } = {}) {
83
+ return (value, path, errs) => {
84
+ if (!Array.isArray(value)) return fail(errs, path, `expected array, got ${typeName(value)}`);
85
+ if (max !== undefined && value.length > max) fail(errs, path, `expected length <= ${max}`);
86
+ if (min !== undefined && value.length < min) fail(errs, path, `expected length >= ${min}`);
87
+ value.forEach((entry, i) => item(entry, `${path}[${i}]`, errs));
88
+ };
89
+ },
90
+
91
+ /** Record<string, V>,键需匹配 keyPattern(hosts、inject.env 用)。 */
92
+ rec(keyPattern, valueSchema) {
93
+ return (value, path, errs) => {
94
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
95
+ return fail(errs, path, `expected object, got ${typeName(value)}`);
96
+ }
97
+ for (const [key, entry] of Object.entries(value)) {
98
+ const child = path ? `${path}.${key}` : key;
99
+ if (keyPattern && !keyPattern.test(key)) fail(errs, child, `invalid key, expected match ${keyPattern}`);
100
+ valueSchema(entry, child, errs);
101
+ }
102
+ };
103
+ },
104
+
105
+ tuple(items) {
106
+ return (value, path, errs) => {
107
+ if (!Array.isArray(value)) return fail(errs, path, `expected array, got ${typeName(value)}`);
108
+ if (value.length !== items.length) fail(errs, path, `expected tuple of ${items.length}`);
109
+ items.forEach((schema, i) => {
110
+ if (i < value.length) schema(value[i], `${path}[${i}]`, errs);
111
+ });
112
+ };
113
+ },
114
+
115
+ nullable(inner) {
116
+ return (value, path, errs) => {
117
+ if (value === null) return;
118
+ inner(value, path, errs);
119
+ };
120
+ },
121
+
122
+ any() {
123
+ return () => {};
124
+ },
125
+
126
+ /** @param {(value:any)=>boolean|string} fn 返回 true 通过;返回 string 作为错误信息 */
127
+ custom(fn, desc = 'custom constraint failed') {
128
+ return (value, path, errs) => {
129
+ const r = fn(value);
130
+ if (r === true) return;
131
+ fail(errs, path, typeof r === 'string' ? r : desc);
132
+ };
133
+ },
134
+
135
+ /** 多个 schema 依次施加(用于 tuple + 跨元素约束)。 */
136
+ all(...schemas) {
137
+ return (value, path, errs) => {
138
+ for (const s of schemas) s(value, path, errs);
139
+ };
140
+ },
141
+ };
142
+
143
+ /** @returns {{ok:boolean, errors:string[]}} */
144
+ export function validate(schema, value) {
145
+ const errors = [];
146
+ schema(value, '', errors);
147
+ return { ok: errors.length === 0, errors };
148
+ }
149
+
150
+ /** 校验失败即抛 VALIDATION(detail 为逐条错误路径)。 */
151
+ export function assertValid(schema, value, summary) {
152
+ const { ok, errors } = validate(schema, value);
153
+ if (!ok) {
154
+ throw new DshError('VALIDATION', summary, { detail: errors.join('\n') });
155
+ }
156
+ return value;
157
+ }
158
+
159
+ // ── 复用片段 ─────────────────────────────────────────────────────────────
160
+
161
+ const port = V.int({ min: 1, max: 65535 });
162
+
163
+ /**
164
+ * manager 与本机隧道能真正 bind 的范围。1024 以下要 root,写进去只会在拉起时
165
+ * 撞一个看不懂的失败。`dshc up --port` 也用这一份判据(issue #21)。
166
+ */
167
+ export const BINDABLE_PORT_RANGE = { min: 1024, max: 65535 };
168
+ export function isBindablePort(v) {
169
+ return Number.isInteger(v) && v >= BINDABLE_PORT_RANGE.min && v <= BINDABLE_PORT_RANGE.max;
170
+ }
171
+ const bindablePort = V.int(BINDABLE_PORT_RANGE);
172
+
173
+ const injectSchema = V.obj({
174
+ env: V.rec(ENV_KEY_RE, V.str()),
175
+ extraArgs: V.arr(V.str()),
176
+ patches: V.arr(V.str({ min: 1 })),
177
+ });
178
+
179
+ /** null = 维持现状(远端 $HOME);非 null 须过 shq 的形态判定(补丁 01 §4.1)。 */
180
+ const workdirSchema = V.nullable(V.custom(
181
+ (v) => isWorkdirPath(v) || '须为绝对路径(/ 开头)或 ~、~/… 形态',
182
+ ));
183
+
184
+ /**
185
+ * workdir/local 可缺省:configVersion 不升,旧 config 缺字段由 store.migrateConfig
186
+ * 按默认值补齐,故校验层不能因为「没有这个键」就拒绝启动。
187
+ */
188
+ const hostConfigSchema = V.obj(
189
+ {
190
+ local: V.bool(),
191
+ enabled: V.bool(),
192
+ autoStart: V.bool(),
193
+ localPort: V.nullable(port),
194
+ remoteWebPort: V.nullable(port),
195
+ workdir: workdirSchema,
196
+ inject: injectSchema,
197
+ },
198
+ { optional: ['local', 'workdir'] },
199
+ );
200
+
201
+ const hostsSchema = V.all(
202
+ V.rec(null, hostConfigSchema),
203
+ (value, path, errs) => {
204
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return;
205
+ let localCount = 0;
206
+ for (const [name, host] of Object.entries(value)) {
207
+ if (host?.local !== true) continue;
208
+ localCount += 1;
209
+ if (!SAFE_HOST_RE.test(name) || name.startsWith('-')) {
210
+ fail(errs, `${path}.${name}`, `本机主机名须匹配 ${SAFE_HOST_RE} 且不以 - 开头`);
211
+ }
212
+ if (host.localPort !== null) {
213
+ fail(errs, `${path}.${name}.localPort`, '本机主机的 localPort 必须为 null');
214
+ }
215
+ }
216
+ if (localCount > 1) fail(errs, path, '最多只能有一个 local:true 主机');
217
+ },
218
+ );
219
+
220
+ const localPortRangeSchema = V.all(
221
+ V.tuple([bindablePort, bindablePort]),
222
+ V.custom(
223
+ (v) => (Array.isArray(v) && v.length === 2 && Number.isInteger(v[0]) && Number.isInteger(v[1])
224
+ ? v[0] <= v[1] || 'range start must be <= end'
225
+ : true),
226
+ ),
227
+ );
228
+
229
+ const defaultsSchema = V.obj({
230
+ remoteWebPort: port,
231
+ localPortRange: localPortRangeSchema,
232
+ });
233
+
234
+ // ── 四份 schema(11 §4.3) ──────────────────────────────────────────────
235
+
236
+ export const configSchema = V.obj({
237
+ configVersion: V.int({ min: 1 }),
238
+ setupCompleted: V.bool(),
239
+ manager: V.obj({ port }),
240
+ defaults: defaultsSchema,
241
+ hosts: hostsSchema,
242
+ });
243
+
244
+ /** state 取宽松模式(extra=true):12 §4.4 的增补字段允许出现。 */
245
+ export const stateSchema = V.obj(
246
+ {
247
+ hosts: V.rec(null, V.obj(
248
+ {
249
+ phase: V.enum_(PHASES),
250
+ probe: V.nullable(V.obj({}, { extra: true })),
251
+ web: V.nullable(V.obj({}, { extra: true })),
252
+ tunnel: V.nullable(V.obj({}, { extra: true })),
253
+ patchSync: V.nullable(V.obj({}, { extra: true })),
254
+ manualInstances: V.arr(V.obj({}, { extra: true })),
255
+ },
256
+ { extra: true, optional: ['probe', 'web', 'tunnel', 'patchSync', 'manualInstances'] },
257
+ )),
258
+ },
259
+ { extra: true },
260
+ );
261
+
262
+ /** 单主机 state 条目(逐条校验丢弃非法项用,11 §4.5)。 */
263
+ export const hostStateSchema = V.obj(
264
+ {
265
+ phase: V.enum_(PHASES),
266
+ },
267
+ { extra: true },
268
+ );
269
+
270
+ /** POST /api/setup 请求体 = 整份 config(setupCompleted 由后端强制置 true,故此处可选)。 */
271
+ export const setupBodySchema = V.obj(
272
+ {
273
+ configVersion: V.int({ min: 1 }),
274
+ setupCompleted: V.bool(),
275
+ manager: V.obj({ port }),
276
+ defaults: defaultsSchema,
277
+ hosts: hostsSchema,
278
+ },
279
+ { optional: ['configVersion', 'setupCompleted'] },
280
+ );
281
+
282
+ /**
283
+ * PUT /api/hosts/:name/config 局部体:local 可用于回显身份,但 route 层只许它等于现值;
284
+ * localPort 仍由 manager 分配,明令拒收。
285
+ */
286
+ export const hostConfigPatchSchema = V.obj(
287
+ {
288
+ local: V.bool(),
289
+ enabled: V.bool(),
290
+ autoStart: V.bool(),
291
+ remoteWebPort: V.nullable(port),
292
+ workdir: workdirSchema,
293
+ inject: injectSchema,
294
+ },
295
+ { optional: ['local', 'enabled', 'autoStart', 'remoteWebPort', 'workdir', 'inject'] },
296
+ );
297
+
298
+ const safeHostNameSchema = V.all(
299
+ V.str({ min: 1, pattern: SAFE_HOST_RE }),
300
+ V.custom((v) => typeof v !== 'string' || !v.startsWith('-') || '不得以 - 开头'),
301
+ );
302
+
303
+ /** POST /api/hosts/local:名称缺省时由 Node 侧注入 os.hostname()。 */
304
+ export const localHostCreateSchema = V.obj(
305
+ {
306
+ name: safeHostNameSchema,
307
+ },
308
+ { optional: ['name'] },
309
+ );
310
+
311
+ /** POST /api/hosts/:name/dsh-workspace:路径只取后端 HostView,正文必须是空对象。 */
312
+ export const dshWorkspaceCreateSchema = V.obj({});
313
+
314
+ /**
315
+ * POST /api/hosts/sync-config:重复、源混入目标与存在性留给 config-sync 给人话。
316
+ * preview 负责签发 token;apply 必须交回,具体真伪由原子更新入口按最新 config 判断。
317
+ */
318
+ export const syncConfigBodySchema = V.all(
319
+ V.obj(
320
+ {
321
+ source: safeHostNameSchema,
322
+ targets: V.arr(safeHostNameSchema, { min: 1, max: 200 }),
323
+ dryRun: V.bool(),
324
+ previewToken: V.str({ min: 1, max: 200 }),
325
+ },
326
+ { optional: ['previewToken'] },
327
+ ),
328
+ (value, path, errs) => {
329
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return;
330
+ if (value.dryRun === false && !Object.hasOwn(value, 'previewToken')) {
331
+ fail(errs, path ? `${path}.previewToken` : 'previewToken', 'required when dryRun=false');
332
+ }
333
+ },
334
+ );
335
+
336
+ /** PUT /api/hosts/:name/dsh-settings:固定路径,不接受任何额外键。 */
337
+ export const dshSettingsPutSchema = V.obj({
338
+ content: V.str(),
339
+ baseChecksum: V.nullable(V.custom((value) => {
340
+ if (typeof value !== 'string') return '须为 cksum-v1 token 或 null';
341
+ const match = SETTINGS_CHECKSUM_RE.exec(value);
342
+ if (
343
+ !match
344
+ || Number(match[1]) > 0xffff_ffff
345
+ || Number(match[2]) > SETTINGS_CHECKSUM_MAX_BYTES
346
+ ) {
347
+ return '格式无效,应为 cksum-v1:<CRC>:<字节数> 或 null';
348
+ }
349
+ return true;
350
+ })),
351
+ });
352
+
353
+ /** PUT /api/config/defaults 局部体(13 §2.6)。 */
354
+ export const defaultsPatchSchema = V.obj(
355
+ {
356
+ remoteWebPort: port,
357
+ localPortRange: localPortRangeSchema,
358
+ manager: V.obj({ port }),
359
+ },
360
+ { optional: ['remoteWebPort', 'localPortRange', 'manager'] },
361
+ );
362
+
363
+ export { ENV_KEY_RE };
package/src/monitor.js ADDED
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 周期巡检(02 §3.4,边界见 11 §5.5)。
3
+ *
4
+ * 只做两件事:对 running 主机探活本机转发通道;探活失败时经 hostQueue 深复核远端,
5
+ * 死则 crashed、活则委托 tunnel 重建子进程。degraded/crashed 一律跳过——
6
+ * 那是隧道重连环自己的地盘,不重复劳动。
7
+ */
8
+
9
+ import { logEvent } from './lib/bus.js';
10
+ import { buildVerifyScript, kvOne, parseProtoOutput } from './lib/proto.js';
11
+ import {
12
+ execFailure, hostQueue, localExec, sshExec,
13
+ } from './lib/ssh.js';
14
+ import { mapPool } from './lib/pool.js';
15
+ import { SSH_FANOUT_LIMIT } from './defaults.js';
16
+ import * as store from './store.js';
17
+ import * as tunnel from './tunnel.js';
18
+
19
+ export const MONITOR_INTERVAL_MS = 30_000;
20
+ /** 探活要跨 ssh 走一个来回,留够广域网 RTT 的余量。 */
21
+ export const MONITOR_PROBE_TIMEOUT_MS = 2_000;
22
+
23
+ let timer = null;
24
+ /** 上一轮尚未结束时跳过本轮,避免慢 ssh 叠加。 */
25
+ let running = false;
26
+
27
+ export function startLoop({ intervalMs = MONITOR_INTERVAL_MS } = {}) {
28
+ if (timer) return;
29
+ timer = setInterval(() => {
30
+ tick().catch((err) => logEvent(null, 'warn', `巡检异常:${err.message}`, err.detail ?? null));
31
+ }, intervalMs);
32
+ timer.unref?.();
33
+ }
34
+
35
+ export function stopLoop() {
36
+ if (timer) clearInterval(timer);
37
+ timer = null;
38
+ }
39
+
40
+ export function isLooping() {
41
+ return timer !== null;
42
+ }
43
+
44
+ /** 一轮巡检(导出供测试直接驱动,无需等 30s)。 */
45
+ export async function tick() {
46
+ if (running) return { skipped: true };
47
+ running = true;
48
+ try {
49
+ // 兜一道:配置里没有的主机不该还留着隧道。reload/setup 两条路已各自收过一次,
50
+ // 这里防的是「还有别的路会把主机从配置里拿掉」——巡检本来就是收敛现实与记录的偏差
51
+ // (issue #96)。
52
+ await tunnel.closeUnconfigured();
53
+ const targets = store.listHostNames().filter((n) => store.getPhase(n) === 'running');
54
+ // 有闸:合盖睡醒时所有隧道会一起断,深复核随之一起发——那正是跳板机最忙的时候(issue #85)
55
+ const settled = await mapPool(targets, (n) => checkOne(n), SSH_FANOUT_LIMIT);
56
+ const results = settled.map((r, i) => {
57
+ if (r.status === 'fulfilled') return r.value;
58
+ // 有闸之后单台抛错不再连坐整轮,但也不许悄无声息
59
+ logEvent(targets[i], 'warn', `巡检这一台出错:${r.reason?.message ?? r.reason}`);
60
+ return { host: targets[i], outcome: /** @type {const} */ ('unknown') };
61
+ });
62
+ return { checked: targets.length, results };
63
+ } finally {
64
+ running = false;
65
+ }
66
+ }
67
+
68
+ /** @returns {Promise<{host:string, outcome:'ok'|'no-tunnel'|'restarted'|'unresponsive'|'crashed'|'restart-failed'|'unknown'}>} */
69
+ export async function checkOne(name) {
70
+ const t = tunnel.status(name);
71
+ if (!t || t.localPort === null) return { host: name, outcome: 'no-tunnel' };
72
+ if (t.suspendedReason) return { host: name, outcome: 'no-tunnel' };
73
+
74
+ if (await tunnel.probeForward(t.localPort, MONITOR_PROBE_TIMEOUT_MS)) {
75
+ return { host: name, outcome: 'ok' };
76
+ }
77
+
78
+ logEvent(name, 'warn', `巡检发现本机端口 ${t.localPort} 不通,进入深度复核`);
79
+ const local = store.getHostView(name)?.local === true;
80
+ const alive = await deepCheck(name);
81
+
82
+ if (alive === false) {
83
+ store.mutateHostState(name, (st) => { st.tunnel = null; });
84
+ await tunnel.close(name);
85
+ if (store.getPhase(name) === 'running') store.setPhase(name, 'crashed', 'monitor.deepCheck');
86
+ logEvent(
87
+ name,
88
+ 'error',
89
+ local
90
+ ? '深度复核:本机实例已消失或指纹不符,标记 crashed'
91
+ : '深度复核:远端实例已消失或指纹不符,标记 crashed',
92
+ );
93
+ return { host: name, outcome: 'crashed' };
94
+ }
95
+ if (alive === null) {
96
+ logEvent(
97
+ name,
98
+ 'warn',
99
+ local
100
+ ? '深度复核无法判定(本机命令执行故障或无受管记录),本轮不动状态'
101
+ : '深度复核无法判定(ssh 故障或无受管记录),本轮不动状态',
102
+ );
103
+ return { host: name, outcome: 'unknown' };
104
+ }
105
+
106
+ // 本机没有运输通道可重建:进程和指纹仍对就保持 running,下一轮继续探活。
107
+ if (local) {
108
+ logEvent(name, 'warn', '深度复核:本机进程和指纹仍在,但 web 端口无响应');
109
+ return { host: name, outcome: 'unresponsive' };
110
+ }
111
+
112
+ try {
113
+ await tunnel.restartChild(name);
114
+ logEvent(name, 'info', '远端仍在运行,隧道子进程已重建');
115
+ return { host: name, outcome: 'restarted' };
116
+ } catch (err) {
117
+ logEvent(name, 'error', `隧道重建失败:${err.message}`, err.detail ?? null);
118
+ return { host: name, outcome: 'restart-failed' };
119
+ }
120
+ }
121
+
122
+ /**
123
+ * 深复核:VERIFY 存活 + 指纹全等。
124
+ * @returns {Promise<boolean|null>} null = 无从判断(无受管记录 / ssh 层故障)
125
+ */
126
+ async function deepCheck(name) {
127
+ try {
128
+ return await hostQueue(name).run('monitor-verify', async (signal) => {
129
+ // 队首重取 state/config,确保排队期间的 reload 不会留下旧运输类型。
130
+ const web = store.getHostState(name)?.web;
131
+ if (!web?.pid || !web?.cmdFingerprint) return null;
132
+ const local = store.getHostView(name)?.local === true;
133
+ const command = buildVerifyScript({ pid: web.pid, port: web.port ?? 1 });
134
+ const res = local
135
+ ? await localExec(command, { signal })
136
+ : await sshExec(name, command, { signal });
137
+ if (execFailure(name, '巡检复核', res)) return null;
138
+ const out = parseProtoOutput(res.stdout, { requireDone: 'VERIFY_DONE' });
139
+ if (kvOne(out, 'ALIVE') !== 'yes') return false;
140
+ return (out.blocks.ARGS ?? null) === web.cmdFingerprint;
141
+ });
142
+ } catch {
143
+ return null;
144
+ }
145
+ }