@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,82 @@
1
+ /**
2
+ * 发布产物的命名与校验和口径 —— 纯内核,零 import。
3
+ *
4
+ * 构建端(scripts/build-bundle.mjs)产出什么名字、消费端(src/updater.js 与
5
+ * install.sh 的 standalone 通道)去下载什么名字,必须是同一份规则:这类「两边各写一份
6
+ * 字符串拼接」的地方一旦漂移,表现是 404 而不是报错,最难查。
7
+ *
8
+ * 落地形态(解包后):
9
+ * <bundle 名>/bin/dshc 三行 shim,exec 自带 node 跑 app/src/cli.js
10
+ * <bundle 名>/runtime/bin/node 官方 Node 发行版二进制
11
+ * <bundle 名>/app/ 产品本体(内容 = check.mjs 的 PACK_RULES 白名单)
12
+ * <bundle 名>/BUNDLE_INFO.json 通道识别标记(版本 / 架构 / 运行时版本 / 来源)
13
+ */
14
+
15
+ /** 发布仓库(tag、Release、下载都指向它)。install.sh 有一份同值的默认,用例盯着两处一致。 */
16
+ export const RELEASE_REPO = 'shendeguize/Remote_DSH_Center';
17
+
18
+ /** 只发 mac:Linux 走 git 通道(本来就要求有 node),Windows 不支持。 */
19
+ export const BUNDLE_PLATFORM = 'darwin';
20
+
21
+ export const SUPPORTED_ARCHES = Object.freeze(['arm64', 'x64']);
22
+
23
+ export const SUMS_FILE = 'SHA256SUMS';
24
+
25
+ export const BUNDLE_INFO_FILE = 'BUNDLE_INFO.json';
26
+
27
+ /**
28
+ * `uname -m` / `process.arch` 的各种写法归一到发布口径。
29
+ * @returns {'arm64'|'x64'|null} 不支持的架构给 null,由调用方给人话提示
30
+ */
31
+ export function normalizeArch(raw) {
32
+ const arch = String(raw ?? '').trim().toLowerCase();
33
+ if (arch === 'arm64' || arch === 'aarch64') return 'arm64';
34
+ if (arch === 'x64' || arch === 'x86_64' || arch === 'amd64') return 'x64';
35
+ return null;
36
+ }
37
+
38
+ /** 解包后的顶层目录名,也是 tar.gz 的主干名。 */
39
+ export function bundleDirName({ version, arch }) {
40
+ return `dsh-center-v${version}-${BUNDLE_PLATFORM}-${arch}`;
41
+ }
42
+
43
+ /** Release 附件名。 */
44
+ export function assetName({ version, arch }) {
45
+ return `${bundleDirName({ version, arch })}.tar.gz`;
46
+ }
47
+
48
+ /**
49
+ * `shasum -a 256` 的输出格式:`<64 位十六进制><空白>[*]<文件名>`。
50
+ * @returns {Map<string,string>} 文件名 → 小写 sha256
51
+ */
52
+ export function parseSums(text) {
53
+ const sums = new Map();
54
+ for (const line of String(text ?? '').split('\n')) {
55
+ const m = /^([0-9a-fA-F]{64})\s+\*?(\S.*)$/.exec(line.trim());
56
+ if (m) sums.set(m[2].trim(), m[1].toLowerCase());
57
+ }
58
+ return sums;
59
+ }
60
+
61
+ /**
62
+ * 反向:写 SHA256SUMS 文件。与 `shasum -a 256 -c` 兼容(两空格分隔)。
63
+ * @param {Iterable<[string,string]>} entries [文件名, sha]
64
+ */
65
+ export function formatSums(entries) {
66
+ return `${[...entries].map(([name, sha]) => `${sha} ${name}`).join('\n')}\n`;
67
+ }
68
+
69
+ /** 某个 tag 的 Release 元信息接口。 */
70
+ export function releaseByTagUrl(tag, repo = RELEASE_REPO) {
71
+ return `https://api.github.com/repos/${repo}/releases/tags/${tag}`;
72
+ }
73
+
74
+ /** Release 列表接口(挑 latest / 含 pre-release 时用;GitHub 的 /latest 会跳过 pre-release)。 */
75
+ export function releasesUrl(repo = RELEASE_REPO, perPage = 30) {
76
+ return `https://api.github.com/repos/${repo}/releases?per_page=${perPage}`;
77
+ }
78
+
79
+ /** 附件下载地址(走 releases/download,不需要鉴权)。 */
80
+ export function assetUrl({ tag, name, repo = RELEASE_REPO }) {
81
+ return `https://github.com/${repo}/releases/download/${tag}/${name}`;
82
+ }
package/src/lib/bus.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 事件总线 + 事件环形缓冲(11 §1.4)。
3
+ * 事件名与 SSE type 一一对应;host-changed 只传主机名,序列化在发送时刻由 api 完成,
4
+ * 保证客户端拿到的永远是最新视图(天然合并 debounce 窗口内的连续变化)。
5
+ */
6
+
7
+ import { EventEmitter } from 'node:events';
8
+
9
+ export const bus = new EventEmitter();
10
+ bus.setMaxListeners(50);
11
+
12
+ const LOG_BUFFER_CAPACITY = 200;
13
+
14
+ /**
15
+ * 单条日志的字数上限。环形缓冲只按条数限长,不看字节数——「200 条」这个额度对
16
+ * 「每条 8MB」毫无约束:远端打几条巨行(base64 blob、一整坨 JSON、带 traceback 的堆栈)
17
+ * 就能把常驻的 manager 顶上去,实测 20 条 8MB 让堆从 9MB 涨到 176MB。SSE 那边还要
18
+ * 把每条原样推给每个页面、页面再塞进 DOM。
19
+ *
20
+ * msg 本来就只是单行摘要,detail 是排障现场——都不需要无限长。
21
+ */
22
+ const LOG_LINE_MAX_CHARS = 2_000;
23
+ const LOG_DETAIL_MAX_CHARS = 16_384;
24
+
25
+ /** 给截断标记用的人话长度。 */
26
+ function humanLen(chars) {
27
+ if (chars >= 1_048_576) return `${(chars / 1_048_576).toFixed(1)}MB`;
28
+ if (chars >= 1_024) return `${(chars / 1_024).toFixed(1)}KB`;
29
+ return `${chars} 字`;
30
+ }
31
+
32
+ /** 超长就切开头,并说清原本多长——读的人得知道后面还有东西。 */
33
+ function clip(text, max) {
34
+ const s = String(text);
35
+ if (s.length <= max) return s;
36
+ return `${s.slice(0, max)}…(已截断,原文 ${humanLen(s.length)})`;
37
+ }
38
+
39
+ /** @type {{host:string|null, level:'info'|'warn'|'error', msg:string, ts:string, detail:string|null}[]} */
40
+ const logBuffer = [];
41
+
42
+ /** 同一微任务内同名 host-changed 去重(11 §1.4 第 1 点)。 */
43
+ let pendingHosts = null;
44
+
45
+ export function emitHostChanged(name) {
46
+ if (pendingHosts === null) {
47
+ pendingHosts = new Set();
48
+ queueMicrotask(() => {
49
+ const batch = pendingHosts;
50
+ pendingHosts = null;
51
+ for (const host of batch) bus.emit('host-changed', host);
52
+ });
53
+ }
54
+ pendingHosts.add(name);
55
+ }
56
+
57
+ /**
58
+ * 事件面板内容。msg 恒为单行摘要;长文本只进 detail(11 §7.2 原则)。
59
+ * 同时 console 输出一份进 manager.log。
60
+ */
61
+ export function logEvent(host, level, msg, detail = null) {
62
+ // 先截断再折行:对一条 8MB 的串跑正则本身就是笔开销,而结果反正只留开头
63
+ const entry = {
64
+ host: host ?? null,
65
+ level,
66
+ msg: clip(msg, LOG_LINE_MAX_CHARS).replace(/\s*\n\s*/g, ' ').trim(),
67
+ ts: new Date().toISOString(),
68
+ detail: detail === null || detail === undefined ? null : clip(detail, LOG_DETAIL_MAX_CHARS),
69
+ };
70
+ logBuffer.push(entry);
71
+ if (logBuffer.length > LOG_BUFFER_CAPACITY) logBuffer.shift();
72
+
73
+ const tag = entry.host ? `[${entry.host}]` : '[manager]';
74
+ // 页面只吃单行 msg,但 manager.log 是事后排障的唯一现场——detail(ssh stderr 等)
75
+ // 必须落盘,缩进续行以免和下一条日志混淆。
76
+ const detailLines = entry.detail === null
77
+ ? ''
78
+ : `\n${String(entry.detail).replace(/\n+$/, '').split('\n').map((l) => ` | ${l}`).join('\n')}`;
79
+ const line = `${entry.ts} ${level.toUpperCase()} ${tag} ${entry.msg}${detailLines}`;
80
+ if (level === 'error') console.error(line);
81
+ else if (level === 'warn') console.warn(line);
82
+ else console.log(line);
83
+
84
+ bus.emit('log-line', entry);
85
+ return entry;
86
+ }
87
+
88
+ /** SSE snapshot 用:最近 n 条,按时间升序。 */
89
+ export function recentLogs(n = 50) {
90
+ return logBuffer.slice(-n).map((e) => ({ ...e }));
91
+ }
92
+
93
+ export function emitConfigChanged(changed = []) {
94
+ bus.emit('config-changed', changed);
95
+ }
96
+
97
+ /** 202 长动作结束(13 §3.4)。每个 operationId 有且仅有一条。 */
98
+ export function emitOperationDone(payload) {
99
+ bus.emit('operation-done', payload);
100
+ }
101
+
102
+ /** 测试用:清空缓冲与待发批次。 */
103
+ export function _resetForTest() {
104
+ logBuffer.length = 0;
105
+ pendingHosts = null;
106
+ bus.removeAllListeners();
107
+ }
108
+
109
+ export { LOG_BUFFER_CAPACITY, LOG_DETAIL_MAX_CHARS, LOG_LINE_MAX_CHARS };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 有界的「留尾」收集器(issue #92)。
3
+ *
4
+ * 为什么留尾不留头:这东西收的是远端命令的 stdout/stderr,两种超量场合都指向尾巴——
5
+ * POLL/VERIFY 的 `KEY=VALUE` 是我们的脚本在**最后**打的,前面刷屏的是远端登录 shell
6
+ * (.bashrc/motd);看日志的人要的也是最新几行,不是十万行之前的开头。
7
+ *
8
+ * 为什么必须有界:`stdout += chunk` 这种写法等于把 manager 的 RSS 交给远端决定。
9
+ * 越过 V8 的字符串上限(约 512MB)时抛的 `RangeError: Invalid string length` 落在流的
10
+ * data 回调里,没人接得住——manager 当场死,所有隧道陪葬。
11
+ */
12
+
13
+ /**
14
+ * @param {number} cap 留在手里的字符数上限;<=0 表示不封顶
15
+ * @returns {{push(s:string):void, text():string, dropped():number, chunkCount():number}}
16
+ */
17
+ export function createTailCapture(cap) {
18
+ /** @type {string[]} */
19
+ const chunks = [];
20
+ let size = 0;
21
+ let dropped = 0;
22
+
23
+ return {
24
+ push(s) {
25
+ if (s === '') return;
26
+ chunks.push(s);
27
+ size += s.length;
28
+ if (cap <= 0) return;
29
+ // 先整块地扔,再对边界那块切一刀——这样账本长度只跟 cap 有关,与吐了多少无关
30
+ while (chunks.length > 1 && size - chunks[0].length >= cap) {
31
+ size -= chunks[0].length;
32
+ dropped += chunks[0].length;
33
+ chunks.shift();
34
+ }
35
+ if (size > cap) {
36
+ const over = size - cap;
37
+ chunks[0] = chunks[0].slice(over);
38
+ size -= over;
39
+ dropped += over;
40
+ }
41
+ },
42
+ text() {
43
+ return chunks.join('');
44
+ },
45
+ dropped() {
46
+ return dropped;
47
+ },
48
+ /** 判据用:账本自身不许随吐出量增长。 */
49
+ chunkCount() {
50
+ return chunks.length;
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 单调钟(issue #104)。
3
+ *
4
+ * 为什么不能拿 `Date.now()` 算「过了多久 / 还剩多久」:墙钟会跳。休眠唤醒后的 NTP
5
+ * 步进校时、手动改时间、虚拟机快照恢复、和 Windows 双启动把 RTC 按本地时间写坏,
6
+ * 都能让它前后跳上几分钟乃至几小时。`deadline = Date.now() + 8000` 遇上一次 60s
7
+ * 回拨,那个「8s 上界」就变成 68s——超时不再兜底;往前跳则相反,好隧道被判超时。
8
+ *
9
+ * 单调钟只随真实流逝前进,与系统时间无关,是所有上界与窗口该用的那把尺。
10
+ */
11
+
12
+ /**
13
+ * 自进程启动起的毫秒数,单调不减。只可用于求差值,绝对值无意义。
14
+ * @returns {number}
15
+ */
16
+ export function monotonicMs() {
17
+ return performance.now();
18
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * 「这个模块是不是被直接执行的」判定。
3
+ *
4
+ * 看似 `process.argv[1] === fileURLToPath(import.meta.url)` 就够,实则不够:
5
+ * 装到 PATH 的入口是一条软链(`npm link` 与 scripts/install.mjs 都这么干),
6
+ * 此时 argv[1] 是链接名(如 ~/.local/bin/dshc),而 import.meta.url 已经是
7
+ * 解引用后的真身(src/cli.js)——只比字面路径会判成「被 import」,于是
8
+ * 命令什么都不做、还退 0,排查起来极难。故两边都取 realpath 再比。
9
+ */
10
+
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ /** @param {string} importMetaUrl 调用方的 import.meta.url */
16
+ export function isMainEntry(importMetaUrl, argv1 = process.argv[1]) {
17
+ if (!argv1) return false;
18
+ return realOf(argv1) === realOf(fileURLToPath(importMetaUrl));
19
+ }
20
+
21
+ function realOf(p) {
22
+ try {
23
+ return fs.realpathSync(p);
24
+ } catch {
25
+ return path.resolve(p);
26
+ }
27
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 统一错误形状(11 §7.1)。零依赖叶子模块:全库可 import,自身不 import 任何本地模块,
3
+ * 故不参与依赖环(ENG-24 的防环 lint 对本文件豁免 lib 互不 import 规则)。
4
+ */
5
+
6
+ /** 错误码 → 默认 HTTP 状态(11 §7.2)。 */
7
+ export const ERROR_HTTP_STATUS = Object.freeze({
8
+ VALIDATION: 400,
9
+ NOT_FOUND: 404,
10
+ SETUP_REQUIRED: 409,
11
+ PHASE_CONFLICT: 409,
12
+ NOT_ALLOWED: 409,
13
+ // 跨站防线(src/lib/origin-guard.js):都是 403,且都在路由之前判掉
14
+ FORBIDDEN_ORIGIN: 403,
15
+ FORBIDDEN_HOST: 403,
16
+ PORT_EXHAUSTED: 409,
17
+ SSH_UNREACHABLE: 502,
18
+ SSH_TIMEOUT: 504,
19
+ LOCAL_TIMEOUT: 504,
20
+ LOCAL_EXEC_FAILED: 500,
21
+ LOCAL_COPY_FAILED: 500,
22
+ LOCAL_HOST_EXISTS: 409,
23
+ LOCAL_NAME_CONFLICT: 409,
24
+ PROTO_PARSE: 500,
25
+ SETTINGS_TOO_LARGE: 413,
26
+ SETTINGS_BUSY: 409,
27
+ SETTINGS_STALE: 409,
28
+ SETTINGS_WRITE_FAILED: 500,
29
+ SETTINGS_READ_FAILED: 500,
30
+ SETTINGS_UNSUPPORTED: 501,
31
+ SETTINGS_INVALID_UTF8: 422,
32
+ WORKSPACE_BUSY: 409,
33
+ WORKSPACE_WORKDIR_REQUIRED: 400,
34
+ WORKSPACE_CWD_UNAVAILABLE: 409,
35
+ WORKSPACE_INVALID_PATH: 422,
36
+ WORKSPACE_REGISTER_FAILED: 502,
37
+ WORKSPACE_REGISTER_TIMEOUT: 504,
38
+ LAUNCH_FAILED: 500,
39
+ KILL_REFUSED: 409,
40
+ TUNNEL_FORWARD_DISABLED: 500,
41
+ TUNNEL_PORT_BUSY: 500,
42
+ STATE_ILLEGAL_TRANSITION: 500,
43
+ // 配置落盘失败(目录只读 / 磁盘满 / 卷被卸载):改动整份放弃,内存不动
44
+ CONFIG_WRITE_FAILED: 500,
45
+ // config.json 被外部手改,内存那份已过期:拒写而不是拿旧值盖掉(冲突,非故障)
46
+ CONFIG_STALE: 409,
47
+ // pidfile 写不进(同上三种成因):没有它谁也找不到这个 manager,只能硬失败
48
+ PIDFILE_WRITE_FAILED: 500,
49
+ // manager.log 开不出来:后台进程的 stdout/stderr 就指它,没有它等于没有现场
50
+ LOGFILE_OPEN_FAILED: 500,
51
+ INTERNAL: 500,
52
+ });
53
+
54
+ export class DshError extends Error {
55
+ /**
56
+ * @param {keyof typeof ERROR_HTTP_STATUS | string} code
57
+ * @param {string} message 一句话摘要(用户可读,单行)
58
+ * @param {{host?:string|null, detail?:string|null, cause?:Error}} [extra]
59
+ */
60
+ constructor(code, message, extra = {}) {
61
+ super(message, extra.cause ? { cause: extra.cause } : undefined);
62
+ this.name = 'DshError';
63
+ this.code = code;
64
+ this.host = extra.host ?? null;
65
+ this.detail = extra.detail ?? null;
66
+ }
67
+
68
+ get httpStatus() {
69
+ return ERROR_HTTP_STATUS[this.code] ?? 500;
70
+ }
71
+
72
+ /** HTTP 响应体形状(13 §1.1)。 */
73
+ toBody() {
74
+ const body = { error: this.message, code: this.code };
75
+ if (this.detail) body.detail = this.detail;
76
+ return body;
77
+ }
78
+ }
79
+
80
+ /** 把任意异常规整为 DshError(api/queue 边界用,11 §7.1)。 */
81
+ export function asDshError(err, fallbackCode = 'INTERNAL') {
82
+ if (err instanceof DshError) return err;
83
+ const message = err instanceof Error ? err.message : String(err);
84
+ return new DshError(fallbackCode, message, {
85
+ cause: err instanceof Error ? err : undefined,
86
+ detail: err instanceof Error && err.stack ? err.stack : null,
87
+ });
88
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * manager.log 的自我封顶(issue #81)。
3
+ *
4
+ * manager 在 launchd 下是 7×24 的常驻进程,日志只追加、从不回收。健康态几乎不写,
5
+ * 但链路不稳的一台主机(断联 → 退避 → 重连,每一拍都记)实测约 8MB/天——放一年
6
+ * 就是几个 G 躺在 `~/.dsh_center/` 里,没人会去看一眼。
7
+ *
8
+ * 必须**原地**截断:日志是被继承的 O_APPEND fd 在写(`launchDetached` 把 stdout/stderr
9
+ * 都指到这个文件),改名换文件的话进程还往原来那个 inode 里写,新文件永远是空的,
10
+ * 老文件继续长。
11
+ */
12
+
13
+ import fs from 'node:fs';
14
+
15
+ /** 到这个大小就动手。 */
16
+ export const LOG_FILE_CAP_BYTES = 8 * 1024 * 1024;
17
+
18
+ /** 截完留这么多尾巴——排障看的都是最近发生的事。 */
19
+ export const LOG_FILE_KEEP_BYTES = 1024 * 1024;
20
+
21
+ /**
22
+ * 要不要截、从哪儿开始留。
23
+ * @param {number} size 当前字节数
24
+ * @returns {{trim:boolean, from:number}} from = 从这个偏移读到末尾
25
+ */
26
+ export function planTrim(size, { capBytes = LOG_FILE_CAP_BYTES, keepBytes = LOG_FILE_KEEP_BYTES } = {}) {
27
+ if (!(size > capBytes)) return { trim: false, from: 0 };
28
+ const keep = Math.min(keepBytes, capBytes);
29
+ return { trim: true, from: Math.max(0, size - keep) };
30
+ }
31
+
32
+ /**
33
+ * 超了就原地截断,只留尾巴,并在最前面补一行说明丢了多少。
34
+ * 任何 IO 失败都吞掉:日志封顶失败不该把调用它的主流程(启动、巡检)带下去。
35
+ * @returns {{trimmed:boolean, dropped:number}} dropped = 丢掉的字节数
36
+ */
37
+ export function trimLogFile(file, { capBytes = LOG_FILE_CAP_BYTES, keepBytes = LOG_FILE_KEEP_BYTES } = {}) {
38
+ try {
39
+ const { size } = fs.statSync(file);
40
+ const plan = planTrim(size, { capBytes, keepBytes });
41
+ if (!plan.trim) return { trimmed: false, dropped: 0 };
42
+
43
+ const fd = fs.openSync(file, 'r');
44
+ let tail;
45
+ try {
46
+ const buf = Buffer.alloc(size - plan.from);
47
+ fs.readSync(fd, buf, 0, buf.length, plan.from);
48
+ tail = buf.toString('utf8');
49
+ } finally {
50
+ fs.closeSync(fd);
51
+ }
52
+
53
+ // 从块中间切进来的首行是残行,丢掉它:留半行只会让人怀疑自己看错了
54
+ const cut = tail.indexOf('\n');
55
+ const body = cut === -1 ? '' : tail.slice(cut + 1);
56
+ const dropped = size - Buffer.byteLength(body, 'utf8');
57
+ const head = `${new Date().toISOString()} INFO [manager] 日志已原地截断,丢掉较早的 ${dropped} 字节`
58
+ + `(上限 ${capBytes} 字节)\n`;
59
+ // 同一个 inode 上覆盖写:换文件的话那个 O_APPEND fd 就写进孤立文件里了
60
+ fs.writeFileSync(file, body.endsWith('\n') || body === '' ? `${head}${body}` : `${head}${body}\n`);
61
+ return { trimmed: true, dropped };
62
+ } catch {
63
+ return { trimmed: false, dropped: 0 };
64
+ }
65
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * 主机状态机(11 §2)。README 状态机图为子集,02 行为要求的补全迁移已在
3
+ * README「契约疑议」第 2 条报备并裁决采纳。
4
+ */
5
+
6
+ import { DshError } from './errors.js';
7
+
8
+ export const PHASES = Object.freeze([
9
+ 'unknown',
10
+ 'unreachable',
11
+ 'no_dsh',
12
+ 'ready',
13
+ 'starting',
14
+ 'running',
15
+ 'degraded',
16
+ 'crashed',
17
+ ]);
18
+
19
+ /** 探测的三分类结果。 */
20
+ export const PROBE_OUT = Object.freeze(['unreachable', 'no_dsh', 'ready']);
21
+
22
+ /**
23
+ * from → 允许的 to。不含 from===to 的自环:自环恒许可(只刷新数据,不算迁移)。
24
+ * @type {Readonly<Record<string, readonly string[]>>}
25
+ */
26
+ export const TRANSITIONS = Object.freeze({
27
+ unknown: PROBE_OUT,
28
+ unreachable: PROBE_OUT,
29
+ no_dsh: PROBE_OUT,
30
+ ready: Object.freeze([...PROBE_OUT, 'starting']),
31
+ crashed: Object.freeze([...PROBE_OUT, 'starting']),
32
+ starting: Object.freeze(['running', 'ready']),
33
+ running: Object.freeze(['degraded', 'crashed', 'ready']),
34
+ degraded: Object.freeze(['running', 'crashed', 'ready']),
35
+ });
36
+
37
+ /** 探测不得改写这三态的 phase(11 §2.2 说明):只刷新 manualInstances。 */
38
+ export const PROBE_PROTECTED_PHASES = Object.freeze(['starting', 'running', 'degraded']);
39
+
40
+ export function isPhase(p) {
41
+ return PHASES.includes(p);
42
+ }
43
+
44
+ export function canTransition(from, to) {
45
+ if (!isPhase(from) || !isPhase(to)) return false;
46
+ if (from === to) return true;
47
+ return (TRANSITIONS[from] ?? []).includes(to);
48
+ }
49
+
50
+ /**
51
+ * 终审守卫(11 §2.3 第三层)。走到这里还非法 = 代码 bug。
52
+ * @throws {DshError} STATE_ILLEGAL_TRANSITION
53
+ */
54
+ export function assertTransition(from, to, cause = 'unknown') {
55
+ if (!canTransition(from, to)) {
56
+ throw new DshError(
57
+ 'STATE_ILLEGAL_TRANSITION',
58
+ `非法状态迁移 ${from} → ${to}(触发:${cause})`,
59
+ { detail: `合法目标:${(TRANSITIONS[from] ?? []).join(', ') || '(无)'}` },
60
+ );
61
+ }
62
+ return to;
63
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 本机 HTTP 面的跨站防线(纯函数,便于逐条判定)。
3
+ *
4
+ * manager 只监听 127.0.0.1,但「只听本机」挡不住**浏览器**替别人发请求:用户随便打开
5
+ * 一个网页,那个网页就能 `fetch('http://127.0.0.1:<port>/api/hosts/x/start')`。这种简单
6
+ * 请求不触发预检,浏览器照发;攻击者读不到响应,可副作用照样发生——实测里一个陌生
7
+ * origin 的页面真把远端会话拉起来了。
8
+ *
9
+ * 两道闸:
10
+ *
11
+ * 1. `Origin` 在场就必须是 manager 自己的 origin。命令行工具不带这个头,一律放行;
12
+ * 浏览器发跨站请求时必带,于是被挡。
13
+ * 2. `Host` 必须是环回名。DNS rebinding 的落地形态就是「攻击者域名解析到 127.0.0.1」,
14
+ * 那时浏览器认为同源,第 1 道闸失效,只有这道拦得住。
15
+ */
16
+
17
+ /** 允许出现在 Host / Origin 里的主机名。 */
18
+ const LOOPBACK_NAMES = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
19
+
20
+ /** Host 头里取主机名:去掉端口,IPv6 的方括号一并留着比对。 */
21
+ export function hostnameOf(hostHeader) {
22
+ const raw = String(hostHeader ?? '').trim();
23
+ if (raw === '') return '';
24
+ if (raw.startsWith('[')) return raw.slice(0, raw.indexOf(']') + 1).toLowerCase();
25
+ const i = raw.lastIndexOf(':');
26
+ return (i === -1 ? raw : raw.slice(0, i)).toLowerCase();
27
+ }
28
+
29
+ export function isLoopbackHost(hostHeader) {
30
+ return LOOPBACK_NAMES.has(hostnameOf(hostHeader));
31
+ }
32
+
33
+ /**
34
+ * @param {{headers:Record<string,string|undefined>, port:number}} req
35
+ * @returns {{ok:true}|{ok:false, status:number, code:string, message:string}}
36
+ */
37
+ export function checkRequestOrigin({ headers = {}, port }) {
38
+ const host = headers.host ?? headers.Host;
39
+ if (!isLoopbackHost(host)) {
40
+ return {
41
+ ok: false,
42
+ status: 403,
43
+ code: 'FORBIDDEN_HOST',
44
+ // 不回显攻击者给的域名:这段文本会原样出现在响应体里
45
+ message: 'manager 只接受来自本机的请求(Host 必须是 127.0.0.1 或 localhost)。',
46
+ };
47
+ }
48
+
49
+ const origin = headers.origin ?? headers.Origin;
50
+ if (origin === undefined || origin === '') return { ok: true };
51
+ // 'null' 是 file:// 与沙箱 iframe 的 origin,同样不是自己
52
+ let parsed;
53
+ try {
54
+ parsed = new URL(origin);
55
+ } catch {
56
+ return { ok: false, status: 403, code: 'FORBIDDEN_ORIGIN', message: 'manager 拒绝跨站请求。' };
57
+ }
58
+ const sameOrigin = parsed.protocol === 'http:'
59
+ && LOOPBACK_NAMES.has(parsed.hostname.toLowerCase())
60
+ && parsed.port === String(port);
61
+ return sameOrigin
62
+ ? { ok: true }
63
+ : { ok: false, status: 403, code: 'FORBIDDEN_ORIGIN', message: 'manager 拒绝跨站请求。' };
64
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * 有界并发(扇出闸)。
3
+ *
4
+ * 为什么需要:一台 manager 管 N 台远端时,探测/恢复/自启这类「对每台各来一次 ssh」的
5
+ * 动作,无闸就是 N 条 ssh 同时握手。远端 sshd 的 `MaxStartups` 出厂值是 `10:30:100`——
6
+ * 未完成认证的连接超过 10 条起就按比例随机丢,到 100 条全丢;多台远端共用一台跳板机
7
+ * (ProxyJump)时这个额度是**所有主机合起来**算的。被丢的那几台会以
8
+ * `kex_exchange_identification: Connection closed by remote host` 收场,页面上表现为
9
+ * 「随机几台不可达」,而它们其实好端端的。
10
+ *
11
+ * 语义与 `Promise.allSettled` 对齐:不短路、结果按入参顺序、单个任务抛错不影响其余。
12
+ */
13
+
14
+ /**
15
+ * 长期在那儿的那道闸(issue #100)。
16
+ *
17
+ * `mapPool` 管的是「一把任务一次发完」,隧道重连环不是那个形状:每台主机各有一个退避
18
+ * 定时器,各自到点各自敲门,谁也不知道此刻还有几台也在敲。网络整体回来的那一瞬
19
+ * (合盖睡醒、Wi-Fi 切换、跳板机抖一下),这些定时器会挤在同一刻——那正是跳板机
20
+ * 最忙的时候,而它们全都绕过了 `mapPool` 那道闸。
21
+ *
22
+ * 排队是 FIFO:先到的先走,不许后来的插队饿死前面的。
23
+ * @param {number} limit 同时在里面的上限;<=0 表示不限
24
+ */
25
+ export function createGate(limit) {
26
+ let inFlight = 0;
27
+ /** @type {(() => void)[]} */
28
+ const waiting = [];
29
+
30
+ const release = () => {
31
+ inFlight -= 1;
32
+ waiting.shift()?.();
33
+ };
34
+
35
+ return {
36
+ /**
37
+ * @template R
38
+ * @param {() => Promise<R>} fn
39
+ * @returns {Promise<R>}
40
+ */
41
+ async run(fn) {
42
+ if (limit > 0) {
43
+ if (inFlight >= limit) await new Promise((resolve) => { waiting.push(resolve); });
44
+ inFlight += 1;
45
+ }
46
+ try {
47
+ return await fn();
48
+ } finally {
49
+ if (limit > 0) release();
50
+ }
51
+ },
52
+ /** 判据用。 */
53
+ stats() {
54
+ return { inFlight, waiting: waiting.length };
55
+ },
56
+ };
57
+ }
58
+
59
+ /**
60
+ * @template T, R
61
+ * @param {T[]} items
62
+ * @param {(item:T, index:number)=>Promise<R>} fn
63
+ * @param {number} limit 同时在飞的上限;<=0 或 >= items.length 时等价于全并发
64
+ * @returns {Promise<PromiseSettledResult<R>[]>}
65
+ */
66
+ export async function mapPool(items, fn, limit) {
67
+ const results = new Array(items.length);
68
+ if (items.length === 0) return results;
69
+
70
+ const width = limit > 0 ? Math.min(limit, items.length) : items.length;
71
+ let next = 0;
72
+
73
+ const worker = async () => {
74
+ for (;;) {
75
+ const i = next;
76
+ next += 1;
77
+ if (i >= items.length) return;
78
+ try {
79
+ results[i] = { status: 'fulfilled', value: await fn(items[i], i) };
80
+ } catch (reason) {
81
+ results[i] = { status: 'rejected', reason };
82
+ }
83
+ }
84
+ };
85
+
86
+ await Promise.all(Array.from({ length: width }, worker));
87
+ return results;
88
+ }