@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
package/src/api.js ADDED
@@ -0,0 +1,725 @@
1
+ /**
2
+ * HTTP API:REST 路由(ENG-12)+ SSE 推送(ENG-13)。规格=13_api_schema.md。
3
+ *
4
+ * 两条纪律:
5
+ * 1. 永不 import server.js——manager 自身能力(info/restart/shutdown/setup)由
6
+ * `managerCtl` 注入(依赖倒置,防环规则 2)。
7
+ * 2. 长动作统一「202 受理 + operation-done 结算」:每个 202 有且仅有一条
8
+ * operation-done(含失败路径,13 §3.4)。
9
+ */
10
+
11
+ import crypto from 'node:crypto';
12
+ import os from 'node:os';
13
+
14
+ import {
15
+ applyConfigSync,
16
+ createConfigSyncPreview,
17
+ requireConfigSyncPreview,
18
+ } from './config-sync.js';
19
+ import { registerDshWorkspace } from './dsh-workspace.js';
20
+ import { DshError, asDshError } from './lib/errors.js';
21
+ import { bus, emitOperationDone, logEvent, recentLogs } from './lib/bus.js';
22
+ import {
23
+ assertValid,
24
+ defaultsPatchSchema,
25
+ dshSettingsPutSchema,
26
+ dshWorkspaceCreateSchema,
27
+ hostConfigPatchSchema,
28
+ localHostCreateSchema,
29
+ setupBodySchema,
30
+ syncConfigBodySchema,
31
+ } from './lib/validate.js';
32
+ import * as launcher from './launcher.js';
33
+ import * as prober from './prober.js';
34
+ import {
35
+ readDshSettings,
36
+ SETTINGS_MAX_BYTES,
37
+ writeDshSettings,
38
+ } from './settings-file.js';
39
+ import * as store from './store.js';
40
+ import * as tunnel from './tunnel.js';
41
+
42
+ const MAX_BODY_BYTES = 1_048_576;
43
+ const SETTINGS_MAX_BODY_BYTES = 6 * SETTINGS_MAX_BYTES + 4096;
44
+ const DSH_WORKSPACE_MAX_BODY_BYTES = 256;
45
+ /**
46
+ * 超限之后还愿意替对面读完的上限(issue #89)。
47
+ * 超一点点多半是「值填大了」,读完再回 400,对面能看到那句人话;
48
+ * 超到这个量级就是在灌了,直接掐——排空不是义务。
49
+ */
50
+ const MAX_DRAIN_BYTES = Math.max(4 * MAX_BODY_BYTES, SETTINGS_MAX_BODY_BYTES);
51
+ const SSE_HEARTBEAT_MS = 25_000;
52
+ const SKIP_CONFIG_SYNC_WRITE = Symbol('skip-config-sync-write');
53
+
54
+ /**
55
+ * 一条 SSE 连接的积压上限。客户端不读的时候(标签被系统冻结、笔记本合盖、网络黑洞),
56
+ * `res.write` 只能把帧堆在内存里——manager 是常驻进程,堆着堆着就涨到几个 G,而没人
57
+ * 会想到是「那个后台标签没在读」。实测:一个不读的客户端 + 20000 条 1KB 日志 = 堆从
58
+ * 8MB 涨到 56MB,线性且 GC 收不回(无客户端的对照组恒定 8MB)。
59
+ *
60
+ * 判据是「**一直**积压」,不是「这一下超线」:日志本来就成串来,一个读得很正常的
61
+ * 客户端也可能在一个 tick 里被灌进几 MB,然后几毫秒内就排空。只按瞬时值踢会误伤它。
62
+ * 所以软线(超了开始计时)+ 宽限期(期间排空就一笔勾销)+ 硬顶(再离谱也不许过)。
63
+ *
64
+ * 踢掉是安全的:页面本来就有断线重连,重连首帧是完整 snapshot,状态照样对得上。
65
+ */
66
+ const SSE_BACKLOG_SOFT_BYTES = 4_194_304;
67
+ const SSE_BACKLOG_HARD_BYTES = 33_554_432;
68
+ const SSE_BACKLOG_GRACE_MS = 3_000;
69
+
70
+ /** setup 门禁白名单(13 §4)。 */
71
+ const SETUP_ALLOWED = [
72
+ 'GET /api/manager/info',
73
+ 'GET /api/config',
74
+ 'GET /api/hosts',
75
+ 'GET /api/events',
76
+ 'POST /api/hosts/probe',
77
+ 'POST /api/setup',
78
+ ];
79
+
80
+ // ── 响应工具 ─────────────────────────────────────────────────────────────
81
+
82
+ function sendJson(res, status, body) {
83
+ const text = JSON.stringify(body);
84
+ res.writeHead(status, {
85
+ 'content-type': 'application/json; charset=utf-8',
86
+ 'content-length': Buffer.byteLength(text),
87
+ 'cache-control': 'no-store',
88
+ });
89
+ res.end(text);
90
+ }
91
+
92
+ function sendText(res, status, text) {
93
+ res.writeHead(status, {
94
+ 'content-type': 'text/plain; charset=utf-8',
95
+ 'content-length': Buffer.byteLength(text),
96
+ 'cache-control': 'no-store',
97
+ });
98
+ res.end(text);
99
+ }
100
+
101
+ /** DshError → HTTP(11 §7.2 表)。 */
102
+ function sendError(res, err) {
103
+ const e = asDshError(err);
104
+ sendJson(res, e.httpStatus, e.toBody());
105
+ return e;
106
+ }
107
+
108
+ function readJsonBody(req, {
109
+ maxBytes = MAX_BODY_BYTES,
110
+ fatalUtf8 = false,
111
+ overLimitCode = 'VALIDATION',
112
+ redactParseError = false,
113
+ requireBody = false,
114
+ } = {}) {
115
+ return new Promise((resolve, reject) => {
116
+ let size = 0;
117
+ let over = false;
118
+ const chunks = [];
119
+ req.on('data', (c) => {
120
+ size += c.length;
121
+ if (size > MAX_DRAIN_BYTES) {
122
+ // 排空也得有个头:一直读下去,对面每传 64MB 我们就得吃 64MB 的临时缓冲,
123
+ // 常驻进程的 RSS 会被这么顶上去。到这个量级已经不像「不小心传大了」,掐掉。
124
+ reject(new DshError(overLimitCode, `请求体超过 ${maxBytes} 字节上限`));
125
+ req.destroy();
126
+ return;
127
+ }
128
+ if (over) return; // 超了就一路丢弃:不攒内存,但也不掐连接
129
+ if (size > maxBytes) {
130
+ over = true;
131
+ chunks.length = 0; // 已经攒的立刻扔掉,超限的体一个字节也不留在内存里
132
+ return;
133
+ }
134
+ chunks.push(c);
135
+ });
136
+ req.on('error', reject);
137
+ req.on('end', () => {
138
+ // 等它传完再回话。半路 destroy 或半路回话都会让对面拿到 ECONNRESET/EPIPE,
139
+ // 只看到「网络错误」而不知道是体太大(issue #89)。
140
+ if (over) {
141
+ reject(new DshError(overLimitCode, `请求体超过 ${maxBytes} 字节上限`));
142
+ return;
143
+ }
144
+ let text;
145
+ try {
146
+ const bytes = Buffer.concat(chunks);
147
+ text = fatalUtf8
148
+ ? new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes)
149
+ : bytes.toString('utf8');
150
+ } catch {
151
+ reject(new DshError('VALIDATION', '请求体不是有效的 UTF-8 JSON'));
152
+ return;
153
+ }
154
+ text = text.trim();
155
+ if (text === '') {
156
+ if (requireBody) {
157
+ reject(new DshError('VALIDATION', '请求体必须是空 JSON 对象 {}'));
158
+ return;
159
+ }
160
+ return resolve({});
161
+ }
162
+ try {
163
+ const parsed = JSON.parse(text);
164
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
165
+ return reject(new DshError('VALIDATION', '请求体必须是 JSON 对象'));
166
+ }
167
+ resolve(parsed);
168
+ } catch (err) {
169
+ reject(new DshError(
170
+ 'VALIDATION',
171
+ redactParseError ? '请求体不是合法 JSON' : `请求体不是合法 JSON:${err.message}`,
172
+ ));
173
+ }
174
+ });
175
+ });
176
+ }
177
+
178
+ // ── SSE hub(13 §3) ─────────────────────────────────────────────────────
179
+
180
+ export function createSseHub({ managerCtl, heartbeatMs = SSE_HEARTBEAT_MS } = {}) {
181
+ /** @type {Set<import('node:http').ServerResponse>} */
182
+ const clients = new Set();
183
+
184
+ /** 本轮广播里被踢掉的连接,攒到广播结束再收尾(免得在 for…of 里边改边遍历)。 */
185
+ const dropped = [];
186
+
187
+ /** 超了软线的连接:记一个宽限期结束后的复查定时器。排空即撤。 */
188
+ const recheck = new Map();
189
+
190
+ const kick = (res) => {
191
+ clients.delete(res);
192
+ clearTimeout(recheck.get(res));
193
+ recheck.delete(res);
194
+ dropped.push(res);
195
+ };
196
+
197
+ /**
198
+ * 写一帧,顺手判这条连接是不是已经积压得不像话了;是就踢掉。
199
+ *
200
+ * 量的是 `writableLength`(还没交给内核的字节数),不是 `write()` 的返回值:
201
+ * 返回 false 只说明「这一下超过了 highWaterMark」,读得好好的客户端也常有。
202
+ *
203
+ * 超软线不当场踢,而是挂一次复查——洪峰可能打完就静默,光靠「下一帧再看」会一直
204
+ * 等不到那一帧(心跳 25s 才来一次,这期间那几 MB 就白占着)。
205
+ */
206
+ const push = (res, frame) => {
207
+ res.write(frame);
208
+ const backlog = res.writableLength ?? 0;
209
+ if (backlog >= SSE_BACKLOG_HARD_BYTES) {
210
+ kick(res);
211
+ return;
212
+ }
213
+ if (backlog <= SSE_BACKLOG_SOFT_BYTES) {
214
+ // 排空了,之前那次超线一笔勾销
215
+ clearTimeout(recheck.get(res));
216
+ recheck.delete(res);
217
+ return;
218
+ }
219
+ if (recheck.has(res)) return;
220
+ const timer = setTimeout(() => {
221
+ recheck.delete(res);
222
+ if (!clients.has(res)) return;
223
+ if ((res.writableLength ?? 0) <= SSE_BACKLOG_SOFT_BYTES) return;
224
+ kick(res);
225
+ reapDropped();
226
+ }, SSE_BACKLOG_GRACE_MS);
227
+ timer.unref?.();
228
+ recheck.set(res, timer);
229
+ };
230
+
231
+ const reapDropped = () => {
232
+ if (dropped.length === 0) return;
233
+ const n = dropped.length;
234
+ for (const res of dropped.splice(0)) {
235
+ try {
236
+ res.destroy();
237
+ } catch {
238
+ // 已经断了
239
+ }
240
+ }
241
+ // 这行日志本身又要广播一次(log-line)。此时被踢的连接已经不在名单里,
242
+ // 递归只会多走一层就停,且那一层没有可踢的对象。
243
+ logEvent(null, 'warn', `${n} 个页面连接积压过多(不在读),已断开;它们会自行重连并重新同步`);
244
+ };
245
+
246
+ const write = (res, type, payload) => {
247
+ // revision 在发送时刻自增:全客户端同帧同值,且天然合并 debounce 窗口内的连续变化
248
+ const data = JSON.stringify({ revision: store.bumpRevision(), ...payload });
249
+ res.write(`event: ${type}\ndata: ${data}\n\n`);
250
+ };
251
+
252
+ const broadcast = (type, buildPayload) => {
253
+ if (clients.size === 0) return;
254
+ const payload = buildPayload();
255
+ if (payload === null) return;
256
+ const data = JSON.stringify({ revision: store.bumpRevision(), ...payload });
257
+ const frame = `event: ${type}\ndata: ${data}\n\n`;
258
+ for (const res of clients) push(res, frame);
259
+ reapDropped();
260
+ };
261
+
262
+ const onHostChanged = (name) => broadcast('host-changed', () => {
263
+ const host = store.getHostView(name);
264
+ return host ? { host } : null;
265
+ });
266
+ const onLogLine = (entry) => broadcast('log-line', () => entry);
267
+ const onConfigChanged = (changed) => broadcast('config-changed', () => {
268
+ const cfg = store.getConfig();
269
+ return { defaults: cfg.defaults, manager: cfg.manager, changed: changed ?? [] };
270
+ });
271
+ const onOperationDone = (payload) => broadcast('operation-done', () => payload);
272
+
273
+ bus.on('host-changed', onHostChanged);
274
+ bus.on('log-line', onLogLine);
275
+ bus.on('config-changed', onConfigChanged);
276
+ bus.on('operation-done', onOperationDone);
277
+
278
+ const heartbeat = setInterval(() => {
279
+ for (const res of clients) push(res, ':hb\n\n');
280
+ reapDropped();
281
+ }, heartbeatMs);
282
+ heartbeat.unref?.();
283
+
284
+ return {
285
+ get size() {
286
+ return clients.size;
287
+ },
288
+
289
+ /** 新连接:首帧 snapshot(13 §3.2)——前端据此完成首屏同步,无需去抖窗口。 */
290
+ attach(req, res) {
291
+ res.writeHead(200, {
292
+ 'content-type': 'text/event-stream; charset=utf-8',
293
+ 'cache-control': 'no-store',
294
+ connection: 'keep-alive',
295
+ 'x-accel-buffering': 'no',
296
+ });
297
+ res.write(':ok\n\n');
298
+ clients.add(res);
299
+
300
+ const cfg = store.getConfig();
301
+ write(res, 'snapshot', {
302
+ manager: managerCtl.info(),
303
+ configuredPort: cfg?.manager?.port ?? null,
304
+ defaults: cfg?.defaults ?? null,
305
+ hosts: store.listHostViews(),
306
+ logs: recentLogs(50),
307
+ });
308
+
309
+ const drop = () => {
310
+ clients.delete(res);
311
+ clearTimeout(recheck.get(res));
312
+ recheck.delete(res);
313
+ };
314
+ req.on('close', drop);
315
+ req.on('error', drop);
316
+ res.on('error', drop);
317
+ },
318
+
319
+ /** §3.4 优雅退出:不主动断 SSE,server.close() 永不完成。 */
320
+ closeAll() {
321
+ for (const res of clients) {
322
+ try {
323
+ res.end();
324
+ } catch {
325
+ // 已断开
326
+ }
327
+ }
328
+ clients.clear();
329
+ },
330
+
331
+ dispose() {
332
+ clearInterval(heartbeat);
333
+ for (const timer of recheck.values()) clearTimeout(timer);
334
+ recheck.clear();
335
+ bus.off('host-changed', onHostChanged);
336
+ bus.off('log-line', onLogLine);
337
+ bus.off('config-changed', onConfigChanged);
338
+ bus.off('operation-done', onOperationDone);
339
+ this.closeAll();
340
+ },
341
+ };
342
+ }
343
+
344
+ // ── 长动作:202 受理 + operation-done 结算 ───────────────────────────────
345
+
346
+ function accept(res, { host, action }, run) {
347
+ const operationId = crypto.randomUUID();
348
+ sendJson(res, 202, { accepted: true, operationId, host: host ?? null });
349
+
350
+ Promise.resolve()
351
+ .then(run)
352
+ .then(() => emitOperationDone({
353
+ operationId, host: host ?? null, action, status: 'ok', error: null, code: null, detail: null,
354
+ }))
355
+ .catch((err) => {
356
+ const e = asDshError(err);
357
+ emitOperationDone({
358
+ operationId,
359
+ host: host ?? null,
360
+ action,
361
+ status: 'failed',
362
+ error: e.message,
363
+ code: e.code,
364
+ detail: e.detail,
365
+ });
366
+ });
367
+ }
368
+
369
+ // ── preflight(13 §2.10 / 11 §2.3 第 1 层) ──────────────────────────────
370
+
371
+ function requireHost(name) {
372
+ const view = store.getHostView(name);
373
+ if (!view) throw new DshError('NOT_FOUND', `未知主机 ${name}`, { host: name });
374
+ return view;
375
+ }
376
+
377
+ function rejectQuery(req, url) {
378
+ if (url.search !== '' || req.url.includes('?')) {
379
+ throw new DshError('VALIDATION', '该接口不接受 query 参数');
380
+ }
381
+ }
382
+
383
+ function decodeSettingsHost(segment) {
384
+ try {
385
+ return decodeURIComponent(segment);
386
+ } catch {
387
+ throw new DshError('VALIDATION', '主机名 URL 编码无效');
388
+ }
389
+ }
390
+
391
+ function requirePhase(view, allowed, action) {
392
+ if (!allowed.includes(view.phase)) {
393
+ throw new DshError('PHASE_CONFLICT', `${action} 要求主机处于 ${allowed.join('/')},当前为 ${view.phase}`, {
394
+ host: view.name,
395
+ });
396
+ }
397
+ }
398
+
399
+ /**
400
+ * 「这个动作只对本 manager 拉起的实例生效」。
401
+ *
402
+ * 两种落空要分开说(issue #98):远端确实有个不是我们拉的实例,和远端上压根什么都没有。
403
+ * 揉成一句「不动手动实例」,会让一台根本没在跑的主机也收到这句话——把人往
404
+ * 「是不是有个我不知道的进程」上引,而真相只是「它没在跑」。
405
+ */
406
+ function requireManaged(view, action) {
407
+ if (view.web?.startedByUs === true) return;
408
+ if ((view.manualInstances?.length ?? 0) > 0 || view.web) {
409
+ throw new DshError('NOT_ALLOWED', `${action} 不动手动实例:${view.name} 上跑的不是本 manager 拉起的`, { host: view.name });
410
+ }
411
+ throw new DshError('NOT_ALLOWED', `${view.name} 上没有本 manager 拉起的实例,无从${action}`, { host: view.name });
412
+ }
413
+
414
+ // ── 路由表 ───────────────────────────────────────────────────────────────
415
+
416
+ /**
417
+ * @param {{managerCtl:{info:Function, restart:Function, shutdown:Function,
418
+ * applySetup:Function, setupGateActive:Function}}} deps
419
+ */
420
+ export function createHandler({ managerCtl }) {
421
+ const sseHub = createSseHub({ managerCtl });
422
+
423
+ const routes = [
424
+ ['GET', /^\/api\/hosts$/, (req, res) => {
425
+ sendJson(res, 200, { revision: store.currentRevision(), hosts: store.listHostViews() });
426
+ }],
427
+
428
+ ['POST', /^\/api\/hosts\/local$/, async (req, res) => {
429
+ const body = await readJsonBody(req);
430
+ assertValid(localHostCreateSchema, body, '本机主机创建请求校验失败');
431
+ const host = store.createLocalHost(body.name ?? os.hostname());
432
+ sendJson(res, 201, { host });
433
+ }],
434
+
435
+ ['POST', /^\/api\/hosts\/sync-config$/, async (req, res) => {
436
+ const body = await readJsonBody(req);
437
+ assertValid(syncConfigBodySchema, body, '批量配置同步请求校验失败');
438
+ if (body.dryRun) {
439
+ const { plan, previewToken } = createConfigSyncPreview(store.getConfig(), body);
440
+ sendJson(res, 200, {
441
+ source: plan.source,
442
+ dryRun: true,
443
+ targets: plan.targets,
444
+ applied: [],
445
+ hosts: [],
446
+ previewToken,
447
+ });
448
+ return;
449
+ }
450
+
451
+ let plan;
452
+ let applied = [];
453
+ try {
454
+ store.updateConfig((draft) => {
455
+ // 重算、验 token、复制都在 updateConfig 的同一个同步 mutator 内;
456
+ // 任一配置请求只能看见上一笔完整提交,不能夹在校验与落盘之间。
457
+ plan = requireConfigSyncPreview(draft, body, body.previewToken);
458
+ applied = applyConfigSync(draft, plan);
459
+ if (applied.length === 0) throw SKIP_CONFIG_SYNC_WRITE;
460
+ });
461
+ } catch (err) {
462
+ if (err !== SKIP_CONFIG_SYNC_WRITE) throw err;
463
+ }
464
+ sendJson(res, 200, {
465
+ source: plan.source,
466
+ dryRun: false,
467
+ targets: plan.targets,
468
+ applied,
469
+ hosts: body.targets.map((name) => store.getHostView(name)),
470
+ });
471
+ }],
472
+
473
+ ['GET', /^\/api\/config$/, (req, res) => {
474
+ sendJson(res, 200, store.getConfig());
475
+ }],
476
+
477
+ ['GET', /^\/api\/manager\/info$/, (req, res) => {
478
+ sendJson(res, 200, managerCtl.info());
479
+ }],
480
+
481
+ ['GET', /^\/api\/events$/, (req, res) => {
482
+ sseHub.attach(req, res);
483
+ }],
484
+
485
+ ['GET', /^\/api\/hosts\/([^/]+)\/log$/, async (req, res, [name], url) => {
486
+ const view = requireHost(decodeURIComponent(name));
487
+ const raw = url.searchParams.get('lines');
488
+ const lines = raw === null ? 200 : Number(raw);
489
+ if (!Number.isInteger(lines) || lines < 1 || lines > 10_000) {
490
+ throw new DshError('VALIDATION', 'lines 需为 1..10000 的整数');
491
+ }
492
+ const logName = view.web?.log ?? null;
493
+ if (!logName) return sendText(res, 200, '(no log)\n');
494
+ const text = await launcher.tailRemoteLog(view.name, { logName, lines });
495
+ sendText(res, 200, text);
496
+ }],
497
+
498
+ ['GET', /^\/api\/hosts\/([^/]+)\/dsh-settings$/, async (req, res, [name], url) => {
499
+ rejectQuery(req, url);
500
+ const view = requireHost(decodeSettingsHost(name));
501
+ const canonicalName = view.name;
502
+ const result = await readDshSettings(canonicalName, {
503
+ resolveLocal: () => requireHost(canonicalName).local,
504
+ });
505
+ sendJson(res, 200, result);
506
+ }],
507
+
508
+ ['PUT', /^\/api\/hosts\/([^/]+)\/dsh-settings$/, async (req, res, [name], url) => {
509
+ rejectQuery(req, url);
510
+ const view = requireHost(decodeSettingsHost(name));
511
+ const canonicalName = view.name;
512
+ const body = await readJsonBody(req, {
513
+ maxBytes: SETTINGS_MAX_BODY_BYTES,
514
+ fatalUtf8: true,
515
+ overLimitCode: 'SETTINGS_TOO_LARGE',
516
+ redactParseError: true,
517
+ });
518
+ assertValid(dshSettingsPutSchema, body, 'settings.yaml 保存请求校验失败');
519
+ const result = await writeDshSettings(canonicalName, {
520
+ ...body,
521
+ resolveLocal: () => requireHost(canonicalName).local,
522
+ });
523
+ sendJson(res, 200, result);
524
+ }],
525
+
526
+ ['POST', /^\/api\/hosts\/([^/]+)\/dsh-workspace$/, async (req, res, [name], url) => {
527
+ rejectQuery(req, url);
528
+ const view = requireHost(decodeSettingsHost(name));
529
+ const body = await readJsonBody(req, {
530
+ maxBytes: DSH_WORKSPACE_MAX_BODY_BYTES,
531
+ fatalUtf8: true,
532
+ redactParseError: true,
533
+ requireBody: true,
534
+ });
535
+ assertValid(dshWorkspaceCreateSchema, body, 'Workspace 登记请求体必须是空 JSON 对象');
536
+
537
+ const controller = new AbortController();
538
+ const abortRequest = () => controller.abort();
539
+ const abortResponse = () => {
540
+ if (!res.writableEnded) controller.abort();
541
+ };
542
+ req.once('aborted', abortRequest);
543
+ res.once('close', abortResponse);
544
+ try {
545
+ const result = await registerDshWorkspace(view.name, {
546
+ resolveView: store.getHostView,
547
+ fetchImpl: globalThis.fetch,
548
+ signal: controller.signal,
549
+ });
550
+ if (!res.destroyed) sendJson(res, 200, result);
551
+ } catch (error) {
552
+ if (controller.signal.aborted && (req.aborted || res.destroyed)) return;
553
+ throw error;
554
+ } finally {
555
+ req.off('aborted', abortRequest);
556
+ res.off('close', abortResponse);
557
+ }
558
+ }],
559
+
560
+ ['PUT', /^\/api\/hosts\/([^/]+)\/config$/, async (req, res, [name]) => {
561
+ const view = requireHost(decodeURIComponent(name));
562
+ const body = await readJsonBody(req);
563
+ assertValid(hostConfigPatchSchema, body, '主机配置校验失败(localPort 由 manager 分配,不接受提交)');
564
+
565
+ const current = requireHost(view.name);
566
+ if ('local' in body && body.local !== current.local) {
567
+ throw new DshError('NOT_ALLOWED', `主机 ${view.name} 的本机/SSH 身份不允许修改`, { host: view.name });
568
+ }
569
+ const hasMutableField = Object.keys(body).some((key) => key !== 'local');
570
+ if (!hasMutableField) {
571
+ sendJson(res, 200, { host: current });
572
+ return;
573
+ }
574
+
575
+ store.updateConfig((draft) => {
576
+ const host = draft.hosts[view.name];
577
+ if ('local' in body && body.local !== (host.local === true)) {
578
+ throw new DshError('NOT_ALLOWED', `主机 ${view.name} 的本机/SSH 身份不允许修改`, { host: view.name });
579
+ }
580
+ if ('enabled' in body) host.enabled = body.enabled;
581
+ if ('autoStart' in body) host.autoStart = body.autoStart;
582
+ if ('remoteWebPort' in body) host.remoteWebPort = body.remoteWebPort;
583
+ // 与 inject 同款语义:落盘即生效于「下一次拉起」,不动正在跑的实例
584
+ if ('workdir' in body) host.workdir = body.workdir;
585
+ if ('inject' in body) {
586
+ host.inject = {
587
+ env: { ...body.inject.env },
588
+ extraArgs: [...body.inject.extraArgs],
589
+ patches: [...body.inject.patches],
590
+ };
591
+ }
592
+ });
593
+ sendJson(res, 200, { host: store.getHostView(view.name) });
594
+ }],
595
+
596
+ ['PUT', /^\/api\/config\/defaults$/, async (req, res) => {
597
+ const body = await readJsonBody(req);
598
+ assertValid(defaultsPatchSchema, body, 'defaults 校验失败');
599
+
600
+ store.updateConfig((draft) => {
601
+ if ('remoteWebPort' in body) draft.defaults.remoteWebPort = body.remoteWebPort;
602
+ if ('localPortRange' in body) draft.defaults.localPortRange = [...body.localPortRange];
603
+ if (body.manager && 'port' in body.manager) draft.manager.port = body.manager.port;
604
+ });
605
+ const cfg = store.getConfig();
606
+ // manager.port 只落盘不热切换(13 §2.6)
607
+ const restartRequired = Boolean(body.manager && body.manager.port !== managerCtl.info().port);
608
+ sendJson(res, 200, { defaults: cfg.defaults, manager: cfg.manager, restartRequired });
609
+ }],
610
+
611
+ ['POST', /^\/api\/reload$/, async (req, res) => {
612
+ const result = store.reloadConfig();
613
+ // 这一趟可能把某台主机从配置里去掉了。它的隧道此刻既看不见也停不掉,
614
+ // 只能由 manager 自己收(issue #96)。
615
+ await tunnel.closeUnconfigured();
616
+ sendJson(res, 200, result);
617
+ }],
618
+
619
+ ['POST', /^\/api\/setup$/, async (req, res) => {
620
+ const body = await readJsonBody(req);
621
+ assertValid(setupBodySchema, body, '初始化配置校验失败');
622
+ const result = await managerCtl.applySetup(body);
623
+ await tunnel.closeUnconfigured(); // setup 是整份替换,同上
624
+ sendJson(res, 200, result);
625
+ }],
626
+
627
+ ['POST', /^\/api\/manager\/restart$/, async (req, res) => {
628
+ const result = await managerCtl.restart();
629
+ sendJson(res, 202, { accepted: true, ...result });
630
+ }],
631
+
632
+ ['POST', /^\/api\/manager\/shutdown$/, async (req, res) => {
633
+ const result = await managerCtl.shutdown();
634
+ sendJson(res, 202, { accepted: true, ...result });
635
+ }],
636
+
637
+ ['POST', /^\/api\/hosts\/probe$/, (req, res) => {
638
+ accept(res, { host: null, action: 'probe-all' }, () => prober.probeAll());
639
+ }],
640
+
641
+ ['POST', /^\/api\/hosts\/([^/]+)\/probe$/, (req, res, [name]) => {
642
+ const view = requireHost(decodeURIComponent(name));
643
+ accept(res, { host: view.name, action: 'probe' }, () => prober.probeHost(view.name));
644
+ }],
645
+
646
+ ['POST', /^\/api\/hosts\/([^/]+)\/start$/, (req, res, [name]) => {
647
+ const view = requireHost(decodeURIComponent(name));
648
+ if (!view.config.enabled) {
649
+ throw new DshError('NOT_ALLOWED', `主机 ${view.name} 已在配置中停用`, { host: view.name });
650
+ }
651
+ requirePhase(view, ['ready', 'crashed'], '启动');
652
+ accept(res, { host: view.name, action: 'start' }, () => launcher.start(view.name));
653
+ }],
654
+
655
+ ['POST', /^\/api\/hosts\/([^/]+)\/stop$/, (req, res, [name]) => {
656
+ const view = requireHost(decodeURIComponent(name));
657
+ requireManaged(view, '关停');
658
+ requirePhase(view, ['running', 'degraded'], '关停');
659
+ accept(res, { host: view.name, action: 'stop' }, () => launcher.stop(view.name));
660
+ }],
661
+
662
+ ['POST', /^\/api\/hosts\/([^/]+)\/restart$/, (req, res, [name]) => {
663
+ const view = requireHost(decodeURIComponent(name));
664
+ requireManaged(view, '重启');
665
+ requirePhase(view, ['running', 'degraded', 'crashed'], '重启');
666
+ accept(res, { host: view.name, action: 'restart' }, () => launcher.restart(view.name));
667
+ }],
668
+
669
+ ['POST', /^\/api\/hosts\/([^/]+)\/reconnect$/, (req, res, [name]) => {
670
+ const view = requireHost(decodeURIComponent(name));
671
+ requirePhase(view, ['degraded', 'running'], '重连');
672
+ accept(res, { host: view.name, action: 'reconnect' }, () => tunnel.requestReconnect(view.name));
673
+ }],
674
+ ];
675
+
676
+ /** @type {import('node:http').RequestListener & {sseHub:any}} */
677
+ const handler = async (req, res) => {
678
+ const url = new URL(req.url, 'http://127.0.0.1');
679
+ const method = req.method === 'HEAD' ? 'GET' : req.method;
680
+
681
+ for (const [m, re, fn] of routes) {
682
+ if (m !== method) continue;
683
+ const match = re.exec(url.pathname);
684
+ if (!match) continue;
685
+
686
+ const key = `${m} ${url.pathname.replace(/\/api\/hosts\/[^/]+\//, '/api/hosts/:name/')}`;
687
+ const generic = `${m} ${url.pathname}`;
688
+ if (managerCtl.setupGateActive() && !SETUP_ALLOWED.includes(key) && !SETUP_ALLOWED.includes(generic)) {
689
+ return sendError(res, new DshError('SETUP_REQUIRED', '首次配置尚未完成,该接口暂不可用'));
690
+ }
691
+
692
+ try {
693
+ await fn(req, res, match.slice(1), url);
694
+ } catch (err) {
695
+ if (res.headersSent) {
696
+ res.end();
697
+ return;
698
+ }
699
+ sendError(res, err);
700
+ }
701
+ return;
702
+ }
703
+
704
+ if (url.pathname.startsWith('/api/')) {
705
+ sendError(res, new DshError('NOT_FOUND', `未知接口 ${method} ${url.pathname}`));
706
+ return;
707
+ }
708
+ // 非 /api/ 前缀交由 server.js 的静态处理器(此处返回 false 语义用 404 兜底)
709
+ sendError(res, new DshError('NOT_FOUND', `未知路径 ${url.pathname}`));
710
+ };
711
+
712
+ handler.sseHub = sseHub;
713
+ return handler;
714
+ }
715
+
716
+ export {
717
+ readJsonBody,
718
+ sendError,
719
+ sendJson,
720
+ sendText,
721
+ SETUP_ALLOWED,
722
+ MAX_BODY_BYTES,
723
+ SETTINGS_MAX_BODY_BYTES,
724
+ DSH_WORKSPACE_MAX_BODY_BYTES,
725
+ };