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