@fkom13/mcp-sftp-orchestrator 6.0.0 → 11.3.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/sshPool.js CHANGED
@@ -2,57 +2,69 @@ import { Client } from 'ssh2';
2
2
  import fs from 'fs/promises';
3
3
  import queue from './queue.js';
4
4
 
5
+ import config from './config.js';
6
+
5
7
  class SSHConnectionPool {
6
8
  constructor() {
7
9
  this.pools = new Map(); // Map<serverAlias, Connection[]>
8
10
  this.activeConnections = new Map(); // Map<connectionId, {conn, serverAlias, inUse, lastUsed}>
9
11
  this.config = {
10
- maxConnections: 5, // Max connexions par serveur
11
- minConnections: 1, // Min connexions maintenues
12
- idleTimeout: 300000, // 5 minutes avant fermeture si inactif
13
- keepAliveInterval: 30000, // Keep-alive toutes les 30s
12
+ maxConnections: config.maxConnectionsPerServer,
13
+ minConnections: config.minConnectionsPerServer,
14
+ idleTimeout: config.idleTimeout,
15
+ keepAliveInterval: config.keepAliveInterval,
14
16
  connectionTimeout: 20000,
15
17
  retryAttempts: 3
16
18
  };
17
-
19
+
18
20
  // Nettoyage périodique des connexions inactives
19
21
  this.startCleanupInterval();
20
22
  }
21
-
23
+
24
+ isConnectionReady(connId) {
25
+ const connInfo = this.activeConnections.get(connId);
26
+ if (!connInfo) return false;
27
+
28
+ // Vérifier l'état réel
29
+ return connInfo.conn._sock &&
30
+ connInfo.conn._sock.readable &&
31
+ this.activeConnections.get(connId)?.inUse !== undefined; // Basic check
32
+ }
33
+
22
34
  // Obtenir ou créer une connexion
23
35
  async getConnection(serverAlias, serverConfig) {
24
36
  // Chercher une connexion disponible
25
37
  const pool = this.pools.get(serverAlias) || [];
26
-
38
+
27
39
  for (const connId of pool) {
28
40
  const connInfo = this.activeConnections.get(connId);
29
- if (connInfo && !connInfo.inUse && connInfo.conn.isReady) {
41
+ if (connInfo && !connInfo.inUse && this.isConnectionReady(connId)) {
30
42
  connInfo.inUse = true;
31
43
  connInfo.lastUsed = Date.now();
32
44
  queue.log('info', `Réutilisation connexion SSH existante pour ${serverAlias}`);
33
45
  return { id: connId, client: connInfo.conn };
34
46
  }
35
47
  }
36
-
48
+
37
49
  // Si pas de connexion disponible, en créer une nouvelle
38
50
  if (pool.length < this.config.maxConnections) {
39
51
  const newConn = await this.createConnection(serverAlias, serverConfig);
40
52
  return newConn;
41
53
  }
42
-
54
+
43
55
  // Si pool plein, attendre qu'une connexion se libère
44
56
  queue.log('warn', `Pool SSH saturé pour ${serverAlias}, attente...`);
45
57
  return await this.waitForConnection(serverAlias, serverConfig);
46
58
  }
47
-
59
+
48
60
  // Créer une nouvelle connexion
49
61
  async createConnection(serverAlias, serverConfig) {
50
62
  const conn = new Client();
51
63
  const connId = `${serverAlias}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
52
-
64
+
53
65
  return new Promise((resolve, reject) => {
54
66
  let retries = 0;
55
-
67
+
56
68
  const tryConnect = async () => {
57
69
  try {
58
70
  const config = {
@@ -63,22 +75,22 @@ class SSHConnectionPool {
63
75
  keepaliveInterval: this.config.keepAliveInterval,
64
76
  keepaliveCountMax: 3
65
77
  };
66
-
78
+
67
79
  if (serverConfig.keyPath) {
68
80
  config.privateKey = await fs.readFile(serverConfig.keyPath);
69
81
  } else if (serverConfig.password) {
70
82
  config.password = serverConfig.password;
71
83
  }
72
-
84
+
73
85
  conn.on('ready', () => {
74
86
  queue.log('info', `Nouvelle connexion SSH établie pour ${serverAlias}`);
75
-
87
+
76
88
  // Ajouter au pool
77
89
  if (!this.pools.has(serverAlias)) {
78
90
  this.pools.set(serverAlias, []);
79
91
  }
80
92
  this.pools.get(serverAlias).push(connId);
81
-
93
+
82
94
  // Enregistrer la connexion
83
95
  this.activeConnections.set(connId, {
84
96
  conn,
@@ -87,15 +99,11 @@ class SSHConnectionPool {
87
99
  lastUsed: Date.now(),
88
100
  config: serverConfig
89
101
  });
90
-
91
- // Ajouter un flag pour vérifier si la connexion est prête
92
- conn.isReady = true;
93
-
102
+
94
103
  resolve({ id: connId, client: conn });
95
104
  });
96
-
105
+
97
106
  conn.on('error', (err) => {
98
- conn.isReady = false;
99
107
  if (retries < this.config.retryAttempts) {
100
108
  retries++;
101
109
  queue.log('warn', `Tentative ${retries}/${this.config.retryAttempts} de connexion à ${serverAlias}`);
@@ -105,23 +113,22 @@ class SSHConnectionPool {
105
113
  reject(new Error(`Impossible de se connecter à ${serverAlias}: ${err.message}`));
106
114
  }
107
115
  });
108
-
116
+
109
117
  conn.on('close', () => {
110
- conn.isReady = false;
111
118
  this.removeConnection(connId);
112
119
  queue.log('info', `Connexion SSH fermée pour ${serverAlias}`);
113
120
  });
114
-
121
+
115
122
  conn.connect(config);
116
123
  } catch (err) {
117
124
  reject(err);
118
125
  }
119
126
  };
120
-
127
+
121
128
  tryConnect();
122
129
  });
123
130
  }
124
-
131
+
125
132
  // Libérer une connexion
126
133
  releaseConnection(connId) {
127
134
  const connInfo = this.activeConnections.get(connId);
@@ -131,7 +138,7 @@ class SSHConnectionPool {
131
138
  queue.log('debug', `Connexion ${connId} libérée`);
132
139
  }
133
140
  }
134
-
141
+
135
142
  // Fermer une connexion spécifique
136
143
  closeConnection(connId) {
137
144
  const connInfo = this.activeConnections.get(connId);
@@ -144,7 +151,7 @@ class SSHConnectionPool {
144
151
  this.removeConnection(connId);
145
152
  }
146
153
  }
147
-
154
+
148
155
  // Retirer une connexion du pool
149
156
  removeConnection(connId) {
150
157
  const connInfo = this.activeConnections.get(connId);
@@ -162,11 +169,11 @@ class SSHConnectionPool {
162
169
  this.activeConnections.delete(connId);
163
170
  }
164
171
  }
165
-
172
+
166
173
  // Attendre qu'une connexion se libère
167
174
  async waitForConnection(serverAlias, serverConfig, timeout = 30000) {
168
175
  const startTime = Date.now();
169
-
176
+
170
177
  return new Promise((resolve, reject) => {
171
178
  const checkInterval = setInterval(async () => {
172
179
  // Vérifier le timeout
@@ -175,12 +182,12 @@ class SSHConnectionPool {
175
182
  reject(new Error(`Timeout en attendant une connexion pour ${serverAlias}`));
176
183
  return;
177
184
  }
178
-
185
+
179
186
  // Essayer d'obtenir une connexion
180
187
  const pool = this.pools.get(serverAlias) || [];
181
188
  for (const connId of pool) {
182
189
  const connInfo = this.activeConnections.get(connId);
183
- if (connInfo && !connInfo.inUse && connInfo.conn.isReady) {
190
+ if (connInfo && !connInfo.inUse && this.isConnectionReady(connId)) {
184
191
  clearInterval(checkInterval);
185
192
  connInfo.inUse = true;
186
193
  connInfo.lastUsed = Date.now();
@@ -191,50 +198,50 @@ class SSHConnectionPool {
191
198
  }, 500);
192
199
  });
193
200
  }
194
-
201
+
195
202
  // Nettoyer les connexions inactives
196
203
  startCleanupInterval() {
197
204
  setInterval(() => {
198
205
  const now = Date.now();
199
-
206
+
200
207
  for (const [connId, connInfo] of this.activeConnections) {
201
208
  // Fermer les connexions inactives depuis trop longtemps
202
209
  if (!connInfo.inUse && (now - connInfo.lastUsed) > this.config.idleTimeout) {
203
210
  const pool = this.pools.get(connInfo.serverAlias) || [];
204
-
211
+
205
212
  // Garder au moins minConnections
206
213
  if (pool.length > this.config.minConnections) {
207
214
  queue.log('info', `Fermeture connexion inactive: ${connId}`);
208
215
  this.closeConnection(connId);
209
216
  }
210
217
  }
211
-
218
+
212
219
  // Vérifier que la connexion est toujours vivante
213
- if (!connInfo.conn.isReady && !connInfo.inUse) {
220
+ if (!this.isConnectionReady(connId) && !connInfo.inUse) {
214
221
  this.removeConnection(connId);
215
222
  }
216
223
  }
217
224
  }, 60000); // Vérifier toutes les minutes
218
225
  }
219
-
226
+
220
227
  // Obtenir les statistiques du pool
221
228
  getStats() {
222
229
  const stats = {
223
230
  totalConnections: this.activeConnections.size,
224
231
  byServer: {}
225
232
  };
226
-
233
+
227
234
  for (const [serverAlias, pool] of this.pools) {
228
235
  const connections = pool.map(connId => {
229
236
  const info = this.activeConnections.get(connId);
230
237
  return {
231
238
  id: connId,
232
239
  inUse: info?.inUse || false,
233
- ready: info?.conn?.isReady || false,
240
+ ready: this.isConnectionReady(connId),
234
241
  lastUsed: info?.lastUsed
235
242
  };
236
243
  });
237
-
244
+
238
245
  stats.byServer[serverAlias] = {
239
246
  total: connections.length,
240
247
  inUse: connections.filter(c => c.inUse).length,
@@ -242,10 +249,10 @@ class SSHConnectionPool {
242
249
  connections
243
250
  };
244
251
  }
245
-
252
+
246
253
  return stats;
247
254
  }
248
-
255
+
249
256
  // Fermer toutes les connexions
250
257
  closeAll() {
251
258
  queue.log('info', 'Fermeture de toutes les connexions SSH...');
@@ -258,15 +265,4 @@ class SSHConnectionPool {
258
265
  // Instance singleton
259
266
  const sshPool = new SSHConnectionPool();
260
267
 
261
- // Nettoyer à la fermeture du processus
262
- process.on('SIGINT', () => {
263
- sshPool.closeAll();
264
- process.exit(0);
265
- });
266
-
267
- process.on('SIGTERM', () => {
268
- sshPool.closeAll();
269
- process.exit(0);
270
- });
271
-
272
268
  export default sshPool;
package/test_mcp.js ADDED
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { z } from "zod";
5
+
6
+ console.error("🧪 Test MCP Server...");
7
+
8
+ try {
9
+ const server = new McpServer({
10
+ name: "test-server",
11
+ version: "1.0.0",
12
+ description: "Test"
13
+ });
14
+
15
+ console.error("✅ Serveur créé");
16
+
17
+ // Test tool simple
18
+ server.registerTool(
19
+ "test_tool",
20
+ {
21
+ title: "Test",
22
+ description: "Un test simple",
23
+ inputSchema: z.object({})
24
+ },
25
+ async () => {
26
+ return { content: [{ type: "text", text: "OK" }] };
27
+ }
28
+ );
29
+
30
+ console.error("✅ Tool enregistré");
31
+
32
+ // Test tool avec params
33
+ server.registerTool(
34
+ "test_tool_with_params",
35
+ {
36
+ title: "Test avec params",
37
+ description: "Test avec paramètres",
38
+ inputSchema: z.object({
39
+ message: z.string().describe("Un message")
40
+ })
41
+ },
42
+ async (params) => {
43
+ return { content: [{ type: "text", text: params.message }] };
44
+ }
45
+ );
46
+
47
+ console.error("✅ Tool avec params enregistré");
48
+ console.error("🎉 Tout fonctionne !");
49
+ process.exit(0);
50
+
51
+ } catch (error) {
52
+ console.error("❌ ERREUR:", error);
53
+ console.error("Stack:", error.stack);
54
+ process.exit(1);
55
+ }
package/tunnels.js ADDED
@@ -0,0 +1,224 @@
1
+ import { spawn } from 'child_process';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import config from './config.js';
5
+ import queue from './queue.js';
6
+ import history from './history.js';
7
+ import ssh from './ssh.js';
8
+
9
+
10
+ const TUNNELS_PATH = path.join(config.dataDir, 'tunnels.json');
11
+ const ALLOWLIST_PATH = path.join(config.dataDir, 'tunnel_allowlist.json');
12
+
13
+ const DEFAULT_ALLOWLIST = [80, 443, 3000, 8080, 8443, 9090, 3002, 3100, 3102, 4520, 5002, 4001, 51821, 5678, 8081, 8083, 8090];
14
+
15
+ const LOCAL_TUNNELS = new Map();
16
+
17
+ async function loadJson(p, def) {
18
+ try { await fs.access(p); return JSON.parse(await fs.readFile(p, 'utf-8')); }
19
+ catch { return def; }
20
+ }
21
+ async function saveJson(p, data) {
22
+ await fs.writeFile(p, JSON.stringify(data, null, 2));
23
+ }
24
+
25
+ async function loadAllowlist() {
26
+ return await loadJson(ALLOWLIST_PATH, DEFAULT_ALLOWLIST);
27
+ }
28
+ async function saveAllowlist(list) {
29
+ await saveJson(ALLOWLIST_PATH, list);
30
+ }
31
+
32
+ async function loadRegistry() {
33
+ return await loadJson(TUNNELS_PATH, {});
34
+ }
35
+ async function saveRegistry(reg) {
36
+ await saveJson(TUNNELS_PATH, reg);
37
+ }
38
+
39
+ async function getSshTarget(alias, serverManager) {
40
+ const cfg = await serverManager.getServer(alias);
41
+ return { host: cfg.host, port: cfg.port || 22, user: cfg.user, keyPath: cfg.keyPath };
42
+ }
43
+
44
+ function spawnLocalSsh(args, name, info) {
45
+ const proc = spawn('ssh', args, { stdio: 'ignore', detached: false });
46
+ LOCAL_TUNNELS.set(name, { process: proc, info });
47
+ proc.on('exit', (code) => {
48
+ LOCAL_TUNNELS.delete(name);
49
+ });
50
+ proc.on('error', () => LOCAL_TUNNELS.delete(name));
51
+ return proc.pid;
52
+ }
53
+
54
+ function buildArgs(type, listenPort, target, via, remoteKeyPath) {
55
+ const args = ['-N', '-o', 'ServerAliveInterval=30', '-o', 'StrictHostKeyChecking=no'];
56
+ if (type !== 'remote') args.push('-o', 'ExitOnForwardFailure=yes');
57
+ switch (type) {
58
+ case 'local': args.push('-L', `${listenPort}:${target}`); break;
59
+ case 'remote': args.push('-R', `${listenPort}:${target}`); break;
60
+ case 'socks': args.push('-D', `${listenPort}`); break;
61
+ }
62
+ args.push('-p', String(via.port), `${via.user}@${via.host}`);
63
+ const keyToUse = remoteKeyPath || via.keyPath;
64
+ if (keyToUse) args.push('-i', keyToUse);
65
+ return args;
66
+ }
67
+
68
+ export default {
69
+ async create(params, serverManager) {
70
+ const { name, type, listen_port, target, via, source, key_path } = params;
71
+ if (!name) throw new Error("Le paramètre 'name' est requis.");
72
+ if (!['local', 'remote', 'socks'].includes(type)) throw new Error("type doit être 'local', 'remote' ou 'socks'");
73
+ if (!listen_port || listen_port < 1 || listen_port > 65535) throw new Error("listen_port invalide");
74
+ if (type !== 'socks' && !target) throw new Error("target requis pour les tunnels local/remote");
75
+ if (listen_port < 1024) throw new Error("Les ports < 1024 nécessitent root. Utilisez un port > 1023.");
76
+
77
+ const allowlist = await loadAllowlist();
78
+ if (!allowlist.includes(listen_port)) {
79
+ throw new Error(`Port ${listen_port} non autorisé. Autorisez-le: tunnel_allowlist_add ${listen_port}. Ports autorisés: ${allowlist.join(', ')}`);
80
+ }
81
+
82
+ const registry = await loadRegistry();
83
+ if (registry[name]) throw new Error(`Un tunnel nommé '${name}' existe déjà. Fermez-le d'abord.`);
84
+
85
+ const viaTarget = await getSshTarget(via, serverManager);
86
+
87
+ // Pour les tunnels sur serveur distant, le key_path doit exister SUR ce serveur
88
+ const remoteKeyPath = source ? (key_path || null) : null;
89
+ const args = buildArgs(type, listen_port, target || '', viaTarget, source ? (key_path || null) : null);
90
+
91
+ if (!source) {
92
+ const pid = spawnLocalSsh(args, name, { type, listen_port, target, via, source: null });
93
+ registry[name] = { type, listen_port, target, via, source: null, pid, created_at: new Date().toISOString() };
94
+ await saveRegistry(registry);
95
+ const desc = type === 'socks'
96
+ ? `Proxy SOCKS5 sur 127.0.0.1:${listen_port} (via ${via})`
97
+ : `http://127.0.0.1:${listen_port} → ${via}:${target}`;
98
+ return `Tunnel '${name}' créé localement (PID ${pid}). ${desc}\n⚠️ Ne survit pas au redémarrage du MCP.`;
99
+ }
100
+
101
+ const sessionName = `tunnel-${name}`;
102
+ const sshCmd = 'ssh ' + args.join(' ');
103
+ const createCmd = `sh -c "tmux new-session -d -s ${sessionName} '${sshCmd}'"`;
104
+
105
+ const job = queue.addJob({ type: 'ssh', alias: source, cmd: createCmd, pty: true, timeout: 20, skip_policy: true, status: 'pending' });
106
+ history.logTask(job);
107
+ ssh.executeCommand(job.id);
108
+
109
+ const result = await new Promise(resolve => {
110
+ const start = Date.now();
111
+ const poll = () => {
112
+ const j = queue.getJob(job.id);
113
+ if (!j || j.status === 'completed') resolve(j);
114
+ else if (j.status === 'failed') resolve(j);
115
+ else if (Date.now() - start > 30000) resolve({ status: 'failed', error: 'timeout 30s' });
116
+ else setTimeout(poll, 200);
117
+ };
118
+ poll();
119
+ });
120
+
121
+ if (!result || result.status === 'failed') {
122
+ const detail = result ? (result.stderr || result.output || result.error || '') : 'job introuvable';
123
+ throw new Error(`Échec création tunnel sur ${source}: ${detail.substring(0, 500)}`);
124
+ }
125
+
126
+ registry[name] = { type, listen_port, target, via, source, tmux_session: sessionName, key_path, created_at: new Date().toISOString() };
127
+ await saveRegistry(registry);
128
+
129
+ const desc = type === 'socks'
130
+ ? `Proxy SOCKS5 sur ${source}:${listen_port} (via ${via})`
131
+ : `${source}:${listen_port} → ${via}:${target}`;
132
+ return `Tunnel '${name}' créé sur ${source} (session tmux: ${sessionName}). ${desc}\n✅ Persistant (survit au redémarrage du MCP).`;
133
+ },
134
+
135
+ async list() {
136
+ const registry = await loadRegistry();
137
+ const alive = [];
138
+
139
+ for (const [name, info] of Object.entries(registry)) {
140
+ let status;
141
+ if (!info.source) {
142
+ const local = LOCAL_TUNNELS.get(name);
143
+ status = local && local.process && !local.process.killed ? 'actif' : 'mort (MCP relancé, relancez le tunnel)';
144
+ } else {
145
+ const ckJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux has-session -t ${info.tmux_session} 2>/dev/null && echo actif || echo mort`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
146
+ ssh.executeCommand(ckJob.id);
147
+ await new Promise(r => { const p = () => { const j = queue.getJob(ckJob.id); if (!j || j.status === 'completed' || j.status === 'failed') r(j); else setTimeout(p, 200); }; p(); });
148
+ const j = queue.getJob(ckJob.id);
149
+ status = j && j.output && j.output.trim() === 'actif' ? 'actif' : 'mort';
150
+ }
151
+ alive.push({ name, ...info, status });
152
+ }
153
+
154
+ const allowlist = await loadAllowlist();
155
+ return { tunnels: alive, allowlist };
156
+ },
157
+
158
+ async close(name, serverManager) {
159
+ const registry = await loadRegistry();
160
+ const info = registry[name];
161
+ if (!info) throw new Error(`Tunnel '${name}' introuvable.`);
162
+
163
+ if (!info.source) {
164
+ const local = LOCAL_TUNNELS.get(name);
165
+ if (local && local.process && !local.process.killed) {
166
+ local.process.kill('SIGTERM');
167
+ setTimeout(() => { if (!local.process.killed) local.process.kill('SIGKILL'); }, 2000);
168
+ }
169
+ LOCAL_TUNNELS.delete(name);
170
+ } else {
171
+ const killJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux kill-session -t ${info.tmux_session} 2>/dev/null; echo OK`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
172
+ ssh.executeCommand(killJob.id);
173
+ }
174
+
175
+ delete registry[name];
176
+ await saveRegistry(registry);
177
+ return `Tunnel '${name}' fermé.`;
178
+ },
179
+
180
+ async allowlistList() {
181
+ return await loadAllowlist();
182
+ },
183
+
184
+ async allowlistAdd(port) {
185
+ const list = await loadAllowlist();
186
+ if (!list.includes(port)) list.push(port);
187
+ await saveAllowlist(list);
188
+ return list;
189
+ },
190
+
191
+ async allowlistRemove(port) {
192
+ const list = await loadAllowlist();
193
+ const filtered = list.filter(p => p !== port);
194
+ await saveAllowlist(filtered);
195
+ return filtered;
196
+ },
197
+
198
+ async restore(servers) {
199
+ const registry = await loadRegistry();
200
+ let restored = 0;
201
+ for (const [name, info] of Object.entries(registry)) {
202
+ if (!info.source) {
203
+ try {
204
+ const viaTarget = await getSshTarget(info.via, servers);
205
+ const args = buildArgs(info.type, info.listen_port, info.target || '', viaTarget, false);
206
+ const pid = spawnLocalSsh(args, name, info);
207
+ registry[name].pid = pid;
208
+ restored++;
209
+ } catch { /* silencieux, tunnel perdu */ }
210
+ }
211
+ }
212
+ await saveRegistry(registry);
213
+ return restored;
214
+ },
215
+
216
+ shutdown() {
217
+ for (const [name, entry] of LOCAL_TUNNELS) {
218
+ if (entry.process && !entry.process.killed) {
219
+ entry.process.kill('SIGTERM');
220
+ }
221
+ }
222
+ LOCAL_TUNNELS.clear();
223
+ }
224
+ };
package/utils.js ADDED
@@ -0,0 +1,6 @@
1
+ function escapeShellArg(arg) {
2
+ if (typeof arg !== 'string') return String(arg);
3
+ return "'" + arg.replace(/'/g, "'\\''") + "'";
4
+ }
5
+
6
+ export default { escapeShellArg };