@yawlabs/ssh-mcp 0.7.0 → 0.9.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 +219 -213
- package/dist/index.js +791 -679
- package/dist/server.d.ts +44 -40
- package/dist/server.js +255 -152
- package/package.json +61 -61
package/dist/index.js
CHANGED
|
@@ -3,15 +3,14 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
|
|
6
|
-
// src/
|
|
7
|
-
import { readFileSync as readFileSync2 } from "fs";
|
|
6
|
+
// src/env.ts
|
|
7
|
+
import { appendFileSync, existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, statSync } from "fs";
|
|
8
8
|
import { homedir as homedir2 } from "os";
|
|
9
9
|
import { join as join2 } from "path";
|
|
10
|
-
import { Client } from "ssh2";
|
|
11
10
|
|
|
12
11
|
// src/diagnose.ts
|
|
13
12
|
import { execFileSync } from "child_process";
|
|
14
|
-
import { existsSync,
|
|
13
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
15
14
|
import { homedir } from "os";
|
|
16
15
|
import { join } from "path";
|
|
17
16
|
function isValidHostname(host) {
|
|
@@ -19,7 +18,7 @@ function isValidHostname(host) {
|
|
|
19
18
|
if (host.startsWith("[")) {
|
|
20
19
|
return /^\[[0-9a-fA-F:]+\]$/.test(host);
|
|
21
20
|
}
|
|
22
|
-
return /^[a-zA-Z0-9._
|
|
21
|
+
return /^[a-zA-Z0-9._-]+$/.test(host);
|
|
23
22
|
}
|
|
24
23
|
function runArgs(cmd, args) {
|
|
25
24
|
try {
|
|
@@ -260,714 +259,831 @@ function diagnose(host, port = 22) {
|
|
|
260
259
|
return { overall, checks, suggestions };
|
|
261
260
|
}
|
|
262
261
|
|
|
263
|
-
// src/
|
|
264
|
-
function
|
|
262
|
+
// src/env.ts
|
|
263
|
+
function probeAgent(socket, agentLabel) {
|
|
264
|
+
const { stdout, ok } = runArgs("ssh-add", ["-l"]);
|
|
265
|
+
const noIdentities = stdout.includes("no identities") || stdout.includes("The agent has no identities");
|
|
266
|
+
if (!ok && !noIdentities) return null;
|
|
267
|
+
const keys = ok && !noIdentities ? stdout.split("\n").filter(Boolean) : [];
|
|
268
|
+
return {
|
|
269
|
+
running: true,
|
|
270
|
+
reachable: true,
|
|
271
|
+
socket,
|
|
272
|
+
keys,
|
|
273
|
+
started: false,
|
|
274
|
+
message: keys.length > 0 ? `${agentLabel} running with ${keys.length} key(s) loaded` : `${agentLabel} running but no keys loaded. Use ssh_key_load to add one.`
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
var startedAgentPid = null;
|
|
278
|
+
function killStartedAgent() {
|
|
279
|
+
if (startedAgentPid === null) return;
|
|
265
280
|
try {
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
281
|
+
process.kill(startedAgentPid);
|
|
282
|
+
} catch {
|
|
283
|
+
}
|
|
284
|
+
startedAgentPid = null;
|
|
285
|
+
}
|
|
286
|
+
function ensureAgent() {
|
|
287
|
+
const sock = process.env.SSH_AUTH_SOCK;
|
|
288
|
+
if (sock) {
|
|
289
|
+
const result = probeAgent(sock, "ssh-agent");
|
|
290
|
+
if (result) return result;
|
|
291
|
+
}
|
|
292
|
+
if (!sock && process.platform === "win32") {
|
|
293
|
+
const result = probeAgent("\\\\.\\pipe\\openssh-ssh-agent", "Windows OpenSSH agent");
|
|
294
|
+
if (result) return result;
|
|
295
|
+
}
|
|
296
|
+
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
297
|
+
if (ok) {
|
|
298
|
+
const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
299
|
+
const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
|
|
300
|
+
if (sockMatch) {
|
|
301
|
+
process.env.SSH_AUTH_SOCK = sockMatch[1];
|
|
302
|
+
if (pidMatch) {
|
|
303
|
+
process.env.SSH_AGENT_PID = pidMatch[1];
|
|
304
|
+
startedAgentPid = Number.parseInt(pidMatch[1], 10);
|
|
280
305
|
}
|
|
306
|
+
return {
|
|
307
|
+
running: true,
|
|
308
|
+
reachable: true,
|
|
309
|
+
socket: sockMatch[1],
|
|
310
|
+
keys: [],
|
|
311
|
+
started: true,
|
|
312
|
+
env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
|
|
313
|
+
message: "Started new ssh-agent scoped to the ssh-mcp server process. Your shell's environment is NOT modified \u2014 this agent is only visible to this MCP server and will terminate when the server exits. No keys loaded yet \u2014 use ssh_key_load to add one."
|
|
314
|
+
};
|
|
281
315
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
316
|
+
}
|
|
317
|
+
return {
|
|
318
|
+
running: false,
|
|
319
|
+
reachable: false,
|
|
320
|
+
keys: [],
|
|
321
|
+
started: false,
|
|
322
|
+
message: process.platform === "win32" ? "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent" : 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
function detectKeyType(filePath, fileName) {
|
|
326
|
+
const pubPath = `${filePath}.pub`;
|
|
327
|
+
if (existsSync2(pubPath)) {
|
|
328
|
+
try {
|
|
329
|
+
const pub = readFileSync2(pubPath, "utf8");
|
|
330
|
+
if (pub.includes("ssh-ed25519")) return "ed25519";
|
|
331
|
+
if (pub.includes("ssh-rsa")) return "rsa";
|
|
332
|
+
if (pub.includes("ecdsa")) return "ecdsa";
|
|
333
|
+
if (pub.includes("ssh-dss")) return "dsa";
|
|
334
|
+
} catch {
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if (fileName.includes("ed25519")) return "ed25519";
|
|
338
|
+
if (fileName.includes("rsa")) return "rsa";
|
|
339
|
+
if (fileName.includes("ecdsa")) return "ecdsa";
|
|
340
|
+
if (fileName.includes("dsa")) return "dsa";
|
|
341
|
+
try {
|
|
342
|
+
const content = readFileSync2(filePath, "utf8");
|
|
343
|
+
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
344
|
+
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
345
|
+
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
289
346
|
} catch {
|
|
290
|
-
return null;
|
|
291
347
|
}
|
|
348
|
+
return "unknown";
|
|
292
349
|
}
|
|
293
|
-
function
|
|
294
|
-
|
|
295
|
-
|
|
350
|
+
function listSshKeys() {
|
|
351
|
+
const sshDir = join2(homedir2(), ".ssh");
|
|
352
|
+
if (!existsSync2(sshDir)) return [];
|
|
353
|
+
const loadedFingerprints = /* @__PURE__ */ new Set();
|
|
354
|
+
const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
|
|
355
|
+
if (agentOk && !agentOut.includes("no identities")) {
|
|
356
|
+
for (const line of agentOut.split("\n").filter(Boolean)) {
|
|
357
|
+
const match = line.match(/(\S+:\S+)/);
|
|
358
|
+
if (match) loadedFingerprints.add(match[1]);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
|
|
296
362
|
const keys = [];
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
363
|
+
let files;
|
|
364
|
+
try {
|
|
365
|
+
files = readdirSync2(sshDir);
|
|
366
|
+
} catch {
|
|
367
|
+
return [];
|
|
368
|
+
}
|
|
369
|
+
for (const file of files) {
|
|
370
|
+
if (file.endsWith(".pub") || file.startsWith(".") || skipFiles.has(file)) continue;
|
|
371
|
+
const filePath = join2(sshDir, file);
|
|
372
|
+
try {
|
|
373
|
+
const stat = statSync(filePath);
|
|
374
|
+
if (!stat.isFile()) continue;
|
|
375
|
+
const content = readFileSync2(filePath, "utf8");
|
|
376
|
+
if (!content.includes("PRIVATE KEY")) continue;
|
|
377
|
+
const type = detectKeyType(filePath, file);
|
|
378
|
+
let fingerprint;
|
|
379
|
+
const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
|
|
380
|
+
if (fpOk) {
|
|
381
|
+
const match = fpOut.match(/(\S+:\S+)/);
|
|
382
|
+
fingerprint = match?.[1];
|
|
308
383
|
}
|
|
384
|
+
const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
|
|
385
|
+
keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
|
|
386
|
+
} catch {
|
|
309
387
|
}
|
|
310
388
|
}
|
|
311
389
|
return keys;
|
|
312
390
|
}
|
|
313
|
-
function
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
if (known.length === 0) {
|
|
318
|
-
return !strict;
|
|
319
|
-
}
|
|
320
|
-
return known.some((k) => k.equals(key));
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
function resolveConfig(config) {
|
|
324
|
-
const sshConfig = resolveFromSshConfig(config.host);
|
|
325
|
-
const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
|
|
326
|
-
const verifierHosts = [config.host];
|
|
327
|
-
if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
|
|
328
|
-
verifierHosts.push(sshConfig.hostname);
|
|
391
|
+
function loadKey(keyPath) {
|
|
392
|
+
const agent = ensureAgent();
|
|
393
|
+
if (!agent.reachable) {
|
|
394
|
+
return { status: "error", message: agent.message };
|
|
329
395
|
}
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
334
|
-
keepaliveInterval: 15e3,
|
|
335
|
-
keepaliveCountMax: 3,
|
|
336
|
-
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
337
|
-
};
|
|
338
|
-
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
339
|
-
if (agentSock) {
|
|
340
|
-
connectConfig.agent = agentSock;
|
|
396
|
+
const resolved = keyPath.startsWith("~") ? join2(homedir2(), keyPath.slice(1)) : keyPath;
|
|
397
|
+
if (!existsSync2(resolved)) {
|
|
398
|
+
return { status: "error", message: `Key not found: ${resolved}` };
|
|
341
399
|
}
|
|
342
|
-
|
|
343
|
-
|
|
400
|
+
const { stdout, ok } = runArgs("ssh-add", [resolved]);
|
|
401
|
+
if (ok) {
|
|
402
|
+
return { status: "ok", message: `Key loaded: ${resolved}` };
|
|
344
403
|
}
|
|
345
|
-
if (
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
const home = homedir2();
|
|
349
|
-
const keyPaths = sshConfig && sshConfig.identityFiles.length > 0 ? sshConfig.identityFiles.map((p) => p.startsWith("~") ? join2(home, p.slice(1)) : p) : [join2(home, ".ssh", "id_ed25519"), join2(home, ".ssh", "id_rsa"), join2(home, ".ssh", "id_ecdsa")];
|
|
350
|
-
for (const keyPath of keyPaths) {
|
|
351
|
-
try {
|
|
352
|
-
connectConfig.privateKey = readFileSync2(keyPath);
|
|
353
|
-
break;
|
|
354
|
-
} catch {
|
|
355
|
-
}
|
|
404
|
+
if (stdout.includes("passphrase") || stdout.includes("incorrect") || stdout.includes("bad permissions")) {
|
|
405
|
+
if (stdout.includes("UNPROTECTED PRIVATE KEY")) {
|
|
406
|
+
return { status: "error", message: `Key ${resolved} has too-open permissions. Fix: chmod 600 ${resolved}` };
|
|
356
407
|
}
|
|
408
|
+
return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
|
|
357
409
|
}
|
|
358
|
-
return {
|
|
410
|
+
return { status: "error", message: `Failed to load key: ${stdout}` };
|
|
359
411
|
}
|
|
360
|
-
function
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
412
|
+
function configLookup(host) {
|
|
413
|
+
if (!isValidHostname(host)) {
|
|
414
|
+
return { error: `Invalid hostname: "${host}"` };
|
|
415
|
+
}
|
|
416
|
+
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
417
|
+
if (!ok) {
|
|
418
|
+
return { error: `Failed to resolve SSH config for ${host}: ${stdout}` };
|
|
419
|
+
}
|
|
420
|
+
const all = {};
|
|
421
|
+
const identityFiles = [];
|
|
422
|
+
for (const line of stdout.split("\n")) {
|
|
423
|
+
const spaceIdx = line.indexOf(" ");
|
|
424
|
+
if (spaceIdx > 0) {
|
|
425
|
+
const key = line.substring(0, spaceIdx);
|
|
426
|
+
const value = line.substring(spaceIdx + 1);
|
|
427
|
+
if (key === "identityfile") {
|
|
428
|
+
identityFiles.push(value);
|
|
429
|
+
} else {
|
|
430
|
+
all[key] = value;
|
|
373
431
|
}
|
|
374
432
|
}
|
|
375
|
-
const agent = checks[0];
|
|
376
|
-
if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
|
|
377
|
-
if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
|
|
378
|
-
const keys = checks[1];
|
|
379
|
-
if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
|
|
380
|
-
const known = checks[3];
|
|
381
|
-
if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
|
|
382
|
-
if (suggestions.length > 0) {
|
|
383
|
-
parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
|
|
384
|
-
}
|
|
385
|
-
return parts.length > 0 ? parts.join("\n") : "";
|
|
386
|
-
} catch {
|
|
387
|
-
return "";
|
|
388
433
|
}
|
|
434
|
+
return {
|
|
435
|
+
hostname: all.hostname || host,
|
|
436
|
+
user: all.user || "",
|
|
437
|
+
port: all.port || "22",
|
|
438
|
+
identityFile: identityFiles,
|
|
439
|
+
proxyJump: all.proxyjump && all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
440
|
+
proxyCommand: all.proxycommand && all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
441
|
+
all,
|
|
442
|
+
raw: stdout
|
|
443
|
+
};
|
|
389
444
|
}
|
|
390
|
-
function
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
394
|
-
});
|
|
395
|
-
}
|
|
396
|
-
async function connectWithProxy(resolved) {
|
|
397
|
-
if (!resolved.proxyJump) {
|
|
398
|
-
return connectRaw(resolved.connectConfig);
|
|
445
|
+
function fixKnownHosts(host, port = 22) {
|
|
446
|
+
if (!isValidHostname(host)) {
|
|
447
|
+
return { status: "error", message: `Invalid hostname: "${host}"`, actions: [] };
|
|
399
448
|
}
|
|
400
|
-
const
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
423
|
-
function exec(client, command, timeoutMs = 3e4) {
|
|
424
|
-
return new Promise((resolve, reject) => {
|
|
425
|
-
let settled = false;
|
|
426
|
-
const settle = (fn) => {
|
|
427
|
-
if (settled) return;
|
|
428
|
-
settled = true;
|
|
429
|
-
clearTimeout(timer);
|
|
430
|
-
fn();
|
|
431
|
-
};
|
|
432
|
-
const timer = setTimeout(() => {
|
|
433
|
-
settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
|
|
434
|
-
}, timeoutMs);
|
|
435
|
-
client.exec(command, (err, stream) => {
|
|
436
|
-
if (err) {
|
|
437
|
-
settle(() => reject(err));
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
let stdout = "";
|
|
441
|
-
let stderr = "";
|
|
442
|
-
stream.on("close", (code) => {
|
|
443
|
-
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
444
|
-
}).on("data", (data) => {
|
|
445
|
-
stdout += data.toString();
|
|
446
|
-
}).on("error", (err2) => {
|
|
447
|
-
settle(() => reject(err2));
|
|
448
|
-
});
|
|
449
|
-
stream.stderr.on("data", (data) => {
|
|
450
|
-
stderr += data.toString();
|
|
451
|
-
}).on("error", (err2) => {
|
|
452
|
-
settle(() => reject(err2));
|
|
453
|
-
});
|
|
454
|
-
});
|
|
455
|
-
});
|
|
456
|
-
}
|
|
457
|
-
function getSftp(client) {
|
|
458
|
-
return new Promise((resolve, reject) => {
|
|
459
|
-
client.sftp((err, sftp) => {
|
|
460
|
-
if (err) return reject(err);
|
|
461
|
-
resolve(sftp);
|
|
462
|
-
});
|
|
463
|
-
});
|
|
464
|
-
}
|
|
465
|
-
var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
466
|
-
async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
467
|
-
const sftp = await getSftp(client);
|
|
468
|
-
try {
|
|
469
|
-
const stats = await new Promise((resolve, reject) => {
|
|
470
|
-
sftp.stat(remotePath, (err, stats2) => {
|
|
471
|
-
if (err) return reject(err);
|
|
472
|
-
resolve(stats2);
|
|
473
|
-
});
|
|
474
|
-
});
|
|
475
|
-
if (stats.size > maxBytes) {
|
|
476
|
-
throw new Error(
|
|
477
|
-
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
|
|
478
|
-
);
|
|
449
|
+
const actions = [];
|
|
450
|
+
const { ok: removeOk } = runArgs("ssh-keygen", ["-R", host]);
|
|
451
|
+
if (removeOk) {
|
|
452
|
+
actions.push(`Removed old host key for ${host}`);
|
|
453
|
+
}
|
|
454
|
+
if (port !== 22) {
|
|
455
|
+
const { ok } = runArgs("ssh-keygen", ["-R", `[${host}]:${port}`]);
|
|
456
|
+
if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
|
|
457
|
+
}
|
|
458
|
+
const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
|
|
459
|
+
const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
|
|
460
|
+
if (scanOk && scanOut.trim()) {
|
|
461
|
+
try {
|
|
462
|
+
const knownHostsPath = join2(homedir2(), ".ssh", "known_hosts");
|
|
463
|
+
appendFileSync(knownHostsPath, `
|
|
464
|
+
${scanOut.trim()}
|
|
465
|
+
`);
|
|
466
|
+
actions.push(`Added new host key for ${host}`);
|
|
467
|
+
return { status: "ok", message: `Host key refreshed for ${host}`, actions };
|
|
468
|
+
} catch (e) {
|
|
469
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
470
|
+
return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
|
|
479
471
|
}
|
|
480
|
-
return await new Promise((resolve, reject) => {
|
|
481
|
-
sftp.readFile(remotePath, (err, data) => {
|
|
482
|
-
if (err) return reject(err);
|
|
483
|
-
resolve(data.toString("utf8"));
|
|
484
|
-
});
|
|
485
|
-
});
|
|
486
|
-
} finally {
|
|
487
|
-
sftp.end();
|
|
488
472
|
}
|
|
473
|
+
return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
|
|
489
474
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
await new Promise((resolve, reject) => {
|
|
494
|
-
sftp.writeFile(remotePath, content, (err) => {
|
|
495
|
-
if (err) return reject(err);
|
|
496
|
-
resolve();
|
|
497
|
-
});
|
|
498
|
-
});
|
|
499
|
-
} finally {
|
|
500
|
-
sftp.end();
|
|
475
|
+
function checkGitSsh(host = "github.com", user = "git") {
|
|
476
|
+
if (!isValidHostname(host)) {
|
|
477
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
501
478
|
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
});
|
|
512
|
-
} finally {
|
|
513
|
-
sftp.end();
|
|
479
|
+
const { stdout } = runArgs("ssh", ["-T", "-o", "ConnectTimeout=5", "-o", "BatchMode=yes", `${user}@${host}`]);
|
|
480
|
+
const text = stdout;
|
|
481
|
+
if (text.includes("successfully authenticated") || text.includes("Welcome to GitLab") || text.includes("logged in as")) {
|
|
482
|
+
const userMatch = text.match(/Hi (\S+)!/) || text.match(/@(\S+)!/) || text.match(/logged in as (\S+)/);
|
|
483
|
+
return {
|
|
484
|
+
status: "ok",
|
|
485
|
+
message: `Git SSH authentication to ${host} succeeded${userMatch ? ` as ${userMatch[1]}` : ""}`,
|
|
486
|
+
authenticatedAs: userMatch?.[1]
|
|
487
|
+
};
|
|
514
488
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
sftp.fastGet(remotePath, localPath, (err) => {
|
|
521
|
-
if (err) return reject(err);
|
|
522
|
-
resolve();
|
|
523
|
-
});
|
|
524
|
-
});
|
|
525
|
-
} finally {
|
|
526
|
-
sftp.end();
|
|
489
|
+
if (text.includes("Permission denied")) {
|
|
490
|
+
return {
|
|
491
|
+
status: "error",
|
|
492
|
+
message: `Permission denied for ${host}. Either no key is loaded in the agent or your key isn't registered with ${host}. Run ssh_key_list to check, then ssh_key_load if needed.`
|
|
493
|
+
};
|
|
527
494
|
}
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
const sftp = await getSftp(client);
|
|
531
|
-
try {
|
|
532
|
-
return await new Promise((resolve, reject) => {
|
|
533
|
-
sftp.readdir(remotePath, (err, list) => {
|
|
534
|
-
if (err) return reject(err);
|
|
535
|
-
resolve(list.map((item) => item.filename));
|
|
536
|
-
});
|
|
537
|
-
});
|
|
538
|
-
} finally {
|
|
539
|
-
sftp.end();
|
|
495
|
+
if (text.includes("Connection refused")) {
|
|
496
|
+
return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
|
|
540
497
|
}
|
|
498
|
+
if (text.includes("timed out") || text.includes("Connection timed out")) {
|
|
499
|
+
return { status: "error", message: `Connection to ${host} timed out. Check your network or firewall.` };
|
|
500
|
+
}
|
|
501
|
+
if (text.includes("Could not resolve")) {
|
|
502
|
+
return { status: "error", message: `Could not resolve hostname "${host}". Check DNS or spelling.` };
|
|
503
|
+
}
|
|
504
|
+
return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
|
|
541
505
|
}
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
entries = /* @__PURE__ */ new Map();
|
|
546
|
-
idleTtlMs;
|
|
547
|
-
maxPoolSize;
|
|
548
|
-
constructor(options) {
|
|
549
|
-
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
550
|
-
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
506
|
+
function testConnection(host, port = 22) {
|
|
507
|
+
if (!isValidHostname(host)) {
|
|
508
|
+
return { status: "error", message: `Invalid hostname: "${host}"` };
|
|
551
509
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
let evicted = false;
|
|
570
|
-
for (const [k, e] of this.entries) {
|
|
571
|
-
if (e.refCount === 0) {
|
|
572
|
-
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
573
|
-
try {
|
|
574
|
-
e.client.end();
|
|
575
|
-
} catch {
|
|
576
|
-
}
|
|
577
|
-
this.entries.delete(k);
|
|
578
|
-
evicted = true;
|
|
579
|
-
break;
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
if (!evicted) {
|
|
583
|
-
throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
try {
|
|
587
|
-
const client = await connectWithProxy(resolved);
|
|
588
|
-
const entry = { client, key, refCount: 1, idleTimer: null, dead: false };
|
|
589
|
-
const markDead = () => {
|
|
590
|
-
entry.dead = true;
|
|
591
|
-
if (entry.idleTimer) {
|
|
592
|
-
clearTimeout(entry.idleTimer);
|
|
593
|
-
entry.idleTimer = null;
|
|
594
|
-
}
|
|
595
|
-
if (this.entries.get(key) === entry) {
|
|
596
|
-
this.entries.delete(key);
|
|
597
|
-
}
|
|
598
|
-
};
|
|
599
|
-
client.on("close", markDead);
|
|
600
|
-
client.on("end", markDead);
|
|
601
|
-
client.on("error", markDead);
|
|
602
|
-
this.entries.set(key, entry);
|
|
603
|
-
return client;
|
|
604
|
-
} catch (err) {
|
|
605
|
-
const diag = formatDiagnostics(config.host);
|
|
606
|
-
if (diag) {
|
|
607
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
608
|
-
const enhanced = new Error(`${message}
|
|
609
|
-
|
|
610
|
-
SSH Diagnostics:
|
|
611
|
-
${diag}`);
|
|
612
|
-
enhanced.cause = err;
|
|
613
|
-
throw enhanced;
|
|
614
|
-
}
|
|
615
|
-
throw err;
|
|
616
|
-
}
|
|
510
|
+
const start = Date.now();
|
|
511
|
+
const { ok, stdout } = runArgs("ssh", [
|
|
512
|
+
"-o",
|
|
513
|
+
"ConnectTimeout=5",
|
|
514
|
+
"-o",
|
|
515
|
+
"BatchMode=yes",
|
|
516
|
+
"-o",
|
|
517
|
+
"StrictHostKeyChecking=no",
|
|
518
|
+
"-p",
|
|
519
|
+
String(port),
|
|
520
|
+
host,
|
|
521
|
+
"echo",
|
|
522
|
+
"SSH_OK"
|
|
523
|
+
]);
|
|
524
|
+
const elapsed = Date.now() - start;
|
|
525
|
+
if (ok && stdout.includes("SSH_OK")) {
|
|
526
|
+
return { status: "ok", message: `Connected to ${host}:${port} in ${elapsed}ms` };
|
|
617
527
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
entry.idleTimer = setTimeout(() => {
|
|
624
|
-
try {
|
|
625
|
-
entry.client.end();
|
|
626
|
-
} catch {
|
|
627
|
-
}
|
|
628
|
-
this.entries.delete(entry.key);
|
|
629
|
-
}, this.idleTtlMs);
|
|
630
|
-
entry.idleTimer.unref();
|
|
631
|
-
}
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
}
|
|
635
|
-
try {
|
|
636
|
-
client.end();
|
|
637
|
-
} catch {
|
|
638
|
-
}
|
|
528
|
+
if (stdout.includes("Permission denied")) {
|
|
529
|
+
return {
|
|
530
|
+
status: "error",
|
|
531
|
+
message: `Authentication failed to ${host}:${port} (${elapsed}ms). Key not authorized. Check: ssh-add -l, verify correct username, verify key is in remote authorized_keys.`
|
|
532
|
+
};
|
|
639
533
|
}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
}
|
|
645
|
-
this.release(client);
|
|
646
|
-
}
|
|
534
|
+
if (stdout.includes("Connection refused")) {
|
|
535
|
+
return {
|
|
536
|
+
status: "error",
|
|
537
|
+
message: `Connection refused at ${host}:${port}. SSH server not running or port blocked.`
|
|
538
|
+
};
|
|
647
539
|
}
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
if (entry.idleTimer) {
|
|
651
|
-
clearTimeout(entry.idleTimer);
|
|
652
|
-
}
|
|
653
|
-
try {
|
|
654
|
-
entry.client.end();
|
|
655
|
-
} catch {
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
this.entries.clear();
|
|
540
|
+
if (stdout.includes("timed out")) {
|
|
541
|
+
return { status: "error", message: `Connection timed out to ${host}:${port}. Host down or firewall blocking.` };
|
|
659
542
|
}
|
|
660
|
-
|
|
661
|
-
return
|
|
543
|
+
if (stdout.includes("Host key verification failed")) {
|
|
544
|
+
return {
|
|
545
|
+
status: "error",
|
|
546
|
+
message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
|
|
547
|
+
};
|
|
662
548
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
let idle = 0;
|
|
666
|
-
for (const entry of this.entries.values()) {
|
|
667
|
-
if (entry.refCount > 0) active++;
|
|
668
|
-
else idle++;
|
|
669
|
-
}
|
|
670
|
-
return { active, idle };
|
|
549
|
+
if (stdout.includes("Could not resolve")) {
|
|
550
|
+
return { status: "error", message: `Could not resolve "${host}". Check DNS, /etc/hosts, or SSH config.` };
|
|
671
551
|
}
|
|
672
|
-
};
|
|
673
|
-
|
|
674
|
-
// src/server.ts
|
|
675
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
676
|
-
|
|
677
|
-
// src/tools.ts
|
|
678
|
-
import { z } from "zod";
|
|
552
|
+
return { status: "error", message: `Connection failed to ${host}:${port}: ${stdout}` };
|
|
553
|
+
}
|
|
679
554
|
|
|
680
|
-
// src/
|
|
681
|
-
import {
|
|
555
|
+
// src/ssh.ts
|
|
556
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
682
557
|
import { homedir as homedir3 } from "os";
|
|
683
558
|
import { join as join3 } from "path";
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
const { stdout
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
const { stdout: stdout2, ok: ok2 } = runArgs("ssh-add", ["-l"]);
|
|
703
|
-
const noIdentities = stdout2.includes("no identities") || stdout2.includes("The agent has no identities");
|
|
704
|
-
if (ok2 || noIdentities) {
|
|
705
|
-
const keys = ok2 && !noIdentities ? stdout2.split("\n").filter(Boolean) : [];
|
|
706
|
-
return {
|
|
707
|
-
running: true,
|
|
708
|
-
reachable: true,
|
|
709
|
-
socket: "\\\\.\\pipe\\openssh-ssh-agent",
|
|
710
|
-
keys,
|
|
711
|
-
started: false,
|
|
712
|
-
message: keys.length > 0 ? `Windows OpenSSH agent running with ${keys.length} key(s) loaded` : "Windows OpenSSH agent running but no keys loaded. Use ssh_key_load to add one."
|
|
713
|
-
};
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
const { stdout, ok } = runArgs("ssh-agent", ["-s"]);
|
|
717
|
-
if (ok) {
|
|
718
|
-
const sockMatch = stdout.match(/SSH_AUTH_SOCK=([^;]+)/);
|
|
719
|
-
const pidMatch = stdout.match(/SSH_AGENT_PID=([^;]+)/);
|
|
720
|
-
if (sockMatch) {
|
|
721
|
-
process.env.SSH_AUTH_SOCK = sockMatch[1];
|
|
722
|
-
if (pidMatch) process.env.SSH_AGENT_PID = pidMatch[1];
|
|
723
|
-
return {
|
|
724
|
-
running: true,
|
|
725
|
-
reachable: true,
|
|
726
|
-
socket: sockMatch[1],
|
|
727
|
-
keys: [],
|
|
728
|
-
started: true,
|
|
729
|
-
env: { SSH_AUTH_SOCK: sockMatch[1], SSH_AGENT_PID: pidMatch?.[1] },
|
|
730
|
-
message: "Started new ssh-agent. No keys loaded yet \u2014 use ssh_key_load to add one."
|
|
731
|
-
};
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
return {
|
|
735
|
-
running: false,
|
|
736
|
-
reachable: false,
|
|
737
|
-
keys: [],
|
|
738
|
-
started: false,
|
|
739
|
-
message: process.platform === "win32" ? "Windows OpenSSH agent not running. Start it: Get-Service ssh-agent | Set-Service -StartupType Automatic; Start-Service ssh-agent" : 'Could not start ssh-agent. Run manually: eval "$(ssh-agent -s)"'
|
|
740
|
-
};
|
|
741
|
-
}
|
|
742
|
-
function detectKeyType(filePath, fileName) {
|
|
743
|
-
const pubPath = `${filePath}.pub`;
|
|
744
|
-
if (existsSync2(pubPath)) {
|
|
745
|
-
try {
|
|
746
|
-
const pub = readFileSync3(pubPath, "utf8");
|
|
747
|
-
if (pub.includes("ssh-ed25519")) return "ed25519";
|
|
748
|
-
if (pub.includes("ssh-rsa")) return "rsa";
|
|
749
|
-
if (pub.includes("ecdsa")) return "ecdsa";
|
|
750
|
-
if (pub.includes("ssh-dss")) return "dsa";
|
|
751
|
-
} catch {
|
|
559
|
+
import { Client } from "ssh2";
|
|
560
|
+
function resolveFromSshConfig(host) {
|
|
561
|
+
try {
|
|
562
|
+
const { stdout, ok } = runArgs("ssh", ["-G", host]);
|
|
563
|
+
if (!ok) return null;
|
|
564
|
+
const config = {};
|
|
565
|
+
const identityFiles = [];
|
|
566
|
+
for (const line of stdout.split("\n")) {
|
|
567
|
+
const spaceIdx = line.indexOf(" ");
|
|
568
|
+
if (spaceIdx > 0) {
|
|
569
|
+
const key = line.substring(0, spaceIdx);
|
|
570
|
+
const value = line.substring(spaceIdx + 1);
|
|
571
|
+
if (key === "identityfile") {
|
|
572
|
+
identityFiles.push(value);
|
|
573
|
+
} else {
|
|
574
|
+
config[key] = value;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
752
577
|
}
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
if (content.includes("RSA PRIVATE KEY")) return "rsa";
|
|
761
|
-
if (content.includes("EC PRIVATE KEY")) return "ecdsa";
|
|
762
|
-
if (content.includes("DSA PRIVATE KEY")) return "dsa";
|
|
578
|
+
return {
|
|
579
|
+
hostname: config.hostname || host,
|
|
580
|
+
user: config.user || "",
|
|
581
|
+
port: config.port || "22",
|
|
582
|
+
identityFiles,
|
|
583
|
+
proxyJump: config.proxyjump && config.proxyjump !== "none" ? config.proxyjump : void 0
|
|
584
|
+
};
|
|
763
585
|
} catch {
|
|
586
|
+
return null;
|
|
764
587
|
}
|
|
765
|
-
return "unknown";
|
|
766
588
|
}
|
|
767
|
-
function
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
const loadedFingerprints = /* @__PURE__ */ new Set();
|
|
771
|
-
const { stdout: agentOut, ok: agentOk } = runArgs("ssh-add", ["-l"]);
|
|
772
|
-
if (agentOk && !agentOut.includes("no identities")) {
|
|
773
|
-
for (const line of agentOut.split("\n").filter(Boolean)) {
|
|
774
|
-
const match = line.match(/(\S+:\S+)/);
|
|
775
|
-
if (match) loadedFingerprints.add(match[1]);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
const skipFiles = /* @__PURE__ */ new Set(["known_hosts", "known_hosts.old", "config", "authorized_keys", "environment"]);
|
|
589
|
+
function readKnownHostsKeys(host, port) {
|
|
590
|
+
if (!isValidHostname(host)) return [];
|
|
591
|
+
const targets = port && port !== 22 ? [`[${host}]:${port}`, host] : [host];
|
|
779
592
|
const keys = [];
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
if (!stat.isFile()) continue;
|
|
792
|
-
const content = readFileSync3(filePath, "utf8");
|
|
793
|
-
if (!content.includes("PRIVATE KEY")) continue;
|
|
794
|
-
const type = detectKeyType(filePath, file);
|
|
795
|
-
let fingerprint;
|
|
796
|
-
const { stdout: fpOut, ok: fpOk } = runArgs("ssh-keygen", ["-lf", filePath]);
|
|
797
|
-
if (fpOk) {
|
|
798
|
-
const match = fpOut.match(/(\S+:\S+)/);
|
|
799
|
-
fingerprint = match?.[1];
|
|
593
|
+
for (const target of targets) {
|
|
594
|
+
const { stdout, ok } = runArgs("ssh-keygen", ["-F", target]);
|
|
595
|
+
if (!ok || !stdout.trim()) continue;
|
|
596
|
+
for (const line of stdout.split("\n")) {
|
|
597
|
+
const trimmed = line.trim();
|
|
598
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
599
|
+
const parts = trimmed.split(/\s+/);
|
|
600
|
+
if (parts.length < 3) continue;
|
|
601
|
+
try {
|
|
602
|
+
keys.push(Buffer.from(parts[2], "base64"));
|
|
603
|
+
} catch {
|
|
800
604
|
}
|
|
801
|
-
const loadedInAgent = fingerprint ? loadedFingerprints.has(fingerprint) : false;
|
|
802
|
-
keys.push({ name: file, path: filePath, type, fingerprint, loadedInAgent });
|
|
803
|
-
} catch {
|
|
804
605
|
}
|
|
805
606
|
}
|
|
806
607
|
return keys;
|
|
807
608
|
}
|
|
808
|
-
function
|
|
809
|
-
const
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
return
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
609
|
+
function buildHostVerifier(hosts, port) {
|
|
610
|
+
const strict = process.env.SSH_MCP_STRICT_HOST_KEY === "1";
|
|
611
|
+
return (key) => {
|
|
612
|
+
const known = hosts.flatMap((h) => readKnownHostsKeys(h, port));
|
|
613
|
+
if (known.length === 0) {
|
|
614
|
+
return !strict;
|
|
615
|
+
}
|
|
616
|
+
return known.some((k) => k.equals(key));
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
function resolveConfig(config) {
|
|
620
|
+
const sshConfig = resolveFromSshConfig(config.host);
|
|
621
|
+
const port = config.port || (sshConfig ? Number.parseInt(sshConfig.port, 10) : 22);
|
|
622
|
+
const verifierHosts = [config.host];
|
|
623
|
+
if (sshConfig?.hostname && sshConfig.hostname !== config.host) {
|
|
624
|
+
verifierHosts.push(sshConfig.hostname);
|
|
820
625
|
}
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
626
|
+
const connectConfig = {
|
|
627
|
+
host: sshConfig?.hostname || config.host,
|
|
628
|
+
port,
|
|
629
|
+
username: config.username || sshConfig?.user || process.env.USER || process.env.USERNAME || "root",
|
|
630
|
+
keepaliveInterval: 15e3,
|
|
631
|
+
keepaliveCountMax: 3,
|
|
632
|
+
hostVerifier: buildHostVerifier(verifierHosts, port)
|
|
633
|
+
};
|
|
634
|
+
if (config.privateKeyPath) {
|
|
635
|
+
connectConfig.privateKey = readFileSync3(config.privateKeyPath);
|
|
636
|
+
} else if (config.password) {
|
|
637
|
+
connectConfig.password = config.password;
|
|
638
|
+
} else {
|
|
639
|
+
const agentSock = config.agent || process.env.SSH_AUTH_SOCK || (process.platform === "win32" ? "\\\\.\\pipe\\openssh-ssh-agent" : void 0);
|
|
640
|
+
if (agentSock) {
|
|
641
|
+
connectConfig.agent = agentSock;
|
|
642
|
+
} else {
|
|
643
|
+
const home = homedir3();
|
|
644
|
+
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")];
|
|
645
|
+
for (const keyPath of keyPaths) {
|
|
646
|
+
try {
|
|
647
|
+
connectConfig.privateKey = readFileSync3(keyPath);
|
|
648
|
+
break;
|
|
649
|
+
} catch {
|
|
650
|
+
}
|
|
651
|
+
}
|
|
824
652
|
}
|
|
825
|
-
return { status: "error", message: `Key ${resolved} requires a passphrase. Add it manually: ssh-add ${resolved}` };
|
|
826
653
|
}
|
|
827
|
-
return {
|
|
654
|
+
return { connectConfig, proxyJump: sshConfig?.proxyJump };
|
|
828
655
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
656
|
+
var DIAG_CACHE_TTL_MS = 2e3;
|
|
657
|
+
var diagAgentCache = null;
|
|
658
|
+
var diagKeysCache = null;
|
|
659
|
+
function cachedAgentCheck() {
|
|
660
|
+
const now = Date.now();
|
|
661
|
+
if (diagAgentCache && now - diagAgentCache.at < DIAG_CACHE_TTL_MS) {
|
|
662
|
+
return diagAgentCache.result;
|
|
663
|
+
}
|
|
664
|
+
const result = checkSshAgent();
|
|
665
|
+
diagAgentCache = { at: now, result };
|
|
666
|
+
return result;
|
|
667
|
+
}
|
|
668
|
+
function cachedKeysCheck() {
|
|
669
|
+
const now = Date.now();
|
|
670
|
+
if (diagKeysCache && now - diagKeysCache.at < DIAG_CACHE_TTL_MS) {
|
|
671
|
+
return diagKeysCache.result;
|
|
672
|
+
}
|
|
673
|
+
const result = checkSshKeys();
|
|
674
|
+
diagKeysCache = { at: now, result };
|
|
675
|
+
return result;
|
|
676
|
+
}
|
|
677
|
+
function formatDiagnostics(host) {
|
|
678
|
+
try {
|
|
679
|
+
const checks = [
|
|
680
|
+
{ name: "SSH Agent", ...cachedAgentCheck() },
|
|
681
|
+
{ name: "SSH Keys", ...cachedKeysCheck() },
|
|
682
|
+
{ name: "SSH Config", ...checkSshConfig(host) },
|
|
683
|
+
{ name: "Known Hosts", ...checkKnownHosts(host) }
|
|
684
|
+
];
|
|
685
|
+
const parts = [];
|
|
686
|
+
const suggestions = [];
|
|
687
|
+
for (const check of checks) {
|
|
688
|
+
if (check.status !== "ok") {
|
|
689
|
+
parts.push(`[${check.status.toUpperCase()}] ${check.name}: ${check.message}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
const agent = checks[0];
|
|
693
|
+
if (agent.status === "error") suggestions.push('Start ssh-agent: eval "$(ssh-agent -s)"');
|
|
694
|
+
if (agent.status === "warning") suggestions.push("Load a key: ssh-add ~/.ssh/id_ed25519");
|
|
695
|
+
const keys = checks[1];
|
|
696
|
+
if (keys.status === "error") suggestions.push('Generate a key: ssh-keygen -t ed25519 -C "your@email.com"');
|
|
697
|
+
const known = checks[3];
|
|
698
|
+
if (known.status === "warning") suggestions.push(`Add host key: ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts`);
|
|
699
|
+
if (suggestions.length > 0) {
|
|
700
|
+
parts.push(`Suggested fixes: ${suggestions.join(" | ")}`);
|
|
701
|
+
}
|
|
702
|
+
return parts.length > 0 ? parts.join("\n") : "";
|
|
703
|
+
} catch {
|
|
704
|
+
return "";
|
|
832
705
|
}
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
706
|
+
}
|
|
707
|
+
function connectRaw(connectConfig) {
|
|
708
|
+
return new Promise((resolve, reject) => {
|
|
709
|
+
const client = new Client();
|
|
710
|
+
client.on("ready", () => resolve(client)).on("error", (err) => reject(err)).connect(connectConfig);
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
async function connectWithProxy(resolved) {
|
|
714
|
+
if (!resolved.proxyJump) {
|
|
715
|
+
return connectRaw(resolved.connectConfig);
|
|
836
716
|
}
|
|
837
|
-
const
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
}
|
|
847
|
-
|
|
717
|
+
const jumpResolved = resolveConfig({ host: resolved.proxyJump });
|
|
718
|
+
const jumpClient = await connectWithProxy(jumpResolved);
|
|
719
|
+
const targetHost = resolved.connectConfig.host;
|
|
720
|
+
const targetPort = resolved.connectConfig.port;
|
|
721
|
+
const stream = await new Promise((resolve, reject) => {
|
|
722
|
+
jumpClient.forwardOut("127.0.0.1", 0, targetHost, targetPort, (err, stream2) => {
|
|
723
|
+
if (err) {
|
|
724
|
+
jumpClient.end();
|
|
725
|
+
return reject(err);
|
|
726
|
+
}
|
|
727
|
+
resolve(stream2);
|
|
728
|
+
});
|
|
729
|
+
});
|
|
730
|
+
return new Promise((resolve, reject) => {
|
|
731
|
+
const client = new Client();
|
|
732
|
+
client.on("ready", () => resolve(client)).on("error", (err) => {
|
|
733
|
+
jumpClient.end();
|
|
734
|
+
reject(err);
|
|
735
|
+
}).on("close", () => {
|
|
736
|
+
jumpClient.end();
|
|
737
|
+
}).connect({ ...resolved.connectConfig, sock: stream });
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
var DEFAULT_MAX_EXEC_BYTES = 10 * 1024 * 1024;
|
|
741
|
+
function exec(client, command, timeoutMs = 3e4, maxBytes = DEFAULT_MAX_EXEC_BYTES) {
|
|
742
|
+
return new Promise((resolve, reject) => {
|
|
743
|
+
let settled = false;
|
|
744
|
+
let activeStream = null;
|
|
745
|
+
const settle = (fn) => {
|
|
746
|
+
if (settled) return;
|
|
747
|
+
settled = true;
|
|
748
|
+
clearTimeout(timer);
|
|
749
|
+
fn();
|
|
750
|
+
};
|
|
751
|
+
const timer = setTimeout(() => {
|
|
752
|
+
if (activeStream) {
|
|
753
|
+
try {
|
|
754
|
+
activeStream.signal("TERM");
|
|
755
|
+
} catch {
|
|
756
|
+
}
|
|
757
|
+
try {
|
|
758
|
+
activeStream.close();
|
|
759
|
+
} catch {
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
settle(() => reject(new Error(`Command timed out after ${timeoutMs}ms`)));
|
|
763
|
+
}, timeoutMs);
|
|
764
|
+
client.exec(command, (err, stream) => {
|
|
765
|
+
if (err) {
|
|
766
|
+
settle(() => reject(err));
|
|
767
|
+
return;
|
|
848
768
|
}
|
|
769
|
+
activeStream = stream;
|
|
770
|
+
const stdoutChunks = [];
|
|
771
|
+
const stderrChunks = [];
|
|
772
|
+
let stdoutBytes = 0;
|
|
773
|
+
let stderrBytes = 0;
|
|
774
|
+
let stdoutTruncated = false;
|
|
775
|
+
let stderrTruncated = false;
|
|
776
|
+
const appendStdout = (data) => {
|
|
777
|
+
if (stdoutTruncated) return;
|
|
778
|
+
const remaining = maxBytes - stdoutBytes;
|
|
779
|
+
if (data.length <= remaining) {
|
|
780
|
+
stdoutChunks.push(data);
|
|
781
|
+
stdoutBytes += data.length;
|
|
782
|
+
} else {
|
|
783
|
+
if (remaining > 0) {
|
|
784
|
+
stdoutChunks.push(data.subarray(0, remaining));
|
|
785
|
+
stdoutBytes += remaining;
|
|
786
|
+
}
|
|
787
|
+
stdoutTruncated = true;
|
|
788
|
+
}
|
|
789
|
+
};
|
|
790
|
+
const appendStderr = (data) => {
|
|
791
|
+
if (stderrTruncated) return;
|
|
792
|
+
const remaining = maxBytes - stderrBytes;
|
|
793
|
+
if (data.length <= remaining) {
|
|
794
|
+
stderrChunks.push(data);
|
|
795
|
+
stderrBytes += data.length;
|
|
796
|
+
} else {
|
|
797
|
+
if (remaining > 0) {
|
|
798
|
+
stderrChunks.push(data.subarray(0, remaining));
|
|
799
|
+
stderrBytes += remaining;
|
|
800
|
+
}
|
|
801
|
+
stderrTruncated = true;
|
|
802
|
+
}
|
|
803
|
+
};
|
|
804
|
+
stream.on("close", (code) => {
|
|
805
|
+
let stdout = Buffer.concat(stdoutChunks).toString("utf8");
|
|
806
|
+
let stderr = Buffer.concat(stderrChunks).toString("utf8");
|
|
807
|
+
if (stdoutTruncated) stdout += `
|
|
808
|
+
[output truncated at ${maxBytes} bytes]`;
|
|
809
|
+
if (stderrTruncated) stderr += `
|
|
810
|
+
[stderr truncated at ${maxBytes} bytes]`;
|
|
811
|
+
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
|
|
812
|
+
}).on("data", appendStdout).on("error", (err2) => {
|
|
813
|
+
settle(() => reject(err2));
|
|
814
|
+
});
|
|
815
|
+
stream.stderr.on("data", appendStderr).on("error", (err2) => {
|
|
816
|
+
settle(() => reject(err2));
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
function getSftp(client) {
|
|
822
|
+
return new Promise((resolve, reject) => {
|
|
823
|
+
client.sftp((err, sftp) => {
|
|
824
|
+
if (err) return reject(err);
|
|
825
|
+
resolve(sftp);
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
var DEFAULT_MAX_READ_BYTES = 10 * 1024 * 1024;
|
|
830
|
+
async function readFile(client, remotePath, maxBytes = DEFAULT_MAX_READ_BYTES) {
|
|
831
|
+
const sftp = await getSftp(client);
|
|
832
|
+
try {
|
|
833
|
+
const stats = await new Promise((resolve, reject) => {
|
|
834
|
+
sftp.stat(remotePath, (err, stats2) => {
|
|
835
|
+
if (err) return reject(err);
|
|
836
|
+
resolve(stats2);
|
|
837
|
+
});
|
|
838
|
+
});
|
|
839
|
+
if (stats.size > maxBytes) {
|
|
840
|
+
throw new Error(
|
|
841
|
+
`File is ${(stats.size / 1024 / 1024).toFixed(1)} MB, exceeds ${(maxBytes / 1024 / 1024).toFixed(0)} MB limit. Use ssh_exec with head/tail to read a portion.`
|
|
842
|
+
);
|
|
849
843
|
}
|
|
844
|
+
return await new Promise((resolve, reject) => {
|
|
845
|
+
sftp.readFile(remotePath, (err, data) => {
|
|
846
|
+
if (err) return reject(err);
|
|
847
|
+
resolve(data.toString("utf8"));
|
|
848
|
+
});
|
|
849
|
+
});
|
|
850
|
+
} finally {
|
|
851
|
+
sftp.end();
|
|
850
852
|
}
|
|
851
|
-
return {
|
|
852
|
-
hostname: all.hostname || host,
|
|
853
|
-
user: all.user || "",
|
|
854
|
-
port: all.port || "22",
|
|
855
|
-
identityFile: identityFiles,
|
|
856
|
-
proxyJump: all.proxyjump !== "none" ? all.proxyjump : void 0,
|
|
857
|
-
proxyCommand: all.proxycommand !== "none" ? all.proxycommand : void 0,
|
|
858
|
-
all,
|
|
859
|
-
raw: stdout
|
|
860
|
-
};
|
|
861
853
|
}
|
|
862
|
-
function
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
if (ok) actions.push(`Removed old host key for [${host}]:${port}`);
|
|
874
|
-
}
|
|
875
|
-
const scanArgs = port !== 22 ? ["-H", "-p", String(port), host] : ["-H", host];
|
|
876
|
-
const { stdout: scanOut, ok: scanOk } = runArgs("ssh-keyscan", scanArgs);
|
|
877
|
-
if (scanOk && scanOut.trim()) {
|
|
878
|
-
try {
|
|
879
|
-
const knownHostsPath = join3(homedir3(), ".ssh", "known_hosts");
|
|
880
|
-
appendFileSync(knownHostsPath, `
|
|
881
|
-
${scanOut.trim()}
|
|
882
|
-
`);
|
|
883
|
-
actions.push(`Added new host key for ${host}`);
|
|
884
|
-
return { status: "ok", message: `Host key refreshed for ${host}`, actions };
|
|
885
|
-
} catch (e) {
|
|
886
|
-
const msg = e instanceof Error ? e.message : String(e);
|
|
887
|
-
return { status: "error", message: `Scanned key but failed to write known_hosts: ${msg}`, actions };
|
|
888
|
-
}
|
|
854
|
+
async function writeFile(client, remotePath, content) {
|
|
855
|
+
const sftp = await getSftp(client);
|
|
856
|
+
try {
|
|
857
|
+
await new Promise((resolve, reject) => {
|
|
858
|
+
sftp.writeFile(remotePath, content, (err) => {
|
|
859
|
+
if (err) return reject(err);
|
|
860
|
+
resolve();
|
|
861
|
+
});
|
|
862
|
+
});
|
|
863
|
+
} finally {
|
|
864
|
+
sftp.end();
|
|
889
865
|
}
|
|
890
|
-
return { status: "error", message: `Could not scan host key for ${host}. Host may be unreachable.`, actions };
|
|
891
866
|
}
|
|
892
|
-
function
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
authenticatedAs: userMatch?.[1]
|
|
904
|
-
};
|
|
905
|
-
}
|
|
906
|
-
if (text.includes("Permission denied")) {
|
|
907
|
-
return {
|
|
908
|
-
status: "error",
|
|
909
|
-
message: `Permission denied for ${host}. Either no key is loaded in the agent or your key isn't registered with ${host}. Run ssh_key_list to check, then ssh_key_load if needed.`
|
|
910
|
-
};
|
|
911
|
-
}
|
|
912
|
-
if (text.includes("Connection refused")) {
|
|
913
|
-
return { status: "error", message: `Connection refused by ${host}. SSH may not be available on this host.` };
|
|
867
|
+
async function uploadFile(client, localPath, remotePath) {
|
|
868
|
+
const sftp = await getSftp(client);
|
|
869
|
+
try {
|
|
870
|
+
await new Promise((resolve, reject) => {
|
|
871
|
+
sftp.fastPut(localPath, remotePath, (err) => {
|
|
872
|
+
if (err) return reject(err);
|
|
873
|
+
resolve();
|
|
874
|
+
});
|
|
875
|
+
});
|
|
876
|
+
} finally {
|
|
877
|
+
sftp.end();
|
|
914
878
|
}
|
|
915
|
-
|
|
916
|
-
|
|
879
|
+
}
|
|
880
|
+
async function downloadFile(client, remotePath, localPath) {
|
|
881
|
+
const sftp = await getSftp(client);
|
|
882
|
+
try {
|
|
883
|
+
await new Promise((resolve, reject) => {
|
|
884
|
+
sftp.fastGet(remotePath, localPath, (err) => {
|
|
885
|
+
if (err) return reject(err);
|
|
886
|
+
resolve();
|
|
887
|
+
});
|
|
888
|
+
});
|
|
889
|
+
} finally {
|
|
890
|
+
sftp.end();
|
|
917
891
|
}
|
|
918
|
-
|
|
919
|
-
|
|
892
|
+
}
|
|
893
|
+
async function listDir(client, remotePath) {
|
|
894
|
+
const sftp = await getSftp(client);
|
|
895
|
+
try {
|
|
896
|
+
return await new Promise((resolve, reject) => {
|
|
897
|
+
sftp.readdir(remotePath, (err, list) => {
|
|
898
|
+
if (err) return reject(err);
|
|
899
|
+
resolve(list.map((item) => item.filename));
|
|
900
|
+
});
|
|
901
|
+
});
|
|
902
|
+
} finally {
|
|
903
|
+
sftp.end();
|
|
920
904
|
}
|
|
921
|
-
return { status: "error", message: `Git SSH check for ${host}: ${text || "no response (agent may not be running)"}` };
|
|
922
905
|
}
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
906
|
+
|
|
907
|
+
// src/pool.ts
|
|
908
|
+
var ConnectionPool = class {
|
|
909
|
+
entries = /* @__PURE__ */ new Map();
|
|
910
|
+
// Coalesces concurrent connect attempts for the same key so we don't open N
|
|
911
|
+
// duplicate TCP connections when N tool calls fire simultaneously.
|
|
912
|
+
pending = /* @__PURE__ */ new Map();
|
|
913
|
+
idleTtlMs;
|
|
914
|
+
maxPoolSize;
|
|
915
|
+
// Total number of successful connects ever made by this pool. Useful for
|
|
916
|
+
// introspection and for tests that want to prove connection reuse.
|
|
917
|
+
_connectCount = 0;
|
|
918
|
+
constructor(options) {
|
|
919
|
+
this.idleTtlMs = options?.idleTtlMs ?? 6e4;
|
|
920
|
+
this.maxPoolSize = options?.maxPoolSize ?? 100;
|
|
926
921
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
922
|
+
async acquire(config) {
|
|
923
|
+
const resolved = resolveConfig(config);
|
|
924
|
+
const cc = resolved.connectConfig;
|
|
925
|
+
const key = `${cc.username}@${cc.host}:${cc.port}`;
|
|
926
|
+
const MAX_ACQUIRE_ATTEMPTS = 3;
|
|
927
|
+
let lastErr;
|
|
928
|
+
for (let attempt = 0; attempt < MAX_ACQUIRE_ATTEMPTS; attempt++) {
|
|
929
|
+
const existing = this.entries.get(key);
|
|
930
|
+
if (existing && !existing.dead) {
|
|
931
|
+
existing.refCount++;
|
|
932
|
+
if (existing.idleTimer) {
|
|
933
|
+
clearTimeout(existing.idleTimer);
|
|
934
|
+
existing.idleTimer = null;
|
|
935
|
+
}
|
|
936
|
+
return existing.client;
|
|
937
|
+
}
|
|
938
|
+
if (existing?.dead) {
|
|
939
|
+
this.entries.delete(key);
|
|
940
|
+
}
|
|
941
|
+
let pending = this.pending.get(key);
|
|
942
|
+
if (!pending) {
|
|
943
|
+
if (this.entries.size >= this.maxPoolSize) {
|
|
944
|
+
let evicted = false;
|
|
945
|
+
for (const [k, e] of this.entries) {
|
|
946
|
+
if (e.refCount === 0) {
|
|
947
|
+
if (e.idleTimer) clearTimeout(e.idleTimer);
|
|
948
|
+
try {
|
|
949
|
+
e.client.end();
|
|
950
|
+
} catch {
|
|
951
|
+
}
|
|
952
|
+
this.entries.delete(k);
|
|
953
|
+
evicted = true;
|
|
954
|
+
break;
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
if (!evicted) {
|
|
958
|
+
throw new Error(`Connection pool is full (${this.maxPoolSize} active connections)`);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
pending = (async () => {
|
|
962
|
+
try {
|
|
963
|
+
const client2 = await connectWithProxy(resolved);
|
|
964
|
+
this._connectCount++;
|
|
965
|
+
const entry2 = { client: client2, key, refCount: 0, idleTimer: null, dead: false };
|
|
966
|
+
const markDead = () => {
|
|
967
|
+
entry2.dead = true;
|
|
968
|
+
if (entry2.idleTimer) {
|
|
969
|
+
clearTimeout(entry2.idleTimer);
|
|
970
|
+
entry2.idleTimer = null;
|
|
971
|
+
}
|
|
972
|
+
if (this.entries.get(key) === entry2) {
|
|
973
|
+
this.entries.delete(key);
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
client2.on("close", markDead);
|
|
977
|
+
client2.on("end", markDead);
|
|
978
|
+
client2.on("error", markDead);
|
|
979
|
+
this.entries.set(key, entry2);
|
|
980
|
+
return client2;
|
|
981
|
+
} finally {
|
|
982
|
+
this.pending.delete(key);
|
|
983
|
+
}
|
|
984
|
+
})();
|
|
985
|
+
this.pending.set(key, pending);
|
|
986
|
+
}
|
|
987
|
+
let client;
|
|
988
|
+
try {
|
|
989
|
+
client = await pending;
|
|
990
|
+
} catch (err) {
|
|
991
|
+
const diag = formatDiagnostics(config.host);
|
|
992
|
+
if (diag) {
|
|
993
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
994
|
+
const enhanced = new Error(`${message}
|
|
995
|
+
|
|
996
|
+
SSH Diagnostics:
|
|
997
|
+
${diag}`);
|
|
998
|
+
enhanced.cause = err;
|
|
999
|
+
throw enhanced;
|
|
1000
|
+
}
|
|
1001
|
+
throw err;
|
|
1002
|
+
}
|
|
1003
|
+
const entry = this.entries.get(key);
|
|
1004
|
+
if (!entry || entry.dead || entry.client !== client) {
|
|
1005
|
+
lastErr = new Error("connection died before acquire could take a ref");
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
entry.refCount++;
|
|
1009
|
+
if (entry.idleTimer) {
|
|
1010
|
+
clearTimeout(entry.idleTimer);
|
|
1011
|
+
entry.idleTimer = null;
|
|
1012
|
+
}
|
|
1013
|
+
return client;
|
|
1014
|
+
}
|
|
1015
|
+
throw new Error(
|
|
1016
|
+
`Failed to acquire SSH connection for ${key} after ${MAX_ACQUIRE_ATTEMPTS} attempts: ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`
|
|
1017
|
+
);
|
|
944
1018
|
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1019
|
+
release(client) {
|
|
1020
|
+
for (const entry of this.entries.values()) {
|
|
1021
|
+
if (entry.client === client) {
|
|
1022
|
+
entry.refCount = Math.max(0, entry.refCount - 1);
|
|
1023
|
+
if (entry.refCount === 0 && !entry.dead) {
|
|
1024
|
+
entry.idleTimer = setTimeout(() => {
|
|
1025
|
+
try {
|
|
1026
|
+
entry.client.end();
|
|
1027
|
+
} catch {
|
|
1028
|
+
}
|
|
1029
|
+
this.entries.delete(entry.key);
|
|
1030
|
+
}, this.idleTtlMs);
|
|
1031
|
+
entry.idleTimer.unref();
|
|
1032
|
+
}
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
try {
|
|
1037
|
+
client.end();
|
|
1038
|
+
} catch {
|
|
1039
|
+
}
|
|
950
1040
|
}
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
}
|
|
1041
|
+
async withConnection(config, fn) {
|
|
1042
|
+
const client = await this.acquire(config);
|
|
1043
|
+
try {
|
|
1044
|
+
return await fn(client);
|
|
1045
|
+
} finally {
|
|
1046
|
+
this.release(client);
|
|
1047
|
+
}
|
|
956
1048
|
}
|
|
957
|
-
|
|
958
|
-
|
|
1049
|
+
drain() {
|
|
1050
|
+
for (const entry of this.entries.values()) {
|
|
1051
|
+
if (entry.idleTimer) {
|
|
1052
|
+
clearTimeout(entry.idleTimer);
|
|
1053
|
+
}
|
|
1054
|
+
try {
|
|
1055
|
+
entry.client.end();
|
|
1056
|
+
} catch {
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
this.entries.clear();
|
|
959
1060
|
}
|
|
960
|
-
|
|
961
|
-
return
|
|
962
|
-
status: "error",
|
|
963
|
-
message: `Host key mismatch for ${host}. Instance was likely recreated. Fix with ssh_known_hosts_fix.`
|
|
964
|
-
};
|
|
1061
|
+
get size() {
|
|
1062
|
+
return this.entries.size;
|
|
965
1063
|
}
|
|
966
|
-
|
|
967
|
-
|
|
1064
|
+
get stats() {
|
|
1065
|
+
let active = 0;
|
|
1066
|
+
let idle = 0;
|
|
1067
|
+
for (const entry of this.entries.values()) {
|
|
1068
|
+
if (entry.refCount > 0) active++;
|
|
1069
|
+
else idle++;
|
|
1070
|
+
}
|
|
1071
|
+
return { active, idle };
|
|
968
1072
|
}
|
|
969
|
-
|
|
970
|
-
|
|
1073
|
+
/** Total number of successful SSH connects made by this pool since construction. */
|
|
1074
|
+
get connectCount() {
|
|
1075
|
+
return this._connectCount;
|
|
1076
|
+
}
|
|
1077
|
+
};
|
|
1078
|
+
|
|
1079
|
+
// src/server.ts
|
|
1080
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
1081
|
+
import { dirname, join as join4 } from "path";
|
|
1082
|
+
import { fileURLToPath } from "url";
|
|
1083
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
1084
|
+
|
|
1085
|
+
// src/tools.ts
|
|
1086
|
+
import { z } from "zod";
|
|
971
1087
|
|
|
972
1088
|
// src/ops.ts
|
|
973
1089
|
function shellQuote(s) {
|
|
@@ -1007,39 +1123,43 @@ async function find(client, options, timeoutMs = 3e4) {
|
|
|
1007
1123
|
`Invalid maxsize format: "${options.maxsize}". Expected: digits followed by optional k/M/G/T/P (e.g. "10M", "500k")`
|
|
1008
1124
|
);
|
|
1009
1125
|
}
|
|
1010
|
-
const args = [shellQuote(options.path)];
|
|
1126
|
+
const args = ["--", shellQuote(options.path)];
|
|
1011
1127
|
if (options.maxdepth !== void 0) args.push("-maxdepth", String(options.maxdepth));
|
|
1012
1128
|
if (options.type) args.push("-type", options.type);
|
|
1013
1129
|
if (options.name) args.push("-name", shellQuote(options.name));
|
|
1014
1130
|
if (options.minsize) args.push("-size", `+${options.minsize}`);
|
|
1015
1131
|
if (options.maxsize) args.push("-size", `-${options.maxsize}`);
|
|
1016
1132
|
if (options.newer) args.push("-newer", shellQuote(options.newer));
|
|
1017
|
-
const command = `find ${args.join(" ")}
|
|
1133
|
+
const command = `find ${args.join(" ")}`;
|
|
1018
1134
|
const result = await exec(client, command, timeoutMs);
|
|
1135
|
+
if (!result.stdout.trim() && result.stderr.trim()) {
|
|
1136
|
+
throw new Error(result.stderr.trim());
|
|
1137
|
+
}
|
|
1019
1138
|
return result.stdout.split("\n").filter(Boolean);
|
|
1020
1139
|
}
|
|
1021
1140
|
async function tail(client, path, lines = 100, grep, timeoutMs = 3e4) {
|
|
1022
|
-
let command = `tail -n ${lines} ${shellQuote(path)}`;
|
|
1141
|
+
let command = `tail -n ${lines} -- ${shellQuote(path)}`;
|
|
1023
1142
|
if (grep) {
|
|
1024
|
-
command += ` | grep -i ${shellQuote(grep)}`;
|
|
1143
|
+
command += ` | grep -i -e ${shellQuote(grep)}`;
|
|
1025
1144
|
}
|
|
1026
1145
|
const result = await exec(client, command, timeoutMs);
|
|
1027
|
-
if (result.
|
|
1146
|
+
if (result.stderr.trim()) {
|
|
1028
1147
|
throw new Error(result.stderr.trim());
|
|
1029
1148
|
}
|
|
1030
1149
|
return result.stdout;
|
|
1031
1150
|
}
|
|
1032
1151
|
async function serviceStatus(client, serviceName, timeoutMs = 3e4) {
|
|
1033
|
-
const result = await exec(client, `systemctl status ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1152
|
+
const result = await exec(client, `systemctl status -- ${shellQuote(serviceName)} 2>&1`, timeoutMs);
|
|
1034
1153
|
const raw = result.stdout;
|
|
1035
1154
|
const activeMatch = raw.match(/Active:\s+(\S+)\s+\(([^)]+)\)/);
|
|
1036
1155
|
const descMatch = raw.match(/^\s+.*?-\s+(.+)$/m);
|
|
1037
1156
|
const pidMatch = raw.match(/Main PID:\s+(\d+)/);
|
|
1038
1157
|
const sinceMatch = raw.match(/since\s+(.+?);/);
|
|
1158
|
+
const fallbackStatus = result.code === 0 ? "active" : "inactive";
|
|
1039
1159
|
return {
|
|
1040
1160
|
name: serviceName,
|
|
1041
1161
|
active: activeMatch?.[1] === "active",
|
|
1042
|
-
status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` :
|
|
1162
|
+
status: activeMatch ? `${activeMatch[1]} (${activeMatch[2]})` : fallbackStatus,
|
|
1043
1163
|
description: descMatch?.[1]?.trim(),
|
|
1044
1164
|
since: sinceMatch?.[1]?.trim(),
|
|
1045
1165
|
pid: pidMatch ? Number.parseInt(pidMatch[1], 10) : void 0,
|
|
@@ -1052,7 +1172,9 @@ var HostSchema = z.string().describe("SSH hostname or IP address");
|
|
|
1052
1172
|
var PortSchema = z.number().int().min(1).max(65535).optional().describe("SSH port (default: 22)");
|
|
1053
1173
|
var UsernameSchema = z.string().optional().describe("SSH username (default: current user)");
|
|
1054
1174
|
var KeyPathSchema = z.string().optional().describe("Path to SSH private key");
|
|
1055
|
-
var PasswordSchema = z.string().optional().describe(
|
|
1175
|
+
var PasswordSchema = z.string().optional().describe(
|
|
1176
|
+
"SSH password. STRONGLY prefer key-based auth (privateKeyPath or ssh-agent). Passwords pass through MCP protocol frames as plaintext and may be logged by the transport or host process."
|
|
1177
|
+
);
|
|
1056
1178
|
var TimeoutSchema = z.number().int().positive().optional().describe("Command timeout in milliseconds (default: 30000)");
|
|
1057
1179
|
var connectionParams = {
|
|
1058
1180
|
host: HostSchema,
|
|
@@ -1071,8 +1193,8 @@ function registerTools(server, pool) {
|
|
|
1071
1193
|
command: z.string().describe("Shell command to execute on the remote host (interpreted by the remote login shell)"),
|
|
1072
1194
|
timeout: TimeoutSchema
|
|
1073
1195
|
},
|
|
1074
|
-
async ({
|
|
1075
|
-
return connectionPool.withConnection(
|
|
1196
|
+
async ({ command, timeout, ...conn }) => {
|
|
1197
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1076
1198
|
const result = await exec(client, command, timeout || 3e4);
|
|
1077
1199
|
const parts = [];
|
|
1078
1200
|
if (result.stdout) parts.push(result.stdout);
|
|
@@ -1090,8 +1212,8 @@ ${result.stderr}`);
|
|
|
1090
1212
|
...connectionParams,
|
|
1091
1213
|
path: z.string().describe("Absolute path to the remote file")
|
|
1092
1214
|
},
|
|
1093
|
-
async ({
|
|
1094
|
-
return connectionPool.withConnection(
|
|
1215
|
+
async ({ path, ...conn }) => {
|
|
1216
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1095
1217
|
const content = await readFile(client, path);
|
|
1096
1218
|
return { content: [{ type: "text", text: content }] };
|
|
1097
1219
|
});
|
|
@@ -1105,8 +1227,8 @@ ${result.stderr}`);
|
|
|
1105
1227
|
path: z.string().describe("Absolute path to the remote file"),
|
|
1106
1228
|
content: z.string().describe("File content to write")
|
|
1107
1229
|
},
|
|
1108
|
-
async ({
|
|
1109
|
-
return connectionPool.withConnection(
|
|
1230
|
+
async ({ path, content, ...conn }) => {
|
|
1231
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1110
1232
|
await writeFile(client, path, content);
|
|
1111
1233
|
return { content: [{ type: "text", text: `Wrote ${content.length} bytes to ${path}` }] };
|
|
1112
1234
|
});
|
|
@@ -1120,8 +1242,8 @@ ${result.stderr}`);
|
|
|
1120
1242
|
localPath: z.string().describe("Path to the local file to upload"),
|
|
1121
1243
|
remotePath: z.string().describe("Absolute path on the remote host")
|
|
1122
1244
|
},
|
|
1123
|
-
async ({
|
|
1124
|
-
return connectionPool.withConnection(
|
|
1245
|
+
async ({ localPath, remotePath, ...conn }) => {
|
|
1246
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1125
1247
|
await uploadFile(client, localPath, remotePath);
|
|
1126
1248
|
return { content: [{ type: "text", text: `Uploaded ${localPath} \u2192 ${remotePath}` }] };
|
|
1127
1249
|
});
|
|
@@ -1135,8 +1257,8 @@ ${result.stderr}`);
|
|
|
1135
1257
|
remotePath: z.string().describe("Absolute path to the remote file"),
|
|
1136
1258
|
localPath: z.string().describe("Local path to save the downloaded file")
|
|
1137
1259
|
},
|
|
1138
|
-
async ({
|
|
1139
|
-
return connectionPool.withConnection(
|
|
1260
|
+
async ({ remotePath, localPath, ...conn }) => {
|
|
1261
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1140
1262
|
await downloadFile(client, remotePath, localPath);
|
|
1141
1263
|
return { content: [{ type: "text", text: `Downloaded ${remotePath} \u2192 ${localPath}` }] };
|
|
1142
1264
|
});
|
|
@@ -1149,8 +1271,8 @@ ${result.stderr}`);
|
|
|
1149
1271
|
...connectionParams,
|
|
1150
1272
|
path: z.string().describe("Absolute path to the remote directory")
|
|
1151
1273
|
},
|
|
1152
|
-
async ({
|
|
1153
|
-
return connectionPool.withConnection(
|
|
1274
|
+
async ({ path, ...conn }) => {
|
|
1275
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1154
1276
|
const files = await listDir(client, path);
|
|
1155
1277
|
return { content: [{ type: "text", text: files.join("\n") }] };
|
|
1156
1278
|
});
|
|
@@ -1356,21 +1478,8 @@ ${result.stderr}`);
|
|
|
1356
1478
|
maxsize: z.string().optional().describe("Maximum file size (e.g. '10M', '500k')"),
|
|
1357
1479
|
timeout: TimeoutSchema
|
|
1358
1480
|
},
|
|
1359
|
-
async ({
|
|
1360
|
-
|
|
1361
|
-
port,
|
|
1362
|
-
username,
|
|
1363
|
-
privateKeyPath,
|
|
1364
|
-
password,
|
|
1365
|
-
path,
|
|
1366
|
-
name,
|
|
1367
|
-
type,
|
|
1368
|
-
maxdepth,
|
|
1369
|
-
minsize,
|
|
1370
|
-
maxsize,
|
|
1371
|
-
timeout
|
|
1372
|
-
}) => {
|
|
1373
|
-
return connectionPool.withConnection({ host, port, username, privateKeyPath, password }, async (client) => {
|
|
1481
|
+
async ({ path, name, type, maxdepth, minsize, maxsize, timeout, ...conn }) => {
|
|
1482
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1374
1483
|
const files = await find(client, { path, name, type, maxdepth, minsize, maxsize }, timeout || 3e4);
|
|
1375
1484
|
if (files.length === 0) {
|
|
1376
1485
|
return { content: [{ type: "text", text: "No files found." }] };
|
|
@@ -1390,8 +1499,8 @@ ${files.join("\n")}` }] };
|
|
|
1390
1499
|
grep: z.string().optional().describe("Case-insensitive pattern to filter lines"),
|
|
1391
1500
|
timeout: TimeoutSchema
|
|
1392
1501
|
},
|
|
1393
|
-
async ({
|
|
1394
|
-
return connectionPool.withConnection(
|
|
1502
|
+
async ({ path, lines, grep, timeout, ...conn }) => {
|
|
1503
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1395
1504
|
const output = await tail(client, path, lines || 100, grep, timeout || 3e4);
|
|
1396
1505
|
if (!output.trim()) {
|
|
1397
1506
|
return {
|
|
@@ -1415,8 +1524,8 @@ ${files.join("\n")}` }] };
|
|
|
1415
1524
|
service: z.string().describe("Systemd service name (e.g. nginx, sshd, docker)"),
|
|
1416
1525
|
timeout: TimeoutSchema
|
|
1417
1526
|
},
|
|
1418
|
-
async ({
|
|
1419
|
-
return connectionPool.withConnection(
|
|
1527
|
+
async ({ service, timeout, ...conn }) => {
|
|
1528
|
+
return connectionPool.withConnection(conn, async (client) => {
|
|
1420
1529
|
const status = await serviceStatus(client, service, timeout || 3e4);
|
|
1421
1530
|
const lines = [];
|
|
1422
1531
|
lines.push(`Service: ${status.name}`);
|
|
@@ -1433,10 +1542,12 @@ ${files.join("\n")}` }] };
|
|
|
1433
1542
|
}
|
|
1434
1543
|
|
|
1435
1544
|
// src/server.ts
|
|
1545
|
+
var pkgPath = join4(dirname(fileURLToPath(import.meta.url)), "..", "package.json");
|
|
1546
|
+
var { version } = JSON.parse(readFileSync4(pkgPath, "utf8"));
|
|
1436
1547
|
function createServer(pool) {
|
|
1437
1548
|
const server = new McpServer({
|
|
1438
1549
|
name: "ssh-mcp",
|
|
1439
|
-
version
|
|
1550
|
+
version
|
|
1440
1551
|
});
|
|
1441
1552
|
registerTools(server, pool);
|
|
1442
1553
|
return server;
|
|
@@ -1449,6 +1560,7 @@ async function main() {
|
|
|
1449
1560
|
const transport = new StdioServerTransport();
|
|
1450
1561
|
const shutdown = () => {
|
|
1451
1562
|
pool.drain();
|
|
1563
|
+
killStartedAgent();
|
|
1452
1564
|
process.exit(0);
|
|
1453
1565
|
};
|
|
1454
1566
|
process.on("SIGINT", shutdown);
|