@hyzyn/dsh-docker 0.6.4 → 0.7.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/README.en.md +31 -16
- package/README.md +31 -18
- package/client-src/current-session.js +52 -0
- package/client-src/docker.css +2180 -0
- package/client-src/index.js +6691 -0
- package/client-src/session-target.js +92 -0
- package/client.js +46 -24
- package/lib/docker.d.ts +62 -5
- package/lib/docker.js +287 -48
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +19 -1
- package/lib/index.js +595 -80
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +30 -11
- package/lib/ssh-exec.js +360 -143
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +4 -2
- package/scripts/build-client.mjs +33 -0
- package/scripts/client-smoke.mjs +1851 -0
- package/scripts/route-smoke.mjs +1091 -0
- package/scripts/smoke.mjs +874 -0
package/lib/ssh-exec.js
CHANGED
|
@@ -67,6 +67,11 @@ export function expandHome(path) {
|
|
|
67
67
|
return homedir();
|
|
68
68
|
if (path.startsWith('~/'))
|
|
69
69
|
return join(homedir(), path.slice(2));
|
|
70
|
+
// `~user/...` 一律明确报错(D30):原样返回会变成 readFileSync 的 ENOENT,路径还带着
|
|
71
|
+
// ~,用户容易误判成「文件真的不存在」。Windows 变量(%USERPROFILE%)不在展开范围。
|
|
72
|
+
if (path.startsWith('~')) {
|
|
73
|
+
throw new Error(`keyPath 仅支持 ~ 与 ~/ 展开(不支持 ${path.split(/[\\/]/)[0]});请写绝对路径或 ~/ 相对路径`);
|
|
74
|
+
}
|
|
70
75
|
return path;
|
|
71
76
|
}
|
|
72
77
|
/** 展示用目标串:user@host(非默认端口时带 :port)。 */
|
|
@@ -82,14 +87,95 @@ export function sshTarget(spec) {
|
|
|
82
87
|
export function shJoin(argv) {
|
|
83
88
|
return argv.map((arg) => "'" + arg.replaceAll("'", "'\\''") + "'").join(' ');
|
|
84
89
|
}
|
|
85
|
-
/**
|
|
90
|
+
/**
|
|
91
|
+
* 连接池键:同一主机同一账号复用一条 SSH 连接。
|
|
92
|
+
*
|
|
93
|
+
* host 要 trim + 小写(D111):否则 `NAS.local` 与 `nas.local` 各建一条连接,而
|
|
94
|
+
* `MAX_STREAMS_PER_TARGET` 与 `shouldRecycleConn` 都是**按连接**计的 → 同一台主机的
|
|
95
|
+
* 长流额度被悄悄翻倍(恰好掩盖 D07 想暴露的 MaxSessions 问题)。口径与 TOFU 的
|
|
96
|
+
* hostVerifier(D03)保持一致:那里也用 `trim().toLowerCase()` 分组指纹。
|
|
97
|
+
*/
|
|
86
98
|
function poolKey(spec) {
|
|
87
|
-
return `${spec.username}@${spec.host}:${String(spec.port ?? 22)}`;
|
|
99
|
+
return `${spec.username}@${spec.host.trim().toLowerCase()}:${String(spec.port ?? 22)}`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 有界输出收集器(短命令路径的 stdout / stderr 各持一个)。
|
|
103
|
+
*
|
|
104
|
+
* 为什么按字节收集、最后统一解码:逐 chunk `toString('utf8')` 会在 TCP 分片
|
|
105
|
+
* 正好落在多字节字符中间时产出 U+FFFD(D08);长流路径早已用 StringDecoder,
|
|
106
|
+
* 这里把同样的解码方式带给一次性命令。
|
|
107
|
+
*
|
|
108
|
+
* 截断方向(D14):`keepTail=false` 保留**头部**(JSONL 列表的前缀行可解析);
|
|
109
|
+
* `keepTail=true` 保留**尾部**——logs 的最新行、pull 的 digest、prune 的总计
|
|
110
|
+
* 都在输出末尾,该丢的是头部。
|
|
111
|
+
*/
|
|
112
|
+
class ByteSink {
|
|
113
|
+
maxBytes;
|
|
114
|
+
keepTail;
|
|
115
|
+
chunks = [];
|
|
116
|
+
bytes = 0;
|
|
117
|
+
truncated = false;
|
|
118
|
+
constructor(maxBytes, keepTail) {
|
|
119
|
+
this.maxBytes = maxBytes;
|
|
120
|
+
this.keepTail = keepTail;
|
|
121
|
+
}
|
|
122
|
+
push(chunk) {
|
|
123
|
+
this.chunks.push(chunk);
|
|
124
|
+
this.bytes += chunk.length;
|
|
125
|
+
if (this.bytes <= this.maxBytes)
|
|
126
|
+
return;
|
|
127
|
+
this.truncated = true;
|
|
128
|
+
if (this.keepTail) {
|
|
129
|
+
let excess = this.bytes - this.maxBytes;
|
|
130
|
+
while (excess > 0 && this.chunks.length > 0) {
|
|
131
|
+
const head = this.chunks[0];
|
|
132
|
+
if (head.length <= excess) {
|
|
133
|
+
excess -= head.length;
|
|
134
|
+
this.chunks.shift();
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
this.chunks[0] = head.subarray(excess);
|
|
138
|
+
excess = 0;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
let room = this.maxBytes;
|
|
144
|
+
const kept = [];
|
|
145
|
+
for (const item of this.chunks) {
|
|
146
|
+
if (room <= 0)
|
|
147
|
+
break;
|
|
148
|
+
kept.push(room >= item.length ? item : item.subarray(0, room));
|
|
149
|
+
room -= item.length;
|
|
150
|
+
}
|
|
151
|
+
this.chunks = kept;
|
|
152
|
+
}
|
|
153
|
+
this.bytes = this.maxBytes;
|
|
154
|
+
}
|
|
155
|
+
/** 统一解码(调用方持有 decoder:跨 chunk 的多字节序列不会碎成 U+FFFD)。 */
|
|
156
|
+
decode(decoder) {
|
|
157
|
+
let text = '';
|
|
158
|
+
for (const chunk of this.chunks)
|
|
159
|
+
text += decoder.write(chunk);
|
|
160
|
+
return text + decoder.end();
|
|
161
|
+
}
|
|
88
162
|
}
|
|
89
163
|
const IDLE_MS = 120_000;
|
|
90
164
|
const SWEEP_MS = 30_000;
|
|
91
165
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
92
166
|
const DEFAULT_MAX_BYTES = 512 * 1024;
|
|
167
|
+
/**
|
|
168
|
+
* SSH 建连超时(默认 20s)。
|
|
169
|
+
*
|
|
170
|
+
* `DSH_DOCKER_CONNECT_TIMEOUT_MS` 可覆盖,**只为测试与排障**:20s 这条路径没法在单测里等,
|
|
171
|
+
* 而「不可达目标(防火墙 DROP)」在真实网络里比 ECONNREFUSED 常见得多,D122 之前它在
|
|
172
|
+
* 脚本与单测里都没有回归。`acquire` 的兜底定时器与 `buildConnectConfig` 的 `readyTimeout`
|
|
173
|
+
* 共用它,避免两处漂移。
|
|
174
|
+
*/
|
|
175
|
+
function connectTimeoutMs() {
|
|
176
|
+
const raw = Number(process.env.DSH_DOCKER_CONNECT_TIMEOUT_MS);
|
|
177
|
+
return Number.isFinite(raw) && raw > 0 ? raw : 20_000;
|
|
178
|
+
}
|
|
93
179
|
/**
|
|
94
180
|
* 每个 SSH 目标上同时可持有的**长流**上限。
|
|
95
181
|
*
|
|
@@ -138,20 +224,26 @@ export function describeExecError(message) {
|
|
|
138
224
|
* 而 `acquire()` 复用 memoized 的 `ready`、不会每次探活。这种时候唯一正确的动作是丢掉
|
|
139
225
|
* 这条连接、重连一次再试;反过来,「命令返回非零」「镜像不存在」这类业务失败**绝不能**
|
|
140
226
|
* 触发重连——那会把一次普通错误变成两条命令。
|
|
227
|
+
*
|
|
228
|
+
* 「Channel open failure / open failed」刻意**不在**传输层名单里:它是远端**拒绝开新
|
|
229
|
+
* 通道**,典型成因是同一连接的 MaxSessions 被长流占满——连接本身是健康的。把它当传输
|
|
230
|
+
* 错误会泄漏健康连接(摘出池却不关闭,keepalive 一直养着),还会把 `describeExecError`
|
|
231
|
+
* 补的可操作文案藏掉(重连后新连接额度是空的,命令反而成功)。见 D07。
|
|
141
232
|
*/
|
|
142
233
|
export function isTransportError(message) {
|
|
143
234
|
// 前两条是我们自己的包装文案:回调迟迟不来 = 这条连接已经不响应了
|
|
144
235
|
if (/打开 channel 超时|SSH 连接超时/.test(message))
|
|
145
236
|
return true;
|
|
146
|
-
return /
|
|
237
|
+
return /Not connected|connection lost|ECONNRESET|EPIPE|ETIMEDOUT|keepalive|No response from server/i.test(message);
|
|
147
238
|
}
|
|
148
239
|
/**
|
|
149
240
|
* 空闲回收判定:busy>0 的连接上挂着长流(docker logs --follow 可以几小时不结束),
|
|
150
|
-
*
|
|
151
|
-
*
|
|
241
|
+
* inflight>0 的连接上有一次性命令在跑(docker pull 默认 600s,期间没有任何请求
|
|
242
|
+
* 刷新 lastUsed)——两类都必须让空闲回收让路,否则在途命令会被 sweeper 掐断在半路
|
|
243
|
+
* (D01)。抽成纯函数便于回归(sweeper 本体依赖定时器,难以直接驱动)。
|
|
152
244
|
*/
|
|
153
245
|
export function shouldRecycleConn(conn, now, idleMs = IDLE_MS) {
|
|
154
|
-
if (conn.busy > 0)
|
|
246
|
+
if (conn.busy > 0 || (conn.inflight ?? 0) > 0)
|
|
155
247
|
return false;
|
|
156
248
|
return now - conn.lastUsed >= idleMs;
|
|
157
249
|
}
|
|
@@ -172,6 +264,9 @@ export class RemoteExec {
|
|
|
172
264
|
this.sweeper = null;
|
|
173
265
|
}
|
|
174
266
|
for (const rt of this.conns.values()) {
|
|
267
|
+
// 先标记再关闭(D94):建连途中的条目被 end() 后,它的 ready 仍会 resolve——
|
|
268
|
+
// 标记让 acquire() 认出「这条已被卸载摘掉」,改为关闭并 reject,不外泄脱管连接。
|
|
269
|
+
rt.disposed = true;
|
|
175
270
|
try {
|
|
176
271
|
rt.client.end();
|
|
177
272
|
}
|
|
@@ -188,69 +283,80 @@ export class RemoteExec {
|
|
|
188
283
|
const maxBytes = options?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
189
284
|
const started = Date.now();
|
|
190
285
|
const channel = await this.openChannel(spec, command, timeoutMs, 0);
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
286
|
+
// 一次性命令也计入「在途」(D01):docker pull 默认 600s,期间没有任何请求
|
|
287
|
+
// 刷新 lastUsed, sweeper 若只认 busy 会把跑了一半的命令连人带输出掐断。
|
|
288
|
+
const key = poolKey(spec);
|
|
289
|
+
const rt = this.conns.get(key);
|
|
290
|
+
if (rt !== undefined)
|
|
291
|
+
rt.inflight += 1;
|
|
292
|
+
try {
|
|
293
|
+
return await new Promise((resolve, reject) => {
|
|
294
|
+
const stdoutSink = new ByteSink(maxBytes, options?.keepTail === true);
|
|
295
|
+
const stderrSink = new ByteSink(maxBytes, options?.keepTail === true);
|
|
296
|
+
const stdoutDecoder = new StringDecoder('utf8');
|
|
297
|
+
const stderrDecoder = new StringDecoder('utf8');
|
|
298
|
+
let timedOut = false;
|
|
299
|
+
let settled = false;
|
|
300
|
+
let timer = null;
|
|
301
|
+
const finish = (code) => {
|
|
302
|
+
if (settled)
|
|
303
|
+
return;
|
|
304
|
+
settled = true;
|
|
305
|
+
if (timer !== null)
|
|
306
|
+
clearTimeout(timer);
|
|
307
|
+
const current = this.conns.get(key);
|
|
308
|
+
if (current !== undefined)
|
|
309
|
+
current.lastUsed = Date.now();
|
|
310
|
+
resolve({
|
|
311
|
+
code,
|
|
312
|
+
stdout: stdoutSink.decode(stdoutDecoder),
|
|
313
|
+
stderr: stderrSink.decode(stderrDecoder),
|
|
314
|
+
timedOut,
|
|
315
|
+
truncated: stdoutSink.truncated || stderrSink.truncated,
|
|
316
|
+
durationMs: Date.now() - started,
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
// 定时器放在 finish **之后**(D112):超时除了打断远端命令,还要**直接 settle**。
|
|
320
|
+
// 原先只 signal('KILL') + close(),若通道静默不响应(既不 emit 'close' 也不 emit
|
|
321
|
+
// 'error'),promise 永不落定 → finally 里的 inflight 减不掉 → 该连接对
|
|
322
|
+
// shouldRecycleConn 永远是「在途」,sweeper 再也回收不了它。
|
|
323
|
+
// settled 守卫保证与随后的 'close' 事件不会重复 resolve(幂等)。
|
|
324
|
+
timer = setTimeout(() => {
|
|
325
|
+
timedOut = true;
|
|
326
|
+
try {
|
|
327
|
+
channel.signal('KILL');
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
/* 远端可能已结束 */
|
|
331
|
+
}
|
|
332
|
+
channel.close();
|
|
333
|
+
finish(null);
|
|
334
|
+
}, timeoutMs);
|
|
335
|
+
channel.on('data', (chunk) => {
|
|
336
|
+
stdoutSink.push(chunk);
|
|
337
|
+
});
|
|
338
|
+
channel.stderr.on('data', (chunk) => {
|
|
339
|
+
stderrSink.push(chunk);
|
|
340
|
+
});
|
|
341
|
+
channel.on('close', (code) => {
|
|
342
|
+
finish(typeof code === 'number' ? code : null);
|
|
343
|
+
});
|
|
344
|
+
channel.on('error', (error) => {
|
|
345
|
+
if (settled)
|
|
346
|
+
return;
|
|
347
|
+
settled = true;
|
|
348
|
+
if (timer !== null)
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
reject(new Error(`SSH exec channel 异常:${error.message}`));
|
|
351
|
+
});
|
|
352
|
+
if (options?.input !== undefined)
|
|
353
|
+
channel.end(options.input);
|
|
250
354
|
});
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
if (rt !== undefined)
|
|
358
|
+
rt.inflight = Math.max(0, rt.inflight - 1);
|
|
359
|
+
}
|
|
254
360
|
}
|
|
255
361
|
/**
|
|
256
362
|
* 在远程开一条**长流**(docker logs --follow):stdout/stderr 逐块回调,
|
|
@@ -264,29 +370,57 @@ export class RemoteExec {
|
|
|
264
370
|
const command = shJoin(argv);
|
|
265
371
|
// 先确保连接已建立:下面的 rt.busy 与配额判定都依赖连接已存在于池里
|
|
266
372
|
await this.acquire(spec);
|
|
267
|
-
const
|
|
268
|
-
|
|
373
|
+
const key = poolKey(spec);
|
|
374
|
+
let holder = this.conns.get(key);
|
|
375
|
+
if (holder === undefined) {
|
|
376
|
+
// acquire 之后条目必在(acquire 先占坑,见 acquire 内注释);真发生说明池状态
|
|
377
|
+
// 被并发破坏——**fail-closed**(D26):静默跳过配额判定会让长流绕开
|
|
378
|
+
// MAX_STREAMS_PER_TARGET,直冲 OpenSSH MaxSessions=10
|
|
379
|
+
throw new Error(`SSH 连接状态异常(${sshTarget(spec)}):请重试;若持续出现请反馈`);
|
|
380
|
+
}
|
|
381
|
+
{
|
|
269
382
|
// 配额判定放在自增**之前**:拒绝时没有自增,finally 里的 release 也就不会去减别人的计数
|
|
270
|
-
const denied = streamBudgetError(sshTarget(spec),
|
|
383
|
+
const denied = streamBudgetError(sshTarget(spec), holder.busy);
|
|
271
384
|
if (denied !== null)
|
|
272
385
|
throw new Error(denied);
|
|
273
|
-
|
|
386
|
+
holder.busy += 1;
|
|
274
387
|
}
|
|
388
|
+
/**
|
|
389
|
+
* 把计数从「开门之前那条条目」搬到「现在池里那条」(D88)。
|
|
390
|
+
*
|
|
391
|
+
* 为什么必须搬:openChannel 遇到传输错误会 dropConn + end() **另建一条**连接
|
|
392
|
+
* (见 openChannel 的重试分支),于是开门前 `busy += 1` 记的那条已被摘掉——新连接
|
|
393
|
+
* 的 busy 是 0 → ① 长流配额少算,MAX_STREAMS_PER_TARGET 的 fail-closed 保护在这条
|
|
394
|
+
* 路径失效;② 用户正在看的 `docker logs -f` 在 120s 后被 sweeper 当空闲连接掐断
|
|
395
|
+
* (sweeper 只看活条目的 busy/inflight),正是 D01 要消灭的症状。
|
|
396
|
+
* 配额判定与自增仍留在 openChannel **之前**:否则被拒的流已经白开了一条通道。
|
|
397
|
+
*/
|
|
398
|
+
const moveBusyToLive = () => {
|
|
399
|
+
const live = this.conns.get(key);
|
|
400
|
+
if (live !== undefined && live !== holder) {
|
|
401
|
+
// holder 在下面的 undefined 守卫后必定有值;因为它在闭包里被重新赋值,
|
|
402
|
+
// TS 的窄化不会跨赋值保留,这里用 `!` 明确(不是新契约)
|
|
403
|
+
holder.busy = Math.max(0, holder.busy - 1);
|
|
404
|
+
live.busy += 1;
|
|
405
|
+
holder = live;
|
|
406
|
+
}
|
|
407
|
+
};
|
|
275
408
|
let released = false;
|
|
276
409
|
// try/finally 保证 busy 增减严格配对:异常路径也不能把连接永久标成 busy
|
|
410
|
+
//(一律作用于 holder:重连后计数已经搬过家,release 必须减现在这条)
|
|
277
411
|
const release = () => {
|
|
278
412
|
if (released)
|
|
279
413
|
return;
|
|
280
414
|
released = true;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
rt.lastUsed = Date.now();
|
|
284
|
-
}
|
|
415
|
+
holder.busy = Math.max(0, holder.busy - 1);
|
|
416
|
+
holder.lastUsed = Date.now();
|
|
285
417
|
};
|
|
286
418
|
try {
|
|
287
419
|
if (signal?.aborted === true)
|
|
288
420
|
return { code: null };
|
|
289
421
|
const channel = await this.openChannel(spec, command, DEFAULT_TIMEOUT_MS, 0);
|
|
422
|
+
// 开门成功才可能发生「重连换条目」;开门抛错时条目仍是我占的那条,无需搬
|
|
423
|
+
moveBusyToLive();
|
|
290
424
|
return await new Promise((resolve, reject) => {
|
|
291
425
|
const stdoutDecoder = new StringDecoder('utf8');
|
|
292
426
|
const stderrDecoder = new StringDecoder('utf8');
|
|
@@ -377,10 +511,12 @@ export class RemoteExec {
|
|
|
377
511
|
/**
|
|
378
512
|
* 开一条 exec channel;**传输层**错误时丢掉连接、重连一次(见 `isTransportError`)。
|
|
379
513
|
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
514
|
+
* 只重试一次:重连之后还报同样的错,多半不是连接的问题(目标本身不可达),
|
|
515
|
+
* 再试只是把失败拖长、还会多压一条命令过去。
|
|
382
516
|
*/
|
|
383
517
|
async openChannel(spec, command, timeoutMs, attempt) {
|
|
518
|
+
// acquire 放在 try **外面**(D27):建连失败(目标不可达等)不该落在「传输错误
|
|
519
|
+
// 重试」的范围内——否则每次命令都要干等两轮 20s 的 readyTimeout。
|
|
384
520
|
const client = await this.acquire(spec);
|
|
385
521
|
try {
|
|
386
522
|
return await new Promise((resolve, reject) => {
|
|
@@ -400,7 +536,15 @@ export class RemoteExec {
|
|
|
400
536
|
catch (error) {
|
|
401
537
|
const message = error instanceof Error ? error.message : String(error);
|
|
402
538
|
if (attempt === 0 && isTransportError(message)) {
|
|
403
|
-
|
|
539
|
+
// 丢掉这条连接并**关闭它**(D07):只摘出池不 end() 的话,keepalive 会一直
|
|
540
|
+
// 养着一条死连接;dropConn 带身份校验,不会误摘同键上的新连接(D06)。
|
|
541
|
+
this.dropConn(poolKey(spec), client);
|
|
542
|
+
try {
|
|
543
|
+
client.end();
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
/* 连接已断开 */
|
|
547
|
+
}
|
|
404
548
|
return await this.openChannel(spec, command, timeoutMs, 1);
|
|
405
549
|
}
|
|
406
550
|
throw error;
|
|
@@ -414,54 +558,116 @@ export class RemoteExec {
|
|
|
414
558
|
existing.lastUsed = Date.now();
|
|
415
559
|
return existing.ready;
|
|
416
560
|
}
|
|
417
|
-
const connectConfig = await buildConnectConfig(spec);
|
|
418
561
|
const target = sshTarget(spec);
|
|
419
|
-
const policy = applyHostKeyPolicy({ connectConfig, spec, store: this.store, logger: this.logger, target });
|
|
420
562
|
const client = new Client();
|
|
563
|
+
let resolveReady;
|
|
564
|
+
let rejectReady;
|
|
421
565
|
const ready = new Promise((resolve, reject) => {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
566
|
+
resolveReady = resolve;
|
|
567
|
+
rejectReady = reject;
|
|
568
|
+
});
|
|
569
|
+
// **先占坑再异步解析配置**(D02):buildConnectConfig 会 await 凭据解析、让出事件
|
|
570
|
+
// 循环——若此时池里还没有条目,并发第二个请求会各自建连,先建好的那条立即脱管
|
|
571
|
+
//(回收不到、disposeAll 关不掉、配额记在别人头上),日后的 close/error 还会误摘
|
|
572
|
+
// 同键的新连接(D06)。占位后并发请求拿到的就是同一条 ready。
|
|
573
|
+
const entry = { client, lastUsed: Date.now(), ready, busy: 0, inflight: 0, disposed: false };
|
|
574
|
+
this.conns.set(key, entry);
|
|
575
|
+
// ready 被拒时不要留下未处理 rejection(调用方 await 时会拿到)
|
|
576
|
+
ready.catch(() => {
|
|
577
|
+
/* 由调用方处理 */
|
|
578
|
+
});
|
|
579
|
+
let settled = false;
|
|
580
|
+
let timer = null;
|
|
581
|
+
/**
|
|
582
|
+
* 这条占位条目还是不是「我的」(D94)。
|
|
583
|
+
*
|
|
584
|
+
* 为什么要单独判:`disposeAll()`(插件卸载)与 sweeper 都能在**建连途中**把占位条目
|
|
585
|
+
* 摘掉并 `end()`。只判 settled 的话,随后到来的 `ready` / `error` 仍会走完正常流程——
|
|
586
|
+
* 前者 resolve 出一条不在池里的活连接(回收不到、disposeAll 再关不掉、keepalive 一直
|
|
587
|
+
* 养着,`run()` 的 inflight 保护也一并失效),后者丢掉真实错误原因只报「连接超时」。
|
|
588
|
+
* 归属口径 = 池里这个键仍指向这条条目,且它没有被 disposeAll 标记(先标记后 clear,
|
|
589
|
+
* 所以标记能覆盖「已摘出但还没被 clear」的窗口)。
|
|
590
|
+
*/
|
|
591
|
+
const ownsEntry = () => this.conns.get(key) === entry && !entry.disposed;
|
|
592
|
+
/** 兜底关闭:ssh2 Client 的 `end()` 对「尚未建连」的实例未必有用,`destroy()` 再补一刀。 */
|
|
593
|
+
const forceClose = () => {
|
|
594
|
+
try {
|
|
595
|
+
client.end();
|
|
596
|
+
}
|
|
597
|
+
catch {
|
|
598
|
+
/* 连接未建立 / 已断开 */
|
|
599
|
+
}
|
|
600
|
+
try {
|
|
601
|
+
client.destroy();
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
/* 同上 */
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
const settleError = (error) => {
|
|
608
|
+
if (settled)
|
|
609
|
+
return;
|
|
610
|
+
settled = true;
|
|
611
|
+
if (timer !== null)
|
|
612
|
+
clearTimeout(timer);
|
|
613
|
+
// 归属校验同样落在错误路径:条目已被摘掉(多为 disposeAll)时,真正该报的是
|
|
614
|
+
// 「这条连接已被释放」,而不是此后某次 close 触发的「握手完成前关闭」或超时(D94)
|
|
615
|
+
const finalError = ownsEntry() ? error : new Error(`SSH 连接已在建立期间被释放(${target}):请重试`);
|
|
616
|
+
this.dropConn(key, client);
|
|
617
|
+
forceClose();
|
|
618
|
+
rejectReady(finalError);
|
|
619
|
+
};
|
|
620
|
+
try {
|
|
621
|
+
const connectConfig = await buildConnectConfig(spec);
|
|
622
|
+
const policy = applyHostKeyPolicy({ connectConfig, spec, store: this.store, logger: this.logger, target });
|
|
623
|
+
/** 指纹变更优先于任何通用文案(含 D94 的「已释放」):安全提示不能被噪音盖掉。 */
|
|
624
|
+
const describe = (error, fallback) => new Error(policy.mismatchMessage() ?? `${fallback}:${error.message}`);
|
|
625
|
+
timer = setTimeout(() => {
|
|
626
|
+
settleError(new Error(`SSH 连接超时(${target})`));
|
|
627
|
+
}, connectTimeoutMs());
|
|
436
628
|
client.once('ready', () => {
|
|
437
629
|
if (settled)
|
|
438
630
|
return;
|
|
439
631
|
settled = true;
|
|
440
|
-
|
|
441
|
-
|
|
632
|
+
if (timer !== null)
|
|
633
|
+
clearTimeout(timer);
|
|
634
|
+
if (!ownsEntry()) {
|
|
635
|
+
// 建连途中被 disposeAll / sweeper 摘掉:这条 client 不属于任何人,
|
|
636
|
+
// 交出去就是脱管连接——关掉再拒绝(D94)
|
|
637
|
+
this.dropConn(key, client);
|
|
638
|
+
forceClose();
|
|
639
|
+
rejectReady(new Error(`SSH 连接已被释放(${target}):请重试`));
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
entry.lastUsed = Date.now();
|
|
643
|
+
resolveReady(client);
|
|
442
644
|
});
|
|
443
645
|
client.once('error', (error) => {
|
|
444
|
-
|
|
445
|
-
if (settled)
|
|
446
|
-
return;
|
|
447
|
-
settled = true;
|
|
448
|
-
clearTimeout(timer);
|
|
449
|
-
const mismatch = policy.mismatchMessage();
|
|
450
|
-
reject(new Error(mismatch ?? `SSH 连接失败(${target}):${error.message}`));
|
|
646
|
+
settleError(describe(error, `SSH 连接失败(${target})`));
|
|
451
647
|
});
|
|
452
648
|
client.once('close', () => {
|
|
453
|
-
this.dropConn(key);
|
|
649
|
+
this.dropConn(key, client);
|
|
650
|
+
if (!settled)
|
|
651
|
+
settleError(new Error(`SSH 连接失败(${target}):连接在握手完成前关闭`));
|
|
454
652
|
});
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
653
|
+
try {
|
|
654
|
+
client.connect(connectConfig);
|
|
655
|
+
}
|
|
656
|
+
catch (error) {
|
|
657
|
+
// connect() 的同步异常(如加密私钥缺 passphrase)必须先出池再拒绝(D05):
|
|
658
|
+
// 否则这条「永远假」的池条目会把同一句旧错误缓存到永远——改对配置也不恢复
|
|
659
|
+
settleError(error instanceof Error ? error : new Error(String(error)));
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
settleError(error instanceof Error ? error : new Error(String(error)));
|
|
664
|
+
}
|
|
462
665
|
return ready;
|
|
463
666
|
}
|
|
464
|
-
|
|
667
|
+
/** 从池里摘掉一条连接。带 client 时做身份校验:只摘自己这条(D06)。 */
|
|
668
|
+
dropConn(key, client) {
|
|
669
|
+
if (client !== undefined && this.conns.get(key)?.client !== client)
|
|
670
|
+
return;
|
|
465
671
|
this.conns.delete(key);
|
|
466
672
|
}
|
|
467
673
|
}
|
|
@@ -472,13 +678,18 @@ export async function buildConnectConfig(spec) {
|
|
|
472
678
|
host: spec.host,
|
|
473
679
|
port: spec.port ?? 22,
|
|
474
680
|
username: spec.username,
|
|
475
|
-
readyTimeout:
|
|
681
|
+
readyTimeout: connectTimeoutMs(),
|
|
476
682
|
keepaliveInterval: 10_000,
|
|
477
683
|
keepaliveCountMax: 3,
|
|
478
684
|
// hostVerifier 依赖 hostHash 计算指纹;放行与否由 TOFU 策略决定
|
|
479
685
|
hostHash: 'sha256',
|
|
480
686
|
};
|
|
481
687
|
if (auth === 'agent') {
|
|
688
|
+
// 缺 SSH_AUTH_SOCK 时没有预检的话,ssh2 会报「All configured authentication
|
|
689
|
+
// methods failed」,把「agent 没跑」这个最可能的原因藏起来(D29;tty 同款已修)
|
|
690
|
+
if (process.env.SSH_AUTH_SOCK === undefined || process.env.SSH_AUTH_SOCK === '') {
|
|
691
|
+
throw new Error(`auth=agent 但 SSH_AUTH_SOCK 未设置(ssh-agent 没在跑?):ssh-agent 是最可能的原因。可改用 auth=key / auth=password,或先启动 ssh-agent`);
|
|
692
|
+
}
|
|
482
693
|
base.agent = process.env.SSH_AUTH_SOCK;
|
|
483
694
|
}
|
|
484
695
|
else if (auth === 'key') {
|
|
@@ -501,6 +712,19 @@ export async function buildConnectConfig(spec) {
|
|
|
501
712
|
if (spec.agentForward === true && process.env.SSH_AUTH_SOCK !== undefined && process.env.SSH_AUTH_SOCK !== '') {
|
|
502
713
|
base.agent = base.agent ?? process.env.SSH_AUTH_SOCK;
|
|
503
714
|
}
|
|
715
|
+
// agentForward 配置要真正生效(D28):ssh2 只认 cfg.agentForward === true 才会发
|
|
716
|
+
// auth-agent-req@openssh.com,只设 base.agent(认证用途)远端永远拿不到本地 agent。
|
|
717
|
+
//
|
|
718
|
+
// 但必须复用上面的「有没有 agent」判定(D81):ssh2 在 connect() 里硬校验
|
|
719
|
+
// `agentForward === true && agent === undefined` → **同步 throw**
|
|
720
|
+
//「You must set a valid agent path to allow agent forwarding」。
|
|
721
|
+
// auth=key / auth=password 时 base.agent 只在有 SSH_AUTH_SOCK 时才会被设上,
|
|
722
|
+
// 于是「宿主无 sock(launchd/systemd/GUI 启动、Windows)+ 勾了转发」会把配置从
|
|
723
|
+
// 「等于没配」变成「整个目标不可用」——同目标每一次操作全失败,文案还是裸英文。
|
|
724
|
+
// README 承诺的是「SSH_AUTH_SOCK 存在时生效」,所以这里**静默降级**:agent 不可用
|
|
725
|
+
// 就当作没勾转发,连接照常建立(D29 已为 auth=agent 单独给出可读的预检错误)。
|
|
726
|
+
if (spec.agentForward === true && base.agent !== undefined)
|
|
727
|
+
base.agentForward = true;
|
|
504
728
|
return base;
|
|
505
729
|
}
|
|
506
730
|
/** TOFU 主机指纹策略(hostVerifier 接线);mismatchMessage() 供错误路径取人类可读拒绝原因。 */
|
|
@@ -509,16 +733,19 @@ export function applyHostKeyPolicy(options) {
|
|
|
509
733
|
const port = spec.port ?? 22;
|
|
510
734
|
let hostKeyMismatch = null;
|
|
511
735
|
connectConfig.hostVerifier = (hash) => {
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
736
|
+
// host 键统一 trim + 小写(D03):tty 落盘时把 host 小写化,比较口径必须一致
|
|
737
|
+
const host = spec.host.trim().toLowerCase();
|
|
738
|
+
const known = store?.get(host, port);
|
|
739
|
+
if (known === undefined || known.length === 0) {
|
|
740
|
+
store?.record(host, port, hash);
|
|
515
741
|
logger?.info(`[dsh-docker] ssh ${target} 首次连接,已记录 host key 指纹 sha256:${hash}(TOFU)`);
|
|
516
742
|
return true;
|
|
517
743
|
}
|
|
518
|
-
|
|
744
|
+
// 命中集合内**任意**一条指纹即放行:同一主机的 rsa / ed25519 各记一条(D03)
|
|
745
|
+
if (known.includes(hash))
|
|
519
746
|
return true;
|
|
520
747
|
hostKeyMismatch =
|
|
521
|
-
`SSH 主机密钥指纹变更:${target} 已记录 sha256:${known},本次为 sha256:${hash}。` +
|
|
748
|
+
`SSH 主机密钥指纹变更:${target} 已记录 sha256:${known.join(' / ')},本次为 sha256:${hash}。` +
|
|
522
749
|
'可能是主机重装或换钥匙,也可能是中间人(MITM)冒充;确认安全后,到 插件配置 → Docker 容器面板 → SSH 主机密钥记录 删除该主机再重连。';
|
|
523
750
|
logger?.warn(`[dsh-docker] ${hostKeyMismatch}`);
|
|
524
751
|
return false;
|
|
@@ -540,39 +767,22 @@ export async function runLocal(argv, options) {
|
|
|
540
767
|
if (bin === undefined)
|
|
541
768
|
throw new Error('runLocal 需要至少一个 argv 元素');
|
|
542
769
|
return await new Promise((resolve, reject) => {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
let truncated = false;
|
|
770
|
+
const stdoutSink = new ByteSink(maxBytes, options?.keepTail === true);
|
|
771
|
+
const stderrSink = new ByteSink(maxBytes, options?.keepTail === true);
|
|
772
|
+
const stdoutDecoder = new StringDecoder('utf8');
|
|
773
|
+
const stderrDecoder = new StringDecoder('utf8');
|
|
548
774
|
let timedOut = false;
|
|
549
775
|
let settled = false;
|
|
550
776
|
const child = spawn(bin, args, { stdio: ['pipe', 'pipe', 'pipe'], env: process.env });
|
|
551
|
-
const cap = (text, current, chunk) => {
|
|
552
|
-
const room = maxBytes - current;
|
|
553
|
-
if (room <= 0) {
|
|
554
|
-
truncated = true;
|
|
555
|
-
return { text, bytes: current };
|
|
556
|
-
}
|
|
557
|
-
if (chunk.length > room) {
|
|
558
|
-
truncated = true;
|
|
559
|
-
return { text: text + chunk.subarray(0, room).toString('utf8'), bytes: maxBytes };
|
|
560
|
-
}
|
|
561
|
-
return { text: text + chunk.toString('utf8'), bytes: current + chunk.length };
|
|
562
|
-
};
|
|
563
777
|
const timer = setTimeout(() => {
|
|
564
778
|
timedOut = true;
|
|
565
779
|
child.kill('SIGKILL');
|
|
566
780
|
}, timeoutMs);
|
|
567
781
|
child.stdout.on('data', (chunk) => {
|
|
568
|
-
|
|
569
|
-
stdout = next.text;
|
|
570
|
-
stdoutBytes = next.bytes;
|
|
782
|
+
stdoutSink.push(chunk);
|
|
571
783
|
});
|
|
572
784
|
child.stderr.on('data', (chunk) => {
|
|
573
|
-
|
|
574
|
-
stderr = next.text;
|
|
575
|
-
stderrBytes = next.bytes;
|
|
785
|
+
stderrSink.push(chunk);
|
|
576
786
|
});
|
|
577
787
|
child.once('error', (error) => {
|
|
578
788
|
if (settled)
|
|
@@ -587,7 +797,14 @@ export async function runLocal(argv, options) {
|
|
|
587
797
|
return;
|
|
588
798
|
settled = true;
|
|
589
799
|
clearTimeout(timer);
|
|
590
|
-
resolve({
|
|
800
|
+
resolve({
|
|
801
|
+
code,
|
|
802
|
+
stdout: stdoutSink.decode(stdoutDecoder),
|
|
803
|
+
stderr: stderrSink.decode(stderrDecoder),
|
|
804
|
+
timedOut,
|
|
805
|
+
truncated: stdoutSink.truncated || stderrSink.truncated,
|
|
806
|
+
durationMs: Date.now() - started,
|
|
807
|
+
});
|
|
591
808
|
});
|
|
592
809
|
if (options?.input !== undefined)
|
|
593
810
|
child.stdin.end(options.input);
|