ssh-mcp-pro 1.0.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/AGENTS.md +127 -0
- package/ARCHITECTURE.md +145 -0
- package/LICENSE +21 -0
- package/LICENSES/MIT.txt +21 -0
- package/MIGRATION.md +14 -0
- package/README.md +175 -0
- package/REGISTRY_SUBMISSION.md +38 -0
- package/SECURITY.md +40 -0
- package/SECURITY_DECISIONS.md +59 -0
- package/dist/agent-bin.d.ts +3 -0
- package/dist/agent-bin.d.ts.map +1 -0
- package/dist/agent-bin.js +8 -0
- package/dist/agent-bin.js.map +1 -0
- package/dist/audit.d.ts +25 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +50 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth.d.ts +4 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +33 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +99 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +103 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +490 -0
- package/dist/config.js.map +1 -0
- package/dist/connector-credentials.d.ts +8 -0
- package/dist/connector-credentials.d.ts.map +1 -0
- package/dist/connector-credentials.js +132 -0
- package/dist/connector-credentials.js.map +1 -0
- package/dist/connector-profile.d.ts +17 -0
- package/dist/connector-profile.d.ts.map +1 -0
- package/dist/connector-profile.js +81 -0
- package/dist/connector-profile.js.map +1 -0
- package/dist/container.d.ts +18 -0
- package/dist/container.d.ts.map +1 -0
- package/dist/container.js +52 -0
- package/dist/container.js.map +1 -0
- package/dist/detect.d.ts +7 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +271 -0
- package/dist/detect.js.map +1 -0
- package/dist/ensure.d.ts +17 -0
- package/dist/ensure.d.ts.map +1 -0
- package/dist/ensure.js +531 -0
- package/dist/ensure.js.map +1 -0
- package/dist/errors.d.ts +54 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +84 -0
- package/dist/errors.js.map +1 -0
- package/dist/fs-tools.d.ts +26 -0
- package/dist/fs-tools.d.ts.map +1 -0
- package/dist/fs-tools.js +599 -0
- package/dist/fs-tools.js.map +1 -0
- package/dist/http-rate-limit.d.ts +9 -0
- package/dist/http-rate-limit.d.ts.map +1 -0
- package/dist/http-rate-limit.js +41 -0
- package/dist/http-rate-limit.js.map +1 -0
- package/dist/http-security.d.ts +22 -0
- package/dist/http-security.d.ts.map +1 -0
- package/dist/http-security.js +88 -0
- package/dist/http-security.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +201 -0
- package/dist/index.js.map +1 -0
- package/dist/logging.d.ts +52 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +180 -0
- package/dist/logging.js.map +1 -0
- package/dist/mcp.d.ts +16 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +159 -0
- package/dist/mcp.js.map +1 -0
- package/dist/metrics.d.ts +95 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +204 -0
- package/dist/metrics.js.map +1 -0
- package/dist/oauth.d.ts +14 -0
- package/dist/oauth.d.ts.map +1 -0
- package/dist/oauth.js +105 -0
- package/dist/oauth.js.map +1 -0
- package/dist/policy.d.ts +64 -0
- package/dist/policy.d.ts.map +1 -0
- package/dist/policy.js +368 -0
- package/dist/policy.js.map +1 -0
- package/dist/process.d.ts +24 -0
- package/dist/process.d.ts.map +1 -0
- package/dist/process.js +212 -0
- package/dist/process.js.map +1 -0
- package/dist/prompts.d.ts +49 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +191 -0
- package/dist/prompts.js.map +1 -0
- package/dist/rate-limiter.d.ts +57 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +141 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/remote/agent-cli.d.ts +2 -0
- package/dist/remote/agent-cli.d.ts.map +1 -0
- package/dist/remote/agent-cli.js +270 -0
- package/dist/remote/agent-cli.js.map +1 -0
- package/dist/remote/agent-executor.d.ts +26 -0
- package/dist/remote/agent-executor.d.ts.map +1 -0
- package/dist/remote/agent-executor.js +400 -0
- package/dist/remote/agent-executor.js.map +1 -0
- package/dist/remote/config.d.ts +3 -0
- package/dist/remote/config.d.ts.map +1 -0
- package/dist/remote/config.js +52 -0
- package/dist/remote/config.js.map +1 -0
- package/dist/remote/control-plane.d.ts +57 -0
- package/dist/remote/control-plane.d.ts.map +1 -0
- package/dist/remote/control-plane.js +1248 -0
- package/dist/remote/control-plane.js.map +1 -0
- package/dist/remote/crypto.d.ts +38 -0
- package/dist/remote/crypto.d.ts.map +1 -0
- package/dist/remote/crypto.js +143 -0
- package/dist/remote/crypto.js.map +1 -0
- package/dist/remote/mcp-tools.d.ts +10 -0
- package/dist/remote/mcp-tools.d.ts.map +1 -0
- package/dist/remote/mcp-tools.js +201 -0
- package/dist/remote/mcp-tools.js.map +1 -0
- package/dist/remote/policy.d.ts +11 -0
- package/dist/remote/policy.d.ts.map +1 -0
- package/dist/remote/policy.js +94 -0
- package/dist/remote/policy.js.map +1 -0
- package/dist/remote/schemas.d.ts +298 -0
- package/dist/remote/schemas.d.ts.map +1 -0
- package/dist/remote/schemas.js +111 -0
- package/dist/remote/schemas.js.map +1 -0
- package/dist/remote/scopes.d.ts +6 -0
- package/dist/remote/scopes.d.ts.map +1 -0
- package/dist/remote/scopes.js +24 -0
- package/dist/remote/scopes.js.map +1 -0
- package/dist/remote/store.d.ts +45 -0
- package/dist/remote/store.d.ts.map +1 -0
- package/dist/remote/store.js +355 -0
- package/dist/remote/store.js.map +1 -0
- package/dist/remote/types.d.ts +183 -0
- package/dist/remote/types.d.ts.map +1 -0
- package/dist/remote/types.js +103 -0
- package/dist/remote/types.js.map +1 -0
- package/dist/remote/util.d.ts +6 -0
- package/dist/remote/util.d.ts.map +1 -0
- package/dist/remote/util.js +45 -0
- package/dist/remote/util.js.map +1 -0
- package/dist/remote/websocket.d.ts +26 -0
- package/dist/remote/websocket.d.ts.map +1 -0
- package/dist/remote/websocket.js +167 -0
- package/dist/remote/websocket.js.map +1 -0
- package/dist/render-http.d.ts +2 -0
- package/dist/render-http.d.ts.map +1 -0
- package/dist/render-http.js +14 -0
- package/dist/render-http.js.map +1 -0
- package/dist/resources.d.ts +19 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +96 -0
- package/dist/resources.js.map +1 -0
- package/dist/retry.d.ts +45 -0
- package/dist/retry.d.ts.map +1 -0
- package/dist/retry.js +120 -0
- package/dist/retry.js.map +1 -0
- package/dist/safety.d.ts +31 -0
- package/dist/safety.d.ts.map +1 -0
- package/dist/safety.js +174 -0
- package/dist/safety.js.map +1 -0
- package/dist/server-http.d.ts +2 -0
- package/dist/server-http.d.ts.map +1 -0
- package/dist/server-http.js +432 -0
- package/dist/server-http.js.map +1 -0
- package/dist/session.d.ts +116 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +666 -0
- package/dist/session.js.map +1 -0
- package/dist/shell.d.ts +10 -0
- package/dist/shell.d.ts.map +1 -0
- package/dist/shell.js +83 -0
- package/dist/shell.js.map +1 -0
- package/dist/ssh-config.d.ts +94 -0
- package/dist/ssh-config.d.ts.map +1 -0
- package/dist/ssh-config.js +234 -0
- package/dist/ssh-config.js.map +1 -0
- package/dist/streaming.d.ts +36 -0
- package/dist/streaming.d.ts.map +1 -0
- package/dist/streaming.js +140 -0
- package/dist/streaming.js.map +1 -0
- package/dist/telemetry.d.ts +17 -0
- package/dist/telemetry.d.ts.map +1 -0
- package/dist/telemetry.js +101 -0
- package/dist/telemetry.js.map +1 -0
- package/dist/tools/connector.provider.d.ts +28 -0
- package/dist/tools/connector.provider.d.ts.map +1 -0
- package/dist/tools/connector.provider.js +360 -0
- package/dist/tools/connector.provider.js.map +1 -0
- package/dist/tools/ensure.provider.d.ts +18 -0
- package/dist/tools/ensure.provider.d.ts.map +1 -0
- package/dist/tools/ensure.provider.js +173 -0
- package/dist/tools/ensure.provider.js.map +1 -0
- package/dist/tools/fs.provider.d.ts +21 -0
- package/dist/tools/fs.provider.d.ts.map +1 -0
- package/dist/tools/fs.provider.js +259 -0
- package/dist/tools/fs.provider.js.map +1 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +68 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/metadata.d.ts +11 -0
- package/dist/tools/metadata.d.ts.map +1 -0
- package/dist/tools/metadata.js +10 -0
- package/dist/tools/metadata.js.map +1 -0
- package/dist/tools/output-schemas.d.ts +217 -0
- package/dist/tools/output-schemas.d.ts.map +1 -0
- package/dist/tools/output-schemas.js +300 -0
- package/dist/tools/output-schemas.js.map +1 -0
- package/dist/tools/process.provider.d.ts +22 -0
- package/dist/tools/process.provider.d.ts.map +1 -0
- package/dist/tools/process.provider.js +146 -0
- package/dist/tools/process.provider.js.map +1 -0
- package/dist/tools/registry.d.ts +12 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/registry.js +163 -0
- package/dist/tools/registry.js.map +1 -0
- package/dist/tools/results.d.ts +4 -0
- package/dist/tools/results.d.ts.map +1 -0
- package/dist/tools/results.js +5 -0
- package/dist/tools/results.js.map +1 -0
- package/dist/tools/session.provider.d.ts +23 -0
- package/dist/tools/session.provider.d.ts.map +1 -0
- package/dist/tools/session.provider.js +299 -0
- package/dist/tools/session.provider.js.map +1 -0
- package/dist/tools/system.provider.d.ts +18 -0
- package/dist/tools/system.provider.d.ts.map +1 -0
- package/dist/tools/system.provider.js +81 -0
- package/dist/tools/system.provider.js.map +1 -0
- package/dist/tools/transfer.provider.d.ts +16 -0
- package/dist/tools/transfer.provider.d.ts.map +1 -0
- package/dist/tools/transfer.provider.js +85 -0
- package/dist/tools/transfer.provider.js.map +1 -0
- package/dist/tools/tunnel.provider.d.ts +18 -0
- package/dist/tools/tunnel.provider.d.ts.map +1 -0
- package/dist/tools/tunnel.provider.js +142 -0
- package/dist/tools/tunnel.provider.js.map +1 -0
- package/dist/tools/types.d.ts +16 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/transfer.d.ts +40 -0
- package/dist/transfer.d.ts.map +1 -0
- package/dist/transfer.js +363 -0
- package/dist/transfer.js.map +1 -0
- package/dist/tunnel.d.ts +37 -0
- package/dist/tunnel.d.ts.map +1 -0
- package/dist/tunnel.js +234 -0
- package/dist/tunnel.js.map +1 -0
- package/dist/types.d.ts +341 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +184 -0
- package/dist/types.js.map +1 -0
- package/docs/docker.md +22 -0
- package/examples/README.md +77 -0
- package/mcp.json +21 -0
- package/package.json +147 -0
- package/registry/ssh-mcp-pro/mcp.json +21 -0
- package/server.json +76 -0
package/dist/session.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import { NodeSSH } from "node-ssh";
|
|
2
|
+
import { createHash, createHmac, randomUUID } from "node:crypto";
|
|
3
|
+
import * as fs from "fs";
|
|
4
|
+
import * as path from "path";
|
|
5
|
+
import * as os from "os";
|
|
6
|
+
import { SSHMCPError, } from "./types.js";
|
|
7
|
+
import { createAuthError, createConnectionError, createHostKeyError, createPolicyError, createTimeoutError, } from "./errors.js";
|
|
8
|
+
import { logger } from "./logging.js";
|
|
9
|
+
import { detectOS } from "./detect.js";
|
|
10
|
+
const KNOWN_HOST_KEY_TYPES = new Set([
|
|
11
|
+
"ssh-ed25519",
|
|
12
|
+
"ssh-ed25519-cert-v01@openssh.com",
|
|
13
|
+
"ssh-rsa",
|
|
14
|
+
"ssh-rsa-cert-v01@openssh.com",
|
|
15
|
+
"rsa-sha2-256",
|
|
16
|
+
"rsa-sha2-256-cert-v01@openssh.com",
|
|
17
|
+
"rsa-sha2-512",
|
|
18
|
+
"rsa-sha2-512-cert-v01@openssh.com",
|
|
19
|
+
"ecdsa-sha2-nistp256",
|
|
20
|
+
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
|
21
|
+
"ecdsa-sha2-nistp384",
|
|
22
|
+
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
|
|
23
|
+
"ecdsa-sha2-nistp521",
|
|
24
|
+
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
|
|
25
|
+
"sk-ssh-ed25519@openssh.com",
|
|
26
|
+
"sk-ssh-ed25519-cert-v01@openssh.com",
|
|
27
|
+
"sk-ecdsa-sha2-nistp256@openssh.com",
|
|
28
|
+
"sk-ecdsa-sha2-nistp256-cert-v01@openssh.com",
|
|
29
|
+
]);
|
|
30
|
+
function normalizeSha256Fingerprint(fingerprint) {
|
|
31
|
+
return fingerprint.replace(/^SHA256:/i, "").trim();
|
|
32
|
+
}
|
|
33
|
+
function knownHostKeyFingerprints(keyBlob) {
|
|
34
|
+
const key = Buffer.from(keyBlob, "base64");
|
|
35
|
+
const base64 = createHash("sha256").update(key).digest("base64").replace(/=+$/, "");
|
|
36
|
+
const hex = createHash("sha256").update(key).digest("hex");
|
|
37
|
+
return [base64, hex];
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Session manager with LRU cache and TTL
|
|
41
|
+
*/
|
|
42
|
+
export class SessionManager {
|
|
43
|
+
security;
|
|
44
|
+
policy;
|
|
45
|
+
sessions = new Map();
|
|
46
|
+
maxSessions;
|
|
47
|
+
defaultTtlMs;
|
|
48
|
+
cleanupInterval;
|
|
49
|
+
acceptedHostKeys = new Map();
|
|
50
|
+
closeListeners = new Set();
|
|
51
|
+
constructor(maxSessions = 20, defaultTtlMs = 900_000, cleanupIntervalMs = 10_000, security = {
|
|
52
|
+
allowRootLogin: false,
|
|
53
|
+
hostKeyPolicy: "strict",
|
|
54
|
+
knownHostsPath: path.join(os.homedir(), ".ssh", "known_hosts"),
|
|
55
|
+
allowedCiphers: [],
|
|
56
|
+
}, policy) {
|
|
57
|
+
this.security = security;
|
|
58
|
+
this.policy = policy;
|
|
59
|
+
this.maxSessions = maxSessions;
|
|
60
|
+
this.defaultTtlMs = defaultTtlMs;
|
|
61
|
+
this.cleanupInterval = setInterval(() => {
|
|
62
|
+
this.cleanupExpiredSessions();
|
|
63
|
+
}, cleanupIntervalMs);
|
|
64
|
+
this.cleanupInterval.unref?.();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Destroys the session manager, cleaning up all sessions and intervals
|
|
68
|
+
*/
|
|
69
|
+
async destroy() {
|
|
70
|
+
if (this.cleanupInterval) {
|
|
71
|
+
clearInterval(this.cleanupInterval);
|
|
72
|
+
this.cleanupInterval = undefined;
|
|
73
|
+
}
|
|
74
|
+
await this.closeAllSessions();
|
|
75
|
+
}
|
|
76
|
+
onSessionClose(listener) {
|
|
77
|
+
this.closeListeners.add(listener);
|
|
78
|
+
return () => {
|
|
79
|
+
this.closeListeners.delete(listener);
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Gets cached OS info for a session (detects and caches if needed)
|
|
84
|
+
*/
|
|
85
|
+
async getOSInfo(sessionId) {
|
|
86
|
+
const session = this.getSession(sessionId);
|
|
87
|
+
if (!session) {
|
|
88
|
+
throw new Error(`Session ${sessionId} not found or expired`);
|
|
89
|
+
}
|
|
90
|
+
if (session.osInfo) {
|
|
91
|
+
return session.osInfo;
|
|
92
|
+
}
|
|
93
|
+
const osInfo = await detectOS(session.ssh);
|
|
94
|
+
session.osInfo = osInfo;
|
|
95
|
+
return osInfo;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Opens a new SSH session with authentication
|
|
99
|
+
*/
|
|
100
|
+
async openSession(params) {
|
|
101
|
+
logger.debug("Opening SSH session", {
|
|
102
|
+
host: params.host,
|
|
103
|
+
username: params.username,
|
|
104
|
+
});
|
|
105
|
+
const sessionId = this.generateSessionId();
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const ttl = params.ttlMs ?? this.defaultTtlMs;
|
|
108
|
+
const policyMode = params.policyMode ?? "enforce";
|
|
109
|
+
const hostKeyPolicy = this.resolveHostKeyPolicy(params);
|
|
110
|
+
const policyHost = params.policyHost ?? params.host;
|
|
111
|
+
const policyDecision = this.policy?.assertAllowed({
|
|
112
|
+
action: "ssh.open",
|
|
113
|
+
host: policyHost,
|
|
114
|
+
username: params.username,
|
|
115
|
+
mode: policyMode,
|
|
116
|
+
});
|
|
117
|
+
const rootDenied = params.username === "root" && !this.security.allowRootLogin;
|
|
118
|
+
if (policyMode === "explain") {
|
|
119
|
+
return {
|
|
120
|
+
sessionId,
|
|
121
|
+
host: params.host,
|
|
122
|
+
username: params.username,
|
|
123
|
+
sftpAvailable: false,
|
|
124
|
+
expiresInMs: ttl,
|
|
125
|
+
policyMode,
|
|
126
|
+
hostKeyPolicy,
|
|
127
|
+
wouldConnect: !rootDenied && (policyDecision?.allowed ?? true),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
if (rootDenied) {
|
|
132
|
+
throw createPolicyError("Root SSH login is disabled by policy", "Connect as an unprivileged user and use approved privilege escalation workflows.");
|
|
133
|
+
}
|
|
134
|
+
// Clean up old sessions if we're at the limit
|
|
135
|
+
if (this.sessions.size >= this.maxSessions) {
|
|
136
|
+
this.evictOldestSession();
|
|
137
|
+
}
|
|
138
|
+
const ssh = new NodeSSH();
|
|
139
|
+
const authConfig = await this.buildAuthConfig(params);
|
|
140
|
+
const knownHostsPath = params.knownHostsPath ?? this.security.knownHostsPath;
|
|
141
|
+
const connectConfig = {
|
|
142
|
+
host: params.host,
|
|
143
|
+
username: params.username,
|
|
144
|
+
port: params.port ?? 22,
|
|
145
|
+
readyTimeout: params.readyTimeoutMs ?? 20000,
|
|
146
|
+
...authConfig,
|
|
147
|
+
};
|
|
148
|
+
if (this.security.allowedCiphers.length > 0) {
|
|
149
|
+
connectConfig.algorithms = {
|
|
150
|
+
...connectConfig.algorithms,
|
|
151
|
+
cipher: this.security.allowedCiphers,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
if (params.expectedHostKeySha256) {
|
|
155
|
+
connectConfig.hostHash = "sha256";
|
|
156
|
+
connectConfig.hostVerifier = (hashedKey) => normalizeSha256Fingerprint(hashedKey) ===
|
|
157
|
+
normalizeSha256Fingerprint(params.expectedHostKeySha256 ?? "");
|
|
158
|
+
}
|
|
159
|
+
else if (hostKeyPolicy === "insecure") {
|
|
160
|
+
connectConfig.hostVerifier = () => true;
|
|
161
|
+
}
|
|
162
|
+
else if (hostKeyPolicy === "accept-new") {
|
|
163
|
+
connectConfig.hostHash = "sha256";
|
|
164
|
+
connectConfig.hostVerifier = (hashedKey) => this.verifyAcceptNewHostKey(params.host, params.port ?? 22, hashedKey);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
connectConfig.hostHash = "sha256";
|
|
168
|
+
connectConfig.hostVerifier = (hashedKey) => this.verifyKnownHostKey(params.host, params.port ?? 22, knownHostsPath, hashedKey);
|
|
169
|
+
}
|
|
170
|
+
logger.debug("Connecting to SSH server");
|
|
171
|
+
await ssh.connect(connectConfig);
|
|
172
|
+
let sftp;
|
|
173
|
+
let sftpAvailable = false;
|
|
174
|
+
try {
|
|
175
|
+
sftp = await ssh.requestSFTP();
|
|
176
|
+
sftpAvailable = true;
|
|
177
|
+
}
|
|
178
|
+
catch (sftpError) {
|
|
179
|
+
logger.warn("SFTP unavailable, continuing with SSH-only session", {
|
|
180
|
+
host: params.host,
|
|
181
|
+
username: params.username,
|
|
182
|
+
error: sftpError instanceof Error ? sftpError.message : String(sftpError),
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
if (hostKeyPolicy !== "strict") {
|
|
186
|
+
logger.warn("Strict host key verification is not active for this session.", {
|
|
187
|
+
sessionId,
|
|
188
|
+
hostKeyPolicy,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
const sessionInfo = {
|
|
192
|
+
sessionId,
|
|
193
|
+
host: params.host,
|
|
194
|
+
username: params.username,
|
|
195
|
+
port: params.port ?? 22,
|
|
196
|
+
createdAt: now,
|
|
197
|
+
expiresAt: now + ttl,
|
|
198
|
+
lastUsed: now,
|
|
199
|
+
policyMode,
|
|
200
|
+
hostKeyPolicy,
|
|
201
|
+
};
|
|
202
|
+
const session = {
|
|
203
|
+
ssh,
|
|
204
|
+
info: sessionInfo,
|
|
205
|
+
connectionParams: params,
|
|
206
|
+
...(sftp ? { sftp } : {}),
|
|
207
|
+
};
|
|
208
|
+
this.sessions.set(sessionId, session);
|
|
209
|
+
logger.info("SSH session opened successfully", {
|
|
210
|
+
sessionId,
|
|
211
|
+
host: params.host,
|
|
212
|
+
username: params.username,
|
|
213
|
+
sftpAvailable,
|
|
214
|
+
expiresInMs: ttl,
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
sessionId,
|
|
218
|
+
host: params.host,
|
|
219
|
+
username: params.username,
|
|
220
|
+
sftpAvailable,
|
|
221
|
+
expiresInMs: ttl,
|
|
222
|
+
policyMode,
|
|
223
|
+
hostKeyPolicy,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
logger.error("Failed to open SSH session", { error, host: params.host });
|
|
228
|
+
if (error instanceof SSHMCPError) {
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
if (error instanceof Error) {
|
|
232
|
+
if (error.message.includes("authentication")) {
|
|
233
|
+
throw createAuthError("SSH authentication failed", "Check your username, password, or SSH key configuration");
|
|
234
|
+
}
|
|
235
|
+
if (error.message.includes("timeout") || error.message.includes("ETIMEDOUT")) {
|
|
236
|
+
throw createTimeoutError("SSH connection timeout", "Check if the host is reachable and the SSH service is running");
|
|
237
|
+
}
|
|
238
|
+
if (error.message.includes("ECONNREFUSED")) {
|
|
239
|
+
throw createConnectionError("SSH connection refused", "Check if the SSH service is running on the target port");
|
|
240
|
+
}
|
|
241
|
+
if (error.message.toLowerCase().includes("host key")) {
|
|
242
|
+
throw createHostKeyError("SSH host key verification failed", "Check known_hosts, hostKeyPolicy, or expectedHostKeySha256");
|
|
243
|
+
}
|
|
244
|
+
if (error.message.toLowerCase().includes("host denied")) {
|
|
245
|
+
throw createHostKeyError("SSH host key verification failed", "Check known_hosts, hostKeyPolicy, or expectedHostKeySha256");
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
throw createConnectionError(`Failed to establish SSH connection: ${error instanceof Error ? error.message : String(error)}`, "Verify the host, port, and network connectivity");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Closes an SSH session
|
|
253
|
+
*/
|
|
254
|
+
async closeSession(sessionId) {
|
|
255
|
+
logger.debug("Closing SSH session", { sessionId });
|
|
256
|
+
const session = this.sessions.get(sessionId);
|
|
257
|
+
if (!session) {
|
|
258
|
+
logger.warn("Session not found for closing", { sessionId });
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
try {
|
|
262
|
+
await this.notifySessionClose(sessionId);
|
|
263
|
+
if (session.sftp) {
|
|
264
|
+
session.sftp.end();
|
|
265
|
+
}
|
|
266
|
+
session.ssh.dispose();
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
logger.warn("Error closing session", { sessionId, error });
|
|
270
|
+
}
|
|
271
|
+
this.sessions.delete(sessionId);
|
|
272
|
+
logger.info("SSH session closed", { sessionId });
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Gets an active session by ID
|
|
277
|
+
*/
|
|
278
|
+
getSession(sessionId) {
|
|
279
|
+
const session = this.sessions.get(sessionId);
|
|
280
|
+
if (!session) {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
if (Date.now() > session.info.expiresAt) {
|
|
284
|
+
void this.closeSession(sessionId);
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
session.info.lastUsed = Date.now();
|
|
288
|
+
return session;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Builds authentication configuration based on the auth strategy
|
|
292
|
+
*/
|
|
293
|
+
async buildAuthConfig(params) {
|
|
294
|
+
const authStrategy = params.auth ?? "auto";
|
|
295
|
+
logger.debug("Building auth config", { strategy: authStrategy });
|
|
296
|
+
switch (authStrategy) {
|
|
297
|
+
case "password":
|
|
298
|
+
if (!params.password) {
|
|
299
|
+
throw createAuthError("Password required for password authentication");
|
|
300
|
+
}
|
|
301
|
+
return { password: params.password };
|
|
302
|
+
case "key":
|
|
303
|
+
return await this.buildKeyAuth(params);
|
|
304
|
+
case "agent":
|
|
305
|
+
return await this.buildAgentAuth();
|
|
306
|
+
case "auto":
|
|
307
|
+
default:
|
|
308
|
+
return await this.buildAutoAuth(params);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Builds key-based authentication
|
|
313
|
+
*/
|
|
314
|
+
async buildKeyAuth(params) {
|
|
315
|
+
if (params.privateKey) {
|
|
316
|
+
logger.debug("Using inline private key");
|
|
317
|
+
return {
|
|
318
|
+
privateKey: params.privateKey,
|
|
319
|
+
...(params.passphrase !== undefined ? { passphrase: params.passphrase } : {}),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
if (params.privateKeyPath) {
|
|
323
|
+
logger.debug("Using private key from path", {
|
|
324
|
+
path: params.privateKeyPath,
|
|
325
|
+
});
|
|
326
|
+
return await this.loadPrivateKeyFromPath(params.privateKeyPath, params.passphrase);
|
|
327
|
+
}
|
|
328
|
+
return await this.discoverPrivateKeys(params.passphrase);
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* Builds SSH agent authentication
|
|
332
|
+
*/
|
|
333
|
+
async buildAgentAuth() {
|
|
334
|
+
const authSock = process.env.SSH_AUTH_SOCK;
|
|
335
|
+
if (!authSock) {
|
|
336
|
+
throw createAuthError("SSH agent not available", "Set SSH_AUTH_SOCK environment variable or use a different auth method");
|
|
337
|
+
}
|
|
338
|
+
logger.debug("Using SSH agent authentication");
|
|
339
|
+
return { agent: authSock };
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Builds automatic authentication (tries password, then key, then agent)
|
|
343
|
+
*/
|
|
344
|
+
async buildAutoAuth(params) {
|
|
345
|
+
if (params.password) {
|
|
346
|
+
logger.debug("Auto auth: trying password");
|
|
347
|
+
return { password: params.password };
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
logger.debug("Auto auth: trying key authentication");
|
|
351
|
+
return await this.buildKeyAuth(params);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
logger.debug("Auto auth: key authentication failed, trying agent");
|
|
355
|
+
}
|
|
356
|
+
try {
|
|
357
|
+
return await this.buildAgentAuth();
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
throw createAuthError("No suitable authentication method found", "Provide a password, private key, or ensure SSH agent is running");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Loads private key from file path
|
|
365
|
+
*/
|
|
366
|
+
async loadPrivateKeyFromPath(keyPath, passphrase) {
|
|
367
|
+
try {
|
|
368
|
+
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
|
369
|
+
return {
|
|
370
|
+
privateKey,
|
|
371
|
+
...(passphrase !== undefined ? { passphrase } : {}),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
catch {
|
|
375
|
+
throw createAuthError(`Failed to load private key from ${keyPath}`, "Check if the file exists and is readable");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* Auto-discovers private keys in standard locations
|
|
380
|
+
*/
|
|
381
|
+
async discoverPrivateKeys(passphrase) {
|
|
382
|
+
const homeDir = os.homedir();
|
|
383
|
+
const keyDir = process.env.SSH_DEFAULT_KEY_DIR ?? path.join(homeDir, ".ssh");
|
|
384
|
+
const keyFiles = ["id_ed25519", "id_ecdsa", "id_ed25519_sk", "id_ecdsa_sk", "id_rsa"];
|
|
385
|
+
for (const keyFile of keyFiles) {
|
|
386
|
+
const keyPath = path.join(keyDir, keyFile);
|
|
387
|
+
try {
|
|
388
|
+
await fs.promises.access(keyPath, fs.constants.R_OK);
|
|
389
|
+
logger.debug("Found SSH key", { path: keyPath });
|
|
390
|
+
return await this.loadPrivateKeyFromPath(keyPath, passphrase);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
logger.debug("SSH key not found or not readable", { path: keyPath });
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
throw createAuthError("No SSH private keys found in standard locations", `Checked: ${keyFiles.map((f) => path.join(keyDir, f)).join(", ")}`);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Generates a unique session ID
|
|
400
|
+
*/
|
|
401
|
+
generateSessionId() {
|
|
402
|
+
return `ssh-${randomUUID()}`;
|
|
403
|
+
}
|
|
404
|
+
async notifySessionClose(sessionId) {
|
|
405
|
+
const listenerTimeoutMs = 5_000;
|
|
406
|
+
const runListener = async (listener) => {
|
|
407
|
+
let timeout;
|
|
408
|
+
try {
|
|
409
|
+
await Promise.race([
|
|
410
|
+
Promise.resolve().then(() => listener(sessionId)),
|
|
411
|
+
new Promise((_, reject) => {
|
|
412
|
+
timeout = setTimeout(() => reject(new Error("Session close listener timed out")), listenerTimeoutMs);
|
|
413
|
+
}),
|
|
414
|
+
]);
|
|
415
|
+
}
|
|
416
|
+
finally {
|
|
417
|
+
if (timeout) {
|
|
418
|
+
clearTimeout(timeout);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const results = await Promise.allSettled(Array.from(this.closeListeners).map((listener) => runListener(listener)));
|
|
423
|
+
for (const result of results) {
|
|
424
|
+
if (result.status === "rejected") {
|
|
425
|
+
logger.warn("Session close listener failed", { sessionId, error: result.reason });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
resolveHostKeyPolicy(params) {
|
|
430
|
+
if (params.hostKeyPolicy) {
|
|
431
|
+
return params.hostKeyPolicy;
|
|
432
|
+
}
|
|
433
|
+
if (params.strictHostKeyChecking !== undefined) {
|
|
434
|
+
return params.strictHostKeyChecking ? "strict" : "insecure";
|
|
435
|
+
}
|
|
436
|
+
return this.security.hostKeyPolicy;
|
|
437
|
+
}
|
|
438
|
+
verifyAcceptNewHostKey(host, port, hashedKey) {
|
|
439
|
+
const key = `${host}:${port}`;
|
|
440
|
+
const normalized = normalizeSha256Fingerprint(hashedKey);
|
|
441
|
+
const accepted = this.acceptedHostKeys.get(key);
|
|
442
|
+
if (!accepted) {
|
|
443
|
+
this.acceptedHostKeys.set(key, normalized);
|
|
444
|
+
logger.warn("Accepted first-seen SSH host key for this process only", { host, port });
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
return accepted === normalized;
|
|
448
|
+
}
|
|
449
|
+
verifyKnownHostKey(host, port, knownHostsPath, hashedKey) {
|
|
450
|
+
if (!knownHostsPath) {
|
|
451
|
+
return false;
|
|
452
|
+
}
|
|
453
|
+
let contents;
|
|
454
|
+
try {
|
|
455
|
+
contents = fs.readFileSync(knownHostsPath, "utf8");
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
const expected = normalizeSha256Fingerprint(hashedKey);
|
|
461
|
+
for (const line of contents.split(/\r?\n/)) {
|
|
462
|
+
const parsed = this.parseKnownHostLine(line);
|
|
463
|
+
if (!parsed || !this.knownHostPatternMatches(parsed.hosts, host, port)) {
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (parsed.marker === "@revoked") {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
const fingerprints = knownHostKeyFingerprints(parsed.keyBlob);
|
|
471
|
+
if (fingerprints.some((fingerprint) => normalizeSha256Fingerprint(fingerprint) === expected)) {
|
|
472
|
+
return true;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
parseKnownHostLine(line) {
|
|
482
|
+
const trimmed = line.trim();
|
|
483
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
484
|
+
return undefined;
|
|
485
|
+
}
|
|
486
|
+
const parts = trimmed.split(/\s+/);
|
|
487
|
+
if (parts[0]?.startsWith("@")) {
|
|
488
|
+
if (parts.length < 4) {
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
if (!KNOWN_HOST_KEY_TYPES.has(parts[2] ?? "")) {
|
|
492
|
+
return undefined;
|
|
493
|
+
}
|
|
494
|
+
return { marker: parts[0], hosts: parts[1] ?? "", keyBlob: parts[3] ?? "" };
|
|
495
|
+
}
|
|
496
|
+
if (parts.length < 3) {
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
if (!KNOWN_HOST_KEY_TYPES.has(parts[1] ?? "")) {
|
|
500
|
+
return undefined;
|
|
501
|
+
}
|
|
502
|
+
return { hosts: parts[0] ?? "", keyBlob: parts[2] ?? "" };
|
|
503
|
+
}
|
|
504
|
+
knownHostPatternMatches(hosts, host, port) {
|
|
505
|
+
const candidates = new Set([host, `[${host}]:${port}`]);
|
|
506
|
+
for (const pattern of hosts.split(",")) {
|
|
507
|
+
if (pattern.startsWith("|")) {
|
|
508
|
+
if (this.hashedKnownHostPatternMatches(pattern, candidates)) {
|
|
509
|
+
return true;
|
|
510
|
+
}
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (candidates.has(pattern)) {
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
const regex = new RegExp(`^${pattern
|
|
517
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
518
|
+
.replace(/\*/g, ".*")
|
|
519
|
+
.replace(/\?/g, ".")}$`);
|
|
520
|
+
if (regex.test(host)) {
|
|
521
|
+
return true;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
hashedKnownHostPatternMatches(pattern, candidates) {
|
|
527
|
+
const match = /^\|1\|([^|]+)\|([^|]+)$/u.exec(pattern);
|
|
528
|
+
if (!match) {
|
|
529
|
+
return false;
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
const salt = Buffer.from(match[1] ?? "", "base64");
|
|
533
|
+
const expected = match[2] ?? "";
|
|
534
|
+
for (const candidate of candidates) {
|
|
535
|
+
const digest = createHmac("sha1", salt).update(candidate).digest("base64");
|
|
536
|
+
if (digest === expected) {
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Evicts the oldest (least recently used) session
|
|
548
|
+
*/
|
|
549
|
+
evictOldestSession() {
|
|
550
|
+
let oldestSession;
|
|
551
|
+
let oldestTime = Date.now();
|
|
552
|
+
for (const [sessionId, session] of this.sessions) {
|
|
553
|
+
if (session.info.lastUsed < oldestTime) {
|
|
554
|
+
oldestTime = session.info.lastUsed;
|
|
555
|
+
oldestSession = sessionId;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (oldestSession) {
|
|
559
|
+
logger.info("Evicting oldest session", { sessionId: oldestSession });
|
|
560
|
+
void this.closeSession(oldestSession);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/**
|
|
564
|
+
* Cleans up expired sessions
|
|
565
|
+
*/
|
|
566
|
+
cleanupExpiredSessions() {
|
|
567
|
+
const now = Date.now();
|
|
568
|
+
const expiredSessions = [];
|
|
569
|
+
for (const [sessionId, session] of this.sessions) {
|
|
570
|
+
if (now > session.info.expiresAt) {
|
|
571
|
+
expiredSessions.push(sessionId);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
for (const sessionId of expiredSessions) {
|
|
575
|
+
logger.info("Cleaning up expired session", { sessionId });
|
|
576
|
+
void this.closeSession(sessionId);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Gets information about all active sessions
|
|
581
|
+
*/
|
|
582
|
+
getActiveSessions() {
|
|
583
|
+
return Array.from(this.sessions.values()).map((session) => ({
|
|
584
|
+
...session.info,
|
|
585
|
+
}));
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Closes all active sessions
|
|
589
|
+
*/
|
|
590
|
+
async closeAllSessions() {
|
|
591
|
+
const sessionIds = Array.from(this.sessions.keys());
|
|
592
|
+
await Promise.all(sessionIds.map((id) => this.closeSession(id)));
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Attempts to reconnect a session
|
|
596
|
+
*/
|
|
597
|
+
async reconnectSession(sessionId) {
|
|
598
|
+
const session = this.sessions.get(sessionId);
|
|
599
|
+
if (!session) {
|
|
600
|
+
logger.warn("Session not found for reconnect", { sessionId });
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
if (!session.connectionParams) {
|
|
604
|
+
logger.warn("Session has no stored connection params", { sessionId });
|
|
605
|
+
return null;
|
|
606
|
+
}
|
|
607
|
+
logger.info("Attempting to reconnect session", {
|
|
608
|
+
sessionId,
|
|
609
|
+
host: session.info.host,
|
|
610
|
+
});
|
|
611
|
+
await this.closeSession(sessionId);
|
|
612
|
+
try {
|
|
613
|
+
const result = await this.openSession(session.connectionParams);
|
|
614
|
+
logger.info("Session reconnected successfully", {
|
|
615
|
+
oldSessionId: sessionId,
|
|
616
|
+
newSessionId: result.sessionId,
|
|
617
|
+
});
|
|
618
|
+
return result;
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
logger.error("Failed to reconnect session", { sessionId, error });
|
|
622
|
+
throw error;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Checks if a session is alive by executing a simple command
|
|
627
|
+
*/
|
|
628
|
+
async isSessionAlive(sessionId) {
|
|
629
|
+
const session = this.sessions.get(sessionId);
|
|
630
|
+
if (!session) {
|
|
631
|
+
return false;
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
const result = await session.ssh.execCommand("echo 1");
|
|
635
|
+
return result.code === 0;
|
|
636
|
+
}
|
|
637
|
+
catch (error) {
|
|
638
|
+
logger.debug("Session health check failed", { sessionId, error });
|
|
639
|
+
return false;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Gets session with auto-reconnect if disconnected
|
|
644
|
+
*/
|
|
645
|
+
async getSessionWithReconnect(sessionId) {
|
|
646
|
+
const session = this.getSession(sessionId);
|
|
647
|
+
if (!session) {
|
|
648
|
+
return undefined;
|
|
649
|
+
}
|
|
650
|
+
if (!(await this.isSessionAlive(sessionId))) {
|
|
651
|
+
logger.info("Session disconnected, attempting reconnect", { sessionId });
|
|
652
|
+
if (session.connectionParams) {
|
|
653
|
+
try {
|
|
654
|
+
await this.reconnectSession(sessionId);
|
|
655
|
+
return undefined;
|
|
656
|
+
}
|
|
657
|
+
catch (error) {
|
|
658
|
+
logger.error("Auto-reconnect failed", { sessionId, error });
|
|
659
|
+
return undefined;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
return session;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
//# sourceMappingURL=session.js.map
|