@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/server.js ADDED
@@ -0,0 +1,449 @@
1
+ /**
2
+ * 服务装配与启动序列(11 §3.1、§3.4)。
3
+ *
4
+ * 启动顺序有因:先 listen(进展经 SSE 可见)→ 写 pidfile → 恢复复核与首轮探测并行 →
5
+ * autoStart → 巡检环 → 信号钩子。退出/自我重启严格按 §3.4 的步序,顺序错了会引发
6
+ * 端口占用与 pidfile 竞态。
7
+ */
8
+
9
+ import fs from 'node:fs';
10
+ import http from 'node:http';
11
+ import os from 'node:os';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ import { FACTORY_DEFAULTS, SSH_FANOUT_LIMIT, resolvePaths } from './defaults.js';
16
+ import { DshError, asDshError } from './lib/errors.js';
17
+ import { isMainEntry } from './lib/entry.js';
18
+ import { monotonicMs } from './lib/clock.js';
19
+ import { logEvent } from './lib/bus.js';
20
+ import { trimLogFile } from './lib/logfile.js';
21
+ import { checkRequestOrigin } from './lib/origin-guard.js';
22
+ import { mapPool } from './lib/pool.js';
23
+ import { reopenSsh, shutdownSsh } from './lib/ssh.js';
24
+ import * as daemon from './daemon.js';
25
+ import * as launcher from './launcher.js';
26
+ import * as monitor from './monitor.js';
27
+ import * as prober from './prober.js';
28
+ import * as store from './store.js';
29
+ import * as tunnel from './tunnel.js';
30
+ import { createHandler } from './api.js';
31
+ import { loadHosts } from './ssh-config.js';
32
+
33
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
34
+ const WEB_DIR = path.join(HERE, 'web');
35
+
36
+ const MIME = {
37
+ '.html': 'text/html; charset=utf-8',
38
+ '.js': 'text/javascript; charset=utf-8',
39
+ '.mjs': 'text/javascript; charset=utf-8',
40
+ '.css': 'text/css; charset=utf-8',
41
+ '.json': 'application/json; charset=utf-8',
42
+ '.svg': 'image/svg+xml',
43
+ '.png': 'image/png',
44
+ '.ico': 'image/x-icon',
45
+ '.woff2': 'font/woff2',
46
+ };
47
+
48
+ export const PKG_VERSION = readVersion();
49
+
50
+ function readVersion() {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(path.join(HERE, '..', 'package.json'), 'utf8')).version ?? '0.0.0';
53
+ } catch {
54
+ return '0.0.0';
55
+ }
56
+ }
57
+
58
+ /** 单进程内的运行时(导出供集成测试直接拿句柄)。 */
59
+ export const runtime = {
60
+ /** @type {http.Server|null} */
61
+ httpServer: null,
62
+ /** @type {any} */
63
+ handler: null,
64
+ mode: 'foreground',
65
+ port: null,
66
+ startedAt: null,
67
+ /** 单调钟基准:uptime 是流逝量,墙钟一跳就会算出负数(#104) */
68
+ startedAtMono: null,
69
+ setupGate: false,
70
+ shuttingDown: false,
71
+ /** @type {NodeJS.Timeout|null} */
72
+ logTrimTimer: null,
73
+ };
74
+
75
+ // ── 静态资源(01 文档前端;无构建链,原样吐 ESM) ─────────────────────────
76
+
77
+ function serveStatic(req, res, pathname) {
78
+ const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\/+/, '');
79
+ const target = path.join(WEB_DIR, rel);
80
+ // 目录穿越防护:解析后必须仍在 WEB_DIR 内
81
+ if (!target.startsWith(`${WEB_DIR}${path.sep}`) && target !== WEB_DIR) {
82
+ res.writeHead(403).end('forbidden');
83
+ return;
84
+ }
85
+ fs.readFile(target, (err, data) => {
86
+ if (err) {
87
+ res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' }).end('not found');
88
+ return;
89
+ }
90
+ res.writeHead(200, {
91
+ 'content-type': MIME[path.extname(target).toLowerCase()] ?? 'application/octet-stream',
92
+ 'cache-control': 'no-cache',
93
+ });
94
+ res.end(data);
95
+ });
96
+ }
97
+
98
+ // ── managerCtl(注入 api,禁止 api → server 反向 import) ────────────────
99
+
100
+ function buildManagerCtl() {
101
+ return {
102
+ info() {
103
+ return {
104
+ version: PKG_VERSION,
105
+ pid: process.pid,
106
+ port: runtime.port,
107
+ mode: runtime.mode,
108
+ startedAt: runtime.startedAt,
109
+ uptimeMs: runtime.startedAtMono === null ? 0 : Math.round(monotonicMs() - runtime.startedAtMono),
110
+ setupCompleted: store.isSetupCompleted(),
111
+ setupGateActive: runtime.setupGate,
112
+ hostCounts: store.hostCounts(),
113
+ revision: store.currentRevision(),
114
+ };
115
+ },
116
+ setupGateActive() {
117
+ return runtime.setupGate;
118
+ },
119
+ async restart() {
120
+ return requestRestart();
121
+ },
122
+ async shutdown() {
123
+ setTimeout(() => { gracefulExit('api-shutdown').catch(() => process.exit(1)); }, 50).unref?.();
124
+ return { mode: runtime.mode };
125
+ },
126
+ async applySetup(incoming) {
127
+ return applySetup(incoming);
128
+ },
129
+ };
130
+ }
131
+
132
+ // ── setup(ENG-19 的服务侧) ─────────────────────────────────────────────
133
+
134
+ async function applySetup(incoming) {
135
+ const sshHosts = loadHosts();
136
+ store.assertSetupLocalIdentities(
137
+ incoming,
138
+ os.hostname(),
139
+ sshHosts.map((host) => host.name),
140
+ );
141
+ const before = runtime.port;
142
+ const saved = store.saveConfigFromSetup(incoming);
143
+ store.clearSetupLocalCandidate();
144
+ const portChanged = saved.manager.port !== before;
145
+
146
+ if (!portChanged) {
147
+ // 端口未变:撤门禁 + 热切换,继续走启动序列 6–9 步
148
+ runtime.setupGate = false;
149
+ logEvent(null, 'info', '初始化配置已保存,门禁解除');
150
+ store.mergeSshHosts(sshHosts);
151
+ void postSetupBoot();
152
+ return { ok: true, port: saved.manager.port, portChanged: false, restartRequired: false, restarting: false };
153
+ }
154
+
155
+ if (runtime.mode === 'foreground') {
156
+ // 前台模式不能自我重启(02 §9.4):如实告知,等人工重启
157
+ logEvent(null, 'warn', `manager 端口已改为 ${saved.manager.port},前台模式需手动重启生效`);
158
+ return { ok: true, port: saved.manager.port, portChanged: true, restartRequired: true, restarting: false };
159
+ }
160
+
161
+ logEvent(null, 'info', `manager 端口已改为 ${saved.manager.port},正在自我重启`);
162
+ setTimeout(() => { selfRestart().catch(() => process.exit(1)); }, 50).unref?.();
163
+ return { ok: true, port: saved.manager.port, portChanged: true, restartRequired: false, restarting: true };
164
+ }
165
+
166
+ /** setup 完成后的补跑:探测 → autoStart → 巡检。 */
167
+ async function postSetupBoot() {
168
+ try {
169
+ await prober.probeAll();
170
+ await runAutoStart();
171
+ monitor.startLoop();
172
+ } catch (err) {
173
+ logEvent(null, 'warn', `初始化后自动流程异常:${asDshError(err).message}`);
174
+ }
175
+ }
176
+
177
+ // ── 启动序列 ─────────────────────────────────────────────────────────────
178
+
179
+ /** state 里 running/degraded 的主机各自队列内并行复核(§3.1 第 4–5 步)。 */
180
+ async function recoverState() {
181
+ const targets = store.listHostNames().filter((n) => ['running', 'degraded'].includes(store.getPhase(n)));
182
+ if (targets.length === 0) return [];
183
+ logEvent(null, 'info', `恢复复核 ${targets.length} 台主机`);
184
+ const results = await mapPool(targets, (n) => launcher.recoverOne(n), SSH_FANOUT_LIMIT);
185
+ return targets.map((name, i) => ({
186
+ name,
187
+ outcome: results[i].status === 'fulfilled' ? results[i].value : 'crashed',
188
+ }));
189
+ }
190
+
191
+ /** autoStart:config.autoStart ∧ 探测后 ready → start(单机失败仅 log-line,不阻塞)。 */
192
+ export async function runAutoStart() {
193
+ const cfg = store.getConfig();
194
+ const targets = store.listHostNames().filter((n) => {
195
+ const host = cfg.hosts[n];
196
+ return host?.enabled && host?.autoStart && store.getPhase(n) === 'ready';
197
+ });
198
+ if (targets.length === 0) return [];
199
+ logEvent(null, 'info', `autoStart:${targets.join(', ')}`);
200
+ // 有闸:一次拉起要走 LAUNCH/POLL/VERIFY 数趟 ssh,几十台一起冲最容易把跳板机打爆(issue #85)
201
+ const results = await mapPool(targets, (n) => launcher.start(n), SSH_FANOUT_LIMIT);
202
+ results.forEach((r, i) => {
203
+ if (r.status === 'rejected') {
204
+ const e = asDshError(r.reason);
205
+ logEvent(targets[i], 'warn', `autoStart 失败:${e.message}`, e.detail ?? null);
206
+ }
207
+ });
208
+ return targets;
209
+ }
210
+
211
+ /**
212
+ * @param {{portOverride?:number|null, skipBoot?:boolean}} [opts] skipBoot 供集成测试
213
+ * (只要 HTTP 面,不跑恢复/探测/巡检)
214
+ */
215
+ export async function main({ portOverride = null, skipBoot = false } = {}) {
216
+ runtime.mode = daemon.detectMode();
217
+ runtime.startedAt = new Date().toISOString();
218
+ runtime.startedAtMono = monotonicMs();
219
+ reopenSsh(); // 同进程里关停过又起来的场合(用例装置)不能带着上一轮的关停闩
220
+
221
+ await store.init();
222
+ store.setTunnelStatusProvider(tunnel.status);
223
+ runtime.setupGate = !store.isSetupCompleted();
224
+ // setup 模式也要有主机清单:向导第 3 步要勾选主机(13 §4 允许 GET /api/hosts)
225
+ store.mergeSshHosts(loadHosts());
226
+ if (runtime.setupGate) store.ensureSetupLocalCandidate(os.hostname());
227
+
228
+ const cfg = store.getConfig();
229
+ const port = portOverride ?? (runtime.setupGate ? FACTORY_DEFAULTS.manager.port : cfg.manager.port);
230
+
231
+ const handler = createHandler({ managerCtl: buildManagerCtl() });
232
+ runtime.handler = handler;
233
+
234
+ const httpServer = http.createServer((req, res) => {
235
+ // 跨站防线放在最前面,静态页也要挡:DNS rebinding 是先让攻击者的域名把这个页面
236
+ // 装进他自己的 origin,再从那儿读写 API。
237
+ const verdict = checkRequestOrigin({ headers: req.headers, port: runtime.port });
238
+ if (!verdict.ok) {
239
+ // 错误体沿用全端点统一契约(13 §1.1),连静态页这条路也照办
240
+ const body = JSON.stringify(new DshError(verdict.code, verdict.message).toBody());
241
+ res.writeHead(verdict.status, {
242
+ 'content-type': 'application/json; charset=utf-8',
243
+ 'content-length': Buffer.byteLength(body),
244
+ 'cache-control': 'no-store',
245
+ });
246
+ res.end(body);
247
+ return;
248
+ }
249
+ const pathname = new URL(req.url, 'http://127.0.0.1').pathname;
250
+ if (pathname.startsWith('/api/')) {
251
+ handler(req, res);
252
+ return;
253
+ }
254
+ serveStatic(req, res, pathname);
255
+ });
256
+ // SSE 长连接不能被 keep-alive 超时掐断
257
+ httpServer.keepAliveTimeout = 0;
258
+ httpServer.requestTimeout = 0;
259
+ httpServer.headersTimeout = 60_000;
260
+ trackSockets(httpServer);
261
+ runtime.httpServer = httpServer;
262
+
263
+ await new Promise((resolve, reject) => {
264
+ httpServer.once('error', reject);
265
+ httpServer.listen(port, '127.0.0.1', () => {
266
+ httpServer.removeListener('error', reject);
267
+ resolve();
268
+ });
269
+ });
270
+ runtime.port = httpServer.address().port;
271
+
272
+ daemon.writePidfile({
273
+ pid: process.pid,
274
+ port: runtime.port,
275
+ mode: runtime.mode,
276
+ startedAt: runtime.startedAt,
277
+ });
278
+
279
+ logEvent(null, 'info', `manager 已监听 http://127.0.0.1:${runtime.port}(模式 ${runtime.mode})`);
280
+ if (runtime.setupGate) {
281
+ logEvent(null, 'warn', '尚未完成首次配置:仅开放引导页与 /api/setup');
282
+ }
283
+
284
+ installSignalHooks();
285
+ startLogTrimLoop();
286
+
287
+ if (!runtime.setupGate && !skipBoot) {
288
+ const recovered = await recoverState();
289
+ const recoveredNames = new Set(recovered.map((r) => r.name));
290
+ await prober.probeAll(store.listHostNames().filter((n) => !recoveredNames.has(n)));
291
+ await runAutoStart();
292
+ monitor.startLoop();
293
+ }
294
+
295
+ return { port: runtime.port, setupGate: runtime.setupGate };
296
+ }
297
+
298
+ // ── 日志封顶(issue #81) ────────────────────────────────────────────────
299
+
300
+ const LOG_TRIM_INTERVAL_MS = 10 * 60_000;
301
+
302
+ /**
303
+ * manager.log 只追加、从不回收,而这进程在 launchd 下是 7×24 的:一台链路不稳的主机
304
+ * 实测约 8MB/天。开机看一眼、之后每 10 分钟看一眼,超了就原地截断留尾巴。
305
+ */
306
+ function startLogTrimLoop() {
307
+ const once = () => {
308
+ const res = trimLogFile(resolvePaths().log);
309
+ if (res.trimmed) logEvent(null, 'info', `manager.log 到顶,已原地截断(丢掉较早的 ${res.dropped} 字节)`);
310
+ };
311
+ once();
312
+ if (runtime.logTrimTimer) clearInterval(runtime.logTrimTimer);
313
+ runtime.logTrimTimer = setInterval(once, LOG_TRIM_INTERVAL_MS);
314
+ runtime.logTrimTimer.unref?.(); // 它不该成为「进程还有事做」的理由
315
+ }
316
+
317
+ // ── 退出与自我重启(§3.4) ───────────────────────────────────────────────
318
+
319
+ /** server.close() 需要所有存活 socket 被销毁,否则永不完成。 */
320
+ const sockets = new Set();
321
+
322
+ function trackSockets(server) {
323
+ server.on('connection', (socket) => {
324
+ sockets.add(socket);
325
+ socket.on('close', () => sockets.delete(socket));
326
+ });
327
+ }
328
+
329
+ function destroySockets() {
330
+ for (const s of sockets) s.destroy();
331
+ sockets.clear();
332
+ }
333
+
334
+ /** §3.4 的 2–6 步,gracefulExit 与 selfRestart 共用。 */
335
+ async function teardown() {
336
+ monitor.stopLoop();
337
+ if (runtime.logTrimTimer) {
338
+ clearInterval(runtime.logTrimTimer);
339
+ runtime.logTrimTimer = null;
340
+ }
341
+ // 在飞的一次性 ssh(探测 / 拉起 / 回读)也要收,且从此不再起新的:不收就是把它们
342
+ // 交给 init 当孤儿,重启后新老两批命令同时打同一台远端(issue #73)。
343
+ // 隧道由下面的 closeAll 管。
344
+ shutdownSsh();
345
+ await tunnel.closeAll();
346
+ store.flushStateSync();
347
+ runtime.handler?.sseHub?.closeAll();
348
+ if (runtime.httpServer) {
349
+ const closed = new Promise((resolve) => runtime.httpServer.close(resolve));
350
+ destroySockets();
351
+ await closed;
352
+ }
353
+ daemon.removePidfileIfOwn();
354
+ }
355
+
356
+ export async function gracefulExit(reason, { exit = true } = {}) {
357
+ if (runtime.shuttingDown) return;
358
+ runtime.shuttingDown = true;
359
+ logEvent(null, 'info', `manager 退出(${reason})`);
360
+ try {
361
+ await teardown();
362
+ } catch (err) {
363
+ logEvent(null, 'warn', `退出清理异常:${asDshError(err).message}`);
364
+ }
365
+ if (exit) process.exit(0);
366
+ }
367
+
368
+ /** 裸后台:teardown 后 detach 继任者(§3.4 第 7 步)。 */
369
+ export async function selfRestart() {
370
+ if (runtime.shuttingDown) return;
371
+ runtime.shuttingDown = true;
372
+ logEvent(null, 'info', 'manager 自我重启');
373
+ await teardown();
374
+ const res = await daemon.launchDetached({ waitMs: 5_000 });
375
+ if (!res.confirmed) {
376
+ // 前任已无服务能力,不回滚——交给 dshc status 诊断(§3.4 第 7 步注)
377
+ console.error('继任者未在 5s 内确认健康,请检查 manager.log 与 dshc status');
378
+ }
379
+ process.exit(0);
380
+ }
381
+
382
+ /** POST /api/manager/restart 的三模式分流(02 §9.4)。 */
383
+ async function requestRestart() {
384
+ if (runtime.mode === 'foreground') {
385
+ const err = asDshError(new Error('前台模式不支持自我重启:请在终端 Ctrl-C 后重新执行 dshc up'));
386
+ err.code = 'NOT_ALLOWED';
387
+ throw err;
388
+ }
389
+ if (runtime.mode === 'launchd') {
390
+ // KeepAlive 会把进程拉回来,退出即重启
391
+ setTimeout(() => { gracefulExit('launchd-restart').catch(() => process.exit(1)); }, 50).unref?.();
392
+ return { mode: 'launchd' };
393
+ }
394
+ setTimeout(() => { selfRestart().catch(() => process.exit(1)); }, 50).unref?.();
395
+ return { mode: 'background' };
396
+ }
397
+
398
+ let hooksInstalled = false;
399
+
400
+ function installSignalHooks() {
401
+ if (hooksInstalled) return;
402
+ hooksInstalled = true;
403
+ process.on('SIGTERM', () => { gracefulExit('SIGTERM').catch(() => process.exit(1)); });
404
+ process.on('SIGINT', () => { gracefulExit('SIGINT').catch(() => process.exit(1)); });
405
+ process.on('exit', () => {
406
+ // 同步兜底:debounce 里的 state 与自己的 pidfile
407
+ try {
408
+ store.flushStateSync();
409
+ } catch {
410
+ // 忽略
411
+ }
412
+ daemon.removePidfileIfOwn();
413
+ });
414
+ }
415
+
416
+ /** 集成测试用:拆掉服务但不退出进程。 */
417
+ export async function _shutdownForTest() {
418
+ monitor.stopLoop();
419
+ if (runtime.logTrimTimer) {
420
+ clearInterval(runtime.logTrimTimer);
421
+ runtime.logTrimTimer = null;
422
+ }
423
+ shutdownSsh(); // 与 teardown 同一条:用例进程里留下的孤儿会跨用例互相干扰
424
+ await tunnel.closeAll();
425
+ runtime.handler?.sseHub?.dispose();
426
+ if (runtime.httpServer) {
427
+ const closed = new Promise((resolve) => runtime.httpServer.close(resolve));
428
+ destroySockets();
429
+ await closed;
430
+ }
431
+ daemon.removePidfileIfOwn();
432
+ store.flushStateSync();
433
+ runtime.httpServer = null;
434
+ runtime.handler = null;
435
+ runtime.port = null;
436
+ runtime.shuttingDown = false;
437
+ }
438
+
439
+ // 直接 `node src/server.js` 即前台/后台运行(daemon.launchDetached 走这条)
440
+ if (isMainEntry(import.meta.url)) {
441
+ const idx = process.argv.indexOf('--port');
442
+ const portOverride = idx !== -1 ? Number(process.argv[idx + 1]) : null;
443
+ main({ portOverride: Number.isInteger(portOverride) ? portOverride : null }).catch((err) => {
444
+ const e = asDshError(err);
445
+ console.error(`manager 启动失败:${e.message}`);
446
+ if (e.detail) console.error(e.detail);
447
+ process.exit(1);
448
+ });
449
+ }