@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/.env.example +21 -15
- package/.gencodedoc.yaml +76 -0
- package/README.fr.md +369 -0
- package/README.md +333 -61
- package/ROADMAP.md +21 -0
- package/apis.js +17 -0
- package/compareEngine.js +132 -0
- package/config.js +35 -4
- package/diagnose.js +89 -0
- package/diffEngine.js +141 -0
- package/diffFormatter.js +114 -0
- package/fileOps.js +275 -0
- package/guide.js +236 -0
- package/history.js +8 -1
- package/notes.js +87 -0
- package/package.json +5 -22
- package/policies.js +76 -0
- package/queue.js +107 -121
- package/server.js +1460 -108
- package/sftp.js +131 -41
- package/shellSessions.js +237 -0
- package/snapshotManager.js +324 -0
- package/sourceAdapter.js +357 -0
- package/ssh.js +168 -84
- package/sshPool.js +52 -56
- package/test_mcp.js +55 -0
- package/tunnels.js +224 -0
- package/utils.js +6 -0
- package/publish1github.md +0 -216
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:
|
|
11
|
-
minConnections:
|
|
12
|
-
idleTimeout:
|
|
13
|
-
keepAliveInterval:
|
|
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 &&
|
|
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 &&
|
|
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 (!
|
|
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:
|
|
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
|
+
};
|