@aaarc/handfree-ssh-mcp 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/LICENSE +16 -0
- package/README.md +329 -0
- package/build/config/config-loader.js +329 -0
- package/build/config/index.js +5 -0
- package/build/config/server.js +25 -0
- package/build/config/ssh-config-loader.js +376 -0
- package/build/core/mcp-server.js +176 -0
- package/build/index.js +11 -0
- package/build/models/types.js +1 -0
- package/build/services/ssh-connection-manager.js +1726 -0
- package/build/tests/command-validation.test.js +876 -0
- package/build/tests/config.test.js +617 -0
- package/build/tests/integration.test.js +348 -0
- package/build/tests/output-collector.test.js +70 -0
- package/build/tests/output-log-writer.test.js +205 -0
- package/build/tests/recent-fixes.test.js +229 -0
- package/build/tests/tool-error.test.js +26 -0
- package/build/tests/tools.test.js +486 -0
- package/build/tools/download.js +31 -0
- package/build/tools/execute-command.js +80 -0
- package/build/tools/help.js +147 -0
- package/build/tools/index.js +20 -0
- package/build/tools/list-servers.js +36 -0
- package/build/tools/show-whitelist.js +216 -0
- package/build/tools/transfer.js +96 -0
- package/build/tools/upload.js +36 -0
- package/build/utils/logger.js +27 -0
- package/build/utils/output-collector.js +76 -0
- package/build/utils/output-log-writer.js +232 -0
- package/build/utils/status-collector.js +212 -0
- package/build/utils/tool-error.js +26 -0
- package/package.json +52 -0
|
@@ -0,0 +1,1726 @@
|
|
|
1
|
+
import { Client } from "ssh2";
|
|
2
|
+
import { SocksClient } from "socks";
|
|
3
|
+
import { Logger } from "../utils/logger.js";
|
|
4
|
+
import { collectSystemStatus } from "../utils/status-collector.js";
|
|
5
|
+
import { ToolError } from "../utils/tool-error.js";
|
|
6
|
+
import { OutputCollector } from "../utils/output-collector.js";
|
|
7
|
+
import { OutputLogWriter } from "../utils/output-log-writer.js";
|
|
8
|
+
import fs from "fs";
|
|
9
|
+
import path from "path";
|
|
10
|
+
import crypto from "crypto";
|
|
11
|
+
const CONNECTION_RESET_FIELDS = [
|
|
12
|
+
"host",
|
|
13
|
+
"port",
|
|
14
|
+
"username",
|
|
15
|
+
"password",
|
|
16
|
+
"privateKey",
|
|
17
|
+
"passphrase",
|
|
18
|
+
"agent",
|
|
19
|
+
"identitiesOnly",
|
|
20
|
+
"authOptional",
|
|
21
|
+
"socksProxy",
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Default command whitelist - only allow safe Linux commands
|
|
25
|
+
* These patterns are regex strings that match allowed commands
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Default command whitelist - only allow safe Linux commands
|
|
29
|
+
* These patterns are regex strings that match allowed commands
|
|
30
|
+
*
|
|
31
|
+
* SECURITY: Commands not matching any pattern will be BLOCKED with an error message
|
|
32
|
+
*/
|
|
33
|
+
const DEFAULT_COMMAND_WHITELIST = [
|
|
34
|
+
// Basic file operations (READ-ONLY)
|
|
35
|
+
"^ls( .*)?$",
|
|
36
|
+
"^cat .*$",
|
|
37
|
+
"^head .*$",
|
|
38
|
+
"^tail .*$",
|
|
39
|
+
"^less .*$",
|
|
40
|
+
"^more .*$",
|
|
41
|
+
"^wc .*$",
|
|
42
|
+
"^file .*$",
|
|
43
|
+
"^stat .*$",
|
|
44
|
+
"^find .*$",
|
|
45
|
+
"^grep .*$",
|
|
46
|
+
"^awk .*$",
|
|
47
|
+
"^sed .*$",
|
|
48
|
+
// Directory navigation
|
|
49
|
+
"^pwd$",
|
|
50
|
+
"^cd .*$",
|
|
51
|
+
// System info (read-only)
|
|
52
|
+
"^echo .*$",
|
|
53
|
+
"^hostname$",
|
|
54
|
+
"^whoami$",
|
|
55
|
+
"^id$",
|
|
56
|
+
"^uname .*$",
|
|
57
|
+
"^df .*$",
|
|
58
|
+
"^du .*$",
|
|
59
|
+
"^free .*$",
|
|
60
|
+
"^top -bn1.*$",
|
|
61
|
+
"^ps .*$",
|
|
62
|
+
"^uptime$",
|
|
63
|
+
"^date$",
|
|
64
|
+
"^env$",
|
|
65
|
+
"^printenv.*$",
|
|
66
|
+
// Network diagnostics (read-only)
|
|
67
|
+
"^ping -c \\d+ .*$",
|
|
68
|
+
"^curl .*$",
|
|
69
|
+
"^wget .*$",
|
|
70
|
+
"^netstat .*$",
|
|
71
|
+
"^ss .*$",
|
|
72
|
+
"^ip .*$",
|
|
73
|
+
// Git operations
|
|
74
|
+
"^git .*$",
|
|
75
|
+
// Systemctl (read-only)
|
|
76
|
+
"^systemctl status .*$",
|
|
77
|
+
"^systemctl list-.*$",
|
|
78
|
+
"^journalctl .*$",
|
|
79
|
+
// Docker (read-only)
|
|
80
|
+
"^docker ps.*$",
|
|
81
|
+
"^docker logs.*$",
|
|
82
|
+
"^docker images.*$",
|
|
83
|
+
"^docker inspect.*$",
|
|
84
|
+
// ============================================
|
|
85
|
+
// DANGEROUS COMMANDS - EXPLICITLY BANNED:
|
|
86
|
+
// - rm, rmdir (delete files/dirs)
|
|
87
|
+
// - mv (can overwrite/move files)
|
|
88
|
+
// - cp (can overwrite files)
|
|
89
|
+
// - chmod, chown (change permissions)
|
|
90
|
+
// - kill, pkill (terminate processes)
|
|
91
|
+
// - mkdir, touch (create files/dirs)
|
|
92
|
+
// - >, >> (redirect/overwrite files)
|
|
93
|
+
// - wget -O, curl -o (download and overwrite)
|
|
94
|
+
// ============================================
|
|
95
|
+
];
|
|
96
|
+
/**
|
|
97
|
+
* SSH Connection Manager class
|
|
98
|
+
*/
|
|
99
|
+
export class SSHConnectionManager {
|
|
100
|
+
static instance;
|
|
101
|
+
clients = new Map();
|
|
102
|
+
configs = {};
|
|
103
|
+
connected = new Map();
|
|
104
|
+
statusCache = new Map();
|
|
105
|
+
connectionGenerations = new Map();
|
|
106
|
+
pendingClients = new Map();
|
|
107
|
+
// In-flight connect() promises, keyed by server name. Used to dedupe
|
|
108
|
+
// concurrent connect attempts so we never create two SSH clients for the
|
|
109
|
+
// same server and leak the loser.
|
|
110
|
+
connecting = new Map();
|
|
111
|
+
defaultName = "default";
|
|
112
|
+
enabledServers = null; // null = all servers enabled
|
|
113
|
+
outputLogRoot = null; // null = use <cwd>/.handfree-output at write time
|
|
114
|
+
constructor() { }
|
|
115
|
+
/**
|
|
116
|
+
* Get singleton instance
|
|
117
|
+
*/
|
|
118
|
+
static getInstance() {
|
|
119
|
+
if (!SSHConnectionManager.instance) {
|
|
120
|
+
SSHConnectionManager.instance = new SSHConnectionManager();
|
|
121
|
+
}
|
|
122
|
+
return SSHConnectionManager.instance;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Batch set SSH configurations
|
|
126
|
+
* @param configs - Server configurations
|
|
127
|
+
* @param enabledServers - List of enabled server names (null = all enabled)
|
|
128
|
+
*/
|
|
129
|
+
setConfig(configs, enabledServers) {
|
|
130
|
+
this.configs = configs;
|
|
131
|
+
this.enabledServers = enabledServers || null;
|
|
132
|
+
// Only single-server deployments have a meaningful "default". When >1
|
|
133
|
+
// server is enabled, every tool call must specify connectionName
|
|
134
|
+
// (enforced by resolveServer), so a default would just hide bugs.
|
|
135
|
+
const effectiveNames = enabledServers && enabledServers.length > 0
|
|
136
|
+
? enabledServers
|
|
137
|
+
: Object.keys(configs);
|
|
138
|
+
if (effectiveNames.length === 1) {
|
|
139
|
+
this.defaultName = effectiveNames[0];
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
this.defaultName = "";
|
|
143
|
+
}
|
|
144
|
+
if (this.enabledServers) {
|
|
145
|
+
Logger.log(`Enabled servers: ${this.enabledServers.join(", ")}`, "info");
|
|
146
|
+
if (this.defaultName) {
|
|
147
|
+
Logger.log(`Default server: ${this.defaultName}`, "info");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Replace the full config map during hot-reload. Connections whose host,
|
|
153
|
+
* port, username, authentication, or proxy settings changed are closed so
|
|
154
|
+
* the next tool call reconnects with the fresh OpenSSH/YAML values.
|
|
155
|
+
*/
|
|
156
|
+
replaceConfig(configs, enabledServers) {
|
|
157
|
+
const previous = this.configs;
|
|
158
|
+
const changedConnections = [];
|
|
159
|
+
for (const [name, oldConfig] of Object.entries(previous)) {
|
|
160
|
+
const nextConfig = configs[name];
|
|
161
|
+
if (!nextConfig || this.connectionFieldsChanged(oldConfig, nextConfig)) {
|
|
162
|
+
this.closeClient(name, true);
|
|
163
|
+
this.statusCache.delete(name);
|
|
164
|
+
changedConnections.push(name);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
this.setConfig(configs, enabledServers);
|
|
168
|
+
Logger.log(`Hot-reloaded SSH config: ${Object.keys(configs).length} server(s), ` +
|
|
169
|
+
`${changedConnections.length} connection(s) reset`, "info");
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Set the root directory under which execute-command full-output logs are
|
|
173
|
+
* persisted. If null/unset, defaults to <cwd>/.handfree-output resolved at
|
|
174
|
+
* each write. Per-call logs land under <root>/<server>/<user>/<file>.log.
|
|
175
|
+
*/
|
|
176
|
+
setOutputLogRoot(rootDir) {
|
|
177
|
+
this.outputLogRoot = rootDir && rootDir.length > 0 ? rootDir : null;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Resolve the configured output log root, applying the default
|
|
181
|
+
* (<cwd>/.handfree-output) when nothing was explicitly set.
|
|
182
|
+
*/
|
|
183
|
+
getOutputLogRoot() {
|
|
184
|
+
return this.outputLogRoot ?? path.join(process.cwd(), ".handfree-output");
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Hot-reload mutable policy fields from a fresh config map.
|
|
188
|
+
* Only updates whitelist, blacklist, safeDirectory, allowedRemoteDirectories,
|
|
189
|
+
* and allowedLocalDirectories for servers that already exist. Does NOT touch
|
|
190
|
+
* SSH connections or credentials.
|
|
191
|
+
*/
|
|
192
|
+
updatePolicies(freshConfigs) {
|
|
193
|
+
let changed = 0;
|
|
194
|
+
for (const [name, existing] of Object.entries(this.configs)) {
|
|
195
|
+
const fresh = freshConfigs[name];
|
|
196
|
+
if (!fresh)
|
|
197
|
+
continue;
|
|
198
|
+
const wlChanged = JSON.stringify(existing.commandWhitelist) !==
|
|
199
|
+
JSON.stringify(fresh.commandWhitelist);
|
|
200
|
+
const blChanged = JSON.stringify(existing.commandBlacklist) !==
|
|
201
|
+
JSON.stringify(fresh.commandBlacklist);
|
|
202
|
+
const sdChanged = existing.safeDirectory !== fresh.safeDirectory;
|
|
203
|
+
const ardChanged = JSON.stringify(existing.allowedRemoteDirectories) !==
|
|
204
|
+
JSON.stringify(fresh.allowedRemoteDirectories);
|
|
205
|
+
const aldChanged = JSON.stringify(existing.allowedLocalDirectories) !==
|
|
206
|
+
JSON.stringify(fresh.allowedLocalDirectories);
|
|
207
|
+
if (wlChanged || blChanged || sdChanged || ardChanged || aldChanged) {
|
|
208
|
+
existing.commandWhitelist = fresh.commandWhitelist;
|
|
209
|
+
existing.commandBlacklist = fresh.commandBlacklist;
|
|
210
|
+
existing.safeDirectory = fresh.safeDirectory;
|
|
211
|
+
existing.allowedRemoteDirectories = fresh.allowedRemoteDirectories;
|
|
212
|
+
existing.allowedLocalDirectories = fresh.allowedLocalDirectories;
|
|
213
|
+
changed++;
|
|
214
|
+
const parts = [];
|
|
215
|
+
if (wlChanged)
|
|
216
|
+
parts.push(`whitelist(${(fresh.commandWhitelist ?? []).length})`);
|
|
217
|
+
if (blChanged)
|
|
218
|
+
parts.push(`blacklist(${(fresh.commandBlacklist ?? []).length})`);
|
|
219
|
+
if (sdChanged)
|
|
220
|
+
parts.push(`safeDirectory(${fresh.safeDirectory ?? "none"})`);
|
|
221
|
+
if (ardChanged)
|
|
222
|
+
parts.push(`allowedRemoteDirectories(${(fresh.allowedRemoteDirectories ?? []).length})`);
|
|
223
|
+
if (aldChanged)
|
|
224
|
+
parts.push(`allowedLocalDirectories(${(fresh.allowedLocalDirectories ?? []).length})`);
|
|
225
|
+
Logger.log(`Hot-reloaded policies for [${name}]: ${parts.join(", ")}`, "info");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (changed === 0) {
|
|
229
|
+
Logger.log("Config file changed but no policy updates detected", "info");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
connectionFieldsChanged(oldConfig, nextConfig) {
|
|
233
|
+
return CONNECTION_RESET_FIELDS.some((key) => oldConfig[key] !== nextConfig[key]);
|
|
234
|
+
}
|
|
235
|
+
closeClient(name, bumpGeneration = false) {
|
|
236
|
+
if (bumpGeneration) {
|
|
237
|
+
this.bumpConnectionGeneration(name);
|
|
238
|
+
}
|
|
239
|
+
const pendingClient = this.pendingClients.get(name);
|
|
240
|
+
if (pendingClient) {
|
|
241
|
+
try {
|
|
242
|
+
pendingClient.end();
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
// Ignore close errors for dead pending clients.
|
|
246
|
+
}
|
|
247
|
+
this.pendingClients.delete(name);
|
|
248
|
+
}
|
|
249
|
+
const client = this.clients.get(name);
|
|
250
|
+
if (client) {
|
|
251
|
+
try {
|
|
252
|
+
client.end();
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
// Ignore close errors for dead clients.
|
|
256
|
+
}
|
|
257
|
+
this.clients.delete(name);
|
|
258
|
+
}
|
|
259
|
+
this.connected.set(name, false);
|
|
260
|
+
this.connecting.delete(name);
|
|
261
|
+
}
|
|
262
|
+
bumpConnectionGeneration(name) {
|
|
263
|
+
this.connectionGenerations.set(name, (this.connectionGenerations.get(name) ?? 0) + 1);
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Check if a server is enabled for use
|
|
267
|
+
*/
|
|
268
|
+
isServerEnabled(name) {
|
|
269
|
+
if (!this.enabledServers) {
|
|
270
|
+
return true; // All servers enabled
|
|
271
|
+
}
|
|
272
|
+
return this.enabledServers.includes(name);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* Returns true when more than one server is enabled,
|
|
276
|
+
* meaning callers MUST specify connectionName explicitly.
|
|
277
|
+
*/
|
|
278
|
+
isMultiServer() {
|
|
279
|
+
const count = this.enabledServers
|
|
280
|
+
? this.enabledServers.length
|
|
281
|
+
: Object.keys(this.configs).length;
|
|
282
|
+
return count > 1;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Resolve the target server name.
|
|
286
|
+
* When multiple servers are enabled, connectionName is mandatory.
|
|
287
|
+
*/
|
|
288
|
+
resolveServer(connectionName) {
|
|
289
|
+
if (this.isMultiServer() && !connectionName) {
|
|
290
|
+
const names = this.enabledServers ?? Object.keys(this.configs);
|
|
291
|
+
throw new ToolError("INVALID_CONFIGURATION", `Multiple servers are enabled (${names.join(", ")}). You must specify connectionName explicitly. Call list-servers to see available names.`, false);
|
|
292
|
+
}
|
|
293
|
+
return connectionName || this.defaultName;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Get specified connection configuration
|
|
297
|
+
* Throws error if server is not enabled
|
|
298
|
+
*/
|
|
299
|
+
getConfig(name) {
|
|
300
|
+
const key = name || this.defaultName;
|
|
301
|
+
// Check if server exists
|
|
302
|
+
if (!this.configs[key]) {
|
|
303
|
+
throw new ToolError("INVALID_CONFIGURATION", `SSH configuration for '${key}' not set`, false);
|
|
304
|
+
}
|
|
305
|
+
// Check if server is enabled
|
|
306
|
+
if (!this.isServerEnabled(key)) {
|
|
307
|
+
throw new ToolError("INVALID_CONFIGURATION", `SSH server '${key}' is not enabled. Enabled servers: ${this.enabledServers?.join(", ") || "none"}`, false);
|
|
308
|
+
}
|
|
309
|
+
return this.configs[key];
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Get server config without throwing (returns null if not found/enabled)
|
|
313
|
+
* Useful for tools that want to inspect config without failing
|
|
314
|
+
*/
|
|
315
|
+
getServerConfig(name) {
|
|
316
|
+
const key = name || this.defaultName;
|
|
317
|
+
if (!this.configs[key]) {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
if (!this.isServerEnabled(key)) {
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
return this.configs[key];
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Batch connect all configured SSH connections
|
|
327
|
+
*/
|
|
328
|
+
async connectAll() {
|
|
329
|
+
const names = this.enabledServers ?? Object.keys(this.configs);
|
|
330
|
+
const results = await Promise.allSettled(names.map((name) => this.connect(name)));
|
|
331
|
+
const failures = results
|
|
332
|
+
.map((r, i) => (r.status === "rejected" ? `${names[i]}: ${r.reason.message}` : null))
|
|
333
|
+
.filter(Boolean);
|
|
334
|
+
if (failures.length > 0) {
|
|
335
|
+
Logger.log(`Pre-connect failures: ${failures.join("; ")}`, "error");
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Connect to SSH with specified name.
|
|
340
|
+
*
|
|
341
|
+
* Concurrent callers for the same server share a single in-flight promise,
|
|
342
|
+
* so we never create two SSH clients and leak the loser.
|
|
343
|
+
*/
|
|
344
|
+
async connect(name) {
|
|
345
|
+
const key = name || this.defaultName;
|
|
346
|
+
if (this.connected.get(key) && this.clients.get(key)) {
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
const inFlight = this.connecting.get(key);
|
|
350
|
+
if (inFlight) {
|
|
351
|
+
return inFlight;
|
|
352
|
+
}
|
|
353
|
+
const promise = this.doConnect(key).finally(() => {
|
|
354
|
+
this.connecting.delete(key);
|
|
355
|
+
});
|
|
356
|
+
this.connecting.set(key, promise);
|
|
357
|
+
return promise;
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Actual SSH connect implementation. Callers must go through `connect()`
|
|
361
|
+
* so concurrent requests are deduped.
|
|
362
|
+
* @private
|
|
363
|
+
*/
|
|
364
|
+
async doConnect(key) {
|
|
365
|
+
const config = this.getConfig(key);
|
|
366
|
+
const client = new Client();
|
|
367
|
+
const generation = this.connectionGenerations.get(key) ?? 0;
|
|
368
|
+
this.pendingClients.set(key, client);
|
|
369
|
+
try {
|
|
370
|
+
await new Promise(async (resolve, reject) => {
|
|
371
|
+
client.on("ready", () => {
|
|
372
|
+
if ((this.connectionGenerations.get(key) ?? 0) !== generation) {
|
|
373
|
+
try {
|
|
374
|
+
client.end();
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
// Ignore stale-client close errors.
|
|
378
|
+
}
|
|
379
|
+
reject(new ToolError("SSH_CONNECTION_FAILED", `SSH connection [${key}] was superseded by a config reload`, true));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
this.connected.set(key, true);
|
|
383
|
+
Logger.log(`Successfully connected to SSH server [${key}] ${config.host}:${config.port}`);
|
|
384
|
+
// 先 resolve,让用户命令可以立即执行
|
|
385
|
+
resolve();
|
|
386
|
+
// 延迟执行系统状态收集,避免与用户的第一个命令竞争 SSH 通道
|
|
387
|
+
// 这修复了首次连接后第一个命令失败的竞态条件问题
|
|
388
|
+
// See: https://github.com/classfang/ssh-mcp-server/issues/XX
|
|
389
|
+
setTimeout(() => {
|
|
390
|
+
collectSystemStatus(client, key)
|
|
391
|
+
.then((status) => {
|
|
392
|
+
this.statusCache.set(key, status);
|
|
393
|
+
Logger.log(`System status collected for [${key}]`, "info");
|
|
394
|
+
})
|
|
395
|
+
.catch((error) => {
|
|
396
|
+
Logger.log(`Failed to collect system status for [${key}]: ${error.message}`, "error");
|
|
397
|
+
// Set basic status even if collection fails
|
|
398
|
+
this.statusCache.set(key, {
|
|
399
|
+
reachable: true,
|
|
400
|
+
lastUpdated: new Date().toISOString(),
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
}, 1000); // 延迟 1 秒,确保用户命令有足够的时间窗口
|
|
404
|
+
});
|
|
405
|
+
client.on("error", (err) => {
|
|
406
|
+
if ((this.connectionGenerations.get(key) ?? 0) === generation) {
|
|
407
|
+
this.connected.set(key, false);
|
|
408
|
+
}
|
|
409
|
+
reject(new ToolError("SSH_CONNECTION_FAILED", `SSH connection [${key}] failed: ${err.message}`, true));
|
|
410
|
+
});
|
|
411
|
+
client.on("close", () => {
|
|
412
|
+
if ((this.connectionGenerations.get(key) ?? 0) === generation &&
|
|
413
|
+
this.clients.get(key) === client) {
|
|
414
|
+
this.connected.set(key, false);
|
|
415
|
+
}
|
|
416
|
+
Logger.log(`SSH connection [${key}] closed`, "info");
|
|
417
|
+
});
|
|
418
|
+
const sshConfig = {
|
|
419
|
+
host: config.host,
|
|
420
|
+
port: config.port,
|
|
421
|
+
username: config.username,
|
|
422
|
+
};
|
|
423
|
+
const agent = config.agent === false
|
|
424
|
+
? undefined
|
|
425
|
+
: config.agent || (config.identitiesOnly ? undefined : process.env.SSH_AUTH_SOCK);
|
|
426
|
+
if (agent) {
|
|
427
|
+
sshConfig.agent = agent;
|
|
428
|
+
}
|
|
429
|
+
// Add SOCKS proxy configuration if provided
|
|
430
|
+
if (config.socksProxy) {
|
|
431
|
+
try {
|
|
432
|
+
// Parse SOCKS proxy URL
|
|
433
|
+
const proxyUrl = new URL(config.socksProxy);
|
|
434
|
+
const proxyHost = proxyUrl.hostname;
|
|
435
|
+
const proxyPort = parseInt(proxyUrl.port, 10);
|
|
436
|
+
Logger.log(`Using SOCKS proxy for [${key}]: ${config.socksProxy}`, "info");
|
|
437
|
+
// Create SOCKS connection
|
|
438
|
+
const { socket } = await SocksClient.createConnection({
|
|
439
|
+
proxy: {
|
|
440
|
+
host: proxyHost,
|
|
441
|
+
port: proxyPort,
|
|
442
|
+
type: 5,
|
|
443
|
+
},
|
|
444
|
+
command: "connect",
|
|
445
|
+
destination: {
|
|
446
|
+
host: config.host,
|
|
447
|
+
port: config.port,
|
|
448
|
+
},
|
|
449
|
+
});
|
|
450
|
+
// Set the socket as the sock for SSH connection
|
|
451
|
+
sshConfig.sock = socket;
|
|
452
|
+
Logger.log(`SSH config object with SOCKS proxy: ${JSON.stringify(sshConfig, (k, v) => (k === "sock" ? "[Socket object]" : v))}`, "info");
|
|
453
|
+
}
|
|
454
|
+
catch (err) {
|
|
455
|
+
return reject(new ToolError("SSH_CONNECTION_FAILED", `Failed to create SOCKS proxy connection for [${key}]: ${err.message}`, true));
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
if (config.privateKey) {
|
|
459
|
+
try {
|
|
460
|
+
sshConfig.privateKey = fs.readFileSync(config.privateKey, "utf8");
|
|
461
|
+
if (config.passphrase) {
|
|
462
|
+
sshConfig.passphrase = config.passphrase;
|
|
463
|
+
}
|
|
464
|
+
Logger.log(`Using SSH private key authentication for [${key}]`, "info");
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
return reject(new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read private key file for [${key}]: ${err.message}`, false));
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else if (config.password) {
|
|
471
|
+
sshConfig.password = config.password;
|
|
472
|
+
Logger.log(`Using password authentication for [${key}]`, "info");
|
|
473
|
+
}
|
|
474
|
+
else if (agent || config.authOptional) {
|
|
475
|
+
Logger.log(`Using SSH agent/default authentication for [${key}]`, "info");
|
|
476
|
+
}
|
|
477
|
+
else {
|
|
478
|
+
return reject(new ToolError("SSH_AUTHENTICATION_MISSING", `No valid authentication method provided for [${key}] (password or private key)`, false));
|
|
479
|
+
}
|
|
480
|
+
client.connect(sshConfig);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
if (this.pendingClients.get(key) === client) {
|
|
485
|
+
this.pendingClients.delete(key);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if ((this.connectionGenerations.get(key) ?? 0) !== generation) {
|
|
489
|
+
try {
|
|
490
|
+
client.end();
|
|
491
|
+
}
|
|
492
|
+
catch {
|
|
493
|
+
// Ignore stale-client close errors.
|
|
494
|
+
}
|
|
495
|
+
throw new ToolError("SSH_CONNECTION_FAILED", `SSH connection [${key}] was superseded by a config reload`, true);
|
|
496
|
+
}
|
|
497
|
+
this.clients.set(key, client);
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Get SSH Client with specified name
|
|
501
|
+
*/
|
|
502
|
+
getClient(name) {
|
|
503
|
+
const key = name || this.defaultName;
|
|
504
|
+
const client = this.clients.get(key);
|
|
505
|
+
if (!client) {
|
|
506
|
+
throw new ToolError("SSH_CONNECTION_FAILED", `SSH client for '${key}' not connected`, true);
|
|
507
|
+
}
|
|
508
|
+
return client;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Ensure SSH client is connected
|
|
512
|
+
* @private
|
|
513
|
+
*/
|
|
514
|
+
async ensureConnected(name) {
|
|
515
|
+
const key = name || this.defaultName;
|
|
516
|
+
if (!this.connected.get(key) || !this.clients.get(key)) {
|
|
517
|
+
await this.connect(key);
|
|
518
|
+
}
|
|
519
|
+
const client = this.clients.get(key);
|
|
520
|
+
if (!client) {
|
|
521
|
+
throw new ToolError("SSH_CONNECTION_FAILED", `SSH client for '${key}' not initialized`, true);
|
|
522
|
+
}
|
|
523
|
+
return client;
|
|
524
|
+
}
|
|
525
|
+
/**
|
|
526
|
+
* Check if an error is a connection-related error that can be retried
|
|
527
|
+
* @private
|
|
528
|
+
*/
|
|
529
|
+
isConnectionError(error) {
|
|
530
|
+
if (error instanceof ToolError) {
|
|
531
|
+
return error.code === "SSH_CONNECTION_FAILED";
|
|
532
|
+
}
|
|
533
|
+
const msg = error.message.toLowerCase();
|
|
534
|
+
return (msg.includes("not connected") ||
|
|
535
|
+
msg.includes("connection") ||
|
|
536
|
+
msg.includes("socket") ||
|
|
537
|
+
msg.includes("econnreset") ||
|
|
538
|
+
msg.includes("econnrefused") ||
|
|
539
|
+
msg.includes("epipe") ||
|
|
540
|
+
msg.includes("closed") ||
|
|
541
|
+
msg.includes("end of stream") ||
|
|
542
|
+
msg.includes("channel"));
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Force reconnect to SSH server
|
|
546
|
+
* @private
|
|
547
|
+
*/
|
|
548
|
+
async reconnect(name) {
|
|
549
|
+
const key = name || this.defaultName;
|
|
550
|
+
Logger.log(`Attempting to reconnect SSH [${key}]...`, "info");
|
|
551
|
+
// Close existing connection if any
|
|
552
|
+
const existingClient = this.clients.get(key);
|
|
553
|
+
if (existingClient) {
|
|
554
|
+
try {
|
|
555
|
+
existingClient.end();
|
|
556
|
+
}
|
|
557
|
+
catch (e) {
|
|
558
|
+
// Ignore errors when closing dead connection
|
|
559
|
+
}
|
|
560
|
+
this.clients.delete(key);
|
|
561
|
+
}
|
|
562
|
+
const pendingClient = this.pendingClients.get(key);
|
|
563
|
+
if (pendingClient) {
|
|
564
|
+
try {
|
|
565
|
+
pendingClient.end();
|
|
566
|
+
}
|
|
567
|
+
catch (e) {
|
|
568
|
+
// Ignore errors when closing dead connection
|
|
569
|
+
}
|
|
570
|
+
this.pendingClients.delete(key);
|
|
571
|
+
}
|
|
572
|
+
this.bumpConnectionGeneration(key);
|
|
573
|
+
this.connected.set(key, false);
|
|
574
|
+
// Reconnect
|
|
575
|
+
await this.connect(key);
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Sleep helper for retry backoff
|
|
579
|
+
* @private
|
|
580
|
+
*/
|
|
581
|
+
sleep(ms) {
|
|
582
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* SECURITY: Patterns that indicate command chaining (used to detect hidden rm)
|
|
586
|
+
*/
|
|
587
|
+
static COMMAND_CHAIN_PATTERNS = [
|
|
588
|
+
/;/, // Command separator: cmd1; cmd2
|
|
589
|
+
/&&/, // AND operator: cmd1 && cmd2
|
|
590
|
+
/\|\|/, // OR operator: cmd1 || cmd2
|
|
591
|
+
/\|/, // Pipe: cmd1 | cmd2
|
|
592
|
+
/\$\(/, // Command substitution: $(cmd)
|
|
593
|
+
/`/, // Backtick substitution: `cmd`
|
|
594
|
+
];
|
|
595
|
+
/**
|
|
596
|
+
* SECURITY: Safe directory for destructive operations (rm, mv, etc.)
|
|
597
|
+
*/
|
|
598
|
+
// Default safe directory fallback (used only if username missing for some reason)
|
|
599
|
+
static DEFAULT_SAFE_DIRECTORY = "/home";
|
|
600
|
+
/**
|
|
601
|
+
* SECURITY: Find the first destructive pattern match and return a reason
|
|
602
|
+
* Returns null when no destructive pattern is found
|
|
603
|
+
* @private
|
|
604
|
+
*/
|
|
605
|
+
getDestructiveMatch(command) {
|
|
606
|
+
// This catches: "cd /; rm -rf *", "echo test && rm file", "$(rm file)", and risky writes like "> /etc/..."
|
|
607
|
+
const destructivePatterns = [
|
|
608
|
+
{ regex: /\brm\b/, reason: "rm in command chain" },
|
|
609
|
+
{ regex: /\brmdir\b/, reason: "rmdir in command chain" },
|
|
610
|
+
{ regex: /\bunlink\b/, reason: "unlink in command chain" },
|
|
611
|
+
{ regex: /\bshred\b/, reason: "shred in command chain" },
|
|
612
|
+
{ regex: /\btruncate\b/, reason: "truncate in command chain" },
|
|
613
|
+
{ regex: /-delete\b/, reason: "find -delete detected" },
|
|
614
|
+
{ regex: /-exec\s+rm\b/, reason: "find -exec rm detected" },
|
|
615
|
+
{ regex: /\bdd\b.*\bof=/, reason: "dd with of= (can overwrite files)" },
|
|
616
|
+
{ regex: /\bmv\b/, reason: "mv detected (can overwrite)" },
|
|
617
|
+
{ regex: /\bcp\b.*-f/, reason: "cp -f detected (force overwrite)" },
|
|
618
|
+
// Allow stderr redirection to /dev/null (2>/dev/null, 2>&1>/dev/null, etc.)
|
|
619
|
+
// Block dangerous writes like: echo x > /etc/passwd, cat > /bin/bash
|
|
620
|
+
{ regex: /(?<![0-9])>\s*\/(?!dev\/null)/, reason: "output redirection to absolute path" },
|
|
621
|
+
{ regex: />\s*~/, reason: "output redirection to home path" },
|
|
622
|
+
];
|
|
623
|
+
for (const { regex, reason } of destructivePatterns) {
|
|
624
|
+
if (regex.test(command)) {
|
|
625
|
+
return reason;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* SECURITY: Validate that a path is within the safe directory
|
|
632
|
+
* Handles path traversal attacks like ../../etc/passwd
|
|
633
|
+
* @private
|
|
634
|
+
*/
|
|
635
|
+
isPathInSafeDirectory(filePath, safeDir) {
|
|
636
|
+
// Normalize: remove redundant slashes, handle . and ..
|
|
637
|
+
const parts = filePath.split('/').filter(p => p !== '' && p !== '.');
|
|
638
|
+
const normalized = [];
|
|
639
|
+
for (const part of parts) {
|
|
640
|
+
if (part === '..') {
|
|
641
|
+
normalized.pop(); // Go up one directory
|
|
642
|
+
}
|
|
643
|
+
else {
|
|
644
|
+
normalized.push(part);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
const normalizedPath = '/' + normalized.join('/');
|
|
648
|
+
// After normalization, must be inside safe directory with a `/` boundary
|
|
649
|
+
// so `/home/alice-evil` is rejected when safeDir is `/home/alice`.
|
|
650
|
+
if (safeDir === "/")
|
|
651
|
+
return true;
|
|
652
|
+
return normalizedPath === safeDir || normalizedPath.startsWith(safeDir + "/");
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* SECURITY: Validate rm command specifically
|
|
656
|
+
* Only allow rm on files/dirs inside the safe directory
|
|
657
|
+
* @private
|
|
658
|
+
*/
|
|
659
|
+
validateRmCommand(command, safeDir) {
|
|
660
|
+
// Check for command chaining - rm must be a standalone command, not hidden in a chain
|
|
661
|
+
for (const pattern of SSHConnectionManager.COMMAND_CHAIN_PATTERNS) {
|
|
662
|
+
if (pattern.test(command)) {
|
|
663
|
+
return {
|
|
664
|
+
valid: false,
|
|
665
|
+
reason: `rm command cannot be chained with other commands. Found: ${pattern.toString()}`
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
// Extract the rm command and its arguments
|
|
670
|
+
const rmMatch = command.match(/^rm\s+(.+)$/);
|
|
671
|
+
if (!rmMatch) {
|
|
672
|
+
return { valid: false, reason: "Invalid rm command format" };
|
|
673
|
+
}
|
|
674
|
+
const argsString = rmMatch[1];
|
|
675
|
+
// Parse arguments - split by space but handle quoted strings
|
|
676
|
+
// For simplicity, we'll split by space and filter out flags
|
|
677
|
+
const parts = argsString.split(/\s+/);
|
|
678
|
+
const paths = [];
|
|
679
|
+
for (const part of parts) {
|
|
680
|
+
// Skip flags like -f, -r, -rf, --force, etc.
|
|
681
|
+
if (part.startsWith('-')) {
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
paths.push(part);
|
|
685
|
+
}
|
|
686
|
+
if (paths.length === 0) {
|
|
687
|
+
return { valid: false, reason: "No paths specified for rm" };
|
|
688
|
+
}
|
|
689
|
+
// Validate each path
|
|
690
|
+
for (const p of paths) {
|
|
691
|
+
// Must be absolute path
|
|
692
|
+
if (!p.startsWith('/')) {
|
|
693
|
+
return { valid: false, reason: `rm path must be absolute: ${p}` };
|
|
694
|
+
}
|
|
695
|
+
// Must be inside safe directory
|
|
696
|
+
if (!this.isPathInSafeDirectory(p, safeDir)) {
|
|
697
|
+
return {
|
|
698
|
+
valid: false,
|
|
699
|
+
reason: `rm blocked: path "${p}" is outside safe directory "${safeDir}"`
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return { valid: true };
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* SECURITY: Main command validation with multiple layers of defense
|
|
707
|
+
* @private
|
|
708
|
+
*/
|
|
709
|
+
validateCommand(command, name) {
|
|
710
|
+
// ========================================
|
|
711
|
+
// LAYER 1: Check for hidden destructive commands in chains
|
|
712
|
+
// Block things like: "cd /; rm -rf *", "echo | rm file", "$(rm file)"
|
|
713
|
+
// ========================================
|
|
714
|
+
const config = this.getConfig(name);
|
|
715
|
+
const safeDir = config.safeDirectory
|
|
716
|
+
|| (config.username
|
|
717
|
+
? (config.username === 'root' ? '/root' : `/home/${config.username}`)
|
|
718
|
+
: SSHConnectionManager.DEFAULT_SAFE_DIRECTORY);
|
|
719
|
+
const destructiveReason = this.getDestructiveMatch(command);
|
|
720
|
+
if (destructiveReason) {
|
|
721
|
+
// Command contains rm/rmdir - check if it's a simple rm command or hidden in a chain
|
|
722
|
+
const trimmed = command.trim();
|
|
723
|
+
// If it starts with rm, validate it properly but continue through whitelist/blacklist policy checks
|
|
724
|
+
if (trimmed.startsWith('rm ') || trimmed === 'rm') {
|
|
725
|
+
const rmValidation = this.validateRmCommand(trimmed, safeDir);
|
|
726
|
+
if (!rmValidation.valid) {
|
|
727
|
+
Logger.log(`SECURITY: rm command blocked: ${rmValidation.reason}`, "error");
|
|
728
|
+
return {
|
|
729
|
+
isAllowed: false,
|
|
730
|
+
reason: rmValidation.reason,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
Logger.log(`SECURITY: rm command passed safe directory validation (${safeDir}): ${command}`, "info");
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
// Destructive pattern detected somewhere in the command (chained, subshell, redirection, etc.)
|
|
737
|
+
Logger.log(`SECURITY: Destructive pattern detected (${destructiveReason}): ${command}`, "error");
|
|
738
|
+
return {
|
|
739
|
+
isAllowed: false,
|
|
740
|
+
reason: `Blocked destructive pattern: ${destructiveReason}. Command: "${command}"`,
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
// ========================================
|
|
745
|
+
// LAYER 2: Whitelist check for non-destructive commands
|
|
746
|
+
// ========================================
|
|
747
|
+
// Use config whitelist if provided, otherwise use default whitelist
|
|
748
|
+
const whitelist = (config.commandWhitelist && config.commandWhitelist.length > 0)
|
|
749
|
+
? config.commandWhitelist
|
|
750
|
+
: DEFAULT_COMMAND_WHITELIST;
|
|
751
|
+
// Check whitelist - command must match one of the patterns to be allowed
|
|
752
|
+
const matchesWhitelist = whitelist.some((pattern) => {
|
|
753
|
+
try {
|
|
754
|
+
const regex = new RegExp(pattern);
|
|
755
|
+
return regex.test(command);
|
|
756
|
+
}
|
|
757
|
+
catch (e) {
|
|
758
|
+
Logger.log(`Invalid whitelist regex pattern: ${pattern}`, "error");
|
|
759
|
+
return false;
|
|
760
|
+
}
|
|
761
|
+
});
|
|
762
|
+
if (!matchesWhitelist) {
|
|
763
|
+
Logger.log(`Command blocked by whitelist: ${command}`, "info");
|
|
764
|
+
return {
|
|
765
|
+
isAllowed: false,
|
|
766
|
+
reason: `Command not in whitelist, execution forbidden. Command: "${command}"`,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
// ========================================
|
|
770
|
+
// LAYER 3: Blacklist check
|
|
771
|
+
// ========================================
|
|
772
|
+
if (config.commandBlacklist && config.commandBlacklist.length > 0) {
|
|
773
|
+
const matchesBlacklist = config.commandBlacklist.some((pattern) => {
|
|
774
|
+
try {
|
|
775
|
+
const regex = new RegExp(pattern);
|
|
776
|
+
return regex.test(command);
|
|
777
|
+
}
|
|
778
|
+
catch (e) {
|
|
779
|
+
Logger.log(`Invalid blacklist regex pattern: ${pattern}`, "error");
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
if (matchesBlacklist) {
|
|
784
|
+
Logger.log(`Command blocked by blacklist: ${command}`, "info");
|
|
785
|
+
return {
|
|
786
|
+
isAllowed: false,
|
|
787
|
+
reason: "Command matches blacklist, execution forbidden",
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
// Validation passed
|
|
792
|
+
return {
|
|
793
|
+
isAllowed: true,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Low-level streaming runner shared by both the buffered (`executeCommand`)
|
|
798
|
+
* and progress (`executeCommandWithProgress`) paths.
|
|
799
|
+
*
|
|
800
|
+
* Streams raw stdout/stderr bytes into:
|
|
801
|
+
* - `stdoutCollector` / `stderrCollector` (tail-only, for the returned text)
|
|
802
|
+
* - `logWriter` (full output persisted to disk)
|
|
803
|
+
* - `onProgress` (live forwarding, never truncated)
|
|
804
|
+
*
|
|
805
|
+
* Resolves with the exit code (null if the remote process was signaled).
|
|
806
|
+
* @private
|
|
807
|
+
*/
|
|
808
|
+
async runCommandStream(cmdString, client, timeout, sinks) {
|
|
809
|
+
return new Promise((resolve, reject) => {
|
|
810
|
+
let timeoutId;
|
|
811
|
+
const cleanup = () => {
|
|
812
|
+
if (timeoutId)
|
|
813
|
+
clearTimeout(timeoutId);
|
|
814
|
+
};
|
|
815
|
+
client.exec(cmdString, (err, stream) => {
|
|
816
|
+
if (err) {
|
|
817
|
+
cleanup();
|
|
818
|
+
reject(new ToolError("COMMAND_EXECUTION_ERROR", `Command execution error: ${err.message}`, false));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
stream.on("data", (chunk) => {
|
|
822
|
+
sinks.stdoutCollector?.push(chunk);
|
|
823
|
+
sinks.logWriter?.appendStdout(chunk);
|
|
824
|
+
if (sinks.onProgress)
|
|
825
|
+
sinks.onProgress(chunk.toString());
|
|
826
|
+
});
|
|
827
|
+
stream.stderr.on("data", (chunk) => {
|
|
828
|
+
sinks.stderrCollector?.push(chunk);
|
|
829
|
+
sinks.logWriter?.appendStderr(chunk);
|
|
830
|
+
if (sinks.onProgress)
|
|
831
|
+
sinks.onProgress(`[STDERR] ${chunk.toString()}`);
|
|
832
|
+
});
|
|
833
|
+
stream.on("close", (code) => {
|
|
834
|
+
cleanup();
|
|
835
|
+
resolve(code ?? null);
|
|
836
|
+
});
|
|
837
|
+
stream.on("error", (err) => {
|
|
838
|
+
cleanup();
|
|
839
|
+
reject(new ToolError("COMMAND_EXECUTION_ERROR", `Stream error: ${err.message}`, false));
|
|
840
|
+
});
|
|
841
|
+
timeoutId = setTimeout(() => {
|
|
842
|
+
cleanup();
|
|
843
|
+
try {
|
|
844
|
+
stream.signal("KILL");
|
|
845
|
+
}
|
|
846
|
+
catch (e) {
|
|
847
|
+
// Ignore errors when sending signal
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
stream.close();
|
|
851
|
+
}
|
|
852
|
+
catch (e) {
|
|
853
|
+
// Ignore errors when closing streams during timeout
|
|
854
|
+
}
|
|
855
|
+
reject(new ToolError("COMMAND_TIMEOUT", `Command timeout: execution exceeded ${timeout}ms limit. Remote process killed.`, false));
|
|
856
|
+
}, timeout);
|
|
857
|
+
});
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Default cap on bytes returned to the caller from `execute-command`.
|
|
862
|
+
* Combined stdout + stderr; tail-only truncation past this limit. The
|
|
863
|
+
* full output is always persisted to disk regardless of this cap.
|
|
864
|
+
*/
|
|
865
|
+
static DEFAULT_MAX_OUTPUT_BYTES = 64 * 1024;
|
|
866
|
+
/**
|
|
867
|
+
* Assemble the final user-visible result string from collected tails.
|
|
868
|
+
* Adds a truncation header (with the on-disk log path) and the legacy
|
|
869
|
+
* [STDERR]/[EXIT CODE] markers so the LLM still sees the same shape.
|
|
870
|
+
*/
|
|
871
|
+
buildExecuteResult(args) {
|
|
872
|
+
const { stdoutCollector, stderrCollector, exitCode, logWriter, maxOutputBytes } = args;
|
|
873
|
+
const stdoutSnap = stdoutCollector.getSnapshot();
|
|
874
|
+
const stderrSnap = stderrCollector.getSnapshot();
|
|
875
|
+
const totalBytes = stdoutSnap.totalBytes + stderrSnap.totalBytes;
|
|
876
|
+
const totalDropped = stdoutSnap.droppedBytes + stderrSnap.droppedBytes;
|
|
877
|
+
const truncated = stdoutSnap.truncated || stderrSnap.truncated;
|
|
878
|
+
const stdoutText = stdoutSnap.tail.toString("utf8");
|
|
879
|
+
const stderrText = stderrSnap.tail.toString("utf8");
|
|
880
|
+
let body = "";
|
|
881
|
+
if (stdoutText.trim()) {
|
|
882
|
+
body += stdoutText;
|
|
883
|
+
}
|
|
884
|
+
if (stderrText.trim()) {
|
|
885
|
+
if (body)
|
|
886
|
+
body += "\n";
|
|
887
|
+
body += `[STDERR]\n${stderrText}`;
|
|
888
|
+
}
|
|
889
|
+
if (exitCode !== 0 && exitCode !== null) {
|
|
890
|
+
if (body)
|
|
891
|
+
body += "\n";
|
|
892
|
+
body += `[EXIT CODE: ${exitCode}]`;
|
|
893
|
+
}
|
|
894
|
+
if (!body.trim()) {
|
|
895
|
+
body = exitCode === 0
|
|
896
|
+
? "(Command completed successfully with no output)"
|
|
897
|
+
: `(Command exited with code ${exitCode} and no output)`;
|
|
898
|
+
}
|
|
899
|
+
if (truncated) {
|
|
900
|
+
const logPathLine = logWriter
|
|
901
|
+
? `Full output saved to: ${logWriter.getPath()}\n`
|
|
902
|
+
: "Full output log was not available (disk write failed).\n";
|
|
903
|
+
const header = `[OUTPUT TRUNCATED]\n` +
|
|
904
|
+
`Total bytes: ${totalBytes} (stdout=${stdoutSnap.totalBytes}, stderr=${stderrSnap.totalBytes})\n` +
|
|
905
|
+
`Bytes dropped from head: ${totalDropped}\n` +
|
|
906
|
+
`Showing last <= ${maxOutputBytes} bytes per stream.\n` +
|
|
907
|
+
logPathLine +
|
|
908
|
+
`---\n`;
|
|
909
|
+
return header + body;
|
|
910
|
+
}
|
|
911
|
+
return body;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Execute SSH command with auto-retry on connection errors
|
|
915
|
+
*
|
|
916
|
+
* Features:
|
|
917
|
+
* - Validates command against whitelist/blacklist before execution
|
|
918
|
+
* - Auto-reconnects and retries on connection failures
|
|
919
|
+
* - Exponential backoff between retries (500ms, 1000ms, 2000ms)
|
|
920
|
+
* - Configurable timeout per command
|
|
921
|
+
*/
|
|
922
|
+
async executeCommand(cmdString, name, options = {}) {
|
|
923
|
+
// Validate command input and security
|
|
924
|
+
const validationResult = this.validateCommand(cmdString, name);
|
|
925
|
+
if (!validationResult.isAllowed) {
|
|
926
|
+
throw new ToolError("COMMAND_VALIDATION_FAILED", `Command validation failed: ${validationResult.reason}`, false);
|
|
927
|
+
}
|
|
928
|
+
const timeout = options.timeout || 30000; // Default 30 seconds timeout
|
|
929
|
+
const maxRetries = options.maxRetries ?? 2; // Default 2 retries
|
|
930
|
+
const maxOutputBytes = options.maxOutputBytes ?? SSHConnectionManager.DEFAULT_MAX_OUTPUT_BYTES;
|
|
931
|
+
const key = name || this.defaultName;
|
|
932
|
+
let lastError = null;
|
|
933
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
934
|
+
try {
|
|
935
|
+
// Ensure SSH connection is established
|
|
936
|
+
const client = await this.ensureConnected(name);
|
|
937
|
+
// Per-attempt collectors + log writer. We rebuild them on each retry
|
|
938
|
+
// so partial output from a failed attempt is not mixed into the next.
|
|
939
|
+
const stdoutCollector = new OutputCollector(maxOutputBytes);
|
|
940
|
+
const stderrCollector = new OutputCollector(maxOutputBytes);
|
|
941
|
+
const logWriter = this.createLogWriter(key, cmdString);
|
|
942
|
+
const startedMs = Date.now();
|
|
943
|
+
let exitCode = null;
|
|
944
|
+
try {
|
|
945
|
+
exitCode = await this.runCommandStream(cmdString, client, timeout, {
|
|
946
|
+
stdoutCollector,
|
|
947
|
+
stderrCollector,
|
|
948
|
+
logWriter: logWriter ?? undefined,
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
finally {
|
|
952
|
+
logWriter?.close({ exitCode, durationMs: Date.now() - startedMs });
|
|
953
|
+
}
|
|
954
|
+
const result = this.buildExecuteResult({
|
|
955
|
+
stdoutCollector,
|
|
956
|
+
stderrCollector,
|
|
957
|
+
exitCode,
|
|
958
|
+
logWriter,
|
|
959
|
+
maxOutputBytes,
|
|
960
|
+
});
|
|
961
|
+
// Success - log if this was a retry
|
|
962
|
+
if (attempt > 0) {
|
|
963
|
+
Logger.log(`Command succeeded on retry attempt ${attempt} for [${key}]`, "info");
|
|
964
|
+
}
|
|
965
|
+
return result;
|
|
966
|
+
}
|
|
967
|
+
catch (error) {
|
|
968
|
+
lastError = error;
|
|
969
|
+
// Check if this is a connection error that can be retried
|
|
970
|
+
if (this.isConnectionError(lastError) && attempt < maxRetries) {
|
|
971
|
+
const backoffMs = 500 * Math.pow(2, attempt); // 500ms, 1000ms, 2000ms
|
|
972
|
+
Logger.log(`Connection error on attempt ${attempt + 1}/${maxRetries + 1} for [${key}]: ${lastError.message}. Retrying in ${backoffMs}ms...`, "info");
|
|
973
|
+
// Wait with exponential backoff
|
|
974
|
+
await this.sleep(backoffMs);
|
|
975
|
+
// Force reconnect before retry
|
|
976
|
+
try {
|
|
977
|
+
await this.reconnect(name);
|
|
978
|
+
}
|
|
979
|
+
catch (reconnectError) {
|
|
980
|
+
Logger.log(`Reconnect failed for [${key}]: ${reconnectError.message}`, "error");
|
|
981
|
+
// Continue to next retry attempt anyway
|
|
982
|
+
}
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
// Non-retryable error or max retries reached
|
|
986
|
+
break;
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
// All retries exhausted
|
|
990
|
+
throw lastError || new Error("Command execution failed after all retries");
|
|
991
|
+
}
|
|
992
|
+
/**
|
|
993
|
+
* Execute SSH command with real-time streaming output via progress callback
|
|
994
|
+
*
|
|
995
|
+
* Features:
|
|
996
|
+
* - Validates command against whitelist/blacklist before execution
|
|
997
|
+
* - Streams stdout/stderr chunks to the onProgress callback in real-time
|
|
998
|
+
* - Auto-reconnects and retries on connection failures
|
|
999
|
+
* - Longer default timeout suitable for long-running tasks
|
|
1000
|
+
*
|
|
1001
|
+
* @param cmdString - Command to execute
|
|
1002
|
+
* @param name - SSH connection name (optional)
|
|
1003
|
+
* @param options - Execution options including timeout and progress callback
|
|
1004
|
+
*/
|
|
1005
|
+
async executeCommandWithProgress(cmdString, name, options = {}) {
|
|
1006
|
+
// Validate command input and security
|
|
1007
|
+
const validationResult = this.validateCommand(cmdString, name);
|
|
1008
|
+
if (!validationResult.isAllowed) {
|
|
1009
|
+
throw new ToolError("COMMAND_VALIDATION_FAILED", `Command validation failed: ${validationResult.reason}`, false);
|
|
1010
|
+
}
|
|
1011
|
+
const timeout = options.timeout || 300000; // Default 5 minutes for streaming
|
|
1012
|
+
const maxRetries = options.maxRetries ?? 2; // Default 2 retries
|
|
1013
|
+
const maxOutputBytes = options.maxOutputBytes ?? SSHConnectionManager.DEFAULT_MAX_OUTPUT_BYTES;
|
|
1014
|
+
const key = name || this.defaultName;
|
|
1015
|
+
let lastError = null;
|
|
1016
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
1017
|
+
try {
|
|
1018
|
+
// Ensure SSH connection is established
|
|
1019
|
+
const client = await this.ensureConnected(name);
|
|
1020
|
+
// Per-attempt collectors + log writer. onProgress keeps streaming
|
|
1021
|
+
// every byte live; only the final returned string is capped.
|
|
1022
|
+
const stdoutCollector = new OutputCollector(maxOutputBytes);
|
|
1023
|
+
const stderrCollector = new OutputCollector(maxOutputBytes);
|
|
1024
|
+
const logWriter = this.createLogWriter(key, cmdString);
|
|
1025
|
+
const startedMs = Date.now();
|
|
1026
|
+
let exitCode = null;
|
|
1027
|
+
try {
|
|
1028
|
+
exitCode = await this.runCommandStream(cmdString, client, timeout, {
|
|
1029
|
+
stdoutCollector,
|
|
1030
|
+
stderrCollector,
|
|
1031
|
+
logWriter: logWriter ?? undefined,
|
|
1032
|
+
onProgress: options.onProgress,
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
finally {
|
|
1036
|
+
logWriter?.close({ exitCode, durationMs: Date.now() - startedMs });
|
|
1037
|
+
}
|
|
1038
|
+
const result = this.buildExecuteResult({
|
|
1039
|
+
stdoutCollector,
|
|
1040
|
+
stderrCollector,
|
|
1041
|
+
exitCode,
|
|
1042
|
+
logWriter,
|
|
1043
|
+
maxOutputBytes,
|
|
1044
|
+
});
|
|
1045
|
+
// Success - log if this was a retry
|
|
1046
|
+
if (attempt > 0) {
|
|
1047
|
+
Logger.log(`Streaming command succeeded on retry attempt ${attempt} for [${key}]`, "info");
|
|
1048
|
+
}
|
|
1049
|
+
return result;
|
|
1050
|
+
}
|
|
1051
|
+
catch (error) {
|
|
1052
|
+
lastError = error;
|
|
1053
|
+
// Check if this is a connection error that can be retried
|
|
1054
|
+
if (this.isConnectionError(lastError) && attempt < maxRetries) {
|
|
1055
|
+
const backoffMs = 500 * Math.pow(2, attempt);
|
|
1056
|
+
Logger.log(`Connection error on streaming attempt ${attempt + 1}/${maxRetries + 1} for [${key}]: ${lastError.message}. Retrying in ${backoffMs}ms...`, "info");
|
|
1057
|
+
await this.sleep(backoffMs);
|
|
1058
|
+
try {
|
|
1059
|
+
await this.reconnect(name);
|
|
1060
|
+
}
|
|
1061
|
+
catch (reconnectError) {
|
|
1062
|
+
Logger.log(`Reconnect failed for [${key}]: ${reconnectError.message}`, "error");
|
|
1063
|
+
}
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
break;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
throw lastError || new Error("Streaming command execution failed after all retries");
|
|
1070
|
+
}
|
|
1071
|
+
/**
|
|
1072
|
+
* Build an OutputLogWriter for a given server. Returns null if we cannot
|
|
1073
|
+
* resolve the configured username (in which case the command still runs
|
|
1074
|
+
* but its full output is not persisted to disk).
|
|
1075
|
+
*/
|
|
1076
|
+
createLogWriter(serverName, command) {
|
|
1077
|
+
const config = this.getServerConfig(serverName);
|
|
1078
|
+
if (!config)
|
|
1079
|
+
return null;
|
|
1080
|
+
return new OutputLogWriter({
|
|
1081
|
+
rootDir: this.getOutputLogRoot(),
|
|
1082
|
+
serverName,
|
|
1083
|
+
username: config.username,
|
|
1084
|
+
command,
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Validate a local filesystem path for SFTP transfer.
|
|
1089
|
+
*
|
|
1090
|
+
* The path must be inside the MCP working directory OR inside one of the
|
|
1091
|
+
* server's `allowedLocalDirectories` entries. The working directory is
|
|
1092
|
+
* always allowed implicitly for backward compatibility.
|
|
1093
|
+
*/
|
|
1094
|
+
validateLocalPath(localPath, name) {
|
|
1095
|
+
const resolvedPath = path.resolve(localPath);
|
|
1096
|
+
const allowedRoots = new Set([process.cwd()]);
|
|
1097
|
+
// Add per-server allowedLocalDirectories if a server is targeted
|
|
1098
|
+
if (name) {
|
|
1099
|
+
const config = this.getServerConfig(name);
|
|
1100
|
+
if (config?.allowedLocalDirectories) {
|
|
1101
|
+
for (const dir of config.allowedLocalDirectories) {
|
|
1102
|
+
allowedRoots.add(dir);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
const isAllowed = Array.from(allowedRoots).some((root) => resolvedPath === root || resolvedPath.startsWith(root + path.sep));
|
|
1107
|
+
if (!isAllowed) {
|
|
1108
|
+
const allowedList = Array.from(allowedRoots).join(", ");
|
|
1109
|
+
throw new ToolError("LOCAL_PATH_NOT_ALLOWED", `Local path '${resolvedPath}' is not inside any allowed directory. Allowed: ${allowedList}. ` +
|
|
1110
|
+
`Add the directory under 'allowedLocalDirectories' in the server's YAML config to permit it.`, false);
|
|
1111
|
+
}
|
|
1112
|
+
return resolvedPath;
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Validate a remote (POSIX) path for SFTP transfer.
|
|
1116
|
+
*
|
|
1117
|
+
* The path must be an absolute POSIX path inside one of the server's
|
|
1118
|
+
* `allowedRemoteDirectories` entries. If that list is unset or empty,
|
|
1119
|
+
* SFTP is rejected: configure the list explicitly before using
|
|
1120
|
+
* upload/download/transfer.
|
|
1121
|
+
*/
|
|
1122
|
+
validateRemotePath(remotePath, name) {
|
|
1123
|
+
if (typeof remotePath !== "string" || remotePath.length === 0) {
|
|
1124
|
+
throw new ToolError("REMOTE_PATH_NOT_ALLOWED", "Remote path must be a non-empty string.", false);
|
|
1125
|
+
}
|
|
1126
|
+
if (remotePath.includes("\0")) {
|
|
1127
|
+
throw new ToolError("REMOTE_PATH_NOT_ALLOWED", "Remote path must not contain null bytes.", false);
|
|
1128
|
+
}
|
|
1129
|
+
if (!path.posix.isAbsolute(remotePath)) {
|
|
1130
|
+
throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `Remote path must be an absolute POSIX path, got: ${remotePath}`, false);
|
|
1131
|
+
}
|
|
1132
|
+
// Reject '..' BEFORE normalization so an escape like /allowed/../etc/passwd
|
|
1133
|
+
// is rejected outright instead of collapsing to /etc/passwd and then
|
|
1134
|
+
// being checked against the (now-bypassed) allowlist.
|
|
1135
|
+
if (remotePath.split("/").includes("..")) {
|
|
1136
|
+
throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `Remote path must not contain '..' segments: ${remotePath}`, false);
|
|
1137
|
+
}
|
|
1138
|
+
const normalized = path.posix.normalize(remotePath);
|
|
1139
|
+
const config = this.getConfig(name);
|
|
1140
|
+
const allowedRoots = config.allowedRemoteDirectories ?? [];
|
|
1141
|
+
if (allowedRoots.length === 0) {
|
|
1142
|
+
throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `SFTP is disabled for server '${name}': no 'allowedRemoteDirectories' configured. ` +
|
|
1143
|
+
`Add at least one absolute POSIX directory to 'allowedRemoteDirectories' in the YAML config to permit upload/download.`, false);
|
|
1144
|
+
}
|
|
1145
|
+
const isAllowed = allowedRoots.some((root) => normalized === root || normalized.startsWith(root === "/" ? "/" : root + "/"));
|
|
1146
|
+
if (!isAllowed) {
|
|
1147
|
+
throw new ToolError("REMOTE_PATH_NOT_ALLOWED", `Remote path '${normalized}' is not inside any allowedRemoteDirectories entry for server '${name}'. ` +
|
|
1148
|
+
`Allowed: ${allowedRoots.join(", ")}.`, false);
|
|
1149
|
+
}
|
|
1150
|
+
return normalized;
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Upload file
|
|
1154
|
+
*/
|
|
1155
|
+
async upload(localPath, remotePath, name, options) {
|
|
1156
|
+
const resolvedName = name || this.defaultName;
|
|
1157
|
+
const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
|
|
1158
|
+
const validatedRemotePath = this.validateRemotePath(remotePath, resolvedName);
|
|
1159
|
+
const skipIfIdentical = options?.skipIfIdentical !== false; // default true
|
|
1160
|
+
// ---- Read local file (stat + content as Buffer) ----
|
|
1161
|
+
let stat;
|
|
1162
|
+
try {
|
|
1163
|
+
stat = fs.statSync(validatedLocalPath);
|
|
1164
|
+
}
|
|
1165
|
+
catch (e) {
|
|
1166
|
+
throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to stat local file '${validatedLocalPath}': ${e.message}`, false);
|
|
1167
|
+
}
|
|
1168
|
+
if (!stat.isFile()) {
|
|
1169
|
+
throw new ToolError("LOCAL_FILE_READ_FAILED", `Local path '${validatedLocalPath}' is not a regular file (size=${stat.size}). ` +
|
|
1170
|
+
`Use uploadDirectory / recursive=true for directories.`, false);
|
|
1171
|
+
}
|
|
1172
|
+
let payload;
|
|
1173
|
+
try {
|
|
1174
|
+
payload = fs.readFileSync(validatedLocalPath);
|
|
1175
|
+
}
|
|
1176
|
+
catch (e) {
|
|
1177
|
+
throw new ToolError("LOCAL_FILE_READ_FAILED", `Failed to read local file '${validatedLocalPath}': ${e.message}`, false);
|
|
1178
|
+
}
|
|
1179
|
+
// ---- CRLF auto-fix for shell scripts ----
|
|
1180
|
+
const crlfFixed = SSHConnectionManager.maybeFixShellScriptLineEndings(validatedLocalPath, payload);
|
|
1181
|
+
payload = crlfFixed.buffer;
|
|
1182
|
+
const crlfNote = crlfFixed.fixed
|
|
1183
|
+
? ` (CRLF→LF auto-fix: converted ${crlfFixed.replacedCount} line endings to LF before upload because target is a shell script).`
|
|
1184
|
+
: "";
|
|
1185
|
+
const client = await this.ensureConnected(resolvedName);
|
|
1186
|
+
// ---- Skip-if-identical check ----
|
|
1187
|
+
const isShellScript = SSHConnectionManager.SHELL_SCRIPT_EXTENSIONS.has(path.extname(validatedLocalPath).toLowerCase());
|
|
1188
|
+
if (skipIfIdentical) {
|
|
1189
|
+
const decision = await this.shouldSkipUpload(client, payload, validatedRemotePath, isShellScript);
|
|
1190
|
+
if (decision.skip) {
|
|
1191
|
+
return (`Upload skipped: remote file '${validatedRemotePath}' is already identical to local ` +
|
|
1192
|
+
`'${validatedLocalPath}' (${decision.reason}).${crlfNote}`);
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
// ---- Actually upload ----
|
|
1196
|
+
await this.sftpWriteBuffer(client, validatedRemotePath, payload);
|
|
1197
|
+
return `File uploaded successfully (${payload.length} bytes)${crlfNote}`;
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* Threshold above which we use MD5 hash comparison instead of byte-content
|
|
1201
|
+
* comparison for skip-if-identical.
|
|
1202
|
+
*/
|
|
1203
|
+
static SKIP_IF_IDENTICAL_HASH_THRESHOLD = 256 * 1024 * 1024;
|
|
1204
|
+
/**
|
|
1205
|
+
* Shell scripts that need CRLF→LF normalization on upload to a Linux host.
|
|
1206
|
+
*/
|
|
1207
|
+
static SHELL_SCRIPT_EXTENSIONS = new Set([".sh", ".bash", ".zsh"]);
|
|
1208
|
+
/**
|
|
1209
|
+
* If the local file is a shell script (.sh / .bash / .zsh) and contains
|
|
1210
|
+
* any CRLF line endings, return a new buffer with all CRLF replaced by LF.
|
|
1211
|
+
* Otherwise return the buffer unchanged.
|
|
1212
|
+
*/
|
|
1213
|
+
static maybeFixShellScriptLineEndings(localPath, buffer) {
|
|
1214
|
+
const ext = path.extname(localPath).toLowerCase();
|
|
1215
|
+
if (!SSHConnectionManager.SHELL_SCRIPT_EXTENSIONS.has(ext)) {
|
|
1216
|
+
return { buffer, fixed: false, replacedCount: 0 };
|
|
1217
|
+
}
|
|
1218
|
+
// Count CRLF occurrences. Buffer.indexOf is fast.
|
|
1219
|
+
let count = 0;
|
|
1220
|
+
let idx = buffer.indexOf("\r\n");
|
|
1221
|
+
while (idx !== -1) {
|
|
1222
|
+
count++;
|
|
1223
|
+
idx = buffer.indexOf("\r\n", idx + 2);
|
|
1224
|
+
}
|
|
1225
|
+
if (count === 0) {
|
|
1226
|
+
return { buffer, fixed: false, replacedCount: 0 };
|
|
1227
|
+
}
|
|
1228
|
+
// Replace via string conversion. Safe for shell scripts which are always
|
|
1229
|
+
// text. Use 'binary' encoding to avoid any UTF-8 normalization surprises.
|
|
1230
|
+
const fixed = Buffer.from(buffer.toString("binary").replace(/\r\n/g, "\n"), "binary");
|
|
1231
|
+
return { buffer: fixed, fixed: true, replacedCount: count };
|
|
1232
|
+
}
|
|
1233
|
+
/**
|
|
1234
|
+
* Decide whether an upload can be skipped because the remote file is
|
|
1235
|
+
* already identical to the local payload.
|
|
1236
|
+
*
|
|
1237
|
+
* Strategy for regular files:
|
|
1238
|
+
* - If remote file does not exist → don't skip.
|
|
1239
|
+
* - If sizes differ → don't skip.
|
|
1240
|
+
* - If size ≤ 256 MiB → fetch remote bytes and byte-compare.
|
|
1241
|
+
* - Else → MD5 both sides and compare hashes.
|
|
1242
|
+
*
|
|
1243
|
+
* Strategy for shell scripts (lineEndingAgnostic=true):
|
|
1244
|
+
* The local payload has already been LF-normalized. We must compare
|
|
1245
|
+
* against an LF-normalized view of the remote file too, so a remote that
|
|
1246
|
+
* still contains CRLF is treated as equal to an LF-only local file.
|
|
1247
|
+
* Because remote-side md5sum runs on raw bytes (including CRLF), we
|
|
1248
|
+
* cannot use the hash branch — we always download the remote and
|
|
1249
|
+
* byte-compare after normalizing it.
|
|
1250
|
+
*
|
|
1251
|
+
* Any error during the check is treated as 'don't skip' (i.e. fall through
|
|
1252
|
+
* to a normal upload), since correctness wins over an optimization.
|
|
1253
|
+
*/
|
|
1254
|
+
async shouldSkipUpload(client, localPayload, remotePath, lineEndingAgnostic) {
|
|
1255
|
+
let remoteSize;
|
|
1256
|
+
try {
|
|
1257
|
+
const sftp = await this.openSftp(client, "dest");
|
|
1258
|
+
try {
|
|
1259
|
+
const stat = await this.sftpStat(sftp, remotePath, "dest");
|
|
1260
|
+
remoteSize = stat.size;
|
|
1261
|
+
}
|
|
1262
|
+
finally {
|
|
1263
|
+
sftp.end();
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
catch {
|
|
1267
|
+
return { skip: false, reason: "remote-missing-or-unstat-able" };
|
|
1268
|
+
}
|
|
1269
|
+
if (lineEndingAgnostic) {
|
|
1270
|
+
// For shell scripts we can't trust raw size: remote may have CRLF.
|
|
1271
|
+
// Sanity-cap the remote download size to keep memory bounded.
|
|
1272
|
+
if (remoteSize > SSHConnectionManager.SKIP_IF_IDENTICAL_HASH_THRESHOLD) {
|
|
1273
|
+
// Shell scripts this large are pathological; just re-upload.
|
|
1274
|
+
return { skip: false, reason: `shell-script-too-large-for-content-compare(${remoteSize} bytes)` };
|
|
1275
|
+
}
|
|
1276
|
+
let remoteBuf;
|
|
1277
|
+
try {
|
|
1278
|
+
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
|
|
1279
|
+
}
|
|
1280
|
+
catch {
|
|
1281
|
+
return { skip: false, reason: "remote-read-failed-during-content-compare" };
|
|
1282
|
+
}
|
|
1283
|
+
const remoteNormalized = SSHConnectionManager.normalizeCrlfToLf(remoteBuf);
|
|
1284
|
+
if (remoteNormalized.length === localPayload.length &&
|
|
1285
|
+
remoteNormalized.equals(localPayload)) {
|
|
1286
|
+
const note = remoteNormalized.length !== remoteBuf.length
|
|
1287
|
+
? `identical-content-ignoring-line-endings(${remoteNormalized.length} bytes after LF-normalization, remote raw was ${remoteBuf.length} bytes with CRLF)`
|
|
1288
|
+
: `identical-content(${remoteNormalized.length} bytes)`;
|
|
1289
|
+
return { skip: true, reason: note };
|
|
1290
|
+
}
|
|
1291
|
+
return { skip: false, reason: "content-differs-after-line-ending-normalization" };
|
|
1292
|
+
}
|
|
1293
|
+
// -------- Non-shell-script path --------
|
|
1294
|
+
if (remoteSize !== localPayload.length) {
|
|
1295
|
+
return { skip: false, reason: `size-differs(local=${localPayload.length},remote=${remoteSize})` };
|
|
1296
|
+
}
|
|
1297
|
+
if (remoteSize <= SSHConnectionManager.SKIP_IF_IDENTICAL_HASH_THRESHOLD) {
|
|
1298
|
+
// Byte-content compare
|
|
1299
|
+
let remoteBuf;
|
|
1300
|
+
try {
|
|
1301
|
+
remoteBuf = await this.sftpReadBuffer(client, remotePath, remoteSize);
|
|
1302
|
+
}
|
|
1303
|
+
catch {
|
|
1304
|
+
return { skip: false, reason: "remote-read-failed-during-content-compare" };
|
|
1305
|
+
}
|
|
1306
|
+
if (remoteBuf.length === localPayload.length && remoteBuf.equals(localPayload)) {
|
|
1307
|
+
return { skip: true, reason: `identical-content(${remoteSize} bytes)` };
|
|
1308
|
+
}
|
|
1309
|
+
return { skip: false, reason: "content-differs" };
|
|
1310
|
+
}
|
|
1311
|
+
// Large file → hash compare
|
|
1312
|
+
const localMd5 = crypto.createHash("md5").update(localPayload).digest("hex");
|
|
1313
|
+
let remoteMd5 = null;
|
|
1314
|
+
try {
|
|
1315
|
+
remoteMd5 = await this.remoteMd5(client, remotePath);
|
|
1316
|
+
}
|
|
1317
|
+
catch {
|
|
1318
|
+
return { skip: false, reason: "remote-md5-unavailable" };
|
|
1319
|
+
}
|
|
1320
|
+
if (remoteMd5 === localMd5) {
|
|
1321
|
+
return { skip: true, reason: `identical-md5(${localMd5}, ${remoteSize} bytes)` };
|
|
1322
|
+
}
|
|
1323
|
+
return { skip: false, reason: `md5-differs(local=${localMd5}, remote=${remoteMd5})` };
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Return a copy of `buf` with every CRLF replaced by LF. No-op if the
|
|
1327
|
+
* buffer contains no CRLF.
|
|
1328
|
+
*/
|
|
1329
|
+
static normalizeCrlfToLf(buf) {
|
|
1330
|
+
if (buf.indexOf("\r\n") === -1)
|
|
1331
|
+
return buf;
|
|
1332
|
+
return Buffer.from(buf.toString("binary").replace(/\r\n/g, "\n"), "binary");
|
|
1333
|
+
}
|
|
1334
|
+
/**
|
|
1335
|
+
* Read an SFTP file fully into a Buffer.
|
|
1336
|
+
*/
|
|
1337
|
+
async sftpReadBuffer(client, remotePath, expectedSize) {
|
|
1338
|
+
return new Promise((resolve, reject) => {
|
|
1339
|
+
client.sftp((err, sftp) => {
|
|
1340
|
+
if (err) {
|
|
1341
|
+
return reject(new ToolError("SFTP_ERROR", `SFTP open failed: ${err.message}`, true));
|
|
1342
|
+
}
|
|
1343
|
+
const chunks = [];
|
|
1344
|
+
let received = 0;
|
|
1345
|
+
const stream = sftp.createReadStream(remotePath);
|
|
1346
|
+
stream.on("data", (chunk) => {
|
|
1347
|
+
chunks.push(chunk);
|
|
1348
|
+
received += chunk.length;
|
|
1349
|
+
});
|
|
1350
|
+
stream.on("error", (e) => {
|
|
1351
|
+
sftp.end();
|
|
1352
|
+
reject(new ToolError("SFTP_ERROR", `Remote read failed: ${e.message}`, false));
|
|
1353
|
+
});
|
|
1354
|
+
stream.on("end", () => {
|
|
1355
|
+
sftp.end();
|
|
1356
|
+
if (received !== expectedSize) {
|
|
1357
|
+
return reject(new ToolError("SFTP_ERROR", `Remote read short: expected ${expectedSize} bytes, got ${received}`, false));
|
|
1358
|
+
}
|
|
1359
|
+
resolve(Buffer.concat(chunks, received));
|
|
1360
|
+
});
|
|
1361
|
+
});
|
|
1362
|
+
});
|
|
1363
|
+
}
|
|
1364
|
+
/**
|
|
1365
|
+
* Write a Buffer to an SFTP path (overwrites if exists).
|
|
1366
|
+
*/
|
|
1367
|
+
async sftpWriteBuffer(client, remotePath, payload) {
|
|
1368
|
+
return new Promise((resolve, reject) => {
|
|
1369
|
+
client.sftp((err, sftp) => {
|
|
1370
|
+
if (err) {
|
|
1371
|
+
return reject(new ToolError("SFTP_ERROR", `SFTP open failed: ${err.message}`, true));
|
|
1372
|
+
}
|
|
1373
|
+
const writeStream = sftp.createWriteStream(remotePath);
|
|
1374
|
+
writeStream.on("close", () => {
|
|
1375
|
+
sftp.end();
|
|
1376
|
+
resolve();
|
|
1377
|
+
});
|
|
1378
|
+
writeStream.on("error", (e) => {
|
|
1379
|
+
sftp.end();
|
|
1380
|
+
reject(new ToolError("SFTP_ERROR", `File upload failed: ${e.message}`, false));
|
|
1381
|
+
});
|
|
1382
|
+
writeStream.end(payload);
|
|
1383
|
+
});
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Download file
|
|
1388
|
+
*/
|
|
1389
|
+
async download(remotePath, localPath, name) {
|
|
1390
|
+
const resolvedName = name || this.defaultName;
|
|
1391
|
+
const validatedLocalPath = this.validateLocalPath(localPath, resolvedName);
|
|
1392
|
+
const validatedRemotePath = this.validateRemotePath(remotePath, resolvedName);
|
|
1393
|
+
const client = await this.ensureConnected(resolvedName);
|
|
1394
|
+
return new Promise((resolve, reject) => {
|
|
1395
|
+
client.sftp((err, sftp) => {
|
|
1396
|
+
if (err) {
|
|
1397
|
+
return reject(new ToolError("SFTP_ERROR", `SFTP connection failed: ${err.message}`, true));
|
|
1398
|
+
}
|
|
1399
|
+
const readStream = sftp.createReadStream(validatedRemotePath);
|
|
1400
|
+
const writeStream = fs.createWriteStream(validatedLocalPath);
|
|
1401
|
+
const cleanup = () => {
|
|
1402
|
+
sftp.end();
|
|
1403
|
+
};
|
|
1404
|
+
writeStream.on("finish", () => {
|
|
1405
|
+
cleanup();
|
|
1406
|
+
resolve("File downloaded successfully");
|
|
1407
|
+
});
|
|
1408
|
+
writeStream.on("error", (err) => {
|
|
1409
|
+
cleanup();
|
|
1410
|
+
reject(new ToolError("LOCAL_FILE_WRITE_FAILED", `Failed to save file: ${err.message}`, false));
|
|
1411
|
+
});
|
|
1412
|
+
readStream.on("error", (err) => {
|
|
1413
|
+
cleanup();
|
|
1414
|
+
reject(new ToolError("SFTP_ERROR", `File download failed: ${err.message}`, false));
|
|
1415
|
+
});
|
|
1416
|
+
readStream.pipe(writeStream);
|
|
1417
|
+
});
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Disconnect SSH connection
|
|
1422
|
+
*/
|
|
1423
|
+
disconnect() {
|
|
1424
|
+
if (this.clients.size > 0) {
|
|
1425
|
+
for (const client of this.clients.values()) {
|
|
1426
|
+
client.end();
|
|
1427
|
+
}
|
|
1428
|
+
this.clients.clear();
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Get basic information of all configured servers
|
|
1433
|
+
*/
|
|
1434
|
+
getAllServerInfos() {
|
|
1435
|
+
return Object.keys(this.configs).map((key) => {
|
|
1436
|
+
const config = this.configs[key];
|
|
1437
|
+
const status = this.statusCache.get(key);
|
|
1438
|
+
return {
|
|
1439
|
+
name: key,
|
|
1440
|
+
host: config.host,
|
|
1441
|
+
port: config.port,
|
|
1442
|
+
username: config.username,
|
|
1443
|
+
connected: this.connected.get(key) === true,
|
|
1444
|
+
enabled: this.isServerEnabled(key),
|
|
1445
|
+
status: status,
|
|
1446
|
+
};
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Refresh system status for a server (or all enabled servers)
|
|
1451
|
+
*/
|
|
1452
|
+
async refreshStatus(name) {
|
|
1453
|
+
const results = {};
|
|
1454
|
+
const names = name
|
|
1455
|
+
? [name]
|
|
1456
|
+
: (this.enabledServers ?? Object.keys(this.configs));
|
|
1457
|
+
await Promise.allSettled(names.map(async (key) => {
|
|
1458
|
+
try {
|
|
1459
|
+
const client = await this.ensureConnected(key);
|
|
1460
|
+
const status = await collectSystemStatus(client, key);
|
|
1461
|
+
this.statusCache.set(key, status);
|
|
1462
|
+
results[key] = status;
|
|
1463
|
+
}
|
|
1464
|
+
catch (error) {
|
|
1465
|
+
const fallback = {
|
|
1466
|
+
reachable: false,
|
|
1467
|
+
lastUpdated: new Date().toISOString(),
|
|
1468
|
+
};
|
|
1469
|
+
this.statusCache.set(key, fallback);
|
|
1470
|
+
results[key] = fallback;
|
|
1471
|
+
Logger.log(`Status refresh failed for [${key}]: ${error.message}`, "error");
|
|
1472
|
+
}
|
|
1473
|
+
}));
|
|
1474
|
+
return results;
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Transfer a file between two remote servers by piping SFTP streams
|
|
1478
|
+
* directly through the MCP host memory. No temp file, no SCP, no
|
|
1479
|
+
* authorized-key exchange between the two servers required -- each
|
|
1480
|
+
* side uses its own existing SSH session.
|
|
1481
|
+
* After the transfer, file sizes are compared via SFTP stat.
|
|
1482
|
+
* If both servers have md5sum, a hash verification is also performed.
|
|
1483
|
+
*/
|
|
1484
|
+
async transferBetweenServers(sourceName, sourceRemotePath, destName, destRemotePath, options) {
|
|
1485
|
+
const validatedSourcePath = this.validateRemotePath(sourceRemotePath, sourceName);
|
|
1486
|
+
const validatedDestPath = this.validateRemotePath(destRemotePath, destName);
|
|
1487
|
+
const skipIfIdentical = options?.skipIfIdentical !== false; // default true
|
|
1488
|
+
const srcClient = await this.ensureConnected(sourceName);
|
|
1489
|
+
const dstClient = await this.ensureConnected(destName);
|
|
1490
|
+
const srcSftp = await this.openSftp(srcClient, "source");
|
|
1491
|
+
const dstSftp = await this.openSftp(dstClient, "dest");
|
|
1492
|
+
try {
|
|
1493
|
+
// Get source file size before transfer
|
|
1494
|
+
const srcStat = await this.sftpStat(srcSftp, validatedSourcePath, "source");
|
|
1495
|
+
// Skip-if-identical: same size on both sides AND matching md5sum (when
|
|
1496
|
+
// available on both). We never pull bytes through the MCP host for the
|
|
1497
|
+
// compare — if md5sum is missing on either side we just transfer.
|
|
1498
|
+
if (skipIfIdentical) {
|
|
1499
|
+
const dstStatProbe = await this.sftpStat(dstSftp, validatedDestPath, "dest")
|
|
1500
|
+
.catch(() => null);
|
|
1501
|
+
if (dstStatProbe && dstStatProbe.size === srcStat.size) {
|
|
1502
|
+
const [srcMd5, dstMd5] = await Promise.all([
|
|
1503
|
+
this.remoteMd5(srcClient, validatedSourcePath).catch(() => null),
|
|
1504
|
+
this.remoteMd5(dstClient, validatedDestPath).catch(() => null),
|
|
1505
|
+
]);
|
|
1506
|
+
if (srcMd5 && dstMd5 && srcMd5 === dstMd5) {
|
|
1507
|
+
const srcConfig = this.getConfig(sourceName);
|
|
1508
|
+
const dstConfig = this.getConfig(destName);
|
|
1509
|
+
return (`Transfer skipped: destination already identical ` +
|
|
1510
|
+
`(size=${srcStat.size} bytes, md5=${srcMd5}). ` +
|
|
1511
|
+
`${srcConfig.username}@${srcConfig.host}:${validatedSourcePath}` +
|
|
1512
|
+
` == ${dstConfig.username}@${dstConfig.host}:${validatedDestPath}`);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
const readStream = srcSftp.createReadStream(validatedSourcePath);
|
|
1517
|
+
const writeStream = dstSftp.createWriteStream(validatedDestPath);
|
|
1518
|
+
// Bind the validated paths into the rest of the verification flow so
|
|
1519
|
+
// we never accidentally fall back to the un-validated originals.
|
|
1520
|
+
sourceRemotePath = validatedSourcePath;
|
|
1521
|
+
destRemotePath = validatedDestPath;
|
|
1522
|
+
await new Promise((resolve, reject) => {
|
|
1523
|
+
let settled = false;
|
|
1524
|
+
const settle = (err) => {
|
|
1525
|
+
if (settled)
|
|
1526
|
+
return;
|
|
1527
|
+
settled = true;
|
|
1528
|
+
err ? reject(err) : resolve();
|
|
1529
|
+
};
|
|
1530
|
+
writeStream.on("close", () => settle());
|
|
1531
|
+
writeStream.on("error", (err) => settle(new ToolError("SFTP_ERROR", `Dest write error: ${err.message}`, false)));
|
|
1532
|
+
readStream.on("error", (err) => settle(new ToolError("SFTP_ERROR", `Source read error: ${err.message}`, false)));
|
|
1533
|
+
readStream.pipe(writeStream);
|
|
1534
|
+
});
|
|
1535
|
+
// --- Verification ---
|
|
1536
|
+
const dstStat = await this.sftpStat(dstSftp, destRemotePath, "dest");
|
|
1537
|
+
const verification = [];
|
|
1538
|
+
// Size check
|
|
1539
|
+
if (srcStat.size !== dstStat.size) {
|
|
1540
|
+
throw new ToolError("SFTP_ERROR", `Transfer verification failed: size mismatch (source=${srcStat.size} bytes, dest=${dstStat.size} bytes)`, true);
|
|
1541
|
+
}
|
|
1542
|
+
verification.push(`size=${srcStat.size} bytes ✓`);
|
|
1543
|
+
// MD5 check (best-effort: if md5sum is available on both servers)
|
|
1544
|
+
const [srcMd5, dstMd5] = await Promise.all([
|
|
1545
|
+
this.remoteMd5(srcClient, sourceRemotePath).catch(() => null),
|
|
1546
|
+
this.remoteMd5(dstClient, destRemotePath).catch(() => null),
|
|
1547
|
+
]);
|
|
1548
|
+
if (srcMd5 && dstMd5) {
|
|
1549
|
+
if (srcMd5 !== dstMd5) {
|
|
1550
|
+
throw new ToolError("SFTP_ERROR", `Transfer verification failed: MD5 mismatch (source=${srcMd5}, dest=${dstMd5})`, true);
|
|
1551
|
+
}
|
|
1552
|
+
verification.push(`md5=${srcMd5} ✓`);
|
|
1553
|
+
}
|
|
1554
|
+
const srcConfig = this.getConfig(sourceName);
|
|
1555
|
+
const dstConfig = this.getConfig(destName);
|
|
1556
|
+
return (`Transfer complete (streamed via SFTP, verified: ${verification.join(", ")}): ` +
|
|
1557
|
+
`${srcConfig.username}@${srcConfig.host}:${sourceRemotePath}` +
|
|
1558
|
+
` → ${dstConfig.username}@${dstConfig.host}:${destRemotePath}`);
|
|
1559
|
+
}
|
|
1560
|
+
finally {
|
|
1561
|
+
srcSftp.end();
|
|
1562
|
+
dstSftp.end();
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* SFTP stat a remote file.
|
|
1567
|
+
*/
|
|
1568
|
+
sftpStat(sftp, remotePath, label) {
|
|
1569
|
+
return new Promise((resolve, reject) => {
|
|
1570
|
+
sftp.stat(remotePath, (err, stats) => {
|
|
1571
|
+
if (err) {
|
|
1572
|
+
return reject(new ToolError("SFTP_ERROR", `Failed to stat ${label} file: ${err.message}`, false));
|
|
1573
|
+
}
|
|
1574
|
+
resolve({ size: stats.size });
|
|
1575
|
+
});
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Compute MD5 of a remote file via ssh exec. Returns null if md5sum is unavailable.
|
|
1580
|
+
*/
|
|
1581
|
+
remoteMd5(client, remotePath) {
|
|
1582
|
+
return new Promise((resolve, reject) => {
|
|
1583
|
+
client.exec(`md5sum ${this.shellQuote(remotePath)}`, (err, stream) => {
|
|
1584
|
+
if (err)
|
|
1585
|
+
return reject(err);
|
|
1586
|
+
let data = "";
|
|
1587
|
+
stream.on("data", (chunk) => { data += chunk.toString(); });
|
|
1588
|
+
stream.stderr.on("data", () => { });
|
|
1589
|
+
stream.on("close", (code) => {
|
|
1590
|
+
if (code !== 0)
|
|
1591
|
+
return reject(new Error("md5sum failed"));
|
|
1592
|
+
const hash = data.trim().split(/\s+/)[0];
|
|
1593
|
+
if (!hash || hash.length !== 32)
|
|
1594
|
+
return reject(new Error("unexpected md5sum output"));
|
|
1595
|
+
resolve(hash);
|
|
1596
|
+
});
|
|
1597
|
+
});
|
|
1598
|
+
});
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Minimal POSIX shell quoting for a file path.
|
|
1602
|
+
*/
|
|
1603
|
+
shellQuote(s) {
|
|
1604
|
+
return "'" + s.replace(/'/g, "'\\''") + "'";
|
|
1605
|
+
}
|
|
1606
|
+
/**
|
|
1607
|
+
* Open an SFTP session from an existing SSH client.
|
|
1608
|
+
*/
|
|
1609
|
+
openSftp(client, label) {
|
|
1610
|
+
return new Promise((resolve, reject) => {
|
|
1611
|
+
client.sftp((err, sftp) => {
|
|
1612
|
+
if (err) {
|
|
1613
|
+
return reject(new ToolError("SFTP_ERROR", `SFTP connection failed (${label}): ${err.message}`, true));
|
|
1614
|
+
}
|
|
1615
|
+
resolve(sftp);
|
|
1616
|
+
});
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
/**
|
|
1620
|
+
* List remote files/directories via SFTP readdir
|
|
1621
|
+
*/
|
|
1622
|
+
async listRemoteDir(remotePath, name) {
|
|
1623
|
+
const client = await this.ensureConnected(name);
|
|
1624
|
+
return new Promise((resolve, reject) => {
|
|
1625
|
+
client.sftp((err, sftp) => {
|
|
1626
|
+
if (err) {
|
|
1627
|
+
return reject(new ToolError("SFTP_ERROR", `SFTP connection failed: ${err.message}`, true));
|
|
1628
|
+
}
|
|
1629
|
+
sftp.readdir(remotePath, (err, list) => {
|
|
1630
|
+
sftp.end();
|
|
1631
|
+
if (err) {
|
|
1632
|
+
return reject(new ToolError("SFTP_ERROR", `Failed to list remote directory: ${err.message}`, false));
|
|
1633
|
+
}
|
|
1634
|
+
const entries = list.map((entry) => ({
|
|
1635
|
+
filename: entry.filename,
|
|
1636
|
+
isDirectory: (entry.attrs.mode & 0o40000) !== 0,
|
|
1637
|
+
size: entry.attrs.size,
|
|
1638
|
+
}));
|
|
1639
|
+
resolve(entries);
|
|
1640
|
+
});
|
|
1641
|
+
});
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Upload a local directory recursively to a remote server
|
|
1646
|
+
*/
|
|
1647
|
+
async uploadDirectory(localDir, remoteDir, name, options) {
|
|
1648
|
+
const resolvedName = name || this.defaultName;
|
|
1649
|
+
const resolvedLocal = this.validateLocalPath(localDir, resolvedName);
|
|
1650
|
+
const validatedRemoteDir = this.validateRemotePath(remoteDir, resolvedName);
|
|
1651
|
+
if (!fs.statSync(resolvedLocal).isDirectory()) {
|
|
1652
|
+
throw new ToolError("LOCAL_FILE_READ_FAILED", `Not a directory: ${localDir}`, false);
|
|
1653
|
+
}
|
|
1654
|
+
const results = [];
|
|
1655
|
+
const client = await this.ensureConnected(resolvedName);
|
|
1656
|
+
await this.sftpMkdirRecursive(client, validatedRemoteDir);
|
|
1657
|
+
const entries = fs.readdirSync(resolvedLocal, { withFileTypes: true });
|
|
1658
|
+
for (const entry of entries) {
|
|
1659
|
+
const localPath = path.join(localDir, entry.name);
|
|
1660
|
+
const remoteSub = `${validatedRemoteDir}/${entry.name}`;
|
|
1661
|
+
if (entry.isDirectory()) {
|
|
1662
|
+
const subResults = await this.uploadDirectory(localPath, remoteSub, resolvedName, options);
|
|
1663
|
+
results.push(...subResults);
|
|
1664
|
+
}
|
|
1665
|
+
else {
|
|
1666
|
+
await this.upload(localPath, remoteSub, resolvedName, options);
|
|
1667
|
+
results.push(remoteSub);
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
return results;
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* Download a remote directory recursively to a local path
|
|
1674
|
+
*/
|
|
1675
|
+
async downloadDirectory(remoteDir, localDir, name) {
|
|
1676
|
+
const resolvedName = name || this.defaultName;
|
|
1677
|
+
const resolvedLocal = this.validateLocalPath(localDir, resolvedName);
|
|
1678
|
+
const validatedRemoteDir = this.validateRemotePath(remoteDir, resolvedName);
|
|
1679
|
+
if (!fs.existsSync(resolvedLocal)) {
|
|
1680
|
+
fs.mkdirSync(resolvedLocal, { recursive: true });
|
|
1681
|
+
}
|
|
1682
|
+
const results = [];
|
|
1683
|
+
const entries = await this.listRemoteDir(validatedRemoteDir, resolvedName);
|
|
1684
|
+
for (const entry of entries) {
|
|
1685
|
+
if (entry.filename === "." || entry.filename === "..")
|
|
1686
|
+
continue;
|
|
1687
|
+
const remotePath = `${validatedRemoteDir}/${entry.filename}`;
|
|
1688
|
+
const localPath = path.join(localDir, entry.filename);
|
|
1689
|
+
if (entry.isDirectory) {
|
|
1690
|
+
const subResults = await this.downloadDirectory(remotePath, localPath, resolvedName);
|
|
1691
|
+
results.push(...subResults);
|
|
1692
|
+
}
|
|
1693
|
+
else {
|
|
1694
|
+
await this.download(remotePath, localPath, resolvedName);
|
|
1695
|
+
results.push(localPath);
|
|
1696
|
+
}
|
|
1697
|
+
}
|
|
1698
|
+
return results;
|
|
1699
|
+
}
|
|
1700
|
+
/**
|
|
1701
|
+
* Create remote directory recursively via SFTP
|
|
1702
|
+
*/
|
|
1703
|
+
async sftpMkdirRecursive(client, remotePath) {
|
|
1704
|
+
return new Promise((resolve, reject) => {
|
|
1705
|
+
client.sftp((err, sftp) => {
|
|
1706
|
+
if (err) {
|
|
1707
|
+
return reject(new ToolError("SFTP_ERROR", `SFTP connection failed: ${err.message}`, true));
|
|
1708
|
+
}
|
|
1709
|
+
const parts = remotePath.split("/").filter(Boolean);
|
|
1710
|
+
let current = "";
|
|
1711
|
+
const mkdirNext = (index) => {
|
|
1712
|
+
if (index >= parts.length) {
|
|
1713
|
+
sftp.end();
|
|
1714
|
+
return resolve();
|
|
1715
|
+
}
|
|
1716
|
+
current += "/" + parts[index];
|
|
1717
|
+
sftp.mkdir(current, (err) => {
|
|
1718
|
+
// EEXIST is fine
|
|
1719
|
+
mkdirNext(index + 1);
|
|
1720
|
+
});
|
|
1721
|
+
};
|
|
1722
|
+
mkdirNext(0);
|
|
1723
|
+
});
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
}
|