@akms/mcp-ssh 0.0.2 → 0.1.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.md +11 -11
- package/README.md +70 -4
- package/lib/cli.cjs +2173 -248
- package/lib/cli.cjs.map +1 -1
- package/lib/cli.js +2170 -245
- package/lib/cli.js.map +1 -1
- package/lib/index.cjs +340 -89
- package/lib/index.cjs.map +1 -1
- package/lib/index.d.cts +58 -5
- package/lib/index.d.ts +58 -5
- package/lib/index.js +339 -88
- package/lib/index.js.map +1 -1
- package/package.json +69 -61
package/lib/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import fs2 from 'fs';
|
|
2
3
|
import os from 'os';
|
|
3
4
|
import path2 from 'path';
|
|
4
|
-
import
|
|
5
|
+
import { spawn } from 'child_process';
|
|
5
6
|
import { createHash } from 'crypto';
|
|
7
|
+
import { Duplex } from 'stream';
|
|
6
8
|
import { StringDecoder } from 'string_decoder';
|
|
7
9
|
import ssh2 from 'ssh2';
|
|
8
10
|
import { setInterval, clearInterval } from 'timers';
|
|
@@ -23,7 +25,7 @@ var SESSION_SWEEP_INTERVAL_MS = 6e4;
|
|
|
23
25
|
var CWD_MARKER = "__AKMS_SSH_CWD__";
|
|
24
26
|
var MAX_DIRECTORY_ENTRIES = 1e3;
|
|
25
27
|
var SERVER_NAME = "akms-mcp-ssh";
|
|
26
|
-
var SERVER_VERSION = "0.0
|
|
28
|
+
var SERVER_VERSION = "0.1.0";
|
|
27
29
|
|
|
28
30
|
// src/_defs/command-rules.ts
|
|
29
31
|
var CRITICAL_PATH_PATTERN = new RegExp(
|
|
@@ -445,6 +447,22 @@ var SINGLE_HOST_ENV = {
|
|
|
445
447
|
DESCRIPTION: "SSH_DESCRIPTION",
|
|
446
448
|
/** Directory new sessions start in. */
|
|
447
449
|
CWD: "SSH_CWD",
|
|
450
|
+
/**
|
|
451
|
+
* Command whose stdin/stdout carry the SSH stream, like OpenSSH's `ProxyCommand`.
|
|
452
|
+
* `%h` / `%p` / `%r` expand to the first hop's host, port and user.
|
|
453
|
+
*/
|
|
454
|
+
PROXY_COMMAND: "SSH_PROXY_COMMAND",
|
|
455
|
+
/** Presence of this variable routes the connection through a jump host. */
|
|
456
|
+
JUMP_HOST: "SSH_JUMP_HOST",
|
|
457
|
+
JUMP_PORT: "SSH_JUMP_PORT",
|
|
458
|
+
/** Defaults to `SSH_USER`. */
|
|
459
|
+
JUMP_USER: "SSH_JUMP_USER",
|
|
460
|
+
/** Path to the jump host's private key file; defaults to `SSH_KEY`. */
|
|
461
|
+
JUMP_KEY: "SSH_JUMP_KEY",
|
|
462
|
+
JUMP_PASSPHRASE: "SSH_JUMP_PASSPHRASE",
|
|
463
|
+
JUMP_PASSWORD: "SSH_JUMP_PASSWORD",
|
|
464
|
+
/** Pinned jump host key, `SHA256:…`. */
|
|
465
|
+
JUMP_HOST_KEY: "SSH_JUMP_HOST_KEY",
|
|
448
466
|
READONLY: "SSH_READONLY",
|
|
449
467
|
ALLOW_SUDO: "SSH_ALLOW_SUDO",
|
|
450
468
|
/** Comma-separated binary names. */
|
|
@@ -597,8 +615,8 @@ function isRemotePathWithin(candidate, allowedPrefix) {
|
|
|
597
615
|
}
|
|
598
616
|
return normalizedCandidate.startsWith(`${normalizedPrefix}/`);
|
|
599
617
|
}
|
|
600
|
-
function readEnv(name) {
|
|
601
|
-
const value =
|
|
618
|
+
function readEnv(env, name) {
|
|
619
|
+
const value = env[name];
|
|
602
620
|
if (value == null) {
|
|
603
621
|
return void 0;
|
|
604
622
|
}
|
|
@@ -608,8 +626,8 @@ function readEnv(name) {
|
|
|
608
626
|
}
|
|
609
627
|
return trimmed;
|
|
610
628
|
}
|
|
611
|
-
function readBooleanEnv(name) {
|
|
612
|
-
const value = readEnv(name);
|
|
629
|
+
function readBooleanEnv(env, name) {
|
|
630
|
+
const value = readEnv(env, name);
|
|
613
631
|
if (value == null) {
|
|
614
632
|
return void 0;
|
|
615
633
|
}
|
|
@@ -622,8 +640,8 @@ function readBooleanEnv(name) {
|
|
|
622
640
|
}
|
|
623
641
|
return false;
|
|
624
642
|
}
|
|
625
|
-
function readIntegerEnv(name) {
|
|
626
|
-
const value = readEnv(name);
|
|
643
|
+
function readIntegerEnv(env, name) {
|
|
644
|
+
const value = readEnv(env, name);
|
|
627
645
|
if (value == null) {
|
|
628
646
|
return void 0;
|
|
629
647
|
}
|
|
@@ -633,8 +651,8 @@ function readIntegerEnv(name) {
|
|
|
633
651
|
}
|
|
634
652
|
return parsed;
|
|
635
653
|
}
|
|
636
|
-
function readListEnv(name) {
|
|
637
|
-
const value = readEnv(name);
|
|
654
|
+
function readListEnv(env, name) {
|
|
655
|
+
const value = readEnv(env, name);
|
|
638
656
|
if (value == null) {
|
|
639
657
|
return void 0;
|
|
640
658
|
}
|
|
@@ -659,8 +677,8 @@ function compileDenyPatterns(sources) {
|
|
|
659
677
|
}
|
|
660
678
|
});
|
|
661
679
|
}
|
|
662
|
-
function loadHostProfile() {
|
|
663
|
-
const host = readEnv(SINGLE_HOST_ENV.HOST);
|
|
680
|
+
function loadHostProfile(env = process.env) {
|
|
681
|
+
const host = readEnv(env, SINGLE_HOST_ENV.HOST);
|
|
664
682
|
if (host == null) {
|
|
665
683
|
const _startupLogger = $logger.child({ context: "loadHostProfile" });
|
|
666
684
|
_startupLogger.warn("no host is registered; set the connection variables and restart", {
|
|
@@ -668,40 +686,40 @@ function loadHostProfile() {
|
|
|
668
686
|
});
|
|
669
687
|
return null;
|
|
670
688
|
}
|
|
671
|
-
const username = readEnv(SINGLE_HOST_ENV.USER);
|
|
689
|
+
const username = readEnv(env, SINGLE_HOST_ENV.USER);
|
|
672
690
|
if (username == null) {
|
|
673
691
|
throw new Error(`${SINGLE_HOST_ENV.HOST} is set, so ${SINGLE_HOST_ENV.USER} is required`);
|
|
674
692
|
}
|
|
675
|
-
const name = readEnv(SINGLE_HOST_ENV.NAME) ?? host;
|
|
693
|
+
const name = readEnv(env, SINGLE_HOST_ENV.NAME) ?? host;
|
|
676
694
|
const _logger = $logger.child({ context: "loadHostProfile", profile: name });
|
|
677
|
-
const allowCommands = readListEnv(SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
|
|
695
|
+
const allowCommands = readListEnv(env, SINGLE_HOST_ENV.ALLOW_COMMANDS) ?? [];
|
|
678
696
|
if (allowCommands.length > 0 && allowCommands.includes("pwd") == false) {
|
|
679
697
|
allowCommands.push("pwd");
|
|
680
698
|
}
|
|
681
699
|
const policy = {
|
|
682
|
-
readonly: readBooleanEnv(SINGLE_HOST_ENV.READONLY) ?? false,
|
|
683
|
-
allowSudo: readBooleanEnv(SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
|
|
684
|
-
execTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
685
|
-
connectTimeoutMs: readIntegerEnv(SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS) ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
686
|
-
maxOutputCharacters: readIntegerEnv(SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
|
|
687
|
-
maxReadFileBytes: readIntegerEnv(SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
|
|
700
|
+
readonly: readBooleanEnv(env, SINGLE_HOST_ENV.READONLY) ?? false,
|
|
701
|
+
allowSudo: readBooleanEnv(env, SINGLE_HOST_ENV.ALLOW_SUDO) ?? true,
|
|
702
|
+
execTimeoutMs: readIntegerEnv(env, SINGLE_HOST_ENV.EXEC_TIMEOUT_MS) ?? DEFAULT_EXEC_TIMEOUT_MS,
|
|
703
|
+
connectTimeoutMs: readIntegerEnv(env, SINGLE_HOST_ENV.CONNECT_TIMEOUT_MS) ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
704
|
+
maxOutputCharacters: readIntegerEnv(env, SINGLE_HOST_ENV.MAX_OUTPUT_CHARACTERS) ?? DEFAULT_MAX_OUTPUT_CHARACTERS,
|
|
705
|
+
maxReadFileBytes: readIntegerEnv(env, SINGLE_HOST_ENV.MAX_READ_FILE_BYTES) ?? DEFAULT_MAX_READ_FILE_BYTES,
|
|
688
706
|
allowCommands,
|
|
689
|
-
denyPatterns: compileDenyPatterns(readListEnv(SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
|
|
690
|
-
allowedPaths: readListEnv(SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
|
|
707
|
+
denyPatterns: compileDenyPatterns(readListEnv(env, SINGLE_HOST_ENV.DENY_PATTERNS) ?? []),
|
|
708
|
+
allowedPaths: readListEnv(env, SINGLE_HOST_ENV.ALLOWED_PATHS) ?? []
|
|
691
709
|
};
|
|
692
710
|
const profile = {
|
|
693
711
|
name,
|
|
694
712
|
host,
|
|
695
|
-
port: readIntegerEnv(SINGLE_HOST_ENV.PORT) ?? DEFAULT_SSH_PORT,
|
|
713
|
+
port: readIntegerEnv(env, SINGLE_HOST_ENV.PORT) ?? DEFAULT_SSH_PORT,
|
|
696
714
|
username,
|
|
697
|
-
description: readEnv(SINGLE_HOST_ENV.DESCRIPTION),
|
|
698
|
-
passphrase: readEnv(SINGLE_HOST_ENV.PASSPHRASE),
|
|
699
|
-
password: readEnv(SINGLE_HOST_ENV.PASSWORD),
|
|
700
|
-
hostKeyFingerprint: readEnv(SINGLE_HOST_ENV.HOST_KEY),
|
|
701
|
-
defaultCwd: readEnv(SINGLE_HOST_ENV.CWD),
|
|
715
|
+
description: readEnv(env, SINGLE_HOST_ENV.DESCRIPTION),
|
|
716
|
+
passphrase: readEnv(env, SINGLE_HOST_ENV.PASSPHRASE),
|
|
717
|
+
password: readEnv(env, SINGLE_HOST_ENV.PASSWORD),
|
|
718
|
+
hostKeyFingerprint: readEnv(env, SINGLE_HOST_ENV.HOST_KEY),
|
|
719
|
+
defaultCwd: readEnv(env, SINGLE_HOST_ENV.CWD),
|
|
702
720
|
policy
|
|
703
721
|
};
|
|
704
|
-
const keyPath = readEnv(SINGLE_HOST_ENV.KEY);
|
|
722
|
+
const keyPath = readEnv(env, SINGLE_HOST_ENV.KEY);
|
|
705
723
|
if (keyPath != null) {
|
|
706
724
|
const expandedKeyPath = expandHomePath(keyPath);
|
|
707
725
|
profile.privateKeyPath = expandedKeyPath;
|
|
@@ -711,11 +729,49 @@ function loadHostProfile() {
|
|
|
711
729
|
});
|
|
712
730
|
}
|
|
713
731
|
}
|
|
732
|
+
profile.proxyCommand = readEnv(env, SINGLE_HOST_ENV.PROXY_COMMAND);
|
|
733
|
+
const jumpHost = readEnv(env, SINGLE_HOST_ENV.JUMP_HOST);
|
|
734
|
+
if (jumpHost == null) {
|
|
735
|
+
const strayJumpVariables = [
|
|
736
|
+
SINGLE_HOST_ENV.JUMP_PORT,
|
|
737
|
+
SINGLE_HOST_ENV.JUMP_USER,
|
|
738
|
+
SINGLE_HOST_ENV.JUMP_KEY,
|
|
739
|
+
SINGLE_HOST_ENV.JUMP_PASSPHRASE,
|
|
740
|
+
SINGLE_HOST_ENV.JUMP_PASSWORD,
|
|
741
|
+
SINGLE_HOST_ENV.JUMP_HOST_KEY
|
|
742
|
+
].filter((variable) => readEnv(env, variable) != null);
|
|
743
|
+
if (strayJumpVariables.length > 0) {
|
|
744
|
+
throw new Error(`${strayJumpVariables.join(", ")} is set, so ${SINGLE_HOST_ENV.JUMP_HOST} is required`);
|
|
745
|
+
}
|
|
746
|
+
} else {
|
|
747
|
+
profile.jump = {
|
|
748
|
+
host: jumpHost,
|
|
749
|
+
port: readIntegerEnv(env, SINGLE_HOST_ENV.JUMP_PORT) ?? DEFAULT_SSH_PORT,
|
|
750
|
+
username: readEnv(env, SINGLE_HOST_ENV.JUMP_USER) ?? username,
|
|
751
|
+
password: readEnv(env, SINGLE_HOST_ENV.JUMP_PASSWORD),
|
|
752
|
+
hostKeyFingerprint: readEnv(env, SINGLE_HOST_ENV.JUMP_HOST_KEY)
|
|
753
|
+
};
|
|
754
|
+
const jumpKeyPath = readEnv(env, SINGLE_HOST_ENV.JUMP_KEY);
|
|
755
|
+
if (jumpKeyPath != null) {
|
|
756
|
+
profile.jump.privateKeyPath = expandHomePath(jumpKeyPath);
|
|
757
|
+
profile.jump.passphrase = readEnv(env, SINGLE_HOST_ENV.JUMP_PASSPHRASE);
|
|
758
|
+
if (fs2.existsSync(profile.jump.privateKeyPath) == false) {
|
|
759
|
+
_logger.warn(`${SINGLE_HOST_ENV.JUMP_KEY} does not exist; connections will fail`, {
|
|
760
|
+
private_key_path: profile.jump.privateKeyPath
|
|
761
|
+
});
|
|
762
|
+
}
|
|
763
|
+
} else {
|
|
764
|
+
profile.jump.privateKeyPath = profile.privateKeyPath;
|
|
765
|
+
profile.jump.passphrase = readEnv(env, SINGLE_HOST_ENV.JUMP_PASSPHRASE) ?? profile.passphrase;
|
|
766
|
+
}
|
|
767
|
+
}
|
|
714
768
|
_logger.info("host profile loaded", {
|
|
715
769
|
host,
|
|
716
770
|
port: profile.port,
|
|
717
771
|
username,
|
|
718
|
-
readonly: policy.readonly
|
|
772
|
+
readonly: policy.readonly,
|
|
773
|
+
jump_host: profile.jump?.host,
|
|
774
|
+
via_proxy_command: profile.proxyCommand != null
|
|
719
775
|
});
|
|
720
776
|
return profile;
|
|
721
777
|
}
|
|
@@ -726,18 +782,27 @@ function toHostSummary(profile) {
|
|
|
726
782
|
} else if (profile.password != null) {
|
|
727
783
|
authMethod = "password";
|
|
728
784
|
}
|
|
729
|
-
|
|
785
|
+
const summary = {
|
|
730
786
|
name: profile.name,
|
|
731
787
|
host: profile.host,
|
|
732
788
|
port: profile.port,
|
|
733
789
|
username: profile.username,
|
|
734
790
|
description: profile.description,
|
|
735
791
|
authMethod,
|
|
792
|
+
viaProxyCommand: profile.proxyCommand != null,
|
|
736
793
|
readonly: profile.policy.readonly,
|
|
737
794
|
allowSudo: profile.policy.allowSudo,
|
|
738
795
|
allowedPaths: profile.policy.allowedPaths,
|
|
739
796
|
allowCommands: profile.policy.allowCommands
|
|
740
797
|
};
|
|
798
|
+
if (profile.jump != null) {
|
|
799
|
+
summary.jump = {
|
|
800
|
+
host: profile.jump.host,
|
|
801
|
+
port: profile.jump.port,
|
|
802
|
+
username: profile.jump.username
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
return summary;
|
|
741
806
|
}
|
|
742
807
|
|
|
743
808
|
// src/modules/guard/command-guard.ts
|
|
@@ -1269,7 +1334,7 @@ function resolveAgentPath() {
|
|
|
1269
1334
|
}
|
|
1270
1335
|
return void 0;
|
|
1271
1336
|
}
|
|
1272
|
-
function readPrivateKey(
|
|
1337
|
+
function readPrivateKey(label, keyPath) {
|
|
1273
1338
|
const cached = PRIVATE_KEY_CACHE.get(keyPath);
|
|
1274
1339
|
if (cached != null) {
|
|
1275
1340
|
return cached;
|
|
@@ -1279,32 +1344,46 @@ function readPrivateKey(profile, keyPath) {
|
|
|
1279
1344
|
PRIVATE_KEY_CACHE.set(keyPath, contents);
|
|
1280
1345
|
return contents;
|
|
1281
1346
|
} catch (ex) {
|
|
1282
|
-
const _logger = $logger.child({ context: "readPrivateKey", profile:
|
|
1283
|
-
_logger.error(ex, "cannot read the
|
|
1284
|
-
throw new Error(`cannot read the private key configured for
|
|
1347
|
+
const _logger = $logger.child({ context: "readPrivateKey", profile: label });
|
|
1348
|
+
_logger.error(ex, "cannot read the private key file");
|
|
1349
|
+
throw new Error(`cannot read the private key configured for '${label}' (see the server log for the path)`);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
function applyAuth(connectConfig, credentials, label) {
|
|
1353
|
+
if (credentials.privateKeyPath != null) {
|
|
1354
|
+
connectConfig.privateKey = readPrivateKey(label, credentials.privateKeyPath);
|
|
1355
|
+
}
|
|
1356
|
+
if (credentials.passphrase != null) {
|
|
1357
|
+
connectConfig.passphrase = credentials.passphrase;
|
|
1358
|
+
}
|
|
1359
|
+
if (credentials.password != null) {
|
|
1360
|
+
connectConfig.password = credentials.password;
|
|
1361
|
+
}
|
|
1362
|
+
if (connectConfig.privateKey == null && connectConfig.password == null) {
|
|
1363
|
+
connectConfig.agent = resolveAgentPath();
|
|
1285
1364
|
}
|
|
1286
1365
|
}
|
|
1287
1366
|
function toHostKeyFingerprint(hostKey) {
|
|
1288
1367
|
const digest = createHash("sha256").update(hostKey).digest("base64").replace(/=+$/, "");
|
|
1289
1368
|
return `SHA256:${digest}`;
|
|
1290
1369
|
}
|
|
1291
|
-
function buildHostVerifier(
|
|
1370
|
+
function buildHostVerifier(hop, onHostKeySeen) {
|
|
1292
1371
|
return (hostKey) => {
|
|
1293
1372
|
const fingerprint = toHostKeyFingerprint(hostKey);
|
|
1294
1373
|
onHostKeySeen(fingerprint);
|
|
1295
|
-
const _logger = $logger.child({ context: "hostVerifier", profile:
|
|
1296
|
-
if (
|
|
1297
|
-
_logger.info("host key accepted unpinned;
|
|
1374
|
+
const _logger = $logger.child({ context: "hostVerifier", profile: hop.label, host: hop.host });
|
|
1375
|
+
if (hop.pinnedFingerprint == null) {
|
|
1376
|
+
_logger.info("host key accepted unpinned; pin the fingerprint in the profile to detect a changed key", {
|
|
1298
1377
|
host_key_fingerprint: fingerprint
|
|
1299
1378
|
});
|
|
1300
1379
|
return true;
|
|
1301
1380
|
}
|
|
1302
|
-
if (
|
|
1381
|
+
if (hop.pinnedFingerprint === fingerprint) {
|
|
1303
1382
|
_logger.debug("host key matches the pinned fingerprint");
|
|
1304
1383
|
return true;
|
|
1305
1384
|
}
|
|
1306
1385
|
_logger.error(null, "host key does not match the pinned fingerprint", {
|
|
1307
|
-
expected:
|
|
1386
|
+
expected: hop.pinnedFingerprint,
|
|
1308
1387
|
actual: fingerprint
|
|
1309
1388
|
});
|
|
1310
1389
|
return false;
|
|
@@ -1317,21 +1396,152 @@ function buildConnectConfig(profile, onHostKeySeen) {
|
|
|
1317
1396
|
username: profile.username,
|
|
1318
1397
|
readyTimeout: profile.policy.connectTimeoutMs,
|
|
1319
1398
|
keepaliveInterval: KEEPALIVE_INTERVAL_MS,
|
|
1320
|
-
hostVerifier: buildHostVerifier(
|
|
1399
|
+
hostVerifier: buildHostVerifier(
|
|
1400
|
+
{ label: profile.name, host: profile.host, pinnedFingerprint: profile.hostKeyFingerprint },
|
|
1401
|
+
onHostKeySeen
|
|
1402
|
+
)
|
|
1321
1403
|
};
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1404
|
+
applyAuth(connectConfig, profile, profile.name);
|
|
1405
|
+
return connectConfig;
|
|
1406
|
+
}
|
|
1407
|
+
function expandProxyCommand(template, hop) {
|
|
1408
|
+
return template.replace(/%[hpr%]/g, (token) => {
|
|
1409
|
+
if (token === "%h") {
|
|
1410
|
+
return hop.host;
|
|
1411
|
+
}
|
|
1412
|
+
if (token === "%p") {
|
|
1413
|
+
return String(hop.port);
|
|
1414
|
+
}
|
|
1415
|
+
if (token === "%r") {
|
|
1416
|
+
return hop.username;
|
|
1417
|
+
}
|
|
1418
|
+
return "%";
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
function openProxyCommand(profile, hop) {
|
|
1422
|
+
const command = expandProxyCommand(profile.proxyCommand ?? "", hop);
|
|
1423
|
+
const _logger = $logger.child({ context: "openProxyCommand", profile: profile.name });
|
|
1424
|
+
_logger.debug("starting the proxy command", { command });
|
|
1425
|
+
const child = spawn(command, { shell: true, stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
1426
|
+
const stderrDecoder = new StringDecoder("utf8");
|
|
1427
|
+
child.stderr.on("data", (chunk) => {
|
|
1428
|
+
const text = stderrDecoder.write(chunk).trim();
|
|
1429
|
+
if (text !== "") {
|
|
1430
|
+
_logger.warn("proxy command stderr", { text });
|
|
1431
|
+
}
|
|
1432
|
+
});
|
|
1433
|
+
child.on("exit", (code, signal) => {
|
|
1434
|
+
_logger.debug("proxy command exited", { exit_code: code, signal });
|
|
1435
|
+
});
|
|
1436
|
+
const sock = Duplex.from({ readable: child.stdout, writable: child.stdin });
|
|
1437
|
+
sock.on("error", (error) => {
|
|
1438
|
+
_logger.debug("proxy command stream error", { error_message: error.message });
|
|
1439
|
+
});
|
|
1440
|
+
child.on("error", (error) => {
|
|
1441
|
+
sock.destroy(error);
|
|
1442
|
+
});
|
|
1443
|
+
let closed = false;
|
|
1444
|
+
return {
|
|
1445
|
+
sock,
|
|
1446
|
+
close: () => {
|
|
1447
|
+
if (closed == true) {
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
closed = true;
|
|
1451
|
+
if (child.exitCode != null || child.signalCode != null) {
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
if (process.platform === "win32" && child.pid != null) {
|
|
1455
|
+
const killer = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
|
|
1456
|
+
killer.on("error", (error) => {
|
|
1457
|
+
_logger.error(error, "failed to kill the proxy command's process tree");
|
|
1458
|
+
});
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
child.kill();
|
|
1462
|
+
}
|
|
1463
|
+
};
|
|
1464
|
+
}
|
|
1465
|
+
function openJumpTransport(profile, jump, proxy, onHostKeySeen) {
|
|
1466
|
+
const label = `${profile.name} jump host`;
|
|
1467
|
+
const endpoint = `${jump.username}@${jump.host}:${jump.port}`;
|
|
1468
|
+
const _logger = $logger.child({ context: "openJumpTransport", profile: profile.name, jump_host: jump.host });
|
|
1469
|
+
const jumpConfig = {
|
|
1470
|
+
host: jump.host,
|
|
1471
|
+
port: jump.port,
|
|
1472
|
+
username: jump.username,
|
|
1473
|
+
readyTimeout: profile.policy.connectTimeoutMs,
|
|
1474
|
+
keepaliveInterval: KEEPALIVE_INTERVAL_MS,
|
|
1475
|
+
hostVerifier: buildHostVerifier(
|
|
1476
|
+
{ label, host: jump.host, pinnedFingerprint: jump.hostKeyFingerprint },
|
|
1477
|
+
onHostKeySeen
|
|
1478
|
+
)
|
|
1479
|
+
};
|
|
1480
|
+
applyAuth(jumpConfig, jump, label);
|
|
1481
|
+
if (proxy != null) {
|
|
1482
|
+
jumpConfig.sock = proxy.sock;
|
|
1483
|
+
}
|
|
1484
|
+
return new Promise((resolve, reject) => {
|
|
1485
|
+
const jumpClient = new Client();
|
|
1486
|
+
let settled = false;
|
|
1487
|
+
jumpClient.on("error", (error) => {
|
|
1488
|
+
if (settled == false) {
|
|
1489
|
+
settled = true;
|
|
1490
|
+
jumpClient.destroy();
|
|
1491
|
+
reject(new Error(`jump host ${endpoint}: ${error.message}`));
|
|
1492
|
+
return;
|
|
1493
|
+
}
|
|
1494
|
+
_logger.error(error, "jump host connection error after ready");
|
|
1495
|
+
});
|
|
1496
|
+
jumpClient.on("close", () => {
|
|
1497
|
+
_logger.debug("jump host connection closed");
|
|
1498
|
+
proxy?.close();
|
|
1499
|
+
if (settled == false) {
|
|
1500
|
+
settled = true;
|
|
1501
|
+
reject(new Error(`jump host ${endpoint}: connection closed before the handshake completed`));
|
|
1502
|
+
}
|
|
1503
|
+
});
|
|
1504
|
+
jumpClient.once("ready", () => {
|
|
1505
|
+
if (settled == true) {
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
jumpClient.forwardOut("127.0.0.1", 0, profile.host, profile.port, (error, stream) => {
|
|
1509
|
+
if (settled == true) {
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
settled = true;
|
|
1513
|
+
if (error != null) {
|
|
1514
|
+
jumpClient.end();
|
|
1515
|
+
reject(new Error(`jump host ${endpoint} refused the forward to ${profile.host}:${profile.port}: ${error.message}`));
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1518
|
+
_logger.info("jump host connected, target channel forwarded");
|
|
1519
|
+
resolve({
|
|
1520
|
+
sock: stream,
|
|
1521
|
+
close: () => {
|
|
1522
|
+
jumpClient.end();
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
});
|
|
1526
|
+
});
|
|
1527
|
+
jumpClient.connect(jumpConfig);
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
async function openTransport(profile, onJumpHostKeySeen) {
|
|
1531
|
+
let proxy = null;
|
|
1532
|
+
if (profile.proxyCommand != null) {
|
|
1533
|
+
proxy = openProxyCommand(profile, profile.jump ?? profile);
|
|
1327
1534
|
}
|
|
1328
|
-
if (profile.
|
|
1329
|
-
|
|
1535
|
+
if (profile.jump == null) {
|
|
1536
|
+
return proxy;
|
|
1330
1537
|
}
|
|
1331
|
-
|
|
1332
|
-
|
|
1538
|
+
try {
|
|
1539
|
+
const jumpTransport = await openJumpTransport(profile, profile.jump, proxy, onJumpHostKeySeen);
|
|
1540
|
+
return jumpTransport;
|
|
1541
|
+
} catch (ex) {
|
|
1542
|
+
proxy?.close();
|
|
1543
|
+
throw ex;
|
|
1333
1544
|
}
|
|
1334
|
-
return connectConfig;
|
|
1335
1545
|
}
|
|
1336
1546
|
function buildRemoteCommand(command, cwd, trackCwd) {
|
|
1337
1547
|
const lines = [];
|
|
@@ -1373,6 +1583,7 @@ var SshConnection = class {
|
|
|
1373
1583
|
/** Set when the peer closed a previously established connection. */
|
|
1374
1584
|
this.droppedAfterReady = false;
|
|
1375
1585
|
this.observedFingerprint = null;
|
|
1586
|
+
this.observedJumpFingerprint = null;
|
|
1376
1587
|
}
|
|
1377
1588
|
get isConnected() {
|
|
1378
1589
|
return this.client != null;
|
|
@@ -1381,6 +1592,10 @@ var SshConnection = class {
|
|
|
1381
1592
|
get hostKeyFingerprint() {
|
|
1382
1593
|
return this.observedFingerprint;
|
|
1383
1594
|
}
|
|
1595
|
+
/** Host key the jump host presented; null without a jump host or before its handshake. */
|
|
1596
|
+
get jumpHostKeyFingerprint() {
|
|
1597
|
+
return this.observedJumpFingerprint;
|
|
1598
|
+
}
|
|
1384
1599
|
/** True when a connection was established and then lost, so shell state is gone. */
|
|
1385
1600
|
get wasDropped() {
|
|
1386
1601
|
return this.droppedAfterReady;
|
|
@@ -1388,8 +1603,11 @@ var SshConnection = class {
|
|
|
1388
1603
|
/**
|
|
1389
1604
|
* Opens the connection, or joins the in-flight attempt when one is already running.
|
|
1390
1605
|
*
|
|
1606
|
+
* With a proxy command or a jump host the carrier is opened first and handed to ssh2
|
|
1607
|
+
* as the socket; it is torn down with the target connection, whichever side closes.
|
|
1608
|
+
*
|
|
1391
1609
|
* @throws {Error} If the connection was closed, authentication fails, the host is
|
|
1392
|
-
* unreachable,
|
|
1610
|
+
* unreachable, the handshake times out, or the carrier cannot be opened.
|
|
1393
1611
|
*/
|
|
1394
1612
|
async connect() {
|
|
1395
1613
|
if (this.disposed == true) {
|
|
@@ -1412,43 +1630,66 @@ var SshConnection = class {
|
|
|
1412
1630
|
const connectConfig = buildConnectConfig(this.profile, (fingerprint) => {
|
|
1413
1631
|
this.observedFingerprint = fingerprint;
|
|
1414
1632
|
});
|
|
1415
|
-
this.connecting =
|
|
1416
|
-
const
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1633
|
+
this.connecting = (async () => {
|
|
1634
|
+
const transport = await openTransport(this.profile, (fingerprint) => {
|
|
1635
|
+
this.observedJumpFingerprint = fingerprint;
|
|
1636
|
+
});
|
|
1637
|
+
if (this.disposed == true) {
|
|
1638
|
+
transport?.close();
|
|
1639
|
+
throw new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`);
|
|
1640
|
+
}
|
|
1641
|
+
if (transport != null) {
|
|
1642
|
+
connectConfig.sock = transport.sock;
|
|
1643
|
+
}
|
|
1644
|
+
await new Promise((resolve, reject) => {
|
|
1645
|
+
const client = new Client();
|
|
1646
|
+
this.pendingClient = client;
|
|
1647
|
+
let settled = false;
|
|
1648
|
+
client.on("error", (error) => {
|
|
1649
|
+
if (settled == false) {
|
|
1650
|
+
settled = true;
|
|
1651
|
+
this.pendingClient = null;
|
|
1652
|
+
client.destroy();
|
|
1653
|
+
reject(error);
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
if (this.client !== client) {
|
|
1657
|
+
_logger.debug("ssh connection error during teardown", { error_message: error.message });
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
_logger.error(error, "ssh connection error after ready");
|
|
1661
|
+
});
|
|
1662
|
+
client.on("close", () => {
|
|
1663
|
+
transport?.close();
|
|
1664
|
+
if (this.client === client) {
|
|
1665
|
+
_logger.debug("ssh connection closed by the peer");
|
|
1666
|
+
this.client = null;
|
|
1667
|
+
this.droppedAfterReady = true;
|
|
1668
|
+
return;
|
|
1669
|
+
}
|
|
1670
|
+
if (settled == false) {
|
|
1671
|
+
settled = true;
|
|
1672
|
+
this.pendingClient = null;
|
|
1673
|
+
reject(new Error("connection closed before the handshake completed"));
|
|
1674
|
+
}
|
|
1675
|
+
});
|
|
1676
|
+
client.once("ready", () => {
|
|
1677
|
+
if (settled == true) {
|
|
1678
|
+
return;
|
|
1679
|
+
}
|
|
1421
1680
|
settled = true;
|
|
1422
1681
|
this.pendingClient = null;
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
this.client = null;
|
|
1433
|
-
this.droppedAfterReady = true;
|
|
1434
|
-
}
|
|
1435
|
-
});
|
|
1436
|
-
client.once("ready", () => {
|
|
1437
|
-
if (settled == true) {
|
|
1438
|
-
return;
|
|
1439
|
-
}
|
|
1440
|
-
settled = true;
|
|
1441
|
-
this.pendingClient = null;
|
|
1442
|
-
if (this.disposed == true) {
|
|
1443
|
-
client.destroy();
|
|
1444
|
-
reject(new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`));
|
|
1445
|
-
return;
|
|
1446
|
-
}
|
|
1447
|
-
this.client = client;
|
|
1448
|
-
resolve();
|
|
1682
|
+
if (this.disposed == true) {
|
|
1683
|
+
client.destroy();
|
|
1684
|
+
reject(new Error(`ssh connection for '${this.profile.name}' was closed during the handshake`));
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
this.client = client;
|
|
1688
|
+
resolve();
|
|
1689
|
+
});
|
|
1690
|
+
client.connect(connectConfig);
|
|
1449
1691
|
});
|
|
1450
|
-
|
|
1451
|
-
});
|
|
1692
|
+
})();
|
|
1452
1693
|
try {
|
|
1453
1694
|
await this.connecting;
|
|
1454
1695
|
_logger.info("ssh connection established");
|
|
@@ -2136,6 +2377,16 @@ function formatHostProfile(profile) {
|
|
|
2136
2377
|
if (summary.description != null) {
|
|
2137
2378
|
lines.push(` ${summary.description}`);
|
|
2138
2379
|
}
|
|
2380
|
+
const routeParts = [];
|
|
2381
|
+
if (summary.viaProxyCommand == true) {
|
|
2382
|
+
routeParts.push("proxy command");
|
|
2383
|
+
}
|
|
2384
|
+
if (summary.jump != null) {
|
|
2385
|
+
routeParts.push(`jump host ${summary.jump.username}@${summary.jump.host}:${summary.jump.port}`);
|
|
2386
|
+
}
|
|
2387
|
+
if (routeParts.length > 0) {
|
|
2388
|
+
lines.push(` via ${routeParts.join(" \u2192 ")}`);
|
|
2389
|
+
}
|
|
2139
2390
|
if (summary.allowedPaths.length > 0) {
|
|
2140
2391
|
lines.push(` file tools limited to: ${summary.allowedPaths.join(", ")} (does not restrict ssh_exec)`);
|
|
2141
2392
|
}
|