@hyzyn/dsh-tty 0.21.0 → 0.22.0-rc.2
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 +113 -10
- package/README.md +117 -10
- package/client.js +150 -36
- package/lib/index.d.ts +195 -25
- package/lib/index.js +930 -149
- package/lib/index.js.map +1 -1
- package/lib/probe.d.ts +29 -1
- package/lib/probe.js +98 -19
- package/lib/probe.js.map +1 -1
- package/lib/sftp.js +37 -9
- package/lib/sftp.js.map +1 -1
- package/lib/shell-integration.js +25 -1
- package/lib/shell-integration.js.map +1 -1
- package/lib/ssh-config.d.ts +34 -2
- package/lib/ssh-config.js +150 -32
- package/lib/ssh-config.js.map +1 -1
- package/lib/ssh.d.ts +256 -2
- package/lib/ssh.js +637 -6
- package/lib/ssh.js.map +1 -1
- package/lib/tunnels.js +47 -5
- package/lib/tunnels.js.map +1 -1
- package/package.json +4 -2
- package/scripts/integration.mjs +183 -20
- package/scripts/jump-smoke.mjs +175 -0
- package/scripts/lib/proxy-bridge.mjs +30 -0
- package/scripts/preview/harness.js +74 -26
- package/scripts/probe-route-smoke.mjs +10 -1
- package/scripts/proxycommand-smoke.mjs +267 -0
- package/scripts/windows-smoke.mjs +123 -1
package/lib/ssh.js
CHANGED
|
@@ -19,10 +19,11 @@
|
|
|
19
19
|
* (旧行为,测试路径用)。
|
|
20
20
|
*/
|
|
21
21
|
import { Client } from 'ssh2';
|
|
22
|
+
import { spawn } from 'node:child_process';
|
|
22
23
|
import { readFileSync } from 'node:fs';
|
|
23
24
|
import { homedir } from 'node:os';
|
|
24
25
|
import { join } from 'node:path';
|
|
25
|
-
import { PassThrough } from 'node:stream';
|
|
26
|
+
import { Duplex, PassThrough } from 'node:stream';
|
|
26
27
|
import { StringDecoder } from 'node:string_decoder';
|
|
27
28
|
import { TMUX_SOCKET } from './tmux.js';
|
|
28
29
|
import { shSingleQuote } from './shell-integration.js';
|
|
@@ -113,7 +114,12 @@ export function classifyError(message) {
|
|
|
113
114
|
return '认证被拒绝:所有认证方式均失败(用户名/密码/密钥是否正确,或服务端是否允许该认证方式)';
|
|
114
115
|
}
|
|
115
116
|
if (message.includes('Timed out')) {
|
|
116
|
-
|
|
117
|
+
// 跳板机提示(项目级 ROADMAP 第 2 项):企业内网主机几乎都要过 bastion,而本版本
|
|
118
|
+
// 不支持——「20s 后一句通用超时」正是原文点名的症状。导入侧已改为跳过并明说,
|
|
119
|
+
// 这里补上手工填地址那条路。
|
|
120
|
+
return '超时:主机无响应或认证协商超时(检查地址 / 端口 / 防火墙 / 网络。'
|
|
121
|
+
+ '若该主机只能经跳板机访问(~/.ssh/config 里的 ProxyJump / ProxyCommand),本版本尚不支持,'
|
|
122
|
+
+ '见项目级 ROADMAP 第 2 项)';
|
|
117
123
|
}
|
|
118
124
|
const lower = message.toLowerCase();
|
|
119
125
|
if (lower.includes('econnrefused'))
|
|
@@ -216,6 +222,608 @@ export function applyHostKeyPolicy(options) {
|
|
|
216
222
|
};
|
|
217
223
|
return { mismatchMessage: () => hostKeyMismatch };
|
|
218
224
|
}
|
|
225
|
+
/** 跳板机展示串(`user@host:port`);没有跳板机时返回空串。**凭据不进这里**。 */
|
|
226
|
+
export function jumpTargetLabel(spec) {
|
|
227
|
+
const jump = spec.jump;
|
|
228
|
+
if (jump === undefined || jump.host.trim() === '')
|
|
229
|
+
return '';
|
|
230
|
+
const port = jump.port ?? 22;
|
|
231
|
+
return `${jump.username ?? spec.username}@${jump.host.trim()}${port === 22 ? '' : ':' + String(port)}`;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* 目标那一跳的展示串 + 跳板机 / 代理命令后缀(**给用户看的错误文案**用)。
|
|
235
|
+
*
|
|
236
|
+
* 为什么错误文案必须带这一句:ssh2 在两跳上共用 `readyTimeout`,报的都是
|
|
237
|
+
* `Timed out while waiting for handshake`——不点名的话「跳板机不可达」会伪装成
|
|
238
|
+
* 「目标超时」,用户会去查对端主机而问题在跳板机上。代理命令同理:它一旦启动失败,
|
|
239
|
+
* 报出来的是「握手前连接中断」。
|
|
240
|
+
*
|
|
241
|
+
* **不回显命令原文**(可能含 `-i /path/key` 这类凭据):只写「经代理命令」。
|
|
242
|
+
*/
|
|
243
|
+
function targetWithJump(spec) {
|
|
244
|
+
const label = jumpTargetLabel(spec);
|
|
245
|
+
if (label !== '')
|
|
246
|
+
return `${sshTarget(spec)}(经跳板机 ${label})`;
|
|
247
|
+
if (sanitizeProxyCommand(spec.proxyCommand) !== undefined)
|
|
248
|
+
return `${sshTarget(spec)}(经代理命令)`;
|
|
249
|
+
return sshTarget(spec);
|
|
250
|
+
}
|
|
251
|
+
/** 跳板机那一跳的连接规格:显式给的优先,其余**继承目标**。 */
|
|
252
|
+
function jumpSpecOf(spec) {
|
|
253
|
+
const jump = spec.jump;
|
|
254
|
+
const username = typeof jump.username === 'string' && jump.username.trim() !== '' ? jump.username.trim() : spec.username;
|
|
255
|
+
return {
|
|
256
|
+
host: jump.host.trim(),
|
|
257
|
+
port: jump.port ?? 22,
|
|
258
|
+
username,
|
|
259
|
+
auth: jump.auth ?? spec.auth ?? 'agent',
|
|
260
|
+
keyPath: jump.keyPath ?? spec.keyPath,
|
|
261
|
+
passphrase: jump.passphrase ?? spec.passphrase,
|
|
262
|
+
password: jump.password ?? spec.password,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
/** 跳板机通道打开兜底(与 channel 打开兜底同思路:对端不回 `forwardOut` 回调时不能让 await 挂着)。 */
|
|
266
|
+
const JUMP_CHANNEL_TIMEOUT_MS = 15_000;
|
|
267
|
+
/**
|
|
268
|
+
* 拨跳板机并借一条 `forwardOut` 通道(ProxyJump 单跳)。
|
|
269
|
+
*
|
|
270
|
+
* 三件事刻意做在这里:
|
|
271
|
+
* 1. **失败一律点名跳板机**(见 `targetWithJump` 的理由);
|
|
272
|
+
* 2. **指纹策略单独一份**:TOFU 的键是 `(host, port)`,跳板机与目标撞 host:port
|
|
273
|
+
* (NAT 后的 `127.0.0.1:22` 很常见)时不能共用句柄,否则会出现假「指纹变更」;
|
|
274
|
+
* 3. **失败路径自己关连接**:抛出去之前 `end()` 掉,否则每次重试都会漏一条
|
|
275
|
+
* keepalive 一直养着的连接。
|
|
276
|
+
*/
|
|
277
|
+
export async function dialJump(options) {
|
|
278
|
+
const jumpSpec = jumpSpecOf(options.spec);
|
|
279
|
+
const label = jumpTargetLabel(options.spec);
|
|
280
|
+
const destination = sshTarget(options.spec);
|
|
281
|
+
const bastion = new Client();
|
|
282
|
+
const closeQuietly = () => {
|
|
283
|
+
try {
|
|
284
|
+
bastion.end();
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
/* 已断开 */
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
let connectConfig;
|
|
291
|
+
try {
|
|
292
|
+
connectConfig = await buildConnectConfig(jumpSpec);
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
// 预检类错误(缺 SSH_AUTH_SOCK / keyPath 读不到 / 引用解析不到)也要点名跳板机
|
|
296
|
+
throw new Error(`跳板机连接失败(${label}):${error instanceof Error ? error.message : String(error)}`);
|
|
297
|
+
}
|
|
298
|
+
if (options.readyTimeoutMs !== undefined)
|
|
299
|
+
connectConfig.readyTimeout = options.readyTimeoutMs;
|
|
300
|
+
const policy = applyHostKeyPolicy({ connectConfig, spec: jumpSpec, store: options.store, logger: options.logger, target: `跳板机 ${label}` });
|
|
301
|
+
await new Promise((resolve, reject) => {
|
|
302
|
+
let settled = false;
|
|
303
|
+
const fail = (error) => {
|
|
304
|
+
if (settled)
|
|
305
|
+
return;
|
|
306
|
+
settled = true;
|
|
307
|
+
closeQuietly();
|
|
308
|
+
reject(error);
|
|
309
|
+
};
|
|
310
|
+
bastion.once('ready', () => {
|
|
311
|
+
if (settled)
|
|
312
|
+
return;
|
|
313
|
+
settled = true;
|
|
314
|
+
resolve();
|
|
315
|
+
});
|
|
316
|
+
bastion.on('error', (error) => {
|
|
317
|
+
fail(new Error(policy.mismatchMessage() ?? `跳板机连接失败(${label}):${classifyError(error.message)}`));
|
|
318
|
+
});
|
|
319
|
+
bastion.once('close', () => {
|
|
320
|
+
fail(new Error(`跳板机连接已关闭(${label}):目标连接尚未建立`));
|
|
321
|
+
});
|
|
322
|
+
if (connectConfig.tryKeyboard === true) {
|
|
323
|
+
const password = connectConfig.password ?? '';
|
|
324
|
+
bastion.on('keyboard-interactive', (_name, _instructions, _lang, _prompts, finishKb) => {
|
|
325
|
+
finishKb([password]);
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
bastion.connect(connectConfig);
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
fail(new Error(`跳板机连接失败(${label}):${error instanceof Error ? error.message : String(error)}`));
|
|
333
|
+
}
|
|
334
|
+
});
|
|
335
|
+
const sock = await new Promise((resolve, reject) => {
|
|
336
|
+
let settled = false;
|
|
337
|
+
const timer = setTimeout(() => {
|
|
338
|
+
if (settled)
|
|
339
|
+
return;
|
|
340
|
+
settled = true;
|
|
341
|
+
closeQuietly();
|
|
342
|
+
reject(new Error(`跳板机通道打开超时(${label} → ${destination},${String(JUMP_CHANNEL_TIMEOUT_MS / 1000)}s 无响应):跳板机可能不允许转发或不响应`));
|
|
343
|
+
}, JUMP_CHANNEL_TIMEOUT_MS);
|
|
344
|
+
timer.unref?.();
|
|
345
|
+
bastion.forwardOut('127.0.0.1', 0, options.spec.host, options.spec.port ?? 22, (error, channel) => {
|
|
346
|
+
if (settled)
|
|
347
|
+
return;
|
|
348
|
+
settled = true;
|
|
349
|
+
clearTimeout(timer);
|
|
350
|
+
if (error !== undefined && error !== null) {
|
|
351
|
+
closeQuietly();
|
|
352
|
+
reject(new Error(`跳板机通道打开失败(${label} → ${destination}):${error.message}`));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
resolve(channel);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
options.logger?.info(`[dsh-tty] ssh ${destination} 经跳板机 ${label} 已建立转发通道(ProxyJump)`);
|
|
359
|
+
return { bastion, sock };
|
|
360
|
+
}
|
|
361
|
+
/* ------------------------------------------------------------------ *
|
|
362
|
+
* ProxyCommand:单独一档信任级(默认关闭 + 显式开关 + 导入永不自动带入)
|
|
363
|
+
* ------------------------------------------------------------------ */
|
|
364
|
+
/** 代理命令长度上限:与自定义命令标签同量级(它是一条命令行,不是脚本文件)。 */
|
|
365
|
+
export const PROXY_COMMAND_MAX = 2000;
|
|
366
|
+
/** 保留的子进程 stderr 上限(错误文案里只截一小段,避免把用户的日志整片吞进来)。 */
|
|
367
|
+
const PROXY_STDERR_KEEP = 2048;
|
|
368
|
+
/** 错误文案里展示的 stderr 片段上限。 */
|
|
369
|
+
const PROXY_STDERR_SHOWN = 300;
|
|
370
|
+
/** 代理命令 SIGTERM 之后的 SIGKILL 兜底(毫秒)。 */
|
|
371
|
+
const PROXY_KILL_GRACE_MS = 2000;
|
|
372
|
+
/**
|
|
373
|
+
* `%h` / `%r` 等展开值允许的字符集。
|
|
374
|
+
*
|
|
375
|
+
* 为什么不是「先转义再代入」:代理命令最终交给 `sh -c`(Windows 是 `cmd /c`),
|
|
376
|
+
* 而两边的转义规则不同(单引号在 cmd 里无效)。改为**白名单**:主机名 / 用户名本就
|
|
377
|
+
* 只该由这些字符组成,含别的字符就直接拒绝执行——比「按平台各写一套转义」既短又稳。
|
|
378
|
+
*/
|
|
379
|
+
const PROXY_VALUE_SAFE = /^[A-Za-z0-9._@:\[\]-]+$/;
|
|
380
|
+
/** 关着闸门时携带代理命令的连接报什么错(导出供单测与客户端文案对照)。 */
|
|
381
|
+
export const PROXY_COMMAND_DISABLED = '代理命令(ProxyCommand)未启用:请到 插件配置 → 终端面板 打开「允许 ProxyCommand」后重试。'
|
|
382
|
+
+ '未启用时携带代理命令的连接不会退回直连——直连多半也连不上,还会把配置问题伪装成网络问题。';
|
|
383
|
+
/**
|
|
384
|
+
* 宿主**没有授权**这条能力时报什么错(与「授权了但开关关着」分开报)。
|
|
385
|
+
*
|
|
386
|
+
* 为什么要分开:两者的「下一步动作」完全不同——前者要去宿主侧设环境变量 + 重启(界面上那个
|
|
387
|
+
* 开关是点不动的),后者只是把开关打开。合成一句话会让用户对着一个点不动的开关反复点。
|
|
388
|
+
*/
|
|
389
|
+
export const PROXY_COMMAND_NOT_GRANTED = '代理命令(ProxyCommand)未获宿主授权:'
|
|
390
|
+
+ '在宿主侧设置环境变量 DSH_TTY_ALLOW_PROXY_COMMAND=1(可用 设置 → 环境变量 卡片写入 ~/.dsh/env.yml)'
|
|
391
|
+
+ '并重启宿主;HTTP 侧只能关闭它、不能打开(本机任意进程都能发回环请求,配置路由若能提权,这道闸门等于没有)。';
|
|
392
|
+
/**
|
|
393
|
+
* ProxyCommand 闸门(**模块级策略,缺省「未授权 + 未启用」**)。
|
|
394
|
+
*
|
|
395
|
+
* 两个维度刻意分开:
|
|
396
|
+
* - `granted`:**宿主侧**授权(环境变量,进程启动时采样一次,见 kit 的 capability.js)。
|
|
397
|
+
* 未授权时这个开关在 HTTP 侧点不动——那是整个「只能降不能升」约定的落点;
|
|
398
|
+
* - `enabled`:界面上那个开关(可以随时关,也可以随时开——但只有在授权为真时才可能开)。
|
|
399
|
+
*
|
|
400
|
+
* 为什么是模块级而不是每次调用传参:本包的建连入口有四个(终端 / SFTP / 隧道 / 探针),
|
|
401
|
+
* 而它们**共用** `prepareSshConnect` ——把开关读在拨号那一处,四条路就不会各判一次
|
|
402
|
+
* (漏一条就是「配了等于没配」或「关了还能用」)。写入方只有插件自己的 settings 热应用
|
|
403
|
+
* (`applyPatch`),与 `setCredentialResolver` 同一个模式。
|
|
404
|
+
*/
|
|
405
|
+
let proxyCommandGranted = false;
|
|
406
|
+
let proxyCommandEnabled = false;
|
|
407
|
+
/** 设置 ProxyCommand 闸门(插件 settings 就绪与每次热更新时调用)。 */
|
|
408
|
+
export function setProxyCommandPolicy(next) {
|
|
409
|
+
proxyCommandGranted = next.granted === true;
|
|
410
|
+
proxyCommandEnabled = next.enabled === true;
|
|
411
|
+
}
|
|
412
|
+
/** 当前是否真的会用代理命令(探针用它把「配了但没开」与「连不上」分开报)。 */
|
|
413
|
+
export function proxyCommandAllowedNow() {
|
|
414
|
+
return proxyCommandGranted && proxyCommandEnabled;
|
|
415
|
+
}
|
|
416
|
+
/** 宿主侧是否授权了这条能力(探针用它选文案:未授权 vs 未启用)。 */
|
|
417
|
+
export function proxyCommandGrantedNow() {
|
|
418
|
+
return proxyCommandGranted;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* 清洗一份代理命令输入(settings schema / 宽松清洗 / 内联融合共用)。
|
|
422
|
+
*
|
|
423
|
+
* 返回 `undefined` = 没配。**只做形状校验**(非空 / 单行 / 长度),命令内容本身不解释——
|
|
424
|
+
* 「能不能执行」由闸门决定,不由这里猜(含 `;` `/` `|` 都是合法的 shell 写法)。
|
|
425
|
+
*/
|
|
426
|
+
export function sanitizeProxyCommand(input) {
|
|
427
|
+
if (typeof input !== 'string')
|
|
428
|
+
return undefined;
|
|
429
|
+
const trimmed = input.trim();
|
|
430
|
+
if (trimmed === '' || trimmed.length > PROXY_COMMAND_MAX)
|
|
431
|
+
return undefined;
|
|
432
|
+
if (/[\r\n\0]/.test(trimmed))
|
|
433
|
+
return undefined;
|
|
434
|
+
return trimmed;
|
|
435
|
+
}
|
|
436
|
+
/** 严格校验一份代理命令输入(HTTP POST 路径);返回错误信息或清洗结果。 */
|
|
437
|
+
export function validateProxyCommand(input) {
|
|
438
|
+
if (typeof input !== 'string')
|
|
439
|
+
return { error: 'proxyCommand 必须是字符串' };
|
|
440
|
+
const trimmed = input.trim();
|
|
441
|
+
if (trimmed === '')
|
|
442
|
+
return {};
|
|
443
|
+
if (trimmed.length > PROXY_COMMAND_MAX)
|
|
444
|
+
return { error: `proxyCommand 过长(≤${String(PROXY_COMMAND_MAX)} 字符)` };
|
|
445
|
+
if (/[\r\n\0]/.test(trimmed))
|
|
446
|
+
return { error: 'proxyCommand 必须是单行(不能含换行/NUL)' };
|
|
447
|
+
return { proxyCommand: trimmed };
|
|
448
|
+
}
|
|
449
|
+
/** 展示用片段(不含凭据;控制字符清掉、单行、截断)。 */
|
|
450
|
+
function excerpt(value) {
|
|
451
|
+
const oneLine = value.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
452
|
+
return oneLine.length > 60 ? `${oneLine.slice(0, 60)}…` : oneLine;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* 展开 `%h` / `%p` / `%r` / `%n` / `%%`(与 OpenSSH 同义)。
|
|
456
|
+
*
|
|
457
|
+
* `%h` 目标主机、`%p` 目标端口、`%r` 目标用户名、`%n` 目标主机(如写法里给的名字——
|
|
458
|
+
* 本包没有别名概念,与 `%h` 同值)、`%%` 字面 `%`。其余 `%X` **原样保留**(不报错):
|
|
459
|
+
* 用户可能是在命令里写 `printf '%s'`,替我们没实现的占位符而失败才是意外。
|
|
460
|
+
*
|
|
461
|
+
* 代入值必须过白名单,否则**抛错拒绝执行**(见 `PROXY_VALUE_SAFE`)。
|
|
462
|
+
*/
|
|
463
|
+
export function expandProxyCommand(command, spec) {
|
|
464
|
+
const values = {
|
|
465
|
+
h: spec.host,
|
|
466
|
+
n: spec.host,
|
|
467
|
+
p: String(spec.port ?? 22),
|
|
468
|
+
r: spec.username,
|
|
469
|
+
};
|
|
470
|
+
return command.replace(/%(.)/g, (whole, key) => {
|
|
471
|
+
if (key === '%')
|
|
472
|
+
return '%';
|
|
473
|
+
const value = values[key];
|
|
474
|
+
if (value === undefined)
|
|
475
|
+
return whole;
|
|
476
|
+
if (!PROXY_VALUE_SAFE.test(value)) {
|
|
477
|
+
throw new Error(`代理命令里的 %${key} 无法代入:${excerpt(value)} 含 shell 特殊字符(只允许字母数字与 . _ @ : [ ] -)`);
|
|
478
|
+
}
|
|
479
|
+
return value;
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* 杀代理命令(含它拉起的子孙进程)。
|
|
484
|
+
*
|
|
485
|
+
* POSIX 上用 `detached: true` 让子进程自成进程组,`kill(-pid)` 一次收掉整组——代理命令
|
|
486
|
+
* 常见写法是 `ssh -W %h:%p bastion` 这类**又开了一个进程**的命令,只杀 shell 会留下它。
|
|
487
|
+
* 先 SIGTERM,2s 后仍活着再 SIGKILL(兜底定时器 unref,不挡进程退出)。
|
|
488
|
+
*/
|
|
489
|
+
function killProxyChild(child) {
|
|
490
|
+
const pid = child.pid;
|
|
491
|
+
const kill = (signal) => {
|
|
492
|
+
try {
|
|
493
|
+
if (pid !== undefined && process.platform !== 'win32')
|
|
494
|
+
process.kill(-pid, signal);
|
|
495
|
+
else
|
|
496
|
+
child.kill(signal);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
try {
|
|
500
|
+
child.kill(signal);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
/* 已退出 */
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
kill('SIGTERM');
|
|
508
|
+
const timer = setTimeout(() => {
|
|
509
|
+
if (child.exitCode === null && child.signalCode === null)
|
|
510
|
+
kill('SIGKILL');
|
|
511
|
+
}, PROXY_KILL_GRACE_MS);
|
|
512
|
+
timer.unref?.();
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* 启动代理命令并把它接到目标连接上。
|
|
516
|
+
*
|
|
517
|
+
* 这一跳与 `dialJump` 的差别,逐条都有理由:
|
|
518
|
+
* 1. **先过闸门**:关着就直接抛 `PROXY_COMMAND_DISABLED`(不 spawn、不退回直连);
|
|
519
|
+
* 2. **没有「握手」可等**:传输就是子进程的 stdio,ssh2 会在它上面跑握手——所以这里
|
|
520
|
+
* spawn 成功即返回,剩下的失败由目标连接的 error/close 路径 + `failure()` 共同呈现;
|
|
521
|
+
* 3. **子进程死了要拖垮传输**:提前退出时 destroy 掉 sock,逼 ssh2 立刻报错(否则
|
|
522
|
+
* 「命令一开始就失败」会伪装成 20s 握手超时);
|
|
523
|
+
* 4. **stderr 必须排空**:不排空的话命令输出一多就把管道写满、子进程卡死(经典坑),
|
|
524
|
+
* 同时留一小段尾巴进错误文案——它是排查代理命令失败最直接的信息;
|
|
525
|
+
* 5. **命令原文不进日志与错误文案**:它可能含凭据(如 `-i /path/key`),日志只写
|
|
526
|
+
* 「已启动代理命令」与退出码。
|
|
527
|
+
*/
|
|
528
|
+
export async function dialProxyCommand(options) {
|
|
529
|
+
const raw = sanitizeProxyCommand(options.spec.proxyCommand);
|
|
530
|
+
if (raw === undefined)
|
|
531
|
+
throw new Error('代理命令为空(proxyCommand 需要一条非空的单行命令)');
|
|
532
|
+
if (!proxyCommandGranted)
|
|
533
|
+
throw new Error(PROXY_COMMAND_NOT_GRANTED);
|
|
534
|
+
if (!proxyCommandEnabled)
|
|
535
|
+
throw new Error(PROXY_COMMAND_DISABLED);
|
|
536
|
+
const destination = sshTarget(options.spec);
|
|
537
|
+
const command = expandProxyCommand(raw, options.spec);
|
|
538
|
+
const child = spawn(command, {
|
|
539
|
+
shell: true,
|
|
540
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
541
|
+
windowsHide: true,
|
|
542
|
+
// POSIX:自成进程组,收尾时能一次收掉命令拉起的子孙进程(见 killProxyChild)
|
|
543
|
+
detached: process.platform !== 'win32',
|
|
544
|
+
});
|
|
545
|
+
const stdout = child.stdout;
|
|
546
|
+
const stdin = child.stdin;
|
|
547
|
+
const stderr = child.stderr;
|
|
548
|
+
if (stdout === null || stdin === null || stderr === null) {
|
|
549
|
+
killProxyChild(child);
|
|
550
|
+
throw new Error('代理命令启动失败:stdio 管道未建立');
|
|
551
|
+
}
|
|
552
|
+
const sock = Duplex.from({ readable: stdout, writable: stdin });
|
|
553
|
+
let failure = null;
|
|
554
|
+
let disposed = false;
|
|
555
|
+
/**
|
|
556
|
+
* 传输是否已经关掉(子进程的输出结束)。
|
|
557
|
+
*
|
|
558
|
+
* 为什么要单独记:子进程**先结束输出、后触发 `exit`**,而 ssh2 一看到流断了就立刻报
|
|
559
|
+
* 「Connection lost before handshake」——错误文案那一刻 `exit` 还没到。只认 `exit` 的话,
|
|
560
|
+
* 最需要解释的那种失败(命令一开始就挂)反而拿不到任何解释。于是这里退一步:传输关了
|
|
561
|
+
* 就至少把**已经攒到的 stderr** 交出去(那正是排查命令失败最直接的信息)。
|
|
562
|
+
*/
|
|
563
|
+
let transportClosed = false;
|
|
564
|
+
const chunks = [];
|
|
565
|
+
let kept = 0;
|
|
566
|
+
/** 已攒到的 stderr 摘要(单行、截断;没有则空串)。 */
|
|
567
|
+
const stderrExcerpt = () => {
|
|
568
|
+
if (chunks.length === 0)
|
|
569
|
+
return '';
|
|
570
|
+
const text = Buffer.concat(chunks).toString('utf8').replace(/[\r\n\0]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
571
|
+
return text.slice(0, PROXY_STDERR_SHOWN);
|
|
572
|
+
};
|
|
573
|
+
const stderrTail = () => {
|
|
574
|
+
const text = stderrExcerpt();
|
|
575
|
+
return text === '' ? '' : `;stderr: ${text}`;
|
|
576
|
+
};
|
|
577
|
+
stderr.on('data', (chunk) => {
|
|
578
|
+
// 常驻排空(不排空会把管道写满、子进程卡死);只留最后一小段给错误文案
|
|
579
|
+
if (kept >= PROXY_STDERR_KEEP)
|
|
580
|
+
return;
|
|
581
|
+
kept += chunk.length;
|
|
582
|
+
chunks.push(chunk);
|
|
583
|
+
});
|
|
584
|
+
/** 记下失败事实并拖垮传输(提前退出时让 ssh2 立刻报错,而不是等满 readyTimeout)。 */
|
|
585
|
+
const recordFailure = (error) => {
|
|
586
|
+
// **覆盖**而不是只记第一个:传输关闭往往先触发出一个「语焉不详」的事实,
|
|
587
|
+
// 随后 exit 事件带来的(退出码 + stderr)严格更有信息量
|
|
588
|
+
failure = error;
|
|
589
|
+
try {
|
|
590
|
+
sock.destroy();
|
|
591
|
+
}
|
|
592
|
+
catch {
|
|
593
|
+
/* 已销毁 */
|
|
594
|
+
}
|
|
595
|
+
};
|
|
596
|
+
child.on('error', (error) => {
|
|
597
|
+
recordFailure(new Error(`代理命令出错:${error.message}`));
|
|
598
|
+
});
|
|
599
|
+
child.on('exit', (code, signal) => {
|
|
600
|
+
// 我们自己收尾时杀的(disposed):不是失败,别把正常关闭记成错误
|
|
601
|
+
if (disposed)
|
|
602
|
+
return;
|
|
603
|
+
const how = signal !== null ? `被信号 ${signal} 终止` : `退出码 ${String(code)}`;
|
|
604
|
+
recordFailure(new Error(`代理命令已退出(${how})${stderrTail()}`));
|
|
605
|
+
});
|
|
606
|
+
/**
|
|
607
|
+
* 子进程创建失败(EACCES / 资源耗尽等)要在**这里**就抛出去:否则调用方拿到的是一条
|
|
608
|
+
* 「永远连不上」的传输,错误文案会落到目标主机头上。
|
|
609
|
+
*/
|
|
610
|
+
await new Promise((resolve, reject) => {
|
|
611
|
+
let settled = false;
|
|
612
|
+
child.once('spawn', () => {
|
|
613
|
+
if (settled)
|
|
614
|
+
return;
|
|
615
|
+
settled = true;
|
|
616
|
+
resolve();
|
|
617
|
+
});
|
|
618
|
+
child.once('error', (error) => {
|
|
619
|
+
if (settled)
|
|
620
|
+
return;
|
|
621
|
+
settled = true;
|
|
622
|
+
reject(new Error(`代理命令启动失败(${excerpt(command)}):${error.message}`));
|
|
623
|
+
});
|
|
624
|
+
});
|
|
625
|
+
options.logger?.info(`[dsh-tty] ssh ${destination} 已启动代理命令(ProxyCommand)作为传输`);
|
|
626
|
+
const dispose = () => {
|
|
627
|
+
if (disposed)
|
|
628
|
+
return;
|
|
629
|
+
disposed = true;
|
|
630
|
+
try {
|
|
631
|
+
sock.destroy();
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
/* 已销毁 */
|
|
635
|
+
}
|
|
636
|
+
killProxyChild(child);
|
|
637
|
+
};
|
|
638
|
+
// 传输先关(对端消失 / ssh2 主动 destroy)时也要杀子进程:否则每次重连漏一个常驻进程
|
|
639
|
+
/*
|
|
640
|
+
* **readable 侧 end** 是关键时点:ssh2 就是看到它才判定「握手前连接中断」并发错,
|
|
641
|
+
* 而子进程的 `exit` 还在后面(实测差 1~2ms)。所以在这里就认定「传输已关」,
|
|
642
|
+
* 错误路径才能带上已经攒到的 stderr。
|
|
643
|
+
*/
|
|
644
|
+
sock.once('end', () => {
|
|
645
|
+
transportClosed = true;
|
|
646
|
+
});
|
|
647
|
+
sock.once('close', () => {
|
|
648
|
+
transportClosed = true;
|
|
649
|
+
// 不置 disposed:那是「我们主动收尾」的标记,而这里是传输先没了——子进程该杀,
|
|
650
|
+
// 但它随后带来的 exit 事实仍要记下来(失败文案靠它)
|
|
651
|
+
if (!disposed)
|
|
652
|
+
killProxyChild(child);
|
|
653
|
+
});
|
|
654
|
+
/*
|
|
655
|
+
* 必须挂这个监听器:`Duplex.from({readable, writable})` 销毁时会把 **AbortError**
|
|
656
|
+
* 抛给底层流,没人接就是进程级的「未捕获异常」(实测:光调 dispose() 就能触发)。
|
|
657
|
+
* 收尾途中的错误不算失败(disposed 时忽略);在途的错误记下来,供错误文案点名。
|
|
658
|
+
*/
|
|
659
|
+
sock.on('error', (error) => {
|
|
660
|
+
if (disposed)
|
|
661
|
+
return;
|
|
662
|
+
/*
|
|
663
|
+
* **AbortError 直接忽略**:它不是一个诊断,而是我们自己 destroy 这条传输时 Duplex 抛给
|
|
664
|
+
* 底层流的副产品("The operation was aborted")。把它记成失败原因,用户看到的就会是这句
|
|
665
|
+
* 毫无信息量的话,而不是「命令退出了、stderr 说了什么」。
|
|
666
|
+
*/
|
|
667
|
+
if (error.name === 'AbortError' || error.code === 'ABORT_ERR')
|
|
668
|
+
return;
|
|
669
|
+
recordFailure(new Error(`代理命令传输出错:${error.message}`));
|
|
670
|
+
});
|
|
671
|
+
return {
|
|
672
|
+
child,
|
|
673
|
+
sock,
|
|
674
|
+
failure: () => {
|
|
675
|
+
if (failure !== null)
|
|
676
|
+
return failure;
|
|
677
|
+
// 主动收尾之后不算失败;传输自己关了且还没有 exit 事实时,先把 stderr 尾巴交出去
|
|
678
|
+
if (disposed || !transportClosed)
|
|
679
|
+
return null;
|
|
680
|
+
const tail = stderrTail();
|
|
681
|
+
return new Error(`代理命令传输已关闭${tail === '' ? '(命令已结束)' : tail}`);
|
|
682
|
+
},
|
|
683
|
+
stderrHint: () => {
|
|
684
|
+
// 主动收尾之后不再提示(那是正常结束,不是线索);没有 stderr 也不提示
|
|
685
|
+
if (disposed)
|
|
686
|
+
return '';
|
|
687
|
+
const text = stderrExcerpt();
|
|
688
|
+
return text === '' ? '' : `;代理命令 stderr: ${text}`;
|
|
689
|
+
},
|
|
690
|
+
dispose,
|
|
691
|
+
};
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* 清洗一份跳板机输入(settings schema / 宽松清洗 / 内联融合共用)。
|
|
695
|
+
*
|
|
696
|
+
* 返回 `undefined` = **没有可用的跳板机**(`host` 为空)——调用方据此把 `jump` 整个丢掉,
|
|
697
|
+
* 而不是留下一个 `host: ''` 的半个对象(那会让 `dialJump` 去连空主机名)。
|
|
698
|
+
* 缺省不填的字段**不写进结果**:它们要在拨号时「继承目标那一跳」(见 `jumpSpecOf`)。
|
|
699
|
+
*/
|
|
700
|
+
export function sanitizeJumpSpec(input) {
|
|
701
|
+
if (typeof input !== 'object' || input === null)
|
|
702
|
+
return undefined;
|
|
703
|
+
const raw = input;
|
|
704
|
+
const host = typeof raw.host === 'string' ? raw.host.trim() : '';
|
|
705
|
+
if (host === '')
|
|
706
|
+
return undefined;
|
|
707
|
+
const port = Number(raw.port);
|
|
708
|
+
const jump = { host, port: Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 22 };
|
|
709
|
+
if (typeof raw.username === 'string' && raw.username.trim() !== '')
|
|
710
|
+
jump.username = raw.username.trim();
|
|
711
|
+
if (raw.auth === 'agent' || raw.auth === 'key' || raw.auth === 'password')
|
|
712
|
+
jump.auth = raw.auth;
|
|
713
|
+
if (typeof raw.keyPath === 'string' && raw.keyPath !== '')
|
|
714
|
+
jump.keyPath = raw.keyPath;
|
|
715
|
+
if (typeof raw.passphrase === 'string' && raw.passphrase !== '')
|
|
716
|
+
jump.passphrase = raw.passphrase;
|
|
717
|
+
if (typeof raw.password === 'string' && raw.password !== '')
|
|
718
|
+
jump.password = raw.password;
|
|
719
|
+
return jump;
|
|
720
|
+
}
|
|
721
|
+
/** 严格校验一份跳板机输入(HTTP POST 路径);返回错误信息或清洗结果。 */
|
|
722
|
+
export function validateJumpSpec(input) {
|
|
723
|
+
if (typeof input !== 'object' || input === null)
|
|
724
|
+
return { error: 'jump 必须是对象' };
|
|
725
|
+
const raw = input;
|
|
726
|
+
if (typeof raw.host !== 'string' || raw.host.trim() === '')
|
|
727
|
+
return { error: 'jump.host 必须是非空字符串' };
|
|
728
|
+
if (raw.port !== undefined && raw.port !== '') {
|
|
729
|
+
const port = Number(raw.port);
|
|
730
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
731
|
+
return { error: 'jump.port 必须是 1~65535 的整数' };
|
|
732
|
+
}
|
|
733
|
+
if (raw.username !== undefined && typeof raw.username !== 'string')
|
|
734
|
+
return { error: 'jump.username 必须是字符串' };
|
|
735
|
+
if (raw.auth !== undefined && raw.auth !== 'agent' && raw.auth !== 'key' && raw.auth !== 'password') {
|
|
736
|
+
return { error: 'jump.auth 必须是 agent / key / password' };
|
|
737
|
+
}
|
|
738
|
+
for (const key of ['keyPath', 'passphrase', 'password']) {
|
|
739
|
+
if (raw[key] !== undefined && typeof raw[key] !== 'string')
|
|
740
|
+
return { error: `jump.${key} 必须是字符串` };
|
|
741
|
+
}
|
|
742
|
+
if (raw.auth === 'key' && (typeof raw.keyPath !== 'string' || raw.keyPath.trim() === '')) {
|
|
743
|
+
return { error: 'jump.auth=key 需要 jump.keyPath' };
|
|
744
|
+
}
|
|
745
|
+
return { jump: sanitizeJumpSpec(raw) };
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* 代理命令失败的事实 → 错误文案后缀(空串 = 没失败)。
|
|
749
|
+
*
|
|
750
|
+
* 为什么要它:代理命令死了之后 ssh2 只会报一句「握手前连接中断」,那会把用户支到目标主机上
|
|
751
|
+
* 去查。调用方在错误分支拼上这句,用户才知道是该去看代理命令的 stderr。
|
|
752
|
+
*/
|
|
753
|
+
export function proxyFailureSuffix(proxy) {
|
|
754
|
+
if (proxy === null)
|
|
755
|
+
return '';
|
|
756
|
+
const failure = proxy.failure();
|
|
757
|
+
if (failure !== null)
|
|
758
|
+
return `;${failure.message}`;
|
|
759
|
+
/*
|
|
760
|
+
* 兜底:调用方**已经在报错**了,但「子进程退出 / 传输关闭」还没到(真机验收实测 1~2ms 的
|
|
761
|
+
* 竞态:ssh2 一看到流断就报错)。这时候手里有 stderr 就交出去——否则用户拿到的是一句
|
|
762
|
+
* 「连接已关闭」这种毫无线索的话,还得自己去猜代理命令有没有跑起来。
|
|
763
|
+
*/
|
|
764
|
+
return proxy.stderrHint();
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* 四个连接点(终端 / SFTP / 隧道 / 探针)**共用**的建连前准备。
|
|
768
|
+
*
|
|
769
|
+
* 为什么要有这个函数:跳板机不是「终端的特性」——SFTP、端口转发、探针各自都在建 SSH 连接
|
|
770
|
+
* (`sftp.ts` / `tunnels.ts` / `probe.ts` 各有一处 `new Client()`)。把「构造 config →
|
|
771
|
+
* 需要时拨跳板机 / 起代理命令 → 接上传输 → 装目标 TOFU 策略」收成一处,四条路才不会各写一份
|
|
772
|
+
* (那正是这一项立项时点名的「三处各写一份必然漂」)。
|
|
773
|
+
*
|
|
774
|
+
* **调用方负责**:自己 `conn.connect(connectConfig)`、自己处理 ready/error/close,
|
|
775
|
+
* 并在收尾(成功或失败)时对 `bastion` 调 `end()`、对 `proxy` 调 `dispose()`。
|
|
776
|
+
*/
|
|
777
|
+
export async function prepareSshConnect(options) {
|
|
778
|
+
const { spec, store, logger } = options;
|
|
779
|
+
const target = targetWithJump(spec);
|
|
780
|
+
const attached = await attachSshTransport(options);
|
|
781
|
+
try {
|
|
782
|
+
const policy = applyHostKeyPolicy({ connectConfig: attached.connectConfig, spec, store, logger, target });
|
|
783
|
+
return { connectConfig: attached.connectConfig, policy, bastion: attached.transport.bastion, proxy: attached.transport.proxy, target };
|
|
784
|
+
}
|
|
785
|
+
catch (error) {
|
|
786
|
+
// 传输已经起来了、策略却装不上:这里必须自己收尾,否则漏一条连接 / 一个进程
|
|
787
|
+
try {
|
|
788
|
+
attached.transport.bastion?.end();
|
|
789
|
+
}
|
|
790
|
+
catch {
|
|
791
|
+
/* 已断开 */
|
|
792
|
+
}
|
|
793
|
+
attached.transport.proxy?.dispose();
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* 只做「构造目标 config(需要时拨跳板机 / 起代理命令并把传输接上)」这一半。
|
|
799
|
+
*
|
|
800
|
+
* 拆出来的唯一理由:**探针自己装 hostVerifier**(`makeHostKeyVerifier` 要收集
|
|
801
|
+
* hostkey 结论,不用 `applyHostKeyPolicy`),但它同样需要跳板机 / 代理命令。返回值里的
|
|
802
|
+
* `transport` 由调用方负责收尾。
|
|
803
|
+
*/
|
|
804
|
+
export async function attachSshTransport(options) {
|
|
805
|
+
const { spec, store, logger } = options;
|
|
806
|
+
const connectConfig = await buildConnectConfig(spec);
|
|
807
|
+
if (options.readyTimeoutMs !== undefined)
|
|
808
|
+
connectConfig.readyTimeout = options.readyTimeoutMs;
|
|
809
|
+
const hasJump = spec.jump !== undefined;
|
|
810
|
+
const proxyCommand = sanitizeProxyCommand(spec.proxyCommand);
|
|
811
|
+
if (hasJump && proxyCommand !== undefined) {
|
|
812
|
+
// OpenSSH 语义:两者互斥、ProxyJump 优先。**不静默**——用户以为走的是代理命令是最坏情况
|
|
813
|
+
logger?.warn(`[dsh-tty] ssh ${sshTarget(spec)} 同时配了跳板机与代理命令:按 OpenSSH 语义走跳板机(ProxyJump 优先),代理命令被忽略`);
|
|
814
|
+
}
|
|
815
|
+
if (hasJump) {
|
|
816
|
+
const dialed = await dialJump({ spec, store, logger, readyTimeoutMs: options.readyTimeoutMs });
|
|
817
|
+
connectConfig.sock = dialed.sock;
|
|
818
|
+
return { connectConfig, transport: { bastion: dialed.bastion, proxy: null } };
|
|
819
|
+
}
|
|
820
|
+
if (proxyCommand !== undefined) {
|
|
821
|
+
const dialed = await dialProxyCommand({ spec, logger });
|
|
822
|
+
connectConfig.sock = dialed.sock;
|
|
823
|
+
return { connectConfig, transport: { bastion: null, proxy: dialed } };
|
|
824
|
+
}
|
|
825
|
+
return { connectConfig, transport: { bastion: null, proxy: null } };
|
|
826
|
+
}
|
|
219
827
|
/**
|
|
220
828
|
* 建立 SSH 连接并打开交互 shell channel,返回 TermHandle。
|
|
221
829
|
* 失败(连接超时/认证被拒/host 不可达)时 reject 带人类可读信息。
|
|
@@ -228,6 +836,10 @@ export async function spawnSsh(spec, options) {
|
|
|
228
836
|
throw new Error('agent forwarding 需要 SSH_AUTH_SOCK(本机未运行 ssh-agent 或变量未设置)');
|
|
229
837
|
}
|
|
230
838
|
const conn = new Client();
|
|
839
|
+
/** 跳板机连接(ProxyJump):它拥有目标借用的通道,收尾时必须由本函数 end 掉。 */
|
|
840
|
+
let bastion = null;
|
|
841
|
+
/** 代理命令(ProxyCommand):它拥有目标借用的传输,收尾时必须由本函数 dispose 掉。 */
|
|
842
|
+
let proxy = null;
|
|
231
843
|
const output = new PassThrough();
|
|
232
844
|
let exitCode = null;
|
|
233
845
|
let exitSignal = null;
|
|
@@ -252,11 +864,30 @@ export async function spawnSsh(spec, options) {
|
|
|
252
864
|
catch {
|
|
253
865
|
/* 已断开 */
|
|
254
866
|
}
|
|
867
|
+
// 跳板机**最后**关:顺序反了会在目标还活着时抽掉它借用的通道
|
|
868
|
+
if (bastion !== null) {
|
|
869
|
+
try {
|
|
870
|
+
bastion.end();
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
/* 已断开 */
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// 代理命令同样最后收:先关目标连接再杀子进程,写进子进程的最后一个字节才有机会被读到
|
|
877
|
+
proxy?.dispose();
|
|
255
878
|
settleDone({ exitCode, signal: exitSignal });
|
|
256
879
|
};
|
|
257
880
|
// 认证配置可能抛错(keyPath 读不到 / 引用解析不到)——先构造再连
|
|
258
|
-
|
|
259
|
-
|
|
881
|
+
/*
|
|
882
|
+
* 建连前准备(含跳板机 / 代理命令):拨号失败/超时都在 dialJump / dialProxyCommand 里
|
|
883
|
+
* 点名那一跳并自我清理;成功之后它们的生死由本函数的 finish() 一并负责
|
|
884
|
+
* (目标先关、跳板机与代理命令后关)。
|
|
885
|
+
*/
|
|
886
|
+
const prepared = await prepareSshConnect({ spec, store: options.hostKeyStore, logger });
|
|
887
|
+
const connectConfig = prepared.connectConfig;
|
|
888
|
+
const policy = prepared.policy;
|
|
889
|
+
bastion = prepared.bastion;
|
|
890
|
+
proxy = prepared.proxy;
|
|
260
891
|
/** 持久会话:远程 tmux 探测/降级提示(spawn 后由调用方注入终端)。 */
|
|
261
892
|
let startupNotice;
|
|
262
893
|
let tmuxUsed = false;
|
|
@@ -401,7 +1032,7 @@ export async function spawnSsh(spec, options) {
|
|
|
401
1032
|
if (mismatch !== null)
|
|
402
1033
|
settleErr(new Error(mismatch));
|
|
403
1034
|
else
|
|
404
|
-
settleErr(new Error(`SSH 连接失败(${
|
|
1035
|
+
settleErr(new Error(`SSH 连接失败(${targetWithJump(spec)}): ${classifyError(error.message)}${proxyFailureSuffix(proxy)}`));
|
|
405
1036
|
}
|
|
406
1037
|
else {
|
|
407
1038
|
logger?.warn(`[dsh-tty] ssh ${target} 连接错误: ${error.message}`);
|
|
@@ -410,7 +1041,7 @@ export async function spawnSsh(spec, options) {
|
|
|
410
1041
|
});
|
|
411
1042
|
conn.on('close', () => {
|
|
412
1043
|
// channel 建立前连接就断了:不能让 await 悬挂(0.19.0 兜底)
|
|
413
|
-
settleErr(new Error(`SSH 连接已关闭(${
|
|
1044
|
+
settleErr(new Error(`SSH 连接已关闭(${targetWithJump(spec)},channel 未建立)${proxyFailureSuffix(proxy)}`));
|
|
414
1045
|
finish();
|
|
415
1046
|
});
|
|
416
1047
|
if (connectConfig.tryKeyboard === true) {
|