@yawlabs/ssh-mcp 0.11.3 → 0.11.5
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/dist/index.js +61 -13
- package/dist/server.js +55 -13
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ function isValidHostname(host) {
|
|
|
19
19
|
if (host.startsWith("[")) {
|
|
20
20
|
return /^\[[0-9a-fA-F:]+\]$/.test(host);
|
|
21
21
|
}
|
|
22
|
+
if (host.startsWith("-")) return false;
|
|
22
23
|
return /^[a-zA-Z0-9._-]+$/.test(host);
|
|
23
24
|
}
|
|
24
25
|
function runArgs(cmd, args) {
|
|
@@ -146,6 +147,7 @@ function checkConnectivity(host, port = 22) {
|
|
|
146
147
|
"StrictHostKeyChecking=no",
|
|
147
148
|
"-p",
|
|
148
149
|
String(port),
|
|
150
|
+
"--",
|
|
149
151
|
host,
|
|
150
152
|
"echo",
|
|
151
153
|
"SSH_OK"
|
|
@@ -265,7 +267,7 @@ function diagnose(host, port = 22) {
|
|
|
265
267
|
function parseSshConfigOutput(stdout) {
|
|
266
268
|
const all = {};
|
|
267
269
|
const identityFiles = [];
|
|
268
|
-
for (const line of stdout.split(
|
|
270
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
269
271
|
const spaceIdx = line.indexOf(" ");
|
|
270
272
|
if (spaceIdx > 0) {
|
|
271
273
|
const key = line.substring(0, spaceIdx);
|
|
@@ -569,6 +571,7 @@ function testConnection(host, port = 22) {
|
|
|
569
571
|
"StrictHostKeyChecking=no",
|
|
570
572
|
"-p",
|
|
571
573
|
String(port),
|
|
574
|
+
"--",
|
|
572
575
|
host,
|
|
573
576
|
"echo",
|
|
574
577
|
"SSH_OK"
|
|
@@ -604,6 +607,9 @@ function testConnection(host, port = 22) {
|
|
|
604
607
|
return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
|
|
605
608
|
}
|
|
606
609
|
|
|
610
|
+
// src/pool.ts
|
|
611
|
+
import { createHash } from "crypto";
|
|
612
|
+
|
|
607
613
|
// src/ssh.ts
|
|
608
614
|
import { readFileSync as readFileSync3 } from "fs";
|
|
609
615
|
import { homedir as homedir3 } from "os";
|
|
@@ -655,6 +661,25 @@ function buildHostVerifier(hosts, port) {
|
|
|
655
661
|
return known.some((k) => k.equals(key));
|
|
656
662
|
};
|
|
657
663
|
}
|
|
664
|
+
function isEncryptedKey(content) {
|
|
665
|
+
const text = content.toString("utf8");
|
|
666
|
+
if (text.includes("ENCRYPTED")) return true;
|
|
667
|
+
const m = text.match(/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END/);
|
|
668
|
+
if (m) {
|
|
669
|
+
try {
|
|
670
|
+
const raw = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
|
|
671
|
+
const magic = "openssh-key-v1\0";
|
|
672
|
+
if (raw.toString("latin1", 0, magic.length) === magic) {
|
|
673
|
+
const cipherLen = raw.readUInt32BE(magic.length);
|
|
674
|
+
const cipher = raw.toString("latin1", magic.length + 4, magic.length + 4 + cipherLen);
|
|
675
|
+
return cipher !== "none";
|
|
676
|
+
}
|
|
677
|
+
} catch {
|
|
678
|
+
return true;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
658
683
|
function resolveConfig(config) {
|
|
659
684
|
const sshConfig = resolveFromSshConfig(config.host);
|
|
660
685
|
const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
|
|
@@ -678,15 +703,16 @@ function resolveConfig(config) {
|
|
|
678
703
|
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
679
704
|
if (agentSock) {
|
|
680
705
|
connectConfig.agent = agentSock;
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
706
|
+
}
|
|
707
|
+
const home = homedir3();
|
|
708
|
+
const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join3(home, p.slice(1)) : p) : [join3(home, ".ssh", "id_ed25519"), join3(home, ".ssh", "id_rsa"), join3(home, ".ssh", "id_ecdsa")];
|
|
709
|
+
for (const keyPath of keyPaths) {
|
|
710
|
+
try {
|
|
711
|
+
const keyData = readFileSync3(keyPath);
|
|
712
|
+
if (agentSock && isEncryptedKey(keyData)) continue;
|
|
713
|
+
connectConfig.privateKey = keyData;
|
|
714
|
+
break;
|
|
715
|
+
} catch {
|
|
690
716
|
}
|
|
691
717
|
}
|
|
692
718
|
}
|
|
@@ -1032,6 +1058,22 @@ function defaultMaxPoolSize() {
|
|
|
1032
1058
|
const parsed = Number.parseInt(raw, 10);
|
|
1033
1059
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1034
1060
|
}
|
|
1061
|
+
function authFingerprint(cc) {
|
|
1062
|
+
const h = createHash("sha256");
|
|
1063
|
+
if (cc.privateKey) {
|
|
1064
|
+
h.update("k");
|
|
1065
|
+
h.update(cc.privateKey);
|
|
1066
|
+
}
|
|
1067
|
+
if (cc.password !== void 0) {
|
|
1068
|
+
h.update("p");
|
|
1069
|
+
h.update(cc.password);
|
|
1070
|
+
}
|
|
1071
|
+
if (typeof cc.agent === "string") {
|
|
1072
|
+
h.update("a");
|
|
1073
|
+
h.update(cc.agent);
|
|
1074
|
+
}
|
|
1075
|
+
return h.digest("hex").slice(0, 16);
|
|
1076
|
+
}
|
|
1035
1077
|
var ConnectionPool = class {
|
|
1036
1078
|
entries = /* @__PURE__ */ new Map();
|
|
1037
1079
|
// Coalesces concurrent connect attempts for the same key so we don't open N
|
|
@@ -1053,7 +1095,7 @@ var ConnectionPool = class {
|
|
|
1053
1095
|
async acquire(config) {
|
|
1054
1096
|
const resolved = resolveConfig(config);
|
|
1055
1097
|
const cc = resolved.connectConfig;
|
|
1056
|
-
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
1098
|
+
const key = `${cc.username}@${cc.host}:${cc.port}#${authFingerprint(cc)}`;
|
|
1057
1099
|
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
1058
1100
|
let lastErr;
|
|
1059
1101
|
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
@@ -1295,7 +1337,7 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
1295
1337
|
const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1296
1338
|
const raw = result.stdout;
|
|
1297
1339
|
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
1298
|
-
const descMatch = raw.match(
|
|
1340
|
+
const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
|
|
1299
1341
|
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
1300
1342
|
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
1301
1343
|
const unknown = !activeMatch && result.code !== 0;
|
|
@@ -1395,7 +1437,9 @@ ${result.stderr}`);
|
|
|
1395
1437
|
"Read a file from a remote host via SFTP.",
|
|
1396
1438
|
{
|
|
1397
1439
|
...connectionParams,
|
|
1398
|
-
path: z.string().
|
|
1440
|
+
path: z.string().refine((p) => p.startsWith("/"), {
|
|
1441
|
+
message: "Path must be absolute (start with /). Relative paths resolve against the remote user's CWD, which is rarely what you want."
|
|
1442
|
+
}).describe("Absolute path to the remote file. Must start with /.")
|
|
1399
1443
|
},
|
|
1400
1444
|
async ({ path, ...conn }) => {
|
|
1401
1445
|
return connectionPool.withConnection(conn, async (client) => {
|
|
@@ -1814,6 +1858,10 @@ async function main() {
|
|
|
1814
1858
|
process.on("SIGTERM", () => {
|
|
1815
1859
|
shutdown().catch(() => process.exit(1));
|
|
1816
1860
|
});
|
|
1861
|
+
process.on("exit", () => {
|
|
1862
|
+
pool.drain();
|
|
1863
|
+
killStartedAgent();
|
|
1864
|
+
});
|
|
1817
1865
|
await server.connect(transport);
|
|
1818
1866
|
}
|
|
1819
1867
|
main().catch((err) => {
|
package/dist/server.js
CHANGED
|
@@ -17,6 +17,7 @@ function isValidHostname(host) {
|
|
|
17
17
|
if (host.startsWith("[")) {
|
|
18
18
|
return /^\[[0-9a-fA-F:]+\]$/.test(host);
|
|
19
19
|
}
|
|
20
|
+
if (host.startsWith("-")) return false;
|
|
20
21
|
return /^[a-zA-Z0-9._-]+$/.test(host);
|
|
21
22
|
}
|
|
22
23
|
function runArgs(cmd, args) {
|
|
@@ -144,6 +145,7 @@ function checkConnectivity(host, port = 22) {
|
|
|
144
145
|
"StrictHostKeyChecking=no",
|
|
145
146
|
"-p",
|
|
146
147
|
String(port),
|
|
148
|
+
"--",
|
|
147
149
|
host,
|
|
148
150
|
"echo",
|
|
149
151
|
"SSH_OK"
|
|
@@ -269,7 +271,7 @@ import { join as join2 } from "path";
|
|
|
269
271
|
function parseSshConfigOutput(stdout) {
|
|
270
272
|
const all = {};
|
|
271
273
|
const identityFiles = [];
|
|
272
|
-
for (const line of stdout.split(
|
|
274
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
273
275
|
const spaceIdx = line.indexOf(" ");
|
|
274
276
|
if (spaceIdx > 0) {
|
|
275
277
|
const key = line.substring(0, spaceIdx);
|
|
@@ -565,6 +567,7 @@ function testConnection(host, port = 22) {
|
|
|
565
567
|
"StrictHostKeyChecking=no",
|
|
566
568
|
"-p",
|
|
567
569
|
String(port),
|
|
570
|
+
"--",
|
|
568
571
|
host,
|
|
569
572
|
"echo",
|
|
570
573
|
"SSH_OK"
|
|
@@ -651,6 +654,25 @@ function buildHostVerifier(hosts, port) {
|
|
|
651
654
|
return known.some((k) => k.equals(key));
|
|
652
655
|
};
|
|
653
656
|
}
|
|
657
|
+
function isEncryptedKey(content) {
|
|
658
|
+
const text = content.toString("utf8");
|
|
659
|
+
if (text.includes("ENCRYPTED")) return true;
|
|
660
|
+
const m = text.match(/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END/);
|
|
661
|
+
if (m) {
|
|
662
|
+
try {
|
|
663
|
+
const raw = Buffer.from(m[1].replace(/\s+/g, ""), "base64");
|
|
664
|
+
const magic = "openssh-key-v1\0";
|
|
665
|
+
if (raw.toString("latin1", 0, magic.length) === magic) {
|
|
666
|
+
const cipherLen = raw.readUInt32BE(magic.length);
|
|
667
|
+
const cipher = raw.toString("latin1", magic.length + 4, magic.length + 4 + cipherLen);
|
|
668
|
+
return cipher !== "none";
|
|
669
|
+
}
|
|
670
|
+
} catch {
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
654
676
|
function resolveConfig(config) {
|
|
655
677
|
const sshConfig = resolveFromSshConfig(config.host);
|
|
656
678
|
const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
|
|
@@ -674,15 +696,16 @@ function resolveConfig(config) {
|
|
|
674
696
|
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
675
697
|
if (agentSock) {
|
|
676
698
|
connectConfig.agent = agentSock;
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
699
|
+
}
|
|
700
|
+
const home = homedir3();
|
|
701
|
+
const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join3(home, p.slice(1)) : p) : [join3(home, ".ssh", "id_ed25519"), join3(home, ".ssh", "id_rsa"), join3(home, ".ssh", "id_ecdsa")];
|
|
702
|
+
for (const keyPath of keyPaths) {
|
|
703
|
+
try {
|
|
704
|
+
const keyData = readFileSync3(keyPath);
|
|
705
|
+
if (agentSock && isEncryptedKey(keyData)) continue;
|
|
706
|
+
connectConfig.privateKey = keyData;
|
|
707
|
+
break;
|
|
708
|
+
} catch {
|
|
686
709
|
}
|
|
687
710
|
}
|
|
688
711
|
}
|
|
@@ -1106,7 +1129,7 @@ async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
|
1106
1129
|
const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1107
1130
|
const raw = result.stdout;
|
|
1108
1131
|
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
1109
|
-
const descMatch = raw.match(
|
|
1132
|
+
const descMatch = raw.match(/^[^\w\n]*\S+\s+-\s+(.+)$/m);
|
|
1110
1133
|
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
1111
1134
|
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
1112
1135
|
const unknown = !activeMatch && result.code !== 0;
|
|
@@ -1157,12 +1180,29 @@ function isPolicyConfigured() {
|
|
|
1157
1180
|
}
|
|
1158
1181
|
|
|
1159
1182
|
// src/pool.ts
|
|
1183
|
+
import { createHash } from "crypto";
|
|
1160
1184
|
function defaultMaxPoolSize() {
|
|
1161
1185
|
const raw = process.env.SSH_MCP_MAX_POOL_SIZE;
|
|
1162
1186
|
if (!raw) return 100;
|
|
1163
1187
|
const parsed = Number.parseInt(raw, 10);
|
|
1164
1188
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 100;
|
|
1165
1189
|
}
|
|
1190
|
+
function authFingerprint(cc) {
|
|
1191
|
+
const h = createHash("sha256");
|
|
1192
|
+
if (cc.privateKey) {
|
|
1193
|
+
h.update("k");
|
|
1194
|
+
h.update(cc.privateKey);
|
|
1195
|
+
}
|
|
1196
|
+
if (cc.password !== void 0) {
|
|
1197
|
+
h.update("p");
|
|
1198
|
+
h.update(cc.password);
|
|
1199
|
+
}
|
|
1200
|
+
if (typeof cc.agent === "string") {
|
|
1201
|
+
h.update("a");
|
|
1202
|
+
h.update(cc.agent);
|
|
1203
|
+
}
|
|
1204
|
+
return h.digest("hex").slice(0, 16);
|
|
1205
|
+
}
|
|
1166
1206
|
var ConnectionPool = class {
|
|
1167
1207
|
entries = /* @__PURE__ */ new Map();
|
|
1168
1208
|
// Coalesces concurrent connect attempts for the same key so we don't open N
|
|
@@ -1184,7 +1224,7 @@ var ConnectionPool = class {
|
|
|
1184
1224
|
async acquire(config) {
|
|
1185
1225
|
const resolved = resolveConfig(config);
|
|
1186
1226
|
const cc = resolved.connectConfig;
|
|
1187
|
-
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
1227
|
+
const key = `${cc.username}@${cc.host}:${cc.port}#${authFingerprint(cc)}`;
|
|
1188
1228
|
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
1189
1229
|
let lastErr;
|
|
1190
1230
|
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
@@ -1403,7 +1443,9 @@ ${result.stderr}`);
|
|
|
1403
1443
|
"Read a file from a remote host via SFTP.",
|
|
1404
1444
|
{
|
|
1405
1445
|
...connectionParams,
|
|
1406
|
-
path: z.string().
|
|
1446
|
+
path: z.string().refine((p) => p.startsWith("/"), {
|
|
1447
|
+
message: "Path must be absolute (start with /). Relative paths resolve against the remote user's CWD, which is rarely what you want."
|
|
1448
|
+
}).describe("Absolute path to the remote file. Must start with /.")
|
|
1407
1449
|
},
|
|
1408
1450
|
async ({ path, ...conn }) => {
|
|
1409
1451
|
return connectionPool.withConnection(conn, async (client) => {
|