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.
Files changed (267) hide show
  1. package/AGENTS.md +127 -0
  2. package/ARCHITECTURE.md +145 -0
  3. package/LICENSE +21 -0
  4. package/LICENSES/MIT.txt +21 -0
  5. package/MIGRATION.md +14 -0
  6. package/README.md +175 -0
  7. package/REGISTRY_SUBMISSION.md +38 -0
  8. package/SECURITY.md +40 -0
  9. package/SECURITY_DECISIONS.md +59 -0
  10. package/dist/agent-bin.d.ts +3 -0
  11. package/dist/agent-bin.d.ts.map +1 -0
  12. package/dist/agent-bin.js +8 -0
  13. package/dist/agent-bin.js.map +1 -0
  14. package/dist/audit.d.ts +25 -0
  15. package/dist/audit.d.ts.map +1 -0
  16. package/dist/audit.js +50 -0
  17. package/dist/audit.js.map +1 -0
  18. package/dist/auth.d.ts +4 -0
  19. package/dist/auth.d.ts.map +1 -0
  20. package/dist/auth.js +33 -0
  21. package/dist/auth.js.map +1 -0
  22. package/dist/cli.d.ts +16 -0
  23. package/dist/cli.d.ts.map +1 -0
  24. package/dist/cli.js +99 -0
  25. package/dist/cli.js.map +1 -0
  26. package/dist/config.d.ts +103 -0
  27. package/dist/config.d.ts.map +1 -0
  28. package/dist/config.js +490 -0
  29. package/dist/config.js.map +1 -0
  30. package/dist/connector-credentials.d.ts +8 -0
  31. package/dist/connector-credentials.d.ts.map +1 -0
  32. package/dist/connector-credentials.js +132 -0
  33. package/dist/connector-credentials.js.map +1 -0
  34. package/dist/connector-profile.d.ts +17 -0
  35. package/dist/connector-profile.d.ts.map +1 -0
  36. package/dist/connector-profile.js +81 -0
  37. package/dist/connector-profile.js.map +1 -0
  38. package/dist/container.d.ts +18 -0
  39. package/dist/container.d.ts.map +1 -0
  40. package/dist/container.js +52 -0
  41. package/dist/container.js.map +1 -0
  42. package/dist/detect.d.ts +7 -0
  43. package/dist/detect.d.ts.map +1 -0
  44. package/dist/detect.js +271 -0
  45. package/dist/detect.js.map +1 -0
  46. package/dist/ensure.d.ts +17 -0
  47. package/dist/ensure.d.ts.map +1 -0
  48. package/dist/ensure.js +531 -0
  49. package/dist/ensure.js.map +1 -0
  50. package/dist/errors.d.ts +54 -0
  51. package/dist/errors.d.ts.map +1 -0
  52. package/dist/errors.js +84 -0
  53. package/dist/errors.js.map +1 -0
  54. package/dist/fs-tools.d.ts +26 -0
  55. package/dist/fs-tools.d.ts.map +1 -0
  56. package/dist/fs-tools.js +599 -0
  57. package/dist/fs-tools.js.map +1 -0
  58. package/dist/http-rate-limit.d.ts +9 -0
  59. package/dist/http-rate-limit.d.ts.map +1 -0
  60. package/dist/http-rate-limit.js +41 -0
  61. package/dist/http-rate-limit.js.map +1 -0
  62. package/dist/http-security.d.ts +22 -0
  63. package/dist/http-security.d.ts.map +1 -0
  64. package/dist/http-security.js +88 -0
  65. package/dist/http-security.js.map +1 -0
  66. package/dist/index.d.ts +10 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +201 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/logging.d.ts +52 -0
  71. package/dist/logging.d.ts.map +1 -0
  72. package/dist/logging.js +180 -0
  73. package/dist/logging.js.map +1 -0
  74. package/dist/mcp.d.ts +16 -0
  75. package/dist/mcp.d.ts.map +1 -0
  76. package/dist/mcp.js +159 -0
  77. package/dist/mcp.js.map +1 -0
  78. package/dist/metrics.d.ts +95 -0
  79. package/dist/metrics.d.ts.map +1 -0
  80. package/dist/metrics.js +204 -0
  81. package/dist/metrics.js.map +1 -0
  82. package/dist/oauth.d.ts +14 -0
  83. package/dist/oauth.d.ts.map +1 -0
  84. package/dist/oauth.js +105 -0
  85. package/dist/oauth.js.map +1 -0
  86. package/dist/policy.d.ts +64 -0
  87. package/dist/policy.d.ts.map +1 -0
  88. package/dist/policy.js +368 -0
  89. package/dist/policy.js.map +1 -0
  90. package/dist/process.d.ts +24 -0
  91. package/dist/process.d.ts.map +1 -0
  92. package/dist/process.js +212 -0
  93. package/dist/process.js.map +1 -0
  94. package/dist/prompts.d.ts +49 -0
  95. package/dist/prompts.d.ts.map +1 -0
  96. package/dist/prompts.js +191 -0
  97. package/dist/prompts.js.map +1 -0
  98. package/dist/rate-limiter.d.ts +57 -0
  99. package/dist/rate-limiter.d.ts.map +1 -0
  100. package/dist/rate-limiter.js +141 -0
  101. package/dist/rate-limiter.js.map +1 -0
  102. package/dist/remote/agent-cli.d.ts +2 -0
  103. package/dist/remote/agent-cli.d.ts.map +1 -0
  104. package/dist/remote/agent-cli.js +270 -0
  105. package/dist/remote/agent-cli.js.map +1 -0
  106. package/dist/remote/agent-executor.d.ts +26 -0
  107. package/dist/remote/agent-executor.d.ts.map +1 -0
  108. package/dist/remote/agent-executor.js +400 -0
  109. package/dist/remote/agent-executor.js.map +1 -0
  110. package/dist/remote/config.d.ts +3 -0
  111. package/dist/remote/config.d.ts.map +1 -0
  112. package/dist/remote/config.js +52 -0
  113. package/dist/remote/config.js.map +1 -0
  114. package/dist/remote/control-plane.d.ts +57 -0
  115. package/dist/remote/control-plane.d.ts.map +1 -0
  116. package/dist/remote/control-plane.js +1248 -0
  117. package/dist/remote/control-plane.js.map +1 -0
  118. package/dist/remote/crypto.d.ts +38 -0
  119. package/dist/remote/crypto.d.ts.map +1 -0
  120. package/dist/remote/crypto.js +143 -0
  121. package/dist/remote/crypto.js.map +1 -0
  122. package/dist/remote/mcp-tools.d.ts +10 -0
  123. package/dist/remote/mcp-tools.d.ts.map +1 -0
  124. package/dist/remote/mcp-tools.js +201 -0
  125. package/dist/remote/mcp-tools.js.map +1 -0
  126. package/dist/remote/policy.d.ts +11 -0
  127. package/dist/remote/policy.d.ts.map +1 -0
  128. package/dist/remote/policy.js +94 -0
  129. package/dist/remote/policy.js.map +1 -0
  130. package/dist/remote/schemas.d.ts +298 -0
  131. package/dist/remote/schemas.d.ts.map +1 -0
  132. package/dist/remote/schemas.js +111 -0
  133. package/dist/remote/schemas.js.map +1 -0
  134. package/dist/remote/scopes.d.ts +6 -0
  135. package/dist/remote/scopes.d.ts.map +1 -0
  136. package/dist/remote/scopes.js +24 -0
  137. package/dist/remote/scopes.js.map +1 -0
  138. package/dist/remote/store.d.ts +45 -0
  139. package/dist/remote/store.d.ts.map +1 -0
  140. package/dist/remote/store.js +355 -0
  141. package/dist/remote/store.js.map +1 -0
  142. package/dist/remote/types.d.ts +183 -0
  143. package/dist/remote/types.d.ts.map +1 -0
  144. package/dist/remote/types.js +103 -0
  145. package/dist/remote/types.js.map +1 -0
  146. package/dist/remote/util.d.ts +6 -0
  147. package/dist/remote/util.d.ts.map +1 -0
  148. package/dist/remote/util.js +45 -0
  149. package/dist/remote/util.js.map +1 -0
  150. package/dist/remote/websocket.d.ts +26 -0
  151. package/dist/remote/websocket.d.ts.map +1 -0
  152. package/dist/remote/websocket.js +167 -0
  153. package/dist/remote/websocket.js.map +1 -0
  154. package/dist/render-http.d.ts +2 -0
  155. package/dist/render-http.d.ts.map +1 -0
  156. package/dist/render-http.js +14 -0
  157. package/dist/render-http.js.map +1 -0
  158. package/dist/resources.d.ts +19 -0
  159. package/dist/resources.d.ts.map +1 -0
  160. package/dist/resources.js +96 -0
  161. package/dist/resources.js.map +1 -0
  162. package/dist/retry.d.ts +45 -0
  163. package/dist/retry.d.ts.map +1 -0
  164. package/dist/retry.js +120 -0
  165. package/dist/retry.js.map +1 -0
  166. package/dist/safety.d.ts +31 -0
  167. package/dist/safety.d.ts.map +1 -0
  168. package/dist/safety.js +174 -0
  169. package/dist/safety.js.map +1 -0
  170. package/dist/server-http.d.ts +2 -0
  171. package/dist/server-http.d.ts.map +1 -0
  172. package/dist/server-http.js +432 -0
  173. package/dist/server-http.js.map +1 -0
  174. package/dist/session.d.ts +116 -0
  175. package/dist/session.d.ts.map +1 -0
  176. package/dist/session.js +666 -0
  177. package/dist/session.js.map +1 -0
  178. package/dist/shell.d.ts +10 -0
  179. package/dist/shell.d.ts.map +1 -0
  180. package/dist/shell.js +83 -0
  181. package/dist/shell.js.map +1 -0
  182. package/dist/ssh-config.d.ts +94 -0
  183. package/dist/ssh-config.d.ts.map +1 -0
  184. package/dist/ssh-config.js +234 -0
  185. package/dist/ssh-config.js.map +1 -0
  186. package/dist/streaming.d.ts +36 -0
  187. package/dist/streaming.d.ts.map +1 -0
  188. package/dist/streaming.js +140 -0
  189. package/dist/streaming.js.map +1 -0
  190. package/dist/telemetry.d.ts +17 -0
  191. package/dist/telemetry.d.ts.map +1 -0
  192. package/dist/telemetry.js +101 -0
  193. package/dist/telemetry.js.map +1 -0
  194. package/dist/tools/connector.provider.d.ts +28 -0
  195. package/dist/tools/connector.provider.d.ts.map +1 -0
  196. package/dist/tools/connector.provider.js +360 -0
  197. package/dist/tools/connector.provider.js.map +1 -0
  198. package/dist/tools/ensure.provider.d.ts +18 -0
  199. package/dist/tools/ensure.provider.d.ts.map +1 -0
  200. package/dist/tools/ensure.provider.js +173 -0
  201. package/dist/tools/ensure.provider.js.map +1 -0
  202. package/dist/tools/fs.provider.d.ts +21 -0
  203. package/dist/tools/fs.provider.d.ts.map +1 -0
  204. package/dist/tools/fs.provider.js +259 -0
  205. package/dist/tools/fs.provider.js.map +1 -0
  206. package/dist/tools/index.d.ts +4 -0
  207. package/dist/tools/index.d.ts.map +1 -0
  208. package/dist/tools/index.js +68 -0
  209. package/dist/tools/index.js.map +1 -0
  210. package/dist/tools/metadata.d.ts +11 -0
  211. package/dist/tools/metadata.d.ts.map +1 -0
  212. package/dist/tools/metadata.js +10 -0
  213. package/dist/tools/metadata.js.map +1 -0
  214. package/dist/tools/output-schemas.d.ts +217 -0
  215. package/dist/tools/output-schemas.d.ts.map +1 -0
  216. package/dist/tools/output-schemas.js +300 -0
  217. package/dist/tools/output-schemas.js.map +1 -0
  218. package/dist/tools/process.provider.d.ts +22 -0
  219. package/dist/tools/process.provider.d.ts.map +1 -0
  220. package/dist/tools/process.provider.js +146 -0
  221. package/dist/tools/process.provider.js.map +1 -0
  222. package/dist/tools/registry.d.ts +12 -0
  223. package/dist/tools/registry.d.ts.map +1 -0
  224. package/dist/tools/registry.js +163 -0
  225. package/dist/tools/registry.js.map +1 -0
  226. package/dist/tools/results.d.ts +4 -0
  227. package/dist/tools/results.d.ts.map +1 -0
  228. package/dist/tools/results.js +5 -0
  229. package/dist/tools/results.js.map +1 -0
  230. package/dist/tools/session.provider.d.ts +23 -0
  231. package/dist/tools/session.provider.d.ts.map +1 -0
  232. package/dist/tools/session.provider.js +299 -0
  233. package/dist/tools/session.provider.js.map +1 -0
  234. package/dist/tools/system.provider.d.ts +18 -0
  235. package/dist/tools/system.provider.d.ts.map +1 -0
  236. package/dist/tools/system.provider.js +81 -0
  237. package/dist/tools/system.provider.js.map +1 -0
  238. package/dist/tools/transfer.provider.d.ts +16 -0
  239. package/dist/tools/transfer.provider.d.ts.map +1 -0
  240. package/dist/tools/transfer.provider.js +85 -0
  241. package/dist/tools/transfer.provider.js.map +1 -0
  242. package/dist/tools/tunnel.provider.d.ts +18 -0
  243. package/dist/tools/tunnel.provider.d.ts.map +1 -0
  244. package/dist/tools/tunnel.provider.js +142 -0
  245. package/dist/tools/tunnel.provider.js.map +1 -0
  246. package/dist/tools/types.d.ts +16 -0
  247. package/dist/tools/types.d.ts.map +1 -0
  248. package/dist/tools/types.js +2 -0
  249. package/dist/tools/types.js.map +1 -0
  250. package/dist/transfer.d.ts +40 -0
  251. package/dist/transfer.d.ts.map +1 -0
  252. package/dist/transfer.js +363 -0
  253. package/dist/transfer.js.map +1 -0
  254. package/dist/tunnel.d.ts +37 -0
  255. package/dist/tunnel.d.ts.map +1 -0
  256. package/dist/tunnel.js +234 -0
  257. package/dist/tunnel.js.map +1 -0
  258. package/dist/types.d.ts +341 -0
  259. package/dist/types.d.ts.map +1 -0
  260. package/dist/types.js +184 -0
  261. package/dist/types.js.map +1 -0
  262. package/docs/docker.md +22 -0
  263. package/examples/README.md +77 -0
  264. package/mcp.json +21 -0
  265. package/package.json +147 -0
  266. package/registry/ssh-mcp-pro/mcp.json +21 -0
  267. package/server.json +76 -0
@@ -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