@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.
- package/LICENSE +21 -0
- package/README.en.md +197 -0
- package/README.md +174 -0
- package/package.json +48 -0
- package/scripts/install.mjs +208 -0
- package/src/api.js +725 -0
- package/src/cli.js +1445 -0
- package/src/config-sync.js +157 -0
- package/src/daemon.js +362 -0
- package/src/defaults.js +89 -0
- package/src/dsh-workspace.js +467 -0
- package/src/launcher.js +627 -0
- package/src/lib/bundle.js +82 -0
- package/src/lib/bus.js +109 -0
- package/src/lib/capture.js +53 -0
- package/src/lib/clock.js +18 -0
- package/src/lib/entry.js +27 -0
- package/src/lib/errors.js +88 -0
- package/src/lib/logfile.js +65 -0
- package/src/lib/machine.js +63 -0
- package/src/lib/origin-guard.js +64 -0
- package/src/lib/pool.js +88 -0
- package/src/lib/proto.js +457 -0
- package/src/lib/semver.js +103 -0
- package/src/lib/shq.js +112 -0
- package/src/lib/ssh.js +647 -0
- package/src/lib/validate.js +363 -0
- package/src/monitor.js +145 -0
- package/src/patchsync.js +310 -0
- package/src/ports.js +93 -0
- package/src/prober.js +185 -0
- package/src/server.js +449 -0
- package/src/settings-file.js +550 -0
- package/src/ssh-config.js +152 -0
- package/src/store.js +772 -0
- package/src/tunnel.js +589 -0
- package/src/updater.js +450 -0
- package/src/web/actions.js +409 -0
- package/src/web/api.js +262 -0
- package/src/web/app.js +347 -0
- package/src/web/components/config-sync-dialog.js +469 -0
- package/src/web/components/confirm-dialog.js +61 -0
- package/src/web/components/defaults-card.js +216 -0
- package/src/web/components/event-panel.js +98 -0
- package/src/web/components/host-drawer.js +1039 -0
- package/src/web/components/host-table.js +317 -0
- package/src/web/components/hub.js +143 -0
- package/src/web/components/iframe-pane.js +377 -0
- package/src/web/components/manager-card.js +65 -0
- package/src/web/components/setup-wizard.js +726 -0
- package/src/web/components/tabbar.js +577 -0
- package/src/web/components/toast-region.js +107 -0
- package/src/web/favicon.svg +7 -0
- package/src/web/form.js +220 -0
- package/src/web/host-presentation.js +73 -0
- package/src/web/host-rules.js +76 -0
- package/src/web/index.html +17 -0
- package/src/web/router.js +118 -0
- package/src/web/setup-schema.js +203 -0
- package/src/web/sse.js +118 -0
- package/src/web/store.js +405 -0
- package/src/web/style.css +813 -0
- package/src/web/utils.js +210 -0
package/src/patchsync.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* patch 同步器(12 §4)。
|
|
3
|
+
*
|
|
4
|
+
* 落在业务层而非 src/lib/:它依赖 lib/ssh + lib/proto 两个叶子,放进 lib/ 会破坏
|
|
5
|
+
* 「lib 之间只允许 proto → shq 一条边」的防环规则(11 §1.3)。RMT-07 允许并入
|
|
6
|
+
* launcher.js,此处单独成文件只为可单测。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto';
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { DshError } from './lib/errors.js';
|
|
14
|
+
import { buildPatchCleanupScript, kvOne, parseProtoOutput } from './lib/proto.js';
|
|
15
|
+
import {
|
|
16
|
+
execFailure,
|
|
17
|
+
localCopy,
|
|
18
|
+
prepareLocalCopyTarget,
|
|
19
|
+
scpTo,
|
|
20
|
+
sshExec,
|
|
21
|
+
} from './lib/ssh.js';
|
|
22
|
+
import { REMOTE_DIR } from './defaults.js';
|
|
23
|
+
|
|
24
|
+
const HASH_PREFIX_LEN = 12;
|
|
25
|
+
const LOCAL_NAME_ATTEMPT_LIMIT = 256;
|
|
26
|
+
|
|
27
|
+
/** SHA-256;流式读文件,不整读内存。 */
|
|
28
|
+
function digestFile(file) {
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const hash = crypto.createHash('sha256');
|
|
31
|
+
const stream = fs.createReadStream(file);
|
|
32
|
+
stream.on('error', reject);
|
|
33
|
+
stream.on('data', (chunk) => hash.update(chunk));
|
|
34
|
+
stream.on('end', () => resolve(hash.digest('hex')));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** SHA-256 前 12 位 hex。 */
|
|
39
|
+
export async function hashFile(file) {
|
|
40
|
+
return (await digestFile(file)).slice(0, HASH_PREFIX_LEN);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 本地 basename → 远端安全名片段(12 §4.2)。 */
|
|
44
|
+
export function safeBase(localPath) {
|
|
45
|
+
let base = path.basename(localPath).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 64);
|
|
46
|
+
base = base.replace(/^[-.]+/, '');
|
|
47
|
+
return base === '' ? 'patch' : base;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 内容变则名变,天然免疫「本地改了远端还是旧的」。 */
|
|
51
|
+
export function remoteName(hash, localPath) {
|
|
52
|
+
return `${hash}-${safeBase(localPath)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 建立本次清单。任一文件不可读即快败(patch 缺失会静默改变 dsh 行为,宁可失败)。
|
|
57
|
+
* @param {string[]} patches 本地绝对路径
|
|
58
|
+
* @returns {Promise<{localPath:string, hash:string, contentHash:string,
|
|
59
|
+
* size:number, remoteName:string}[]>}
|
|
60
|
+
*/
|
|
61
|
+
export async function buildManifest(patches) {
|
|
62
|
+
const manifest = [];
|
|
63
|
+
for (const localPath of patches) {
|
|
64
|
+
let stat;
|
|
65
|
+
try {
|
|
66
|
+
fs.accessSync(localPath, fs.constants.R_OK);
|
|
67
|
+
stat = fs.statSync(localPath);
|
|
68
|
+
if (!stat.isFile()) throw new Error('不是普通文件');
|
|
69
|
+
} catch (err) {
|
|
70
|
+
throw new DshError('VALIDATION', `patch 文件不可读:${localPath}`, { detail: String(err.message ?? err) });
|
|
71
|
+
}
|
|
72
|
+
// eslint-disable-next-line no-await-in-loop -- 顺序读文件,避免同时开大量句柄
|
|
73
|
+
const contentHash = await digestFile(localPath);
|
|
74
|
+
const hash = contentHash.slice(0, HASH_PREFIX_LEN);
|
|
75
|
+
manifest.push({
|
|
76
|
+
localPath,
|
|
77
|
+
hash,
|
|
78
|
+
contentHash,
|
|
79
|
+
size: stat.size,
|
|
80
|
+
remoteName: remoteName(hash, localPath),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return manifest;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function localTargetIdentity(target) {
|
|
87
|
+
const lexical = path.resolve(target);
|
|
88
|
+
try {
|
|
89
|
+
fs.lstatSync(lexical);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (err?.code === 'ENOENT') return { lexical, real: lexical, exists: false };
|
|
92
|
+
throw new DshError('LOCAL_COPY_FAILED', '本机 patch 目标路径不可访问', {
|
|
93
|
+
detail: `路径:${target}\n${String(err.message ?? err)}`,
|
|
94
|
+
cause: err instanceof Error ? err : undefined,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
return { lexical, real: fs.realpathSync(lexical), exists: true };
|
|
100
|
+
} catch {
|
|
101
|
+
// dangling symlink / 无权读取的既有项也算占用;不能把它当成空位覆盖。
|
|
102
|
+
return { lexical, real: null, exists: true };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function inspectLocalTarget(remoteName) {
|
|
107
|
+
const rel = `${REMOTE_DIR}/patches/${remoteName}`;
|
|
108
|
+
const { target } = await prepareLocalCopyTarget(rel);
|
|
109
|
+
return { rel, ...localTargetIdentity(target) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function identityKeys(identity) {
|
|
113
|
+
return [...new Set([identity.lexical, identity.real].filter(Boolean))];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sourceIdentity(item) {
|
|
117
|
+
try {
|
|
118
|
+
const lexical = path.resolve(item.localPath);
|
|
119
|
+
const real = fs.realpathSync(item.localPath);
|
|
120
|
+
const key = crypto
|
|
121
|
+
.createHash('sha256')
|
|
122
|
+
.update(lexical)
|
|
123
|
+
.update('\0')
|
|
124
|
+
.update(real)
|
|
125
|
+
.digest('hex')
|
|
126
|
+
.slice(0, 16);
|
|
127
|
+
return { lexical, real, key };
|
|
128
|
+
} catch (err) {
|
|
129
|
+
throw new DshError('VALIDATION', `patch 文件真实路径不可读:${item.localPath}`, {
|
|
130
|
+
detail: String(err.message ?? err),
|
|
131
|
+
cause: err instanceof Error ? err : undefined,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function alternateLocalName(item, sourceKey, attempt) {
|
|
137
|
+
const contentKey = item.contentHash.slice(0, 16);
|
|
138
|
+
const ordinal = attempt === 0 ? '' : `-${attempt}`;
|
|
139
|
+
return `${contentKey}-local-${sourceKey}${ordinal}-${safeBase(item.localPath)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function localTargetHasContent(target, item) {
|
|
143
|
+
if (!target.exists || target.real === null) return false;
|
|
144
|
+
try {
|
|
145
|
+
const stat = fs.statSync(target.lexical);
|
|
146
|
+
if (!stat.isFile() || stat.size !== item.size) return false;
|
|
147
|
+
return await digestFile(target.lexical) === item.contentHash;
|
|
148
|
+
} catch {
|
|
149
|
+
// 无法证明内容相同就必须避让,不能覆盖未知既有项。
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function reservationFor(target, reservations, contentHash) {
|
|
155
|
+
const claims = identityKeys(target)
|
|
156
|
+
.map((key) => reservations.get(key))
|
|
157
|
+
.filter(Boolean);
|
|
158
|
+
return {
|
|
159
|
+
conflict: claims.some((claim) => claim !== contentHash),
|
|
160
|
+
matching: claims.some((claim) => claim === contentHash),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function reserveTarget(target, reservations, contentHash) {
|
|
165
|
+
for (const key of identityKeys(target)) reservations.set(key, contentHash);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function noSafeLocalTarget(item) {
|
|
169
|
+
const sourceKey = crypto
|
|
170
|
+
.createHash('sha256')
|
|
171
|
+
.update(path.resolve(item.localPath))
|
|
172
|
+
.digest('hex')
|
|
173
|
+
.slice(0, HASH_PREFIX_LEN);
|
|
174
|
+
return new DshError('LOCAL_COPY_FAILED', '本机 patch 找不到不会覆盖既有文件的安全目标', {
|
|
175
|
+
detail: `源:${item.localPath}\n已检查初始目标与 ${LOCAL_NAME_ATTEMPT_LIMIT} 个稳定候选(源摘要 ${sourceKey})`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 本机源与生成物共用 patches/ 命名空间。既有目标只有在与当前源是同一真实文件、
|
|
181
|
+
* 或逐内容摘要相等时才可复用;否则依次尝试由内容+源身份摘要生成的稳定安全名。
|
|
182
|
+
* 整个计划完成前不复制任何文件,故不会因执行顺序覆盖后续源。
|
|
183
|
+
*/
|
|
184
|
+
async function planLocalManifest(manifest) {
|
|
185
|
+
const sources = manifest.map(sourceIdentity);
|
|
186
|
+
const reservations = new Map();
|
|
187
|
+
const planned = [];
|
|
188
|
+
|
|
189
|
+
for (let index = 0; index < manifest.length; index += 1) {
|
|
190
|
+
const item = manifest[index];
|
|
191
|
+
const source = sources[index];
|
|
192
|
+
let selected = null;
|
|
193
|
+
|
|
194
|
+
for (let attempt = -1; attempt < LOCAL_NAME_ATTEMPT_LIMIT; attempt += 1) {
|
|
195
|
+
const selectedName = attempt < 0
|
|
196
|
+
? item.remoteName
|
|
197
|
+
: alternateLocalName(item, source.key, attempt);
|
|
198
|
+
// eslint-disable-next-line no-await-in-loop -- 候选必须逐个查真实路径与内容,且循环有硬上限
|
|
199
|
+
const target = await inspectLocalTarget(selectedName);
|
|
200
|
+
const reservation = reservationFor(target, reservations, item.contentHash);
|
|
201
|
+
if (reservation.conflict) continue;
|
|
202
|
+
|
|
203
|
+
if (target.exists) {
|
|
204
|
+
const sameSource = target.real !== null && target.real === source.real;
|
|
205
|
+
// eslint-disable-next-line no-await-in-loop -- 已存在候选必须先证明内容相同,绝不盲目覆盖
|
|
206
|
+
if (!sameSource && !(await localTargetHasContent(target, item))) continue;
|
|
207
|
+
selected = { ...item, remoteName: selectedName, copyNeeded: false };
|
|
208
|
+
} else {
|
|
209
|
+
selected = {
|
|
210
|
+
...item,
|
|
211
|
+
remoteName: selectedName,
|
|
212
|
+
copyNeeded: !reservation.matching,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
reserveTarget(target, reservations, item.contentHash);
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (selected === null) throw noSafeLocalTarget(item);
|
|
221
|
+
planned.push(selected);
|
|
222
|
+
}
|
|
223
|
+
return planned;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 同步流程(12 §4.3):清单 → 远端清理协议(本机永久跳过)→ 只传 hash 变更文件 →
|
|
228
|
+
* 返回新的 patchSync 记录与 PATCH_ARGS 用的目标名清单(按 manifest 顺序)。
|
|
229
|
+
*
|
|
230
|
+
* 本机 patches/ 与用户文件共用命名空间,无法可靠区分旧生成物和用户源,所以只由
|
|
231
|
+
* localCopy 原子覆盖当前目标,绝不主动删除目录内其他文件;远端仍先清理旧目标。
|
|
232
|
+
*
|
|
233
|
+
* @param {string} host
|
|
234
|
+
* @param {string[]} patches
|
|
235
|
+
* @param {{files:Record<string,{hash:string,remoteName:string,syncedAt:string|null}>}} previous
|
|
236
|
+
* @param {{signal?:AbortSignal, local?:boolean}} [opts]
|
|
237
|
+
*/
|
|
238
|
+
export async function syncPatches(
|
|
239
|
+
host,
|
|
240
|
+
patches,
|
|
241
|
+
previous = { files: {} },
|
|
242
|
+
{ signal, local = false } = {},
|
|
243
|
+
) {
|
|
244
|
+
const baseManifest = await buildManifest(patches);
|
|
245
|
+
const manifest = local ? await planLocalManifest(baseManifest) : baseManifest;
|
|
246
|
+
const keepNames = manifest.map((m) => m.remoteName);
|
|
247
|
+
|
|
248
|
+
if (!local) {
|
|
249
|
+
// 远端清理先行:删除旧 hash 与已移除项,并兼职保证目录存在。
|
|
250
|
+
const cleanup = buildPatchCleanupScript({ keepNames });
|
|
251
|
+
const cleanRes = await sshExec(host, cleanup, { signal });
|
|
252
|
+
const cleanErr = execFailure(host, 'patch 目录清理', cleanRes);
|
|
253
|
+
if (cleanErr) throw cleanErr;
|
|
254
|
+
const cleanOut = parseProtoOutput(cleanRes.stdout, { requireDone: 'CLEAN_DONE' });
|
|
255
|
+
if (kvOne(cleanOut, 'ERR') === 'mkdir') {
|
|
256
|
+
throw new DshError('INTERNAL', `远端无法创建 patch 目录(${host})`, {
|
|
257
|
+
host,
|
|
258
|
+
detail: cleanRes.stdout,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const files = {};
|
|
264
|
+
let uploaded = 0;
|
|
265
|
+
let skipped = 0;
|
|
266
|
+
|
|
267
|
+
for (const item of manifest) {
|
|
268
|
+
const prior = previous?.files?.[item.localPath];
|
|
269
|
+
if (local && !item.copyNeeded) {
|
|
270
|
+
const samePrior = prior
|
|
271
|
+
&& prior.hash === item.hash
|
|
272
|
+
&& prior.remoteName === item.remoteName
|
|
273
|
+
&& prior.syncedAt;
|
|
274
|
+
files[item.localPath] = samePrior
|
|
275
|
+
? { ...prior, remoteName: item.remoteName }
|
|
276
|
+
: {
|
|
277
|
+
hash: item.hash,
|
|
278
|
+
remoteName: item.remoteName,
|
|
279
|
+
syncedAt: new Date().toISOString(),
|
|
280
|
+
};
|
|
281
|
+
skipped += 1;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (
|
|
285
|
+
prior
|
|
286
|
+
&& prior.hash === item.hash
|
|
287
|
+
&& !local
|
|
288
|
+
&& prior.syncedAt
|
|
289
|
+
) {
|
|
290
|
+
files[item.localPath] = { ...prior, remoteName: item.remoteName };
|
|
291
|
+
skipped += 1;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const rel = `${REMOTE_DIR}/patches/${item.remoteName}`;
|
|
295
|
+
// eslint-disable-next-line no-await-in-loop -- 逐文件上载,失败即整体快败
|
|
296
|
+
const res = local
|
|
297
|
+
? await localCopy(item.localPath, rel, { signal })
|
|
298
|
+
: await scpTo(host, item.localPath, rel, { signal });
|
|
299
|
+
const err = execFailure(host, `patch 上载 ${path.basename(item.localPath)}`, res);
|
|
300
|
+
if (err) throw err;
|
|
301
|
+
files[item.localPath] = {
|
|
302
|
+
hash: item.hash,
|
|
303
|
+
remoteName: item.remoteName,
|
|
304
|
+
syncedAt: new Date().toISOString(),
|
|
305
|
+
};
|
|
306
|
+
uploaded += 1;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return { patchSync: { files }, remoteNames: keepNames, uploaded, skipped };
|
|
310
|
+
}
|
package/src/ports.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本机映射端口分配(02 §6):区间内取第一个「config 未占用 ∧ 本机未监听」的端口。
|
|
3
|
+
* 分配即回写 config.hosts[x].localPort,此后固定——保证 iframe 地址与浏览器书签稳定。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import net from 'node:net';
|
|
7
|
+
|
|
8
|
+
import { DshError } from './lib/errors.js';
|
|
9
|
+
import * as store from './store.js';
|
|
10
|
+
|
|
11
|
+
/** 试绑探测。单测可经 _setProbe 注入假实现。 */
|
|
12
|
+
const DEFAULT_PROBE = (port) => new Promise((resolve) => {
|
|
13
|
+
const srv = net.createServer();
|
|
14
|
+
srv.once('error', () => resolve(false));
|
|
15
|
+
srv.once('listening', () => srv.close(() => resolve(true)));
|
|
16
|
+
srv.listen(port, '127.0.0.1');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
let probeFree = DEFAULT_PROBE;
|
|
20
|
+
|
|
21
|
+
export function isFree(port) {
|
|
22
|
+
return probeFree(port);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 测试注入点;传 null/undefined 复位为真实试绑。 */
|
|
26
|
+
export function _setProbe(fn) {
|
|
27
|
+
probeFree = typeof fn === 'function' ? fn : DEFAULT_PROBE;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 分配闸(issue #94)。
|
|
32
|
+
*
|
|
33
|
+
* 一次分配是「读 config 算已占用 → await 试绑 → 回写 config」:读在 await 之前、
|
|
34
|
+
* 写在之后。两台同时进来,两边看到的都是同一份旧账,于是分到同一个号。而 localPort
|
|
35
|
+
* 是分配即回写、此后固定的(iframe 地址和书签要稳),撞号会被**永久**写进 config——
|
|
36
|
+
* 后面那几台的隧道每次都撞 `bind: Address already in use`,被判成 local-port-busy 挂起,
|
|
37
|
+
* 提示还让用户去找「占端口的进程」,可占号的正是 manager 自己的另一条隧道。重启修不回来。
|
|
38
|
+
*
|
|
39
|
+
* 跑得到这条路的是 `runAutoStart`/`recoverState`:它们走 mapPool,一次 6 台在飞,
|
|
40
|
+
* 而全新安装的那几台恰好都还没有 localPort。
|
|
41
|
+
* @type {Promise<unknown>}
|
|
42
|
+
*/
|
|
43
|
+
let allocChain = Promise.resolve();
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* localPort 决策。config 已有 → 直接返回;否则区间内分配并回写(整段串行)。
|
|
47
|
+
* @param {string} name
|
|
48
|
+
* @returns {Promise<number>}
|
|
49
|
+
* @throws {DshError} PORT_EXHAUSTED
|
|
50
|
+
*/
|
|
51
|
+
export async function ensureLocalPort(name) {
|
|
52
|
+
const host = store.getConfig().hosts[name];
|
|
53
|
+
if (!host) throw new DshError('NOT_FOUND', `未知主机:${name}`, { host: name });
|
|
54
|
+
if (host.localPort !== null && host.localPort !== undefined) return host.localPort;
|
|
55
|
+
|
|
56
|
+
// 前序成败都不阻断后续(与 hostQueue 同一套写法)
|
|
57
|
+
const run = () => allocate(name);
|
|
58
|
+
const p = allocChain.then(run, run);
|
|
59
|
+
allocChain = p.then(() => {}, () => {});
|
|
60
|
+
return p;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 闸内执行:从这里到回写之间没有别的分配能插进来。 */
|
|
64
|
+
async function allocate(name) {
|
|
65
|
+
const config = store.getConfig();
|
|
66
|
+
const host = config.hosts[name];
|
|
67
|
+
// 排队期间主机可能被删掉,也可能已由并发的同名请求分到了号——两样都要重查
|
|
68
|
+
if (!host) throw new DshError('NOT_FOUND', `未知主机:${name}`, { host: name });
|
|
69
|
+
if (host.localPort !== null && host.localPort !== undefined) return host.localPort;
|
|
70
|
+
|
|
71
|
+
const [lo, hi] = config.defaults.localPortRange;
|
|
72
|
+
const taken = new Set(
|
|
73
|
+
Object.values(config.hosts)
|
|
74
|
+
.map((h) => h.localPort)
|
|
75
|
+
.filter((p) => Number.isInteger(p)),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
for (let p = lo; p <= hi; p += 1) {
|
|
79
|
+
if (taken.has(p)) continue;
|
|
80
|
+
// eslint-disable-next-line no-await-in-loop -- 顺序探测才能取「第一个」可用端口
|
|
81
|
+
if (!(await isFree(p))) continue;
|
|
82
|
+
store.updateConfig((draft) => {
|
|
83
|
+
draft.hosts[name].localPort = p;
|
|
84
|
+
});
|
|
85
|
+
return p;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
throw new DshError(
|
|
89
|
+
'PORT_EXHAUSTED',
|
|
90
|
+
`本机映射端口区间 ${lo}-${hi} 已耗尽,无法为 ${name} 分配`,
|
|
91
|
+
{ host: name, detail: `已占用:${[...taken].sort((a, b) => a - b).join(', ')}` },
|
|
92
|
+
);
|
|
93
|
+
}
|
package/src/prober.js
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 并行探测(03 §2 / 12 §1.1 协议),驱动 phase: unknown → ready/no_dsh/unreachable。
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { logEvent } from './lib/bus.js';
|
|
6
|
+
import { buildProbeScript, kvOne, parseProtoOutput } from './lib/proto.js';
|
|
7
|
+
import { hostQueue, localExec, sshExec } from './lib/ssh.js';
|
|
8
|
+
import { PROBE_PROTECTED_PHASES } from './lib/machine.js';
|
|
9
|
+
import { asDshError } from './lib/errors.js';
|
|
10
|
+
import { mapPool } from './lib/pool.js';
|
|
11
|
+
import { SSH_FANOUT_LIMIT } from './defaults.js';
|
|
12
|
+
import * as store from './store.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {{ok:boolean, phase:'ready'|'no_dsh'|'unreachable', dshPath:string|null,
|
|
16
|
+
* version:string|null, dshHome:string|null, profileWeb:boolean, runningRaw:string,
|
|
17
|
+
* noDshReason:'missing-bin'|'no-web-profile'|null, stderr:string,
|
|
18
|
+
* manualInstances:{pid:number,args:string}[]}} ProbeResult
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** `ps -eo pid,args` 行 → {pid, args}。 */
|
|
22
|
+
export function parseRunningBlock(raw) {
|
|
23
|
+
const out = [];
|
|
24
|
+
for (const line of String(raw ?? '').split('\n')) {
|
|
25
|
+
const m = /^\s*(\d+)\s+(.*\S)\s*$/.exec(line);
|
|
26
|
+
if (!m) continue;
|
|
27
|
+
out.push({ pid: Number(m[1]), args: m[2] });
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 把探测 stdout 解析为 ProbeResult(纯函数,喂样本即可单测)。
|
|
34
|
+
* @param {{code:number|null, stdout:string, stderr:string, timedOut:boolean, aborted:boolean}} res
|
|
35
|
+
* @param {{local?:boolean}} [opts]
|
|
36
|
+
* @returns {ProbeResult}
|
|
37
|
+
*/
|
|
38
|
+
export function interpretProbe(res, { local = false } = {}) {
|
|
39
|
+
const rawStderr = res.stderr ?? '';
|
|
40
|
+
let failureStderr = rawStderr;
|
|
41
|
+
if (local) {
|
|
42
|
+
const summary = res.timedOut
|
|
43
|
+
? '本机探测超时'
|
|
44
|
+
: res.aborted
|
|
45
|
+
? '本机探测被中止'
|
|
46
|
+
: `本机探测命令执行失败(退出码 ${res.code ?? '未知'})`;
|
|
47
|
+
failureStderr = rawStderr ? `${summary}\n${rawStderr}` : summary;
|
|
48
|
+
}
|
|
49
|
+
const base = {
|
|
50
|
+
ok: false,
|
|
51
|
+
phase: 'unreachable',
|
|
52
|
+
dshPath: null,
|
|
53
|
+
version: null,
|
|
54
|
+
dshHome: null,
|
|
55
|
+
profileWeb: false,
|
|
56
|
+
runningRaw: '',
|
|
57
|
+
noDshReason: null,
|
|
58
|
+
stderr: failureStderr,
|
|
59
|
+
manualInstances: [],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (res.timedOut || res.aborted || res.code !== 0) return base;
|
|
63
|
+
|
|
64
|
+
let out;
|
|
65
|
+
try {
|
|
66
|
+
out = parseProtoOutput(res.stdout, { requireDone: 'PROBE_DONE' });
|
|
67
|
+
} catch (err) {
|
|
68
|
+
// 协议输出不可解析:不是「连不上」,但也无法判定 dsh 状态 → 按 unreachable 呈现并留证
|
|
69
|
+
const detail = err.detail ?? err.message;
|
|
70
|
+
return { ...base, stderr: local ? `本机探测输出无法解析:${detail}` : detail };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const bin = kvOne(out, 'DSH_BIN');
|
|
74
|
+
const runningRaw = out.blocks.RUNNING_DSH_WEB ?? '';
|
|
75
|
+
const manualInstances = parseRunningBlock(runningRaw);
|
|
76
|
+
const dshHome = kvOne(out, 'DSH_HOME');
|
|
77
|
+
const profileWeb = kvOne(out, 'PROFILE_WEB') === 'yes';
|
|
78
|
+
|
|
79
|
+
if (!bin || bin === 'MISSING') {
|
|
80
|
+
return {
|
|
81
|
+
...base,
|
|
82
|
+
phase: 'no_dsh',
|
|
83
|
+
noDshReason: 'missing-bin',
|
|
84
|
+
dshHome,
|
|
85
|
+
runningRaw,
|
|
86
|
+
manualInstances,
|
|
87
|
+
stderr: '',
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const common = {
|
|
92
|
+
ok: true,
|
|
93
|
+
dshPath: bin,
|
|
94
|
+
version: kvOne(out, 'DSH_VERSION') || null,
|
|
95
|
+
dshHome,
|
|
96
|
+
profileWeb,
|
|
97
|
+
runningRaw,
|
|
98
|
+
manualInstances,
|
|
99
|
+
stderr: '',
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
if (!profileWeb) {
|
|
103
|
+
return { ...base, ...common, ok: false, phase: 'no_dsh', noDshReason: 'no-web-profile' };
|
|
104
|
+
}
|
|
105
|
+
return { ...base, ...common, phase: 'ready' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 纯探测(不写 state)。dshc init 第 3 步复用(setup 时 server 尚不存在,
|
|
110
|
+
* 「操作收敛到 server」的前提不成立,见 11 §1.3 例外条款)。
|
|
111
|
+
* @returns {Promise<ProbeResult>}
|
|
112
|
+
*/
|
|
113
|
+
export async function probeOnce(host, { local = false, timeoutMs, signal } = {}) {
|
|
114
|
+
const command = buildProbeScript();
|
|
115
|
+
const res = local
|
|
116
|
+
? await localExec(command, { timeoutMs, signal })
|
|
117
|
+
: await sshExec(host, command, { timeoutMs, signal });
|
|
118
|
+
return interpretProbe(res, { local });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 单行 stderr 摘要(长文本不进环形缓冲,11 §7.2)。 */
|
|
122
|
+
function summarize(stderr) {
|
|
123
|
+
const line = String(stderr ?? '')
|
|
124
|
+
.split('\n')
|
|
125
|
+
.map((l) => l.trim())
|
|
126
|
+
.find((l) => l !== '');
|
|
127
|
+
if (!line) return null;
|
|
128
|
+
return line.length > 200 ? `${line.slice(0, 197)}…` : line;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 队列内探测并应用:probeOnce → setPhase(3 分类) + state.probe + manualInstances 并入。 */
|
|
132
|
+
export async function probeHost(name) {
|
|
133
|
+
return hostQueue(name).run('probe', async (signal) => {
|
|
134
|
+
// 队首才重取 HostView:排队期间 reload 可能已换了配置快照,运输类型只认当前 config。
|
|
135
|
+
const local = store.getHostView(name)?.local === true;
|
|
136
|
+
const result = await probeOnce(name, { local, signal });
|
|
137
|
+
applyProbe(name, result);
|
|
138
|
+
return result;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 结果应用。starting/running/degraded 期间禁止探测改写 phase——只刷新 manualInstances
|
|
144
|
+
* 与 probe 详情(11 §2.2)。
|
|
145
|
+
*/
|
|
146
|
+
export function applyProbe(name, result) {
|
|
147
|
+
const phaseNow = store.getPhase(name);
|
|
148
|
+
const protectedPhase = PROBE_PROTECTED_PHASES.includes(phaseNow);
|
|
149
|
+
|
|
150
|
+
const managedPid = store.getHostState(name)?.web?.pid ?? null;
|
|
151
|
+
const manual = result.manualInstances.filter((i) => i.pid !== managedPid);
|
|
152
|
+
|
|
153
|
+
store.mutateHostState(name, (entry) => {
|
|
154
|
+
entry.probe = {
|
|
155
|
+
dshPath: result.dshPath,
|
|
156
|
+
version: result.version,
|
|
157
|
+
dshHome: result.dshHome,
|
|
158
|
+
profileWeb: result.profileWeb,
|
|
159
|
+
noDshReason: result.noDshReason,
|
|
160
|
+
at: new Date().toISOString(),
|
|
161
|
+
errorSummary: result.phase === 'unreachable' ? summarize(result.stderr) : null,
|
|
162
|
+
};
|
|
163
|
+
entry.manualInstances = manual;
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
if (!protectedPhase) {
|
|
167
|
+
store.setPhase(name, result.phase, 'prober.probeHost');
|
|
168
|
+
}
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 并行触发全量/指定探测,立即返回(202 语义);结果经 SSE。
|
|
174
|
+
* @param {string[]|null} names
|
|
175
|
+
* @returns {Promise<PromiseSettledResult<any>[]>} 供 server 启动序列 await
|
|
176
|
+
*/
|
|
177
|
+
export function probeAll(names = null) {
|
|
178
|
+
const targets = names ?? store.listHostNames();
|
|
179
|
+
// 有闸:主机一多,无闸的扇出会把共用跳板机的 MaxStartups 打爆(issue #85)
|
|
180
|
+
return mapPool(targets, (name) => probeHost(name).catch((err) => {
|
|
181
|
+
const e = asDshError(err);
|
|
182
|
+
logEvent(name, 'warn', `探测失败:${e.message}`, e.detail);
|
|
183
|
+
throw e;
|
|
184
|
+
}), SSH_FANOUT_LIMIT);
|
|
185
|
+
}
|