@fkom13/mcp-sftp-orchestrator 11.3.0 → 11.8.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 +6 -0
- package/CHANGELOG.md +174 -0
- package/README.fr.md +230 -243
- package/README.md +268 -286
- package/apis.js +20 -81
- package/atomicJsonStore.js +117 -0
- package/config.js +5 -0
- package/fleet.js +170 -0
- package/groups.js +25 -0
- package/history.js +5 -51
- package/infraTopology.js +336 -0
- package/inventory.js +151 -0
- package/lhm.plugin.json +7 -0
- package/notes.js +3 -87
- package/package.json +8 -6
- package/policies.js +6 -74
- package/projects.js +108 -0
- package/queue.js +83 -14
- package/server.js +821 -90
- package/servers.js +30 -33
- package/sftp.js +168 -60
- package/shellSessions.js +31 -3
- package/snapshotManager.js +3 -7
- package/sourceAdapter.js +54 -1
- package/ssh.js +17 -3
- package/sshPool.js +129 -112
- package/sshTrust.js +214 -0
- package/toolMetadata.js +76 -0
- package/tunnels.js +36 -17
- package/utils.js +210 -1
- package/workSession.js +139 -0
- package/.gencodedoc.yaml +0 -76
- package/ROADMAP.md +0 -21
- package/test_features.js +0 -112
- package/test_mcp.js +0 -55
package/sshPool.js
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import { Client } from 'ssh2';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
3
|
import queue from './queue.js';
|
|
4
|
-
|
|
4
|
+
import utils from './utils.js';
|
|
5
5
|
import config from './config.js';
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Pool de connexions SSH persistantes.
|
|
9
|
+
* v11.4.0 : listeners non empilés sur retry, flag ready fiable, port configurable.
|
|
10
|
+
*/
|
|
7
11
|
class SSHConnectionPool {
|
|
8
12
|
constructor() {
|
|
9
|
-
this.pools = new Map(); // Map<serverAlias,
|
|
10
|
-
this.activeConnections = new Map(); // Map<
|
|
13
|
+
this.pools = new Map(); // Map<serverAlias, connId[]>
|
|
14
|
+
this.activeConnections = new Map(); // Map<connId, {conn, serverAlias, inUse, lastUsed, ready}>
|
|
11
15
|
this.config = {
|
|
12
16
|
maxConnections: config.maxConnectionsPerServer,
|
|
13
17
|
minConnections: config.minConnectionsPerServer,
|
|
@@ -16,24 +20,20 @@ class SSHConnectionPool {
|
|
|
16
20
|
connectionTimeout: 20000,
|
|
17
21
|
retryAttempts: 3
|
|
18
22
|
};
|
|
19
|
-
|
|
20
|
-
// Nettoyage périodique des connexions inactives
|
|
21
23
|
this.startCleanupInterval();
|
|
22
24
|
}
|
|
23
25
|
|
|
24
26
|
isConnectionReady(connId) {
|
|
25
27
|
const connInfo = this.activeConnections.get(connId);
|
|
26
|
-
if (!connInfo) return false;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
if (!connInfo || !connInfo.ready || connInfo.closed) return false;
|
|
29
|
+
const sock = connInfo.conn && connInfo.conn._sock;
|
|
30
|
+
if (!sock) return false;
|
|
31
|
+
// Socket encore ouvert et lisible
|
|
32
|
+
if (sock.destroyed || sock.readyState === 'closed') return false;
|
|
33
|
+
return sock.readable !== false;
|
|
32
34
|
}
|
|
33
35
|
|
|
34
|
-
// Obtenir ou créer une connexion
|
|
35
36
|
async getConnection(serverAlias, serverConfig) {
|
|
36
|
-
// Chercher une connexion disponible
|
|
37
37
|
const pool = this.pools.get(serverAlias) || [];
|
|
38
38
|
|
|
39
39
|
for (const connId of pool) {
|
|
@@ -44,92 +44,123 @@ class SSHConnectionPool {
|
|
|
44
44
|
queue.log('info', `Réutilisation connexion SSH existante pour ${serverAlias}`);
|
|
45
45
|
return { id: connId, client: connInfo.conn };
|
|
46
46
|
}
|
|
47
|
+
// Connexion morte dans le pool → purge
|
|
48
|
+
if (connInfo && !connInfo.inUse && !this.isConnectionReady(connId)) {
|
|
49
|
+
this.removeConnection(connId);
|
|
50
|
+
}
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
return newConn;
|
|
53
|
+
const currentPool = this.pools.get(serverAlias) || [];
|
|
54
|
+
if (currentPool.length < this.config.maxConnections) {
|
|
55
|
+
return await this.createConnection(serverAlias, serverConfig);
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
// Si pool plein, attendre qu'une connexion se libère
|
|
56
58
|
queue.log('warn', `Pool SSH saturé pour ${serverAlias}, attente...`);
|
|
57
59
|
return await this.waitForConnection(serverAlias, serverConfig);
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
// Créer une nouvelle connexion
|
|
61
62
|
async createConnection(serverAlias, serverConfig) {
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return new Promise((resolve, reject) => {
|
|
66
|
-
let retries = 0;
|
|
67
|
-
|
|
68
|
-
const tryConnect = async () => {
|
|
69
|
-
try {
|
|
70
|
-
const config = {
|
|
71
|
-
host: serverConfig.host,
|
|
72
|
-
port: 22,
|
|
73
|
-
username: serverConfig.user,
|
|
74
|
-
readyTimeout: this.config.connectionTimeout,
|
|
75
|
-
keepaliveInterval: this.config.keepAliveInterval,
|
|
76
|
-
keepaliveCountMax: 3
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
if (serverConfig.keyPath) {
|
|
80
|
-
config.privateKey = await fs.readFile(serverConfig.keyPath);
|
|
81
|
-
} else if (serverConfig.password) {
|
|
82
|
-
config.password = serverConfig.password;
|
|
83
|
-
}
|
|
63
|
+
const connId = `${serverAlias}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
|
64
|
+
let lastError = null;
|
|
84
65
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
// Enregistrer la connexion
|
|
95
|
-
this.activeConnections.set(connId, {
|
|
96
|
-
conn,
|
|
97
|
-
serverAlias,
|
|
98
|
-
inUse: true,
|
|
99
|
-
lastUsed: Date.now(),
|
|
100
|
-
config: serverConfig
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
resolve({ id: connId, client: conn });
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
conn.on('error', (err) => {
|
|
107
|
-
if (retries < this.config.retryAttempts) {
|
|
108
|
-
retries++;
|
|
109
|
-
queue.log('warn', `Tentative ${retries}/${this.config.retryAttempts} de connexion à ${serverAlias}`);
|
|
110
|
-
setTimeout(tryConnect, 2000 * retries);
|
|
111
|
-
} else {
|
|
112
|
-
this.removeConnection(connId);
|
|
113
|
-
reject(new Error(`Impossible de se connecter à ${serverAlias}: ${err.message}`));
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
conn.on('close', () => {
|
|
118
|
-
this.removeConnection(connId);
|
|
119
|
-
queue.log('info', `Connexion SSH fermée pour ${serverAlias}`);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
conn.connect(config);
|
|
123
|
-
} catch (err) {
|
|
124
|
-
reject(err);
|
|
66
|
+
for (let attempt = 1; attempt <= this.config.retryAttempts; attempt++) {
|
|
67
|
+
try {
|
|
68
|
+
const conn = await this._connectOnce(serverAlias, serverConfig, connId);
|
|
69
|
+
return conn;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
lastError = err;
|
|
72
|
+
queue.log('warn', `Tentative ${attempt}/${this.config.retryAttempts} de connexion à ${serverAlias}: ${err.message}`);
|
|
73
|
+
if (attempt < this.config.retryAttempts) {
|
|
74
|
+
await new Promise(r => setTimeout(r, 2000 * attempt));
|
|
125
75
|
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
this.removeConnection(connId);
|
|
80
|
+
throw new Error(`Impossible de se connecter à ${serverAlias}: ${lastError?.message || 'erreur inconnue'}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Une seule tentative = un nouveau Client + listeners uniques (pas d'empilement).
|
|
85
|
+
*/
|
|
86
|
+
_connectOnce(serverAlias, serverConfig, connId) {
|
|
87
|
+
return new Promise(async (resolve, reject) => {
|
|
88
|
+
const conn = new Client();
|
|
89
|
+
let settled = false;
|
|
90
|
+
|
|
91
|
+
const fail = (err) => {
|
|
92
|
+
if (settled) return;
|
|
93
|
+
settled = true;
|
|
94
|
+
try { conn.removeAllListeners(); conn.end(); } catch { /* ignore */ }
|
|
95
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const succeed = () => {
|
|
99
|
+
if (settled) return;
|
|
100
|
+
settled = true;
|
|
101
|
+
queue.log('info', `Nouvelle connexion SSH établie pour ${serverAlias}`);
|
|
102
|
+
|
|
103
|
+
if (!this.pools.has(serverAlias)) this.pools.set(serverAlias, []);
|
|
104
|
+
const pool = this.pools.get(serverAlias);
|
|
105
|
+
if (!pool.includes(connId)) pool.push(connId);
|
|
106
|
+
|
|
107
|
+
this.activeConnections.set(connId, {
|
|
108
|
+
conn,
|
|
109
|
+
serverAlias,
|
|
110
|
+
inUse: true,
|
|
111
|
+
lastUsed: Date.now(),
|
|
112
|
+
ready: true,
|
|
113
|
+
closed: false,
|
|
114
|
+
config: serverConfig
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
resolve({ id: connId, client: conn });
|
|
126
118
|
};
|
|
127
119
|
|
|
128
|
-
|
|
120
|
+
conn.once('ready', succeed);
|
|
121
|
+
conn.once('error', (err) => fail(err));
|
|
122
|
+
conn.on('close', () => {
|
|
123
|
+
const info = this.activeConnections.get(connId);
|
|
124
|
+
if (info) {
|
|
125
|
+
info.ready = false;
|
|
126
|
+
info.closed = true;
|
|
127
|
+
}
|
|
128
|
+
this.removeConnection(connId);
|
|
129
|
+
queue.log('info', `Connexion SSH fermée pour ${serverAlias}`);
|
|
130
|
+
});
|
|
131
|
+
conn.on('end', () => {
|
|
132
|
+
const info = this.activeConnections.get(connId);
|
|
133
|
+
if (info) {
|
|
134
|
+
info.ready = false;
|
|
135
|
+
info.closed = true;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
const connectConfig = {
|
|
141
|
+
host: serverConfig.host,
|
|
142
|
+
port: utils.resolveSshPort(serverConfig),
|
|
143
|
+
username: serverConfig.user,
|
|
144
|
+
readyTimeout: this.config.connectionTimeout,
|
|
145
|
+
keepaliveInterval: this.config.keepAliveInterval,
|
|
146
|
+
keepaliveCountMax: 3
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
if (serverConfig.keyPath) {
|
|
150
|
+
connectConfig.privateKey = await fs.readFile(serverConfig.keyPath);
|
|
151
|
+
} else if (serverConfig.password) {
|
|
152
|
+
connectConfig.password = serverConfig.password;
|
|
153
|
+
} else {
|
|
154
|
+
return fail(new Error(`Aucune méthode d'authentification pour '${serverAlias}'`));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
conn.connect(connectConfig);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
fail(err);
|
|
160
|
+
}
|
|
129
161
|
});
|
|
130
162
|
}
|
|
131
163
|
|
|
132
|
-
// Libérer une connexion
|
|
133
164
|
releaseConnection(connId) {
|
|
134
165
|
const connInfo = this.activeConnections.get(connId);
|
|
135
166
|
if (connInfo) {
|
|
@@ -139,51 +170,41 @@ class SSHConnectionPool {
|
|
|
139
170
|
}
|
|
140
171
|
}
|
|
141
172
|
|
|
142
|
-
// Fermer une connexion spécifique
|
|
143
173
|
closeConnection(connId) {
|
|
144
174
|
const connInfo = this.activeConnections.get(connId);
|
|
145
175
|
if (connInfo) {
|
|
146
176
|
try {
|
|
177
|
+
connInfo.ready = false;
|
|
147
178
|
connInfo.conn.end();
|
|
148
|
-
} catch
|
|
149
|
-
// Ignorer les erreurs de fermeture
|
|
150
|
-
}
|
|
179
|
+
} catch { /* ignore */ }
|
|
151
180
|
this.removeConnection(connId);
|
|
152
181
|
}
|
|
153
182
|
}
|
|
154
183
|
|
|
155
|
-
// Retirer une connexion du pool
|
|
156
184
|
removeConnection(connId) {
|
|
157
185
|
const connInfo = this.activeConnections.get(connId);
|
|
158
186
|
if (connInfo) {
|
|
159
187
|
const pool = this.pools.get(connInfo.serverAlias);
|
|
160
188
|
if (pool) {
|
|
161
189
|
const index = pool.indexOf(connId);
|
|
162
|
-
if (index > -1)
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
if (pool.length === 0) {
|
|
166
|
-
this.pools.delete(connInfo.serverAlias);
|
|
167
|
-
}
|
|
190
|
+
if (index > -1) pool.splice(index, 1);
|
|
191
|
+
if (pool.length === 0) this.pools.delete(connInfo.serverAlias);
|
|
168
192
|
}
|
|
169
193
|
this.activeConnections.delete(connId);
|
|
170
194
|
}
|
|
171
195
|
}
|
|
172
196
|
|
|
173
|
-
// Attendre qu'une connexion se libère
|
|
174
197
|
async waitForConnection(serverAlias, serverConfig, timeout = 30000) {
|
|
175
198
|
const startTime = Date.now();
|
|
176
199
|
|
|
177
200
|
return new Promise((resolve, reject) => {
|
|
178
|
-
const checkInterval = setInterval(
|
|
179
|
-
// Vérifier le timeout
|
|
201
|
+
const checkInterval = setInterval(() => {
|
|
180
202
|
if (Date.now() - startTime > timeout) {
|
|
181
203
|
clearInterval(checkInterval);
|
|
182
204
|
reject(new Error(`Timeout en attendant une connexion pour ${serverAlias}`));
|
|
183
205
|
return;
|
|
184
206
|
}
|
|
185
207
|
|
|
186
|
-
// Essayer d'obtenir une connexion
|
|
187
208
|
const pool = this.pools.get(serverAlias) || [];
|
|
188
209
|
for (const connId of pool) {
|
|
189
210
|
const connInfo = this.activeConnections.get(connId);
|
|
@@ -195,36 +216,35 @@ class SSHConnectionPool {
|
|
|
195
216
|
return;
|
|
196
217
|
}
|
|
197
218
|
}
|
|
219
|
+
|
|
220
|
+
// Slot libre → créer
|
|
221
|
+
if (pool.length < this.config.maxConnections) {
|
|
222
|
+
clearInterval(checkInterval);
|
|
223
|
+
this.createConnection(serverAlias, serverConfig).then(resolve).catch(reject);
|
|
224
|
+
}
|
|
198
225
|
}, 500);
|
|
199
226
|
});
|
|
200
227
|
}
|
|
201
228
|
|
|
202
|
-
// Nettoyer les connexions inactives
|
|
203
229
|
startCleanupInterval() {
|
|
204
230
|
setInterval(() => {
|
|
205
231
|
const now = Date.now();
|
|
206
|
-
|
|
207
|
-
for (const [connId, connInfo] of this.activeConnections) {
|
|
208
|
-
// Fermer les connexions inactives depuis trop longtemps
|
|
232
|
+
for (const [connId, connInfo] of [...this.activeConnections.entries()]) {
|
|
209
233
|
if (!connInfo.inUse && (now - connInfo.lastUsed) > this.config.idleTimeout) {
|
|
210
234
|
const pool = this.pools.get(connInfo.serverAlias) || [];
|
|
211
|
-
|
|
212
|
-
// Garder au moins minConnections
|
|
213
235
|
if (pool.length > this.config.minConnections) {
|
|
214
236
|
queue.log('info', `Fermeture connexion inactive: ${connId}`);
|
|
215
237
|
this.closeConnection(connId);
|
|
238
|
+
continue;
|
|
216
239
|
}
|
|
217
240
|
}
|
|
218
|
-
|
|
219
|
-
// Vérifier que la connexion est toujours vivante
|
|
220
241
|
if (!this.isConnectionReady(connId) && !connInfo.inUse) {
|
|
221
242
|
this.removeConnection(connId);
|
|
222
243
|
}
|
|
223
244
|
}
|
|
224
|
-
}, 60000);
|
|
245
|
+
}, 60000);
|
|
225
246
|
}
|
|
226
247
|
|
|
227
|
-
// Obtenir les statistiques du pool
|
|
228
248
|
getStats() {
|
|
229
249
|
const stats = {
|
|
230
250
|
totalConnections: this.activeConnections.size,
|
|
@@ -253,16 +273,13 @@ class SSHConnectionPool {
|
|
|
253
273
|
return stats;
|
|
254
274
|
}
|
|
255
275
|
|
|
256
|
-
// Fermer toutes les connexions
|
|
257
276
|
closeAll() {
|
|
258
277
|
queue.log('info', 'Fermeture de toutes les connexions SSH...');
|
|
259
|
-
for (const connId of this.activeConnections.keys()) {
|
|
278
|
+
for (const connId of [...this.activeConnections.keys()]) {
|
|
260
279
|
this.closeConnection(connId);
|
|
261
280
|
}
|
|
262
281
|
}
|
|
263
282
|
}
|
|
264
283
|
|
|
265
|
-
// Instance singleton
|
|
266
284
|
const sshPool = new SSHConnectionPool();
|
|
267
|
-
|
|
268
285
|
export default sshPool;
|
package/sshTrust.js
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sshTrust.js — Autorisation croisée de clés SSH (pubkey only).
|
|
3
|
+
* Ne copie JAMAIS de clé privée entre serveurs.
|
|
4
|
+
*/
|
|
5
|
+
import fs from 'fs/promises';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import os from 'os';
|
|
8
|
+
import queue from './queue.js';
|
|
9
|
+
import servers from './servers.js';
|
|
10
|
+
import sourceAdapter from './sourceAdapter.js';
|
|
11
|
+
import utils from './utils.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Lit une pubkey depuis:
|
|
15
|
+
* - { type:'string', pubkey }
|
|
16
|
+
* - { type:'local_path', path } (fichier .pub)
|
|
17
|
+
* - { type:'alias', alias } (keyPath du serveur → .pub à côté ou ssh-keygen -y)
|
|
18
|
+
*/
|
|
19
|
+
async function resolvePubkey(source) {
|
|
20
|
+
if (!source || !source.type) {
|
|
21
|
+
throw new Error("source requis: {type:'string'|'local_path'|'alias', ...}");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (source.type === 'string') {
|
|
25
|
+
const pk = (source.pubkey || source.key || '').trim();
|
|
26
|
+
if (!pk.startsWith('ssh-') && !pk.startsWith('ecdsa-') && !pk.startsWith('sk-')) {
|
|
27
|
+
throw new Error('pubkey invalide (doit commencer par ssh-ed25519 / ssh-rsa / …)');
|
|
28
|
+
}
|
|
29
|
+
return pk.split('\n')[0].trim();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (source.type === 'local_path') {
|
|
33
|
+
const p = source.path;
|
|
34
|
+
if (!p) throw new Error('source.path requis pour local_path');
|
|
35
|
+
let content = await fs.readFile(p, 'utf-8');
|
|
36
|
+
// Si c'est une privée, refuse
|
|
37
|
+
if (content.includes('PRIVATE KEY')) {
|
|
38
|
+
// tenter .pub voisin
|
|
39
|
+
const pubPath = p.endsWith('.pub') ? p : p + '.pub';
|
|
40
|
+
try {
|
|
41
|
+
content = await fs.readFile(pubPath, 'utf-8');
|
|
42
|
+
} catch {
|
|
43
|
+
throw new Error(`Refus: ${p} semble une clé privée. Fournissez le .pub`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const line = content.trim().split('\n').find(l => l.startsWith('ssh-') || l.startsWith('ecdsa-'));
|
|
47
|
+
if (!line) throw new Error(`Aucune pubkey trouvée dans ${p}`);
|
|
48
|
+
return line.trim();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (source.type === 'alias') {
|
|
52
|
+
const sc = await servers.getServer(source.alias);
|
|
53
|
+
if (!sc.keyPath) {
|
|
54
|
+
throw new Error(`Alias '${source.alias}' n'a pas de keyPath (password-only non supporté pour trust).`);
|
|
55
|
+
}
|
|
56
|
+
const pubPath = sc.keyPath.endsWith('.pub') ? sc.keyPath : sc.keyPath + '.pub';
|
|
57
|
+
try {
|
|
58
|
+
const content = await fs.readFile(pubPath, 'utf-8');
|
|
59
|
+
const line = content.trim().split('\n').find(l => l.startsWith('ssh-') || l.startsWith('ecdsa-') || l.startsWith('sk-'));
|
|
60
|
+
if (line) return line.trim();
|
|
61
|
+
} catch {
|
|
62
|
+
// fallback: ssh-keygen -y -f private
|
|
63
|
+
}
|
|
64
|
+
// Générer pubkey depuis privée localement (ne sort pas du MCP host)
|
|
65
|
+
const { execFile } = await import('child_process');
|
|
66
|
+
const { promisify } = await import('util');
|
|
67
|
+
const execFileAsync = promisify(execFile);
|
|
68
|
+
try {
|
|
69
|
+
const { stdout } = await execFileAsync('ssh-keygen', ['-y', '-f', sc.keyPath], {
|
|
70
|
+
timeout: 5000,
|
|
71
|
+
env: { ...process.env, SSH_AUTH_SOCK: '' }
|
|
72
|
+
});
|
|
73
|
+
const line = stdout.trim().split('\n')[0];
|
|
74
|
+
if (!line.startsWith('ssh-') && !line.startsWith('ecdsa-')) {
|
|
75
|
+
throw new Error('ssh-keygen -y n\'a pas renvoyé une pubkey valide');
|
|
76
|
+
}
|
|
77
|
+
return line.trim();
|
|
78
|
+
} catch (e) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Impossible d'obtenir la pubkey pour '${source.alias}': ${e.message}. ` +
|
|
81
|
+
`Créez ${pubPath} ou passez source.type=string.`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
throw new Error(`source.type inconnu: ${source.type}`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function authorizedKeysPath(userHome, explicitPath) {
|
|
90
|
+
if (explicitPath) return explicitPath;
|
|
91
|
+
return path.posix.join(userHome || '~', '.ssh', 'authorized_keys');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* options:
|
|
96
|
+
* target_alias, source?, authorized_keys_path?,
|
|
97
|
+
* source: {type, ...},
|
|
98
|
+
* comment?, dryRun?, force?
|
|
99
|
+
*/
|
|
100
|
+
async function authorizeKey(options = {}) {
|
|
101
|
+
const {
|
|
102
|
+
target_alias,
|
|
103
|
+
user = null,
|
|
104
|
+
authorized_keys_path = null,
|
|
105
|
+
source,
|
|
106
|
+
comment = null,
|
|
107
|
+
dryRun = true,
|
|
108
|
+
force = false
|
|
109
|
+
} = options;
|
|
110
|
+
|
|
111
|
+
if (!target_alias) throw new Error('target_alias requis');
|
|
112
|
+
const sc = await servers.getServer(target_alias);
|
|
113
|
+
|
|
114
|
+
const pubkey = await resolvePubkey(source);
|
|
115
|
+
// Normalise: type + body (+ comment optionnel)
|
|
116
|
+
const parts = pubkey.trim().split(/\s+/);
|
|
117
|
+
const typeBody = parts.slice(0, 2).join(' ');
|
|
118
|
+
const existingComment = parts.slice(2).join(' ');
|
|
119
|
+
const lineToAdd = comment
|
|
120
|
+
? `${typeBody} ${comment}`
|
|
121
|
+
: (existingComment ? `${typeBody} ${existingComment}` : typeBody);
|
|
122
|
+
|
|
123
|
+
// Résoudre home distant
|
|
124
|
+
const homeCmd = user ? `getent passwd ${utils.escapeShellArg(user).replace(/^'|'$/g, '')} 2>/dev/null | cut -d: -f6` : 'echo $HOME';
|
|
125
|
+
// Simpler: use ~ expansion via shell on target for path
|
|
126
|
+
const remoteUser = user || sc.user;
|
|
127
|
+
const akPath = authorized_keys_path
|
|
128
|
+
|| `/home/${remoteUser}/.ssh/authorized_keys`;
|
|
129
|
+
// root special case
|
|
130
|
+
const finalAkPath = (remoteUser === 'root' && !authorized_keys_path)
|
|
131
|
+
? '/root/.ssh/authorized_keys'
|
|
132
|
+
: akPath;
|
|
133
|
+
|
|
134
|
+
let existing = '';
|
|
135
|
+
let existed = false;
|
|
136
|
+
try {
|
|
137
|
+
const r = await sourceAdapter.readFile({ type: 'remote', alias: target_alias, path: finalAkPath });
|
|
138
|
+
existing = r.content.toString('utf-8');
|
|
139
|
+
existed = true;
|
|
140
|
+
} catch {
|
|
141
|
+
existing = '';
|
|
142
|
+
existed = false;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const lines = existing.split('\n').map(l => l.trimEnd());
|
|
146
|
+
const pubCore = pubkey.split(' ').slice(0, 2).join(' ');
|
|
147
|
+
const already = lines.some(l => l.includes(pubCore));
|
|
148
|
+
|
|
149
|
+
if (already && !force) {
|
|
150
|
+
return {
|
|
151
|
+
dryRun: !!dryRun,
|
|
152
|
+
applied: false,
|
|
153
|
+
alreadyPresent: true,
|
|
154
|
+
target_alias,
|
|
155
|
+
path: finalAkPath,
|
|
156
|
+
pubkey_fingerprint_hint: pubCore.slice(0, 40) + '…'
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const newContent = already
|
|
161
|
+
? existing // force re-add skipped if already
|
|
162
|
+
: (existing.trimEnd() + (existing && !existing.endsWith('\n') ? '\n' : '') + lineToAdd + '\n');
|
|
163
|
+
|
|
164
|
+
if (dryRun) {
|
|
165
|
+
return {
|
|
166
|
+
dryRun: true,
|
|
167
|
+
applied: false,
|
|
168
|
+
alreadyPresent: already,
|
|
169
|
+
wouldAdd: !already,
|
|
170
|
+
target_alias,
|
|
171
|
+
path: finalAkPath,
|
|
172
|
+
linePreview: lineToAdd.slice(0, 80) + (lineToAdd.length > 80 ? '…' : ''),
|
|
173
|
+
existed
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Ensure .ssh dir exists via write createDirs
|
|
178
|
+
// Write authorized_keys
|
|
179
|
+
// Also set permissions via a quick exec would be better
|
|
180
|
+
await sourceAdapter.writeFile(
|
|
181
|
+
{ type: 'remote', alias: target_alias, path: finalAkPath },
|
|
182
|
+
Buffer.from(newContent, 'utf-8'),
|
|
183
|
+
{ createDirs: true }
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// chmod 700 .ssh / 600 authorized_keys
|
|
187
|
+
const sshDir = path.posix.dirname(finalAkPath);
|
|
188
|
+
const { default: ssh } = await import('./ssh.js');
|
|
189
|
+
const job = queue.addJob({
|
|
190
|
+
type: 'ssh',
|
|
191
|
+
alias: target_alias,
|
|
192
|
+
cmd: `mkdir -p ${utils.escapeShellArg(sshDir)} && chmod 700 ${utils.escapeShellArg(sshDir)} && chmod 600 ${utils.escapeShellArg(finalAkPath)}`,
|
|
193
|
+
timeout: 30,
|
|
194
|
+
skip_policy: true,
|
|
195
|
+
status: 'pending'
|
|
196
|
+
});
|
|
197
|
+
// fire and wait briefly
|
|
198
|
+
await ssh.executeCommand(job.id);
|
|
199
|
+
// small poll
|
|
200
|
+
await new Promise(r => setTimeout(r, 500));
|
|
201
|
+
|
|
202
|
+
queue.log('info', `ssh_authorize_key: pubkey ajoutée sur ${target_alias}:${finalAkPath}`);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
dryRun: false,
|
|
206
|
+
applied: true,
|
|
207
|
+
alreadyPresent: already,
|
|
208
|
+
target_alias,
|
|
209
|
+
path: finalAkPath,
|
|
210
|
+
linePreview: lineToAdd.slice(0, 80) + '…'
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export default { authorizeKey, resolvePubkey };
|
package/toolMetadata.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata MCP agentique centralisée.
|
|
3
|
+
*
|
|
4
|
+
* L'objectif n'est pas de remplacer les garde-fous serveur par des hints :
|
|
5
|
+
* les annotations MCP aident le client/modèle à choisir et confirmer le bon
|
|
6
|
+
* outil, tandis que les policies/guards restent l'autorité réelle.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const READ_ONLY = new Set([
|
|
10
|
+
'system_diagnostics','server_list','server_group_list','fleet_status','infra_audit',
|
|
11
|
+
'project_list','project_get','project_resolve','project_diff','work_list','server_inventory',
|
|
12
|
+
'policy_blocklist_list','api_list','api_check','get_system_resources','get_services_status',
|
|
13
|
+
'check_api_health','get_fail2ban_status','task_queue','task_status','task_history','pool_stats',
|
|
14
|
+
'queue_stats','task_logs','get_pm2_logs','get_docker_logs','tail_file','help','task_wait',
|
|
15
|
+
'file_read','diff_files','diff_folders','compare_all_sources','shell_list','snapshot_list',
|
|
16
|
+
'snapshot_diff','server_note_get','server_note_list','infra_overview','tmux_read','tmux_list',
|
|
17
|
+
'tunnel_list','guide'
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const DESTRUCTIVE = new Set([
|
|
21
|
+
'server_remove','server_group_remove','project_remove','work_end','policy_blocklist_remove',
|
|
22
|
+
'api_remove','task_exec','task_transfer','task_transfer_multi','task_exec_interactive',
|
|
23
|
+
'task_exec_sequence','task_retry','task_retry_all','task_purge','file_write','file_edit',
|
|
24
|
+
'shell_exec','shell_close','snapshot_restore','snapshot_delete','server_note_remove',
|
|
25
|
+
'tmux_exec','tmux_kill','tunnel_close','tunnel_allowlist_remove'
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const IDEMPOTENT_MUTATIONS = new Set([
|
|
29
|
+
'server_group_set','project_set','server_note_set','policy_blocklist_add',
|
|
30
|
+
'tunnel_allowlist_add','tunnel_allowlist_remove'
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
const CLOSED_WORLD = new Set([
|
|
34
|
+
'help','guide','policy_blocklist_list','project_list','project_get','project_resolve',
|
|
35
|
+
'server_group_list','server_note_get','server_note_list','task_queue','task_status',
|
|
36
|
+
'task_history','queue_stats','task_logs','snapshot_list','snapshot_diff'
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
const DESCRIPTION_OVERRIDES = {
|
|
40
|
+
system_diagnostics: 'Diagnostique le processus MCP lui-même (queue, pool SSH, registres, erreurs). Pour l’état réel d’une machine utilisez server_inventory; pour la topologie domaines→ports→services utilisez infra_overview avec alias.',
|
|
41
|
+
infra_audit: 'Contexte de contrôle-plane en un appel: version Orchestrator, registres, groupes, projets, notes, jobs crashés et pool. N’interroge pas profondément les machines. Pour une machine live et ses routes réseau utilisez infra_overview avec alias.',
|
|
42
|
+
infra_overview: 'Cartographie d’infrastructure. Sans alias: vue légère de tout le parc depuis les registres/notes. Avec alias (ou group:/all): découverte live mise en cache des listeners, Docker/Compose, PM2, systemd et Nginx, puis corrélation domaine → proxy → port → conteneur/service.',
|
|
43
|
+
fleet_status: 'Teste en parallèle la joignabilité SSH et quelques ressources de plusieurs serveurs. Choisir cet outil pour “qui est online ?”. Pour le détail d’une machine utilisez server_inventory; pour ses domaines/services exposés utilisez infra_overview.',
|
|
44
|
+
server_inventory: 'Inventaire live léger d’une machine avec cache TTL: ressources, Docker, PM2, HOME et Tailscale. Ne reconstruit pas les routes reverse-proxy; utilisez infra_overview pour domaine → port → service.',
|
|
45
|
+
task_exec: 'Commande distante one-shot. Pour une commande longue utilisez timeout:0 puis task_wait/task_status. Pour conserver cd/export entre appels utilisez shell_create/shell_exec; pour une session terminal survivant au MCP utilisez tmux_*.',
|
|
46
|
+
task_exec_sequence: 'Exécute plusieurs commandes ordonnées sur le même serveur avec résultat par étape. Préférer à plusieurs task_exec lorsque les étapes sont déterministes mais n’ont pas besoin d’un shell persistant.',
|
|
47
|
+
task_exec_interactive: 'Commande distante avec gestion de prompts interactifs connus. Utiliser seulement si la commande attend réellement yes/no/password/choix; sinon préférer task_exec.',
|
|
48
|
+
task_transfer: 'Transfert fichier ou dossier. server_to_server copie directement source_alias → alias. force:false protège une destination existante. Pour une réplication bit-à-bit avec métadonnées/symlinks, vérifier les warnings du résultat.',
|
|
49
|
+
task_transfer_multi: 'Lot de transferts avec patterns glob. À préférer pour plusieurs fichiers indépendants; task_transfer est plus clair pour un fichier/dossier ou un transfert server_to_server.',
|
|
50
|
+
file_edit: 'Édition chirurgicale locale/remote avec protection expectedHash, dryRun et backup. Préférer à file_write pour modifier un fichier existant sans écraser involontairement des changements concurrents.',
|
|
51
|
+
file_write: 'Crée ou remplace un fichier local/remote. Pour un fichier existant, préférer file_edit + expectedHash quand une édition chirurgicale est possible.',
|
|
52
|
+
shell_create: 'Ouvre un shell SSH persistant: cwd et variables survivent entre shell_exec. Pour une tâche détachée survivant à une déconnexion du MCP, préférer tmux_create/tmux_exec.',
|
|
53
|
+
tmux_create: 'Crée une session tmux distante durable. Adapté aux agents/serveurs de dev et tâches longues que l’on doit reprendre après déconnexion.',
|
|
54
|
+
snapshot_create: 'Snapshot CAS dédupliqué de fichiers/dossiers local ou remote pour rollback infra. Ce n’est pas de la mémoire sémantique projet; GenCodeDoc reste la brique Project Intelligence.',
|
|
55
|
+
help: 'Référence compacte des tools et variables Orchestrator. Pour des workflows agentiques guidés, utiliser guide.',
|
|
56
|
+
guide: 'Playbooks agentiques Orchestrator (audit, déploiement, recovery, transferts). Utiliser help pour le schéma/description d’un tool précis.'
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function annotationsFor(name) {
|
|
60
|
+
const readOnlyHint = READ_ONLY.has(name);
|
|
61
|
+
return {
|
|
62
|
+
readOnlyHint,
|
|
63
|
+
destructiveHint: readOnlyHint ? false : DESTRUCTIVE.has(name),
|
|
64
|
+
idempotentHint: readOnlyHint || IDEMPOTENT_MUTATIONS.has(name),
|
|
65
|
+
openWorldHint: !CLOSED_WORLD.has(name)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function enhanceToolConfig(name, config = {}) {
|
|
70
|
+
const annotations = { ...annotationsFor(name), ...(config.annotations || {}) };
|
|
71
|
+
const description = DESCRIPTION_OVERRIDES[name] || config.description;
|
|
72
|
+
return { ...config, description, annotations };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export { annotationsFor, enhanceToolConfig, DESCRIPTION_OVERRIDES };
|
|
76
|
+
export default { annotationsFor, enhanceToolConfig, DESCRIPTION_OVERRIDES };
|