@hyzyn/dsh-docker 0.8.0 → 0.9.0-rc.1
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 +12 -0
- package/README.md +25 -1
- package/client-src/docker.css +95 -1
- package/client-src/index.js +1883 -534
- package/client-src/log-stream.js +7 -1
- package/client.js +119 -25
- package/lib/docker.d.ts +9 -2
- package/lib/docker.js +8 -3
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +18 -0
- package/lib/index.js +303 -162
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +142 -2
- package/lib/ssh-exec.js +549 -12
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +2 -2
- package/scripts/client-smoke.mjs +8 -2
- package/scripts/route-smoke.mjs +15 -0
package/lib/ssh-exec.js
CHANGED
|
@@ -13,11 +13,69 @@
|
|
|
13
13
|
* 这类长流走 stream()/runLocalStream()(无总超时、无上限,靠 AbortSignal 停止)。
|
|
14
14
|
*/
|
|
15
15
|
import { spawn } from 'node:child_process';
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
16
17
|
import { readFileSync } from 'node:fs';
|
|
17
18
|
import { homedir } from 'node:os';
|
|
18
19
|
import { join } from 'node:path';
|
|
20
|
+
import { Duplex } from 'node:stream';
|
|
19
21
|
import { StringDecoder } from 'node:string_decoder';
|
|
20
22
|
import { Client } from 'ssh2';
|
|
23
|
+
/**
|
|
24
|
+
* 清洗一份跳板机输入(`readTtyBooks` 用)。返回 `undefined` = 没配跳板机——不给下游留
|
|
25
|
+
* `host: ''` 的半个对象(那会让拨号去连空主机名)。
|
|
26
|
+
*/
|
|
27
|
+
export function sanitizeJumpSpec(input) {
|
|
28
|
+
if (typeof input !== 'object' || input === null)
|
|
29
|
+
return undefined;
|
|
30
|
+
const raw = input;
|
|
31
|
+
const host = typeof raw.host === 'string' ? raw.host.trim() : '';
|
|
32
|
+
if (host === '')
|
|
33
|
+
return undefined;
|
|
34
|
+
const port = Number(raw.port);
|
|
35
|
+
const jump = { host, port: Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 22 };
|
|
36
|
+
if (typeof raw.username === 'string' && raw.username.trim() !== '')
|
|
37
|
+
jump.username = raw.username.trim();
|
|
38
|
+
if (raw.auth === 'agent' || raw.auth === 'key' || raw.auth === 'password')
|
|
39
|
+
jump.auth = raw.auth;
|
|
40
|
+
if (typeof raw.keyPath === 'string' && raw.keyPath !== '')
|
|
41
|
+
jump.keyPath = raw.keyPath;
|
|
42
|
+
if (typeof raw.passphrase === 'string' && raw.passphrase !== '')
|
|
43
|
+
jump.passphrase = raw.passphrase;
|
|
44
|
+
if (typeof raw.password === 'string' && raw.password !== '')
|
|
45
|
+
jump.password = raw.password;
|
|
46
|
+
return jump;
|
|
47
|
+
}
|
|
48
|
+
/** 跳板机展示串(`user@host:port`);没配时返回空串。**凭据不进这里**。 */
|
|
49
|
+
export function jumpTargetLabel(spec) {
|
|
50
|
+
const jump = spec.jump;
|
|
51
|
+
if (jump === undefined || jump.host.trim() === '')
|
|
52
|
+
return '';
|
|
53
|
+
const port = jump.port ?? 22;
|
|
54
|
+
return `${jump.username ?? spec.username}@${jump.host.trim()}${port === 22 ? '' : ':' + String(port)}`;
|
|
55
|
+
}
|
|
56
|
+
/** 目标那一跳的展示串 + 跳板机 / 代理命令后缀(错误文案用;理由见 tty `src/ssh.ts` 的同名注释)。 */
|
|
57
|
+
export function targetWithJump(spec) {
|
|
58
|
+
const label = jumpTargetLabel(spec);
|
|
59
|
+
if (label !== '')
|
|
60
|
+
return `${sshTarget(spec)}(经跳板机 ${label})`;
|
|
61
|
+
if (sanitizeProxyCommand(spec.proxyCommand) !== undefined)
|
|
62
|
+
return `${sshTarget(spec)}(经代理命令)`;
|
|
63
|
+
return sshTarget(spec);
|
|
64
|
+
}
|
|
65
|
+
/** 跳板机那一跳的连接规格:显式给的优先,其余继承目标。 */
|
|
66
|
+
function jumpSpecOf(spec) {
|
|
67
|
+
const jump = spec.jump;
|
|
68
|
+
const username = typeof jump.username === 'string' && jump.username.trim() !== '' ? jump.username.trim() : spec.username;
|
|
69
|
+
return {
|
|
70
|
+
host: jump.host.trim(),
|
|
71
|
+
port: jump.port ?? 22,
|
|
72
|
+
username,
|
|
73
|
+
auth: jump.auth ?? spec.auth ?? 'agent',
|
|
74
|
+
keyPath: jump.keyPath ?? spec.keyPath,
|
|
75
|
+
passphrase: jump.passphrase ?? spec.passphrase,
|
|
76
|
+
password: jump.password ?? spec.password,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
21
79
|
let credentialsProvider = null;
|
|
22
80
|
/** 由 index.ts 在可选注入里挂上(服务缺失即为 null,退回 process.env)。 */
|
|
23
81
|
export function setCredentialResolver(resolver) {
|
|
@@ -95,8 +153,355 @@ export function shJoin(argv) {
|
|
|
95
153
|
* 长流额度被悄悄翻倍(恰好掩盖 D07 想暴露的 MaxSessions 问题)。口径与 TOFU 的
|
|
96
154
|
* hostVerifier(D03)保持一致:那里也用 `trim().toLowerCase()` 分组指纹。
|
|
97
155
|
*/
|
|
98
|
-
|
|
99
|
-
|
|
156
|
+
/**
|
|
157
|
+
* 池键。**跳板机 / 代理命令身份必须并进来**:不同 bastion(或不同代理命令)到同一目标
|
|
158
|
+
* 绝不是同一条连接——只按 `user@host:port` 记的话,第二个 bastion 会静默复用第一条连接、
|
|
159
|
+
* 走错跳板机。这与 tty 的 SFTP 池不同(那边键是 `JSON.stringify(spec)`,天然带上 jump)。
|
|
160
|
+
*
|
|
161
|
+
* 代理命令**只并入它的哈希**,不并入原文:原文可能含凭据(`-i /path/key`、甚至嵌 token),
|
|
162
|
+
* 而池键会进日志/错误文案附近的诊断路径——摘要足够区分且不泄露。
|
|
163
|
+
*/
|
|
164
|
+
export function poolKey(spec) {
|
|
165
|
+
const basis = `${spec.username}@${spec.host.trim().toLowerCase()}:${String(spec.port ?? 22)}`;
|
|
166
|
+
const label = jumpTargetLabel(spec);
|
|
167
|
+
if (label !== '')
|
|
168
|
+
return `${basis}|jump:${label.toLowerCase()}`;
|
|
169
|
+
const proxyCommand = sanitizeProxyCommand(spec.proxyCommand);
|
|
170
|
+
if (proxyCommand !== undefined) {
|
|
171
|
+
return `${basis}|cmd:${createHash('sha256').update(proxyCommand).digest('hex').slice(0, 12)}`;
|
|
172
|
+
}
|
|
173
|
+
return basis;
|
|
174
|
+
}
|
|
175
|
+
/** 跳板机通道打开兜底(与 tty 同参数:对端不回 forwardOut 回调时不能让 await 挂着)。 */
|
|
176
|
+
const JUMP_CHANNEL_TIMEOUT_MS = 15_000;
|
|
177
|
+
/**
|
|
178
|
+
* 拨跳板机并借一条 forwardOut 通道(ProxyJump 单跳)。与 tty `src/ssh.ts` 的 dialJump 同序。
|
|
179
|
+
* **导出仅供单测**(`test/ssh-jump.test.ts` 用假 ssh2 验「先拨跳板机、再把通道当 sock」)。
|
|
180
|
+
*/
|
|
181
|
+
export async function dialJump(options) {
|
|
182
|
+
const jumpSpec = jumpSpecOf(options.spec);
|
|
183
|
+
const label = jumpTargetLabel(options.spec);
|
|
184
|
+
const destination = sshTarget(options.spec);
|
|
185
|
+
const bastion = new Client();
|
|
186
|
+
const closeQuietly = () => {
|
|
187
|
+
try {
|
|
188
|
+
bastion.end();
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
/* 已断开 */
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
let connectConfig;
|
|
195
|
+
try {
|
|
196
|
+
connectConfig = await buildConnectConfig(jumpSpec);
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
throw new Error(`跳板机连接失败(${label}):${error instanceof Error ? error.message : String(error)}`);
|
|
200
|
+
}
|
|
201
|
+
const policy = applyHostKeyPolicy({ connectConfig, spec: jumpSpec, store: options.store, logger: options.logger, target: `跳板机 ${label}` });
|
|
202
|
+
await new Promise((resolve, reject) => {
|
|
203
|
+
let settled = false;
|
|
204
|
+
const fail = (error) => {
|
|
205
|
+
if (settled)
|
|
206
|
+
return;
|
|
207
|
+
settled = true;
|
|
208
|
+
closeQuietly();
|
|
209
|
+
reject(error);
|
|
210
|
+
};
|
|
211
|
+
bastion.once('ready', () => {
|
|
212
|
+
if (settled)
|
|
213
|
+
return;
|
|
214
|
+
settled = true;
|
|
215
|
+
resolve();
|
|
216
|
+
});
|
|
217
|
+
bastion.on('error', (error) => {
|
|
218
|
+
fail(new Error(policy.mismatchMessage() ?? `跳板机连接失败(${label}):${error.message}`));
|
|
219
|
+
});
|
|
220
|
+
bastion.once('close', () => {
|
|
221
|
+
fail(new Error(`跳板机连接已关闭(${label}):目标连接尚未建立`));
|
|
222
|
+
});
|
|
223
|
+
if (connectConfig.tryKeyboard === true) {
|
|
224
|
+
const password = connectConfig.password ?? '';
|
|
225
|
+
bastion.on('keyboard-interactive', (_name, _instructions, _lang, _prompts, finishKb) => {
|
|
226
|
+
finishKb([password]);
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
bastion.connect(connectConfig);
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
fail(new Error(`跳板机连接失败(${label}):${error instanceof Error ? error.message : String(error)}`));
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
const sock = await new Promise((resolve, reject) => {
|
|
237
|
+
let settled = false;
|
|
238
|
+
const timer = setTimeout(() => {
|
|
239
|
+
if (settled)
|
|
240
|
+
return;
|
|
241
|
+
settled = true;
|
|
242
|
+
closeQuietly();
|
|
243
|
+
reject(new Error(`跳板机通道打开超时(${label} → ${destination},${String(JUMP_CHANNEL_TIMEOUT_MS / 1000)}s 无响应):跳板机可能不允许转发或不响应`));
|
|
244
|
+
}, JUMP_CHANNEL_TIMEOUT_MS);
|
|
245
|
+
timer.unref?.();
|
|
246
|
+
bastion.forwardOut('127.0.0.1', 0, options.spec.host, options.spec.port ?? 22, (error, channel) => {
|
|
247
|
+
if (settled)
|
|
248
|
+
return;
|
|
249
|
+
settled = true;
|
|
250
|
+
clearTimeout(timer);
|
|
251
|
+
if (error !== undefined && error !== null) {
|
|
252
|
+
closeQuietly();
|
|
253
|
+
reject(new Error(`跳板机通道打开失败(${label} → ${destination}):${error.message}`));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
resolve(channel);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
options.logger?.info(`[dsh-docker] ssh ${destination} 经跳板机 ${label} 已建立转发通道(ProxyJump)`);
|
|
260
|
+
return { bastion, sock };
|
|
261
|
+
}
|
|
262
|
+
/* ------------------------------------------------------------------ *
|
|
263
|
+
* ProxyCommand:与 tty 同一档信任级(默认关闭 + 显式开关 + 导入永不自动带入)
|
|
264
|
+
* ------------------------------------------------------------------ */
|
|
265
|
+
/** 代理命令长度上限(与 tty 同值:它是一条命令行)。 */
|
|
266
|
+
export const PROXY_COMMAND_MAX = 2000;
|
|
267
|
+
const PROXY_STDERR_KEEP = 2048;
|
|
268
|
+
const PROXY_STDERR_SHOWN = 300;
|
|
269
|
+
const PROXY_KILL_GRACE_MS = 2000;
|
|
270
|
+
/** `%h` / `%r` 等展开值允许的字符集(与 tty 同口径:白名单而非转义,理由见 tty 同名常量)。 */
|
|
271
|
+
const PROXY_VALUE_SAFE = /^[A-Za-z0-9._@:\[\]-]+$/;
|
|
272
|
+
/**
|
|
273
|
+
* 闸门关着时报什么错。**与 tty 是同一个开关**(tty settings 的 `allowProxyCommand`)——
|
|
274
|
+
* 连接簿只有一处,开关也只能有一处,否则「连接簿配了、docker 不认」会很难解释。
|
|
275
|
+
*/
|
|
276
|
+
export const PROXY_COMMAND_DISABLED = '代理命令(ProxyCommand)未启用:本机命令执行默认关闭,请到 插件配置 → 终端面板 打开「允许 ProxyCommand」后重试。'
|
|
277
|
+
+ '未启用时携带代理命令的目标不会退回直连——直连多半也连不上,还会把配置问题伪装成网络问题。';
|
|
278
|
+
/**
|
|
279
|
+
* 清洗一份代理命令输入(`readTtyBooks` 用)。返回 `undefined` = 没配。
|
|
280
|
+
* 只做形状校验(非空 / 单行 / 长度);命令内容不解释——「能不能执行」由闸门决定。
|
|
281
|
+
*/
|
|
282
|
+
export function sanitizeProxyCommand(input) {
|
|
283
|
+
if (typeof input !== 'string')
|
|
284
|
+
return undefined;
|
|
285
|
+
const trimmed = input.trim();
|
|
286
|
+
if (trimmed === '' || trimmed.length > PROXY_COMMAND_MAX)
|
|
287
|
+
return undefined;
|
|
288
|
+
if (/[\r\n\0]/.test(trimmed))
|
|
289
|
+
return undefined;
|
|
290
|
+
return trimmed;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* 展开 `%h` / `%p` / `%r` / `%n` / `%%`(与 OpenSSH 同义,与 tty 逐字同口径:
|
|
294
|
+
* 代入值必须过白名单,否则拒绝执行——理由见 tty `expandProxyCommand`)。
|
|
295
|
+
*/
|
|
296
|
+
export function expandProxyCommand(command, spec) {
|
|
297
|
+
const values = {
|
|
298
|
+
h: spec.host,
|
|
299
|
+
n: spec.host,
|
|
300
|
+
p: String(spec.port ?? 22),
|
|
301
|
+
r: spec.username,
|
|
302
|
+
};
|
|
303
|
+
return command.replace(/%(.)/g, (whole, key) => {
|
|
304
|
+
if (key === '%')
|
|
305
|
+
return '%';
|
|
306
|
+
const value = values[key];
|
|
307
|
+
if (value === undefined)
|
|
308
|
+
return whole;
|
|
309
|
+
if (!PROXY_VALUE_SAFE.test(value)) {
|
|
310
|
+
throw new Error(`代理命令里的 %${key} 无法代入:目标主机名 / 用户名含 shell 特殊字符(只允许字母数字与 . _ @ : [ ] -)`);
|
|
311
|
+
}
|
|
312
|
+
return value;
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
/** 杀代理命令(含它拉起的子孙进程):与 tty `killProxyChild` 同策略(POSIX 杀进程组)。 */
|
|
316
|
+
function killProxyChild(child) {
|
|
317
|
+
const pid = child.pid;
|
|
318
|
+
const kill = (signal) => {
|
|
319
|
+
try {
|
|
320
|
+
if (pid !== undefined && process.platform !== 'win32')
|
|
321
|
+
process.kill(-pid, signal);
|
|
322
|
+
else
|
|
323
|
+
child.kill(signal);
|
|
324
|
+
}
|
|
325
|
+
catch {
|
|
326
|
+
try {
|
|
327
|
+
child.kill(signal);
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
/* 已退出 */
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
kill('SIGTERM');
|
|
335
|
+
const timer = setTimeout(() => {
|
|
336
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
337
|
+
kill('SIGKILL');
|
|
338
|
+
}, PROXY_KILL_GRACE_MS);
|
|
339
|
+
timer.unref?.();
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* 启动代理命令(ProxyCommand)并把它的 stdio 当作目标连接的传输。
|
|
343
|
+
*
|
|
344
|
+
* 与 tty `src/ssh.ts` 的 dialProxyCommand **逐句同序**(两包不互相 import,只能各写一份;
|
|
345
|
+
* 语义口径由这份注释与单测钉住):闸门 → 展开 → spawn → 提前退出拖垮传输 → stderr 常驻排空。
|
|
346
|
+
* **导出仅供单测**。
|
|
347
|
+
*/
|
|
348
|
+
export async function dialProxyCommand(options) {
|
|
349
|
+
const raw = sanitizeProxyCommand(options.spec.proxyCommand);
|
|
350
|
+
if (raw === undefined)
|
|
351
|
+
throw new Error('代理命令为空(proxyCommand 需要一条非空的单行命令)');
|
|
352
|
+
if ((options.allowed?.() ?? false) !== true)
|
|
353
|
+
throw new Error(PROXY_COMMAND_DISABLED);
|
|
354
|
+
const destination = sshTarget(options.spec);
|
|
355
|
+
const command = expandProxyCommand(raw, options.spec);
|
|
356
|
+
const child = spawn(command, {
|
|
357
|
+
shell: true,
|
|
358
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
359
|
+
windowsHide: true,
|
|
360
|
+
detached: process.platform !== 'win32',
|
|
361
|
+
});
|
|
362
|
+
const stdout = child.stdout;
|
|
363
|
+
const stdin = child.stdin;
|
|
364
|
+
const stderr = child.stderr;
|
|
365
|
+
if (stdout === null || stdin === null || stderr === null) {
|
|
366
|
+
killProxyChild(child);
|
|
367
|
+
throw new Error('代理命令启动失败:stdio 管道未建立');
|
|
368
|
+
}
|
|
369
|
+
const sock = Duplex.from({ readable: stdout, writable: stdin });
|
|
370
|
+
let failure = null;
|
|
371
|
+
let disposed = false;
|
|
372
|
+
/**
|
|
373
|
+
* 传输是否已经关掉(子进程输出结束)。与 tty 同因:子进程**先结束输出、后触发 `exit`**,
|
|
374
|
+
* 而 ssh2 一看到流断了就立刻报「Connection lost before handshake」——那一刻 `exit` 还没到。
|
|
375
|
+
* 只认 `exit` 的话,最需要解释的失败(命令一开始就挂)反而没有任何解释。
|
|
376
|
+
*/
|
|
377
|
+
let transportClosed = false;
|
|
378
|
+
const chunks = [];
|
|
379
|
+
let kept = 0;
|
|
380
|
+
/** 已攒到的 stderr 摘要(单行、截断;没有则空串)。 */
|
|
381
|
+
const stderrExcerpt = () => {
|
|
382
|
+
if (chunks.length === 0)
|
|
383
|
+
return '';
|
|
384
|
+
const text = Buffer.concat(chunks).toString('utf8').replace(/[\r\n\0]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
385
|
+
return text.slice(0, PROXY_STDERR_SHOWN);
|
|
386
|
+
};
|
|
387
|
+
const stderrTail = () => {
|
|
388
|
+
const text = stderrExcerpt();
|
|
389
|
+
return text === '' ? '' : `;stderr: ${text}`;
|
|
390
|
+
};
|
|
391
|
+
stderr.on('data', (chunk) => {
|
|
392
|
+
if (kept >= PROXY_STDERR_KEEP)
|
|
393
|
+
return;
|
|
394
|
+
kept += chunk.length;
|
|
395
|
+
chunks.push(chunk);
|
|
396
|
+
});
|
|
397
|
+
const recordFailure = (error) => {
|
|
398
|
+
// **覆盖**而不是只记第一个:传输关闭先触发的往往语焉不详,随后 exit 带来的(退出码 +
|
|
399
|
+
// stderr)严格更有信息量
|
|
400
|
+
failure = error;
|
|
401
|
+
try {
|
|
402
|
+
sock.destroy();
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
/* 已销毁 */
|
|
406
|
+
}
|
|
407
|
+
};
|
|
408
|
+
child.on('error', (error) => {
|
|
409
|
+
recordFailure(new Error(`代理命令出错:${error.message}`));
|
|
410
|
+
});
|
|
411
|
+
child.on('exit', (code, signal) => {
|
|
412
|
+
if (disposed)
|
|
413
|
+
return;
|
|
414
|
+
const how = signal !== null ? `被信号 ${signal} 终止` : `退出码 ${String(code)}`;
|
|
415
|
+
recordFailure(new Error(`代理命令已退出(${how})${stderrTail()}`));
|
|
416
|
+
});
|
|
417
|
+
await new Promise((resolve, reject) => {
|
|
418
|
+
let settled = false;
|
|
419
|
+
child.once('spawn', () => {
|
|
420
|
+
if (settled)
|
|
421
|
+
return;
|
|
422
|
+
settled = true;
|
|
423
|
+
resolve();
|
|
424
|
+
});
|
|
425
|
+
child.once('error', (error) => {
|
|
426
|
+
if (settled)
|
|
427
|
+
return;
|
|
428
|
+
settled = true;
|
|
429
|
+
reject(new Error(`代理命令启动失败:${error.message}`));
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
options.logger?.info(`[dsh-docker] ssh ${destination} 已启动代理命令(ProxyCommand)作为传输`);
|
|
433
|
+
const dispose = () => {
|
|
434
|
+
if (disposed)
|
|
435
|
+
return;
|
|
436
|
+
disposed = true;
|
|
437
|
+
try {
|
|
438
|
+
sock.destroy();
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
/* 已销毁 */
|
|
442
|
+
}
|
|
443
|
+
killProxyChild(child);
|
|
444
|
+
};
|
|
445
|
+
/*
|
|
446
|
+
* **readable 侧 end** 是关键时点:ssh2 就是看到它才判定「握手前连接中断」并发错,
|
|
447
|
+
* 而子进程的 `exit` 还在后面(实测差 1~2ms)。所以在这里就认定「传输已关」,
|
|
448
|
+
* 错误路径才能带上已经攒到的 stderr。
|
|
449
|
+
*/
|
|
450
|
+
sock.once('end', () => {
|
|
451
|
+
transportClosed = true;
|
|
452
|
+
});
|
|
453
|
+
sock.once('close', () => {
|
|
454
|
+
transportClosed = true;
|
|
455
|
+
// 不置 disposed:那是「我们主动收尾」的标记;子进程该杀,但它随后带来的 exit 事实要记下来
|
|
456
|
+
if (!disposed)
|
|
457
|
+
killProxyChild(child);
|
|
458
|
+
});
|
|
459
|
+
/*
|
|
460
|
+
* 必须挂这个监听器:`Duplex.from({readable, writable})` 销毁时会把 **AbortError** 抛给底层流,
|
|
461
|
+
* 没人接就是进程级「未捕获异常」(与 tty 同因,实测 dispose() 即可触发)。
|
|
462
|
+
*/
|
|
463
|
+
sock.on('error', (error) => {
|
|
464
|
+
if (disposed)
|
|
465
|
+
return;
|
|
466
|
+
/*
|
|
467
|
+
* **AbortError 直接忽略**:它不是一个诊断,而是我们自己 destroy 这条传输时 Duplex 抛给
|
|
468
|
+
* 底层流的副产品("The operation was aborted")。把它记成失败原因,用户看到的就会是这句
|
|
469
|
+
* 毫无信息量的话,而不是「命令退出了、stderr 说了什么」。
|
|
470
|
+
*/
|
|
471
|
+
if (error.name === 'AbortError' || error.code === 'ABORT_ERR')
|
|
472
|
+
return;
|
|
473
|
+
recordFailure(new Error(`代理命令传输出错:${error.message}`));
|
|
474
|
+
});
|
|
475
|
+
return {
|
|
476
|
+
child,
|
|
477
|
+
sock,
|
|
478
|
+
failure: () => {
|
|
479
|
+
if (failure !== null)
|
|
480
|
+
return failure;
|
|
481
|
+
if (disposed || !transportClosed)
|
|
482
|
+
return null;
|
|
483
|
+
const tail = stderrTail();
|
|
484
|
+
return new Error(`代理命令传输已关闭${tail === '' ? '(命令已结束)' : tail}`);
|
|
485
|
+
},
|
|
486
|
+
stderrHint: () => {
|
|
487
|
+
// 主动收尾之后不再提示(那是正常结束,不是线索);没有 stderr 也不提示
|
|
488
|
+
if (disposed)
|
|
489
|
+
return '';
|
|
490
|
+
const text = stderrExcerpt();
|
|
491
|
+
return text === '' ? '' : `;代理命令 stderr: ${text}`;
|
|
492
|
+
},
|
|
493
|
+
dispose,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
/** 代理命令失败的事实 → 错误文案后缀(空串 = 没失败),与 tty 同口径。 */
|
|
497
|
+
export function proxyFailureSuffix(proxy) {
|
|
498
|
+
if (proxy === null)
|
|
499
|
+
return '';
|
|
500
|
+
const failure = proxy.failure();
|
|
501
|
+
if (failure !== null)
|
|
502
|
+
return `;${failure.message}`;
|
|
503
|
+
// 与 tty 同因的兜底:错误已经在报,手里有 stderr 就交出去(真机实测 1~2ms 的竞态)
|
|
504
|
+
return proxy.stderrHint();
|
|
100
505
|
}
|
|
101
506
|
/**
|
|
102
507
|
* 有界输出收集器(短命令路径的 stdout / stderr 各持一个)。
|
|
@@ -211,7 +616,20 @@ export function streamBudgetError(target, busy, max = MAX_STREAMS_PER_TARGET) {
|
|
|
211
616
|
* @param message - ssh2 给出的原始错误文案。
|
|
212
617
|
* @returns 补了指向性说明的文案;不认识的原样返回。
|
|
213
618
|
*/
|
|
619
|
+
/**
|
|
620
|
+
* SSH 超时文案里的**跳板机提示**(项目级 ROADMAP 第 2 项)。
|
|
621
|
+
*
|
|
622
|
+
* 为什么值得单独一句话:本插件经数据级复用读得到 tty 的连接簿,但**不读 `~/.ssh/config`**,
|
|
623
|
+
* 所以「配了跳板机的目标连不上」在 docker 侧只能表现为一句通用超时。企业内网主机几乎都
|
|
624
|
+
* 要过 bastion,用户需要的是「可能是什么原因、以及这个版本到底支不支持」——而不是 20 秒后
|
|
625
|
+
* 一句放之四海皆准的「主机无响应」。
|
|
626
|
+
*/
|
|
627
|
+
export const SSH_TIMEOUT_HINT = '若该主机只能经跳板机访问,请在目标引用的那条 tty 连接簿条目里配置跳板机'
|
|
628
|
+
+ '(连接簿的 `~/.ssh/config` 导入会自动带上 ProxyJump);'
|
|
629
|
+
+ '代理命令(ProxyCommand)同一处手动填写后,还需在 插件配置 → 终端面板 打开「允许 ProxyCommand」';
|
|
214
630
|
export function describeExecError(message) {
|
|
631
|
+
if (/Timed out|ETIMEDOUT/i.test(message))
|
|
632
|
+
return `${message}:${SSH_TIMEOUT_HINT}`;
|
|
215
633
|
if (!/Channel open failure|open failed/i.test(message))
|
|
216
634
|
return message;
|
|
217
635
|
return `${message}(远端 sshd 拒绝了新通道:同一连接上的通道额度可能已被实时流占满——`
|
|
@@ -251,11 +669,21 @@ export function shouldRecycleConn(conn, now, idleMs = IDLE_MS) {
|
|
|
251
669
|
export class RemoteExec {
|
|
252
670
|
logger;
|
|
253
671
|
store;
|
|
672
|
+
options;
|
|
254
673
|
conns = new Map();
|
|
255
674
|
sweeper = null;
|
|
256
|
-
constructor(logger, store
|
|
675
|
+
constructor(logger, store,
|
|
676
|
+
/**
|
|
677
|
+
* ProxyCommand 闸门求值器(缺省 = 恒关)。
|
|
678
|
+
*
|
|
679
|
+
* 为什么是**回调**而不是构造时读一次的布尔值:开关归 tty settings,本包的 settings 句柄是
|
|
680
|
+
* 运行时才就绪的,而且用户随时可能关掉它——关掉之后必须**立刻**对下一次拨号生效
|
|
681
|
+
* (留着旧值意味着「关了还能用」,那正是这一档最不能出的错)。求值只读内存,无 IO。
|
|
682
|
+
*/
|
|
683
|
+
options = {}) {
|
|
257
684
|
this.logger = logger;
|
|
258
685
|
this.store = store;
|
|
686
|
+
this.options = options;
|
|
259
687
|
}
|
|
260
688
|
/** 插件卸载:关定时器与全部连接(幂等)。 */
|
|
261
689
|
disposeAll() {
|
|
@@ -273,6 +701,16 @@ export class RemoteExec {
|
|
|
273
701
|
catch {
|
|
274
702
|
/* 连接已断开 */
|
|
275
703
|
}
|
|
704
|
+
try {
|
|
705
|
+
rt.jump?.end();
|
|
706
|
+
}
|
|
707
|
+
catch {
|
|
708
|
+
/* 连接已断开 */
|
|
709
|
+
}
|
|
710
|
+
rt.jump = null;
|
|
711
|
+
// 代理命令是子进程:只 end() 目标 client 不会杀它(ssh2 不管子进程生死)
|
|
712
|
+
rt.proxy?.dispose();
|
|
713
|
+
rt.proxy = null;
|
|
276
714
|
}
|
|
277
715
|
this.conns.clear();
|
|
278
716
|
}
|
|
@@ -500,6 +938,17 @@ export class RemoteExec {
|
|
|
500
938
|
catch {
|
|
501
939
|
/* 连接已断开 */
|
|
502
940
|
}
|
|
941
|
+
// 空闲回收同样要成对:只关目标会留下一堆 keepalive 养着的跳板机连接
|
|
942
|
+
try {
|
|
943
|
+
rt.jump?.end();
|
|
944
|
+
}
|
|
945
|
+
catch {
|
|
946
|
+
/* 连接已断开 */
|
|
947
|
+
}
|
|
948
|
+
rt.jump = null;
|
|
949
|
+
// 代理命令一并杀(空闲回收后它没有任何用了,留着就是常驻孤儿进程)
|
|
950
|
+
rt.proxy?.dispose();
|
|
951
|
+
rt.proxy = null;
|
|
503
952
|
}
|
|
504
953
|
if (this.conns.size === 0 && this.sweeper !== null) {
|
|
505
954
|
clearInterval(this.sweeper);
|
|
@@ -538,7 +987,21 @@ export class RemoteExec {
|
|
|
538
987
|
if (attempt === 0 && isTransportError(message)) {
|
|
539
988
|
// 丢掉这条连接并**关闭它**(D07):只摘出池不 end() 的话,keepalive 会一直
|
|
540
989
|
// 养着一条死连接;dropConn 带身份校验,不会误摘同键上的新连接(D06)。
|
|
541
|
-
|
|
990
|
+
const key = poolKey(spec);
|
|
991
|
+
const stale = this.conns.get(key);
|
|
992
|
+
if (stale !== undefined && stale.client === client) {
|
|
993
|
+
// 跳板机 / 代理命令也一起收:重连会重新拨一条,旧的留着只会白养一条连接 / 一个进程
|
|
994
|
+
try {
|
|
995
|
+
stale.jump?.end();
|
|
996
|
+
}
|
|
997
|
+
catch {
|
|
998
|
+
/* 连接已断开 */
|
|
999
|
+
}
|
|
1000
|
+
stale.jump = null;
|
|
1001
|
+
stale.proxy?.dispose();
|
|
1002
|
+
stale.proxy = null;
|
|
1003
|
+
}
|
|
1004
|
+
this.dropConn(key, client);
|
|
542
1005
|
try {
|
|
543
1006
|
client.end();
|
|
544
1007
|
}
|
|
@@ -570,7 +1033,7 @@ export class RemoteExec {
|
|
|
570
1033
|
// 循环——若此时池里还没有条目,并发第二个请求会各自建连,先建好的那条立即脱管
|
|
571
1034
|
//(回收不到、disposeAll 关不掉、配额记在别人头上),日后的 close/error 还会误摘
|
|
572
1035
|
// 同键的新连接(D06)。占位后并发请求拿到的就是同一条 ready。
|
|
573
|
-
const entry = { client, lastUsed: Date.now(), ready, busy: 0, inflight: 0, disposed: false };
|
|
1036
|
+
const entry = { client, lastUsed: Date.now(), ready, busy: 0, inflight: 0, disposed: false, jump: null, proxy: null };
|
|
574
1037
|
this.conns.set(key, entry);
|
|
575
1038
|
// ready 被拒时不要留下未处理 rejection(调用方 await 时会拿到)
|
|
576
1039
|
ready.catch(() => {
|
|
@@ -604,6 +1067,27 @@ export class RemoteExec {
|
|
|
604
1067
|
/* 同上 */
|
|
605
1068
|
}
|
|
606
1069
|
};
|
|
1070
|
+
/**
|
|
1071
|
+
* 关掉这条条目上的跳板机 / 代理命令(目标 client 只是借用它们的通道 / stdio,必须成对收)。
|
|
1072
|
+
*
|
|
1073
|
+
* 两者**都要**做(不是二选一):拨号时按 ProxyJump 优先只起一条,但这里只关心「条目上
|
|
1074
|
+
* 挂了什么」,写成两行就不必再判一次谁生效——no-op 的代价远低于漏收尾。
|
|
1075
|
+
*/
|
|
1076
|
+
const closeTransport = () => {
|
|
1077
|
+
const jump = entry.jump;
|
|
1078
|
+
entry.jump = null;
|
|
1079
|
+
if (jump !== null) {
|
|
1080
|
+
try {
|
|
1081
|
+
jump.end();
|
|
1082
|
+
}
|
|
1083
|
+
catch {
|
|
1084
|
+
/* 已断开 */
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
const proxy = entry.proxy;
|
|
1088
|
+
entry.proxy = null;
|
|
1089
|
+
proxy?.dispose();
|
|
1090
|
+
};
|
|
607
1091
|
const settleError = (error) => {
|
|
608
1092
|
if (settled)
|
|
609
1093
|
return;
|
|
@@ -615,15 +1099,52 @@ export class RemoteExec {
|
|
|
615
1099
|
const finalError = ownsEntry() ? error : new Error(`SSH 连接已在建立期间被释放(${target}):请重试`);
|
|
616
1100
|
this.dropConn(key, client);
|
|
617
1101
|
forceClose();
|
|
1102
|
+
closeTransport();
|
|
618
1103
|
rejectReady(finalError);
|
|
619
1104
|
};
|
|
620
1105
|
try {
|
|
621
1106
|
const connectConfig = await buildConnectConfig(spec);
|
|
1107
|
+
/*
|
|
1108
|
+
* 跳板机(ProxyJump 单跳):先拨它、借一条 forwardOut 通道,再把目标连接跑在这条
|
|
1109
|
+
* 通道上(`ConnectConfig.sock`)。拨号失败/超时都在 dialJump 里点名跳板机并自我清理;
|
|
1110
|
+
* 成功后这条「跳板机连接」登记在池条目上,与目标成对关闭(见 closeRuntime)。
|
|
1111
|
+
*/
|
|
1112
|
+
const proxyCommand = sanitizeProxyCommand(spec.proxyCommand);
|
|
1113
|
+
if (spec.jump !== undefined) {
|
|
1114
|
+
if (proxyCommand !== undefined) {
|
|
1115
|
+
// 两者同时配:按 OpenSSH 语义 ProxyJump 优先,但**不静默**(用户以为走的是代理命令最坏)
|
|
1116
|
+
this.logger.warn(`[dsh-docker] ssh ${sshTarget(spec)} 同时配了跳板机与代理命令:按 OpenSSH 语义走跳板机(ProxyJump 优先),代理命令被忽略`);
|
|
1117
|
+
}
|
|
1118
|
+
const dialed = await dialJump({ spec, store: this.store, logger: this.logger });
|
|
1119
|
+
entry.jump = dialed.bastion;
|
|
1120
|
+
connectConfig.sock = dialed.sock;
|
|
1121
|
+
}
|
|
1122
|
+
else if (proxyCommand !== undefined) {
|
|
1123
|
+
/*
|
|
1124
|
+
* 代理命令(ProxyCommand):整档信任级,闸门由 tty settings 的 `allowProxyCommand`
|
|
1125
|
+
* 决定(回调按**每次拨号**求值,用户一关就立刻生效)。关着时 dialProxyCommand 直接抛
|
|
1126
|
+
* 明确的错——**不退回直连**,并由 settleError 收掉占位条目。
|
|
1127
|
+
*/
|
|
1128
|
+
const dialed = await dialProxyCommand({ spec, allowed: this.options.proxyCommandAllowed, logger: this.logger });
|
|
1129
|
+
entry.proxy = dialed;
|
|
1130
|
+
connectConfig.sock = dialed.sock;
|
|
1131
|
+
}
|
|
622
1132
|
const policy = applyHostKeyPolicy({ connectConfig, spec, store: this.store, logger: this.logger, target });
|
|
623
|
-
/**
|
|
624
|
-
|
|
1133
|
+
/**
|
|
1134
|
+
* 指纹变更优先于任何通用文案(含 D94 的「已释放」):安全提示不能被噪音盖掉。
|
|
1135
|
+
* 握手超时则补上跳板机提示——那是「通用超时」最容易被误读的成因(ROADMAP 第 2 项)。
|
|
1136
|
+
*/
|
|
1137
|
+
const describe = (error, fallback) => {
|
|
1138
|
+
const mismatch = policy.mismatchMessage();
|
|
1139
|
+
if (mismatch !== null)
|
|
1140
|
+
return new Error(mismatch);
|
|
1141
|
+
const hint = /Timed out|ETIMEDOUT/i.test(error.message) ? `:${SSH_TIMEOUT_HINT}` : '';
|
|
1142
|
+
// 代理命令死了的话 ssh2 只会报「握手完成前关闭」——把退出码 / stderr 补在后面,
|
|
1143
|
+
// 否则用户会去查目标主机(那里没有任何问题)
|
|
1144
|
+
return new Error(`${fallback}:${error.message}${hint}${proxyFailureSuffix(entry.proxy)}`);
|
|
1145
|
+
};
|
|
625
1146
|
timer = setTimeout(() => {
|
|
626
|
-
settleError(new Error(`SSH 连接超时(${
|
|
1147
|
+
settleError(new Error(`SSH 连接超时(${targetWithJump(spec)}):${SSH_TIMEOUT_HINT}`));
|
|
627
1148
|
}, connectTimeoutMs());
|
|
628
1149
|
client.once('ready', () => {
|
|
629
1150
|
if (settled)
|
|
@@ -636,19 +1157,35 @@ export class RemoteExec {
|
|
|
636
1157
|
// 交出去就是脱管连接——关掉再拒绝(D94)
|
|
637
1158
|
this.dropConn(key, client);
|
|
638
1159
|
forceClose();
|
|
1160
|
+
closeTransport();
|
|
639
1161
|
rejectReady(new Error(`SSH 连接已被释放(${target}):请重试`));
|
|
640
1162
|
return;
|
|
641
1163
|
}
|
|
642
1164
|
entry.lastUsed = Date.now();
|
|
643
1165
|
resolveReady(client);
|
|
644
1166
|
});
|
|
645
|
-
|
|
646
|
-
|
|
1167
|
+
/*
|
|
1168
|
+
* `on` 而不是 `once`(D145):**同一次建连失败**里 ssh2 会 emit 不止一个 `error`——
|
|
1169
|
+
* socket 层报一次,client 层的「握手完成前关闭」再报一次。`once` 会在第一个 error 之后
|
|
1170
|
+
* 把监听摘掉,第二个就成了「没有监听者的 `error` 事件」,Node 按约定**直接抛成 uncaught
|
|
1171
|
+
* exception**:于是「目标不可达」这条最平常的路径会把整个进程带崩(本机实测:`pnpm test`
|
|
1172
|
+
* 22 条断言全过、退出码却是 1)。监听器常驻即可——`settleError` 自己按 `settled` 幂等,
|
|
1173
|
+
* 重复进来是 no-op(后到的那个 error 本来也没什么新信息可报)。
|
|
1174
|
+
*/
|
|
1175
|
+
client.on('error', (error) => {
|
|
1176
|
+
settleError(describe(error, `SSH 连接失败(${targetWithJump(spec)})`));
|
|
647
1177
|
});
|
|
648
|
-
|
|
1178
|
+
// 同理常驻:重复的 close 是 no-op(条目已摘、跳板机与代理命令已收)
|
|
1179
|
+
client.on('close', () => {
|
|
1180
|
+
// 先取下代理命令的失败事实再收尾:closeTransport() 会把 entry.proxy 清掉,
|
|
1181
|
+
// 那时再取就永远是空串了(这正是「错误文案里少了最有用的那一句」的经典成因)
|
|
1182
|
+
const proxySuffix = proxyFailureSuffix(entry.proxy);
|
|
649
1183
|
this.dropConn(key, client);
|
|
1184
|
+
// 目标这条(无论握手期还是就绪后)断了:跳板机 / 代理命令也要一起收,
|
|
1185
|
+
// 否则前者带着 keepalive 一直挂着、后者变成没人管的常驻进程
|
|
1186
|
+
closeTransport();
|
|
650
1187
|
if (!settled)
|
|
651
|
-
settleError(new Error(`SSH 连接失败(${
|
|
1188
|
+
settleError(new Error(`SSH 连接失败(${targetWithJump(spec)}):连接在握手完成前关闭${proxySuffix}`));
|
|
652
1189
|
});
|
|
653
1190
|
try {
|
|
654
1191
|
client.connect(connectConfig);
|