@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,550 @@
1
+ /**
2
+ * 固定路径 dsh settings.yaml 领域模块。
3
+ *
4
+ * 内容只在内存与子进程 stdin 中短暂停留;协议错误一律丢弃原始输出,避免把可能含凭据的
5
+ * content/hex/stdin 带进 DshError。远端与 local:true 共用协议、解析器和 hostQueue。
6
+ */
7
+
8
+ import { randomUUID } from 'node:crypto';
9
+
10
+ import { DshError } from './lib/errors.js';
11
+ import { buildSettingsReadScript, buildSettingsWriteScript } from './lib/proto.js';
12
+ import { assertSafeHost } from './lib/shq.js';
13
+ import {
14
+ execFailure,
15
+ hostQueue,
16
+ localExec,
17
+ sshExec,
18
+ } from './lib/ssh.js';
19
+
20
+ export const SETTINGS_MAX_BYTES = 512 * 1024;
21
+
22
+ const CHECKSUM_RE = /^cksum-v1:(0|[1-9][0-9]{0,9}):(0|[1-9][0-9]{0,6})$/u;
23
+ const UINT_RE = /^(?:0|[1-9][0-9]*)$/u;
24
+ const KV_RE = /^([A-Z][A-Z0-9_]*)=(.*)$/u;
25
+ const BLOCK_RE = /^([A-Z][A-Z0-9_]*)<<([A-Z][A-Z0-9_]*)$/u;
26
+ const ASCII_WHITESPACE_RE = /[\t\n\v\f\r ]/gu;
27
+ const HEX_RE = /^[0-9a-fA-F]*$/u;
28
+ const CRC_POLYNOMIAL = 0x04c11db7;
29
+ const PROTO_MESSAGE = 'settings.yaml 协议响应无效,请重试';
30
+ const activeSettingsHosts = new Set();
31
+ const SETTINGS_DOMAIN_EXIT_CODES = new Set([1, 10, 11, 12]);
32
+
33
+ const CRC_TABLE = Object.freeze(Array.from({ length: 256 }, (_, index) => {
34
+ let crc = (index << 24) >>> 0;
35
+ for (let bit = 0; bit < 8; bit += 1) {
36
+ crc = (crc & 0x80000000) !== 0
37
+ ? (((crc << 1) ^ CRC_POLYNOMIAL) >>> 0)
38
+ : ((crc << 1) >>> 0);
39
+ }
40
+ return crc;
41
+ }));
42
+
43
+ function crcByte(crc, byte) {
44
+ return (((crc << 8) >>> 0) ^ CRC_TABLE[((crc >>> 24) ^ byte) & 0xff]) >>> 0;
45
+ }
46
+
47
+ /**
48
+ * POSIX `cksum` 使用的 CRC(IEEE 1003.1):数据后追加最低字节优先的文件长度,再逐位取反。
49
+ * 这不是密码学 hash,只用于与目标端 cksum 交叉验证及生成 CAS token。
50
+ */
51
+ export function posixCksum(input) {
52
+ if (!Buffer.isBuffer(input) && !(input instanceof Uint8Array)) {
53
+ throw new TypeError('posixCksum input 必须是 Buffer 或 Uint8Array');
54
+ }
55
+ const bytes = Buffer.isBuffer(input)
56
+ ? input
57
+ : Buffer.from(input.buffer, input.byteOffset, input.byteLength);
58
+ let crc = 0;
59
+ for (const byte of bytes) crc = crcByte(crc, byte);
60
+ let length = bytes.byteLength;
61
+ while (length > 0) {
62
+ crc = crcByte(crc, length & 0xff);
63
+ length = Math.floor(length / 256);
64
+ }
65
+ return (~crc) >>> 0;
66
+ }
67
+
68
+ function protocolError(host, operation = 'read') {
69
+ const message = operation === 'write'
70
+ ? 'settings.yaml 保存响应无法确认,保存结果未知,请先重新 GET 后确认实际内容'
71
+ : PROTO_MESSAGE;
72
+ return new DshError('PROTO_PARSE', message, { host });
73
+ }
74
+
75
+ function invalidUtf8Error(host) {
76
+ return new DshError(
77
+ 'SETTINGS_INVALID_UTF8',
78
+ 'settings.yaml 不是有效的 UTF-8 文本,无法安全编辑',
79
+ { host },
80
+ );
81
+ }
82
+
83
+ function tooLargeError(host) {
84
+ return new DshError(
85
+ 'SETTINGS_TOO_LARGE',
86
+ 'settings.yaml 超过 512 KiB,无法安全处理',
87
+ { host },
88
+ );
89
+ }
90
+
91
+ function cleanTransportError(operation, host, label, result) {
92
+ const failure = execFailure(host, label, result);
93
+ if (!failure) return protocolError(host, operation);
94
+ // stderr 由对端控制,settings 命令甚至可能错误回显 stdin;领域边界不透传 detail/cause。
95
+ const message = operation === 'write'
96
+ ? `${failure.message};保存结果未知,请重新 GET 后确认实际内容`
97
+ : failure.message;
98
+ return new DshError(failure.code, message, { host });
99
+ }
100
+
101
+ function assertOnlyKeys(record, allowed) {
102
+ for (const key of Object.keys(record)) {
103
+ if (!allowed.has(key)) throw new Error('unexpected protocol key');
104
+ }
105
+ }
106
+
107
+ function oneValue(frame, key) {
108
+ const values = frame.kv[key];
109
+ if (!values || values.length !== 1) throw new Error('missing or repeated protocol key');
110
+ return values[0];
111
+ }
112
+
113
+ function decimal(value, max) {
114
+ if (!UINT_RE.test(value)) throw new Error('invalid protocol integer');
115
+ const number = Number(value);
116
+ if (!Number.isSafeInteger(number) || number > max) throw new Error('protocol integer out of range');
117
+ return number;
118
+ }
119
+
120
+ function decodeHex(raw) {
121
+ const hex = raw.replace(ASCII_WHITESPACE_RE, '');
122
+ if (!HEX_RE.test(hex) || hex.length % 2 !== 0) throw new Error('invalid protocol hex');
123
+ return Buffer.from(hex, 'hex');
124
+ }
125
+
126
+ function decodeUtf8(bytes) {
127
+ // ignoreBOM=true 表示“不把 BOM 当签名吞掉”,从而保持 GET→PUT 字节全等。
128
+ return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
129
+ }
130
+
131
+ function decodePath(raw) {
132
+ const path = decodeUtf8(decodeHex(raw));
133
+ if (
134
+ path.length === 0
135
+ || !path.startsWith('/')
136
+ || path.includes('\0')
137
+ || !path.endsWith('/settings.yaml')
138
+ ) {
139
+ throw new Error('invalid settings path');
140
+ }
141
+ return path;
142
+ }
143
+
144
+ /**
145
+ * 只解析协议 framing,不把原文装入错误。块允许 POSIX od 的 ASCII 空白,重复块一律拒绝。
146
+ */
147
+ function parseFrame(stdout) {
148
+ const lines = String(stdout ?? '').replace(/\r\n/gu, '\n').split('\n');
149
+ const kv = {};
150
+ const blocks = {};
151
+
152
+ for (let index = 0; index < lines.length; index += 1) {
153
+ const line = lines[index];
154
+ if (line === '') continue;
155
+
156
+ const opener = BLOCK_RE.exec(line);
157
+ if (opener) {
158
+ const [, key, delimiter] = opener;
159
+ if (Object.hasOwn(blocks, key)) throw new Error('repeated protocol block');
160
+ const body = [];
161
+ let closed = false;
162
+ for (index += 1; index < lines.length; index += 1) {
163
+ if (lines[index] === delimiter) {
164
+ closed = true;
165
+ break;
166
+ }
167
+ body.push(lines[index]);
168
+ }
169
+ if (!closed) throw new Error('truncated protocol block');
170
+ blocks[key] = body.join('\n');
171
+ continue;
172
+ }
173
+
174
+ const pair = KV_RE.exec(line);
175
+ if (!pair) throw new Error('unexpected protocol line');
176
+ (kv[pair[1]] ??= []).push(pair[2]);
177
+ }
178
+
179
+ return { kv, blocks };
180
+ }
181
+
182
+ function assertProtocolIdentity(frame, expectedTxn) {
183
+ if (oneValue(frame, 'SETTINGS_PROTO') !== '1') throw new Error('unsupported protocol version');
184
+ if (oneValue(frame, 'SETTINGS_TXN') !== expectedTxn) throw new Error('settings transaction mismatch');
185
+ }
186
+
187
+ function parseSuccessPath(frame, expectedBlocks) {
188
+ assertOnlyKeys(frame.blocks, expectedBlocks);
189
+ if (!Object.hasOwn(frame.blocks, 'PATH_HEX')) throw new Error('missing path block');
190
+ return decodePath(frame.blocks.PATH_HEX);
191
+ }
192
+
193
+ /**
194
+ * READ 成功结果解析测试缝。调用方只应传执行器的 code=0 ExecResult。
195
+ * 任意 framing/CRC/size/path 故障统一折叠为无 detail/cause 的安全 PROTO_PARSE。
196
+ */
197
+ export function _parseSettingsReadResult(result, host = null, expectedTxn) {
198
+ try {
199
+ if (
200
+ result?.timedOut
201
+ || result?.aborted
202
+ || result?.code !== 0
203
+ || result?.stdoutDropped !== 0
204
+ ) {
205
+ throw new Error('incomplete read result');
206
+ }
207
+ const frame = parseFrame(result.stdout);
208
+ assertOnlyKeys(frame.kv, new Set([
209
+ 'SETTINGS_PROTO',
210
+ 'SETTINGS_TXN',
211
+ 'EXISTS',
212
+ 'SIZE',
213
+ 'CRC',
214
+ 'SETTINGS_READ_DONE',
215
+ ]));
216
+ assertProtocolIdentity(frame, expectedTxn);
217
+ if (oneValue(frame, 'SETTINGS_READ_DONE') !== 'yes') throw new Error('missing read sentinel');
218
+ const existsValue = oneValue(frame, 'EXISTS');
219
+ if (existsValue !== 'yes' && existsValue !== 'no') throw new Error('invalid exists flag');
220
+ const exists = existsValue === 'yes';
221
+ const size = decimal(oneValue(frame, 'SIZE'), SETTINGS_MAX_BYTES);
222
+ const path = parseSuccessPath(frame, new Set(['PATH_HEX', 'CONTENT_HEX']));
223
+ if (!Object.hasOwn(frame.blocks, 'CONTENT_HEX')) throw new Error('missing content block');
224
+ const contentBytes = decodeHex(frame.blocks.CONTENT_HEX);
225
+
226
+ if (!exists) {
227
+ if (size !== 0 || Object.hasOwn(frame.kv, 'CRC') || contentBytes.byteLength !== 0) {
228
+ throw new Error('invalid missing-file shape');
229
+ }
230
+ return { exists: false, path, content: '', checksum: null, size: 0 };
231
+ }
232
+
233
+ const crc = decimal(oneValue(frame, 'CRC'), 0xffff_ffff);
234
+ if (contentBytes.byteLength !== size || frame.blocks.CONTENT_HEX.replace(ASCII_WHITESPACE_RE, '').length !== size * 2) {
235
+ throw new Error('content size mismatch');
236
+ }
237
+ if (posixCksum(contentBytes) !== crc) throw new Error('content checksum mismatch');
238
+
239
+ let content;
240
+ try {
241
+ content = decodeUtf8(contentBytes);
242
+ } catch {
243
+ throw invalidUtf8Error(host);
244
+ }
245
+ return {
246
+ exists: true,
247
+ path,
248
+ content,
249
+ checksum: `cksum-v1:${crc}:${size}`,
250
+ size,
251
+ };
252
+ } catch (error) {
253
+ if (error instanceof DshError && error.code === 'SETTINGS_INVALID_UTF8') throw error;
254
+ throw protocolError(host);
255
+ }
256
+ }
257
+
258
+ function parseSettingsWriteResult(result, input, host, expectedTxn) {
259
+ try {
260
+ if (
261
+ result?.timedOut
262
+ || result?.aborted
263
+ || result?.code !== 0
264
+ || result?.stdoutDropped !== 0
265
+ ) {
266
+ throw new Error('incomplete write result');
267
+ }
268
+ const frame = parseFrame(result.stdout);
269
+ assertOnlyKeys(frame.kv, new Set([
270
+ 'SETTINGS_PROTO',
271
+ 'SETTINGS_TXN',
272
+ 'NEW_SIZE',
273
+ 'NEW_CRC',
274
+ 'SETTINGS_WRITE_DONE',
275
+ ]));
276
+ assertProtocolIdentity(frame, expectedTxn);
277
+ if (oneValue(frame, 'SETTINGS_WRITE_DONE') !== 'yes') throw new Error('missing write sentinel');
278
+ const path = parseSuccessPath(frame, new Set(['PATH_HEX']));
279
+ const size = decimal(oneValue(frame, 'NEW_SIZE'), SETTINGS_MAX_BYTES);
280
+ const crc = decimal(oneValue(frame, 'NEW_CRC'), 0xffff_ffff);
281
+ if (size !== input.byteLength || crc !== posixCksum(input)) {
282
+ throw new Error('write verification mismatch');
283
+ }
284
+ return {
285
+ updated: true,
286
+ path,
287
+ checksum: `cksum-v1:${crc}:${size}`,
288
+ size,
289
+ };
290
+ } catch {
291
+ throw protocolError(host, 'write');
292
+ }
293
+ }
294
+
295
+ function hasExpectedTransaction(stdout, expectedTxn) {
296
+ const lines = String(stdout ?? '').split(/\r?\n/u);
297
+ return lines.some((line) => line === `SETTINGS_TXN=${expectedTxn}`);
298
+ }
299
+
300
+ function parseErrorFrame(result, operation, expectedTxn) {
301
+ const frame = parseFrame(result.stdout);
302
+ assertOnlyKeys(frame.kv, new Set(['SETTINGS_PROTO', 'SETTINGS_TXN', 'ERR', 'COMMIT_STATE']));
303
+ assertOnlyKeys(frame.blocks, new Set());
304
+ assertProtocolIdentity(frame, expectedTxn);
305
+ const marker = oneValue(frame, 'ERR');
306
+ const states = frame.kv.COMMIT_STATE;
307
+ const commitState = states === undefined
308
+ ? null
309
+ : states.length === 1 && ['not-committed', 'unknown'].includes(states[0])
310
+ ? states[0]
311
+ : (() => { throw new Error('invalid commit state'); })();
312
+
313
+ if (marker === 'settings-unsupported' && result.code === 1 && commitState === null) {
314
+ return { code: 'SETTINGS_UNSUPPORTED', commitState };
315
+ }
316
+ if (operation === 'read') {
317
+ if (commitState !== null) throw new Error('read result has commit state');
318
+ if (marker === 'settings-too-large' && result.code === 10) {
319
+ return { code: 'SETTINGS_TOO_LARGE', commitState };
320
+ }
321
+ if (marker === 'settings-read' && result.code === 1) {
322
+ return { code: 'SETTINGS_READ_FAILED', commitState };
323
+ }
324
+ throw new Error('invalid read error marker');
325
+ }
326
+
327
+ if (marker === 'settings-too-large' && result.code === 10 && commitState !== null) {
328
+ return { code: 'SETTINGS_TOO_LARGE', commitState };
329
+ }
330
+ if (marker === 'settings-stale' && result.code === 11 && commitState !== null) {
331
+ return { code: 'SETTINGS_STALE', commitState };
332
+ }
333
+ if (marker === 'settings-write' && result.code === 12 && commitState !== null) {
334
+ return { code: 'SETTINGS_WRITE_FAILED', commitState };
335
+ }
336
+ throw new Error('invalid write error marker');
337
+ }
338
+
339
+ function domainFailure(host, code, commitState) {
340
+ if (commitState === 'unknown') {
341
+ if (code === 'SETTINGS_STALE') {
342
+ return new DshError(
343
+ code,
344
+ 'settings.yaml 保存结果未知,请重新 GET 后确认实际内容',
345
+ { host },
346
+ );
347
+ }
348
+ return new DshError(
349
+ 'SETTINGS_WRITE_FAILED',
350
+ 'settings.yaml 保存结果未知,请重新 GET 后确认实际内容',
351
+ { host },
352
+ );
353
+ }
354
+ if (code === 'SETTINGS_TOO_LARGE') return tooLargeError(host);
355
+ if (code === 'SETTINGS_UNSUPPORTED') {
356
+ return new DshError(
357
+ code,
358
+ '该主机缺少兼容的 POSIX 文件工具,无法编辑 settings.yaml',
359
+ { host },
360
+ );
361
+ }
362
+ if (code === 'SETTINGS_READ_FAILED') {
363
+ return new DshError(
364
+ code,
365
+ 'settings.yaml 读取失败,请通过 SSH 检查文件类型与权限',
366
+ { host },
367
+ );
368
+ }
369
+ if (code === 'SETTINGS_STALE') {
370
+ return new DshError(code, 'settings.yaml 已变化,请重新 GET 后再保存', { host });
371
+ }
372
+ return new DshError(code, 'settings.yaml 保存失败,请检查目标目录与文件权限', { host });
373
+ }
374
+
375
+ function assertExecutionSucceeded(operation, host, result, expectedTxn) {
376
+ const label = operation === 'read' ? '读取 settings.yaml' : '保存 settings.yaml';
377
+ if (result?.timedOut || result?.aborted) {
378
+ throw cleanTransportError(operation, host, label, result);
379
+ }
380
+ if (result?.code === 0) return;
381
+
382
+ if (
383
+ SETTINGS_DOMAIN_EXIT_CODES.has(result?.code)
384
+ && hasExpectedTransaction(result?.stdout, expectedTxn)
385
+ ) {
386
+ if (result?.stdoutDropped !== 0) throw protocolError(host, operation);
387
+ try {
388
+ const { code, commitState } = parseErrorFrame(result, operation, expectedTxn);
389
+ throw domainFailure(host, code, commitState);
390
+ } catch (error) {
391
+ if (error instanceof DshError) throw error;
392
+ throw protocolError(host, operation);
393
+ }
394
+ }
395
+ throw cleanTransportError(operation, host, label, result);
396
+ }
397
+
398
+ function assertBaseChecksum(baseChecksum) {
399
+ if (baseChecksum === null) return;
400
+ if (typeof baseChecksum !== 'string') {
401
+ throw new DshError(
402
+ 'VALIDATION',
403
+ 'baseChecksum 必须是 cksum-v1 token 或 null',
404
+ );
405
+ }
406
+ const match = CHECKSUM_RE.exec(baseChecksum);
407
+ if (
408
+ !match
409
+ || Number(match[1]) > 0xffff_ffff
410
+ || Number(match[2]) > SETTINGS_MAX_BYTES
411
+ ) {
412
+ throw new DshError(
413
+ 'VALIDATION',
414
+ 'baseChecksum 格式无效,应为 cksum-v1:<CRC>:<字节数> 或 null',
415
+ );
416
+ }
417
+ }
418
+
419
+ function hasUnpairedSurrogate(content) {
420
+ for (let index = 0; index < content.length; index += 1) {
421
+ const unit = content.charCodeAt(index);
422
+ if (unit >= 0xd800 && unit <= 0xdbff) {
423
+ const next = content.charCodeAt(index + 1);
424
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
425
+ index += 1;
426
+ } else if (unit >= 0xdc00 && unit <= 0xdfff) {
427
+ return true;
428
+ }
429
+ }
430
+ return false;
431
+ }
432
+
433
+ function validateSettingsContent(content, host) {
434
+ if (typeof content !== 'string') {
435
+ throw new DshError('VALIDATION', 'content 必须是 string', { host });
436
+ }
437
+ if (hasUnpairedSurrogate(content)) {
438
+ throw new DshError(
439
+ 'VALIDATION',
440
+ 'content 含未配对的 Unicode surrogate,无法无损编码为 UTF-8',
441
+ { host },
442
+ );
443
+ }
444
+ const size = Buffer.byteLength(content, 'utf8');
445
+ if (size > SETTINGS_MAX_BYTES) throw tooLargeError(host);
446
+ return size;
447
+ }
448
+
449
+ function assertResolveLocal(resolveLocal) {
450
+ if (typeof resolveLocal !== 'function') {
451
+ throw new DshError(
452
+ 'VALIDATION',
453
+ 'settings 操作必须提供 resolveLocal 以在队首读取最新主机配置',
454
+ );
455
+ }
456
+ }
457
+
458
+ function currentLocal(host, resolveLocal) {
459
+ const local = resolveLocal(host);
460
+ if (local !== null && (typeof local === 'object' || typeof local === 'function')) {
461
+ let then;
462
+ try {
463
+ then = local.then;
464
+ } catch {
465
+ throw new DshError('INTERNAL', 'resolveLocal 返回值无法安全检查', { host });
466
+ }
467
+ if (typeof then === 'function') {
468
+ // resolver 契约是同步的,但调用方若误传已拒绝 Promise,仍须立即挂 rejection handler,
469
+ // 否则我们虽同步抛 VALIDATION,原 Promise 会在下一轮触发 unhandledRejection。
470
+ Promise.resolve(local).catch(() => {});
471
+ throw new DshError('VALIDATION', 'resolveLocal 必须是同步 resolver,不能返回 Promise', { host });
472
+ }
473
+ }
474
+ if (typeof local !== 'boolean') {
475
+ throw new DshError('VALIDATION', 'resolveLocal 必须返回 boolean', { host });
476
+ }
477
+ return local;
478
+ }
479
+
480
+ function acquireSettingsSlot(host) {
481
+ if (activeSettingsHosts.has(host)) {
482
+ throw new DshError(
483
+ 'SETTINGS_BUSY',
484
+ '该主机已有 settings.yaml 操作正在进行,请稍后重试',
485
+ { host },
486
+ );
487
+ }
488
+ activeSettingsHosts.add(host);
489
+ return () => activeSettingsHosts.delete(host);
490
+ }
491
+
492
+ function settingsTransaction(operation) {
493
+ return `${operation}-${randomUUID()}`;
494
+ }
495
+
496
+ async function execute(host, local, command, { signal, input } = {}) {
497
+ return local
498
+ ? localExec(command, { signal, input })
499
+ : sshExec(host, command, { signal, input });
500
+ }
501
+
502
+ /** 读取固定 `${DSH_HOME:-$HOME/.dsh}/settings.yaml`。 */
503
+ export async function readDshSettings(host, { resolveLocal } = {}) {
504
+ assertSafeHost(host);
505
+ assertResolveLocal(resolveLocal);
506
+ const release = acquireSettingsSlot(host);
507
+ try {
508
+ return await hostQueue(host).run('settings-read', async (signal) => {
509
+ const local = currentLocal(host, resolveLocal);
510
+ const txn = settingsTransaction('read');
511
+ const command = buildSettingsReadScript({ txn });
512
+ const result = await execute(host, local, command, { signal });
513
+ assertExecutionSucceeded('read', host, result, txn);
514
+ return _parseSettingsReadResult(result, host, txn);
515
+ });
516
+ } finally {
517
+ release();
518
+ }
519
+ }
520
+
521
+ /**
522
+ * 以 baseChecksum 做 CAS,原样写入固定 settings.yaml;成功响应不回显 content。
523
+ */
524
+ export async function writeDshSettings(
525
+ host,
526
+ { resolveLocal, content, baseChecksum } = {},
527
+ ) {
528
+ assertSafeHost(host);
529
+ assertResolveLocal(resolveLocal);
530
+ assertBaseChecksum(baseChecksum);
531
+ validateSettingsContent(content, host);
532
+ const release = acquireSettingsSlot(host);
533
+ try {
534
+ // 512 KiB Buffer 只在成功占位后创建;排队上限由 activeSettingsHosts 保证为每主机一份。
535
+ const input = Buffer.from(content, 'utf8');
536
+ return await hostQueue(host).run('settings-write', async (signal) => {
537
+ const local = currentLocal(host, resolveLocal);
538
+ const txn = settingsTransaction('write');
539
+ const command = buildSettingsWriteScript({
540
+ txn,
541
+ baseChecksum,
542
+ });
543
+ const result = await execute(host, local, command, { signal, input });
544
+ assertExecutionSucceeded('write', host, result, txn);
545
+ return parseSettingsWriteResult(result, input, host, txn);
546
+ });
547
+ } finally {
548
+ release();
549
+ }
550
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * ~/.ssh/config 解析 → 主机清单(11 §1.2)。
3
+ * 规则:只收 Host 块;含 * ? ! 的 pattern 剔除;同名后写覆盖;
4
+ * Include 由 loadHosts 展开后逐段调用 parseSshConfig。
5
+ */
6
+
7
+ import fs from 'node:fs';
8
+ import os from 'node:os';
9
+ import path from 'node:path';
10
+
11
+ /** @typedef {{name:string, hostName?:string, user?:string, port?:number}} SshHost */
12
+
13
+ const WILDCARD_RE = /[*?!]/;
14
+
15
+ /**
16
+ * 纯函数:解析文本 → 主机数组。
17
+ * @param {string} text
18
+ * @returns {SshHost[]}
19
+ */
20
+ export function parseSshConfig(text) {
21
+ /** @type {Map<string, SshHost>} */
22
+ const hosts = new Map();
23
+ /** @type {string[]} */
24
+ let current = [];
25
+
26
+ for (const rawLine of String(text ?? '').replace(/\r/g, '').split('\n')) {
27
+ const line = rawLine.trim();
28
+ if (line === '' || line.startsWith('#')) continue;
29
+
30
+ // ssh_config 允许 `Key value` 与 `Key=value` 两种形式
31
+ const eq = line.indexOf('=');
32
+ const sp = line.search(/\s/);
33
+ let key;
34
+ let value;
35
+ if (eq !== -1 && (sp === -1 || eq < sp)) {
36
+ key = line.slice(0, eq).trim();
37
+ value = line.slice(eq + 1).trim();
38
+ } else if (sp !== -1) {
39
+ key = line.slice(0, sp).trim();
40
+ value = line.slice(sp + 1).trim();
41
+ } else {
42
+ key = line;
43
+ value = '';
44
+ }
45
+
46
+ const lower = key.toLowerCase();
47
+
48
+ if (lower === 'host') {
49
+ current = value
50
+ .split(/\s+/)
51
+ .filter((p) => p !== '' && !WILDCARD_RE.test(p));
52
+ for (const name of current) {
53
+ if (!hosts.has(name)) hosts.set(name, { name });
54
+ }
55
+ continue;
56
+ }
57
+
58
+ if (lower === 'match') {
59
+ // Match 块的条件语义超出 v1 需要,整块跳过(不归属任何 Host)
60
+ current = [];
61
+ continue;
62
+ }
63
+
64
+ if (current.length === 0) continue;
65
+
66
+ for (const name of current) {
67
+ const entry = hosts.get(name);
68
+ if (!entry) continue;
69
+ if (lower === 'hostname') entry.hostName = value;
70
+ else if (lower === 'user') entry.user = value;
71
+ else if (lower === 'port') {
72
+ const p = Number.parseInt(value, 10);
73
+ if (Number.isInteger(p) && p >= 1 && p <= 65535) entry.port = p;
74
+ }
75
+ }
76
+ }
77
+
78
+ return [...hosts.values()];
79
+ }
80
+
81
+ /** Include 的 glob 只需支持 ssh_config 实际用法:`*` 与 `?`,单层目录内匹配。 */
82
+ function expandIncludeGlob(pattern, baseDir) {
83
+ const abs = path.isAbsolute(pattern) ? pattern : path.join(baseDir, pattern);
84
+ if (!/[*?]/.test(abs)) return [abs];
85
+
86
+ const dir = path.dirname(abs);
87
+ const base = path.basename(abs);
88
+ const re = new RegExp(`^${base.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
89
+ try {
90
+ return fs
91
+ .readdirSync(dir, { withFileTypes: true })
92
+ .filter((d) => d.isFile() && re.test(d.name))
93
+ .map((d) => path.join(dir, d.name))
94
+ .sort();
95
+ } catch {
96
+ return [];
97
+ }
98
+ }
99
+
100
+ function readSegments(file, sshDir, visited, depth, out) {
101
+ if (depth > 3 || visited.has(file)) return;
102
+ visited.add(file);
103
+
104
+ let text;
105
+ try {
106
+ text = fs.readFileSync(file, 'utf8');
107
+ } catch {
108
+ return;
109
+ }
110
+
111
+ // Include 行按出现顺序原地展开:先把 Include 之前的段落交给解析器,再递归。
112
+ const lines = text.replace(/\r/g, '').split('\n');
113
+ let buffer = [];
114
+ for (const line of lines) {
115
+ const m = /^\s*[Ii]nclude\s+(.+)$/.exec(line);
116
+ if (!m) {
117
+ buffer.push(line);
118
+ continue;
119
+ }
120
+ out.push(buffer.join('\n'));
121
+ buffer = [];
122
+ for (const token of m[1].trim().split(/\s+/)) {
123
+ for (const target of expandIncludeGlob(token, sshDir)) {
124
+ readSegments(target, sshDir, visited, depth + 1, out);
125
+ }
126
+ }
127
+ }
128
+ out.push(buffer.join('\n'));
129
+ }
130
+
131
+ /**
132
+ * 读 ~/.ssh/config,展开 Include(glob,递归深度 ≤3,环路防护:已访问路径集合)。
133
+ * SSH_CONFIG_PATH 环境变量可覆盖(测试隔离用)。
134
+ * @returns {SshHost[]}
135
+ */
136
+ export function loadHosts({ configPath, homedir = os.homedir() } = {}) {
137
+ const file = configPath || process.env.DSHC_SSH_CONFIG || path.join(homedir, '.ssh', 'config');
138
+ const sshDir = path.dirname(file);
139
+ /** @type {string[]} */
140
+ const segments = [];
141
+ readSegments(file, sshDir, new Set(), 1, segments);
142
+
143
+ /** @type {Map<string, SshHost>} */
144
+ const merged = new Map();
145
+ for (const seg of segments) {
146
+ for (const host of parseSshConfig(seg)) {
147
+ // 同名后写覆盖(只覆盖已给出的字段)
148
+ merged.set(host.name, { ...merged.get(host.name), ...host });
149
+ }
150
+ }
151
+ return [...merged.values()];
152
+ }