@fkom13/mcp-sftp-orchestrator 6.0.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 +27 -15
- package/CHANGELOG.md +174 -0
- package/README.fr.md +356 -0
- package/README.md +314 -60
- package/apis.js +20 -64
- package/atomicJsonStore.js +117 -0
- package/compareEngine.js +132 -0
- package/config.js +40 -4
- package/diagnose.js +89 -0
- package/diffEngine.js +141 -0
- package/diffFormatter.js +114 -0
- package/fileOps.js +275 -0
- package/fleet.js +170 -0
- package/groups.js +25 -0
- package/guide.js +236 -0
- package/history.js +5 -44
- package/infraTopology.js +336 -0
- package/inventory.js +151 -0
- package/lhm.plugin.json +7 -0
- package/notes.js +3 -0
- package/package.json +11 -26
- package/policies.js +8 -0
- package/projects.js +108 -0
- package/queue.js +180 -125
- package/server.js +2552 -469
- package/servers.js +30 -33
- package/sftp.js +284 -86
- package/shellSessions.js +265 -0
- package/snapshotManager.js +320 -0
- package/sourceAdapter.js +410 -0
- package/ssh.js +184 -86
- package/sshPool.js +161 -148
- package/sshTrust.js +214 -0
- package/toolMetadata.js +76 -0
- package/tunnels.js +243 -0
- package/utils.js +215 -0
- package/workSession.js +139 -0
- package/publish1github.md +0 -216
- package/test_features.js +0 -112
package/sshPool.js
CHANGED
|
@@ -1,128 +1,166 @@
|
|
|
1
1
|
import { Client } from 'ssh2';
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
3
|
import queue from './queue.js';
|
|
4
|
+
import utils from './utils.js';
|
|
5
|
+
import config from './config.js';
|
|
4
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Pool de connexions SSH persistantes.
|
|
9
|
+
* v11.4.0 : listeners non empilés sur retry, flag ready fiable, port configurable.
|
|
10
|
+
*/
|
|
5
11
|
class SSHConnectionPool {
|
|
6
12
|
constructor() {
|
|
7
|
-
this.pools = new Map(); // Map<serverAlias,
|
|
8
|
-
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}>
|
|
9
15
|
this.config = {
|
|
10
|
-
maxConnections:
|
|
11
|
-
minConnections:
|
|
12
|
-
idleTimeout:
|
|
13
|
-
keepAliveInterval:
|
|
16
|
+
maxConnections: config.maxConnectionsPerServer,
|
|
17
|
+
minConnections: config.minConnectionsPerServer,
|
|
18
|
+
idleTimeout: config.idleTimeout,
|
|
19
|
+
keepAliveInterval: config.keepAliveInterval,
|
|
14
20
|
connectionTimeout: 20000,
|
|
15
21
|
retryAttempts: 3
|
|
16
22
|
};
|
|
17
|
-
|
|
18
|
-
// Nettoyage périodique des connexions inactives
|
|
19
23
|
this.startCleanupInterval();
|
|
20
24
|
}
|
|
21
|
-
|
|
22
|
-
|
|
25
|
+
|
|
26
|
+
isConnectionReady(connId) {
|
|
27
|
+
const connInfo = this.activeConnections.get(connId);
|
|
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;
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
async getConnection(serverAlias, serverConfig) {
|
|
24
|
-
// 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
|
}
|
|
47
|
+
// Connexion morte dans le pool → purge
|
|
48
|
+
if (connInfo && !connInfo.inUse && !this.isConnectionReady(connId)) {
|
|
49
|
+
this.removeConnection(connId);
|
|
50
|
+
}
|
|
35
51
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
return newConn;
|
|
52
|
+
|
|
53
|
+
const currentPool = this.pools.get(serverAlias) || [];
|
|
54
|
+
if (currentPool.length < this.config.maxConnections) {
|
|
55
|
+
return await this.createConnection(serverAlias, serverConfig);
|
|
41
56
|
}
|
|
42
|
-
|
|
43
|
-
// Si pool plein, attendre qu'une connexion se libère
|
|
57
|
+
|
|
44
58
|
queue.log('warn', `Pool SSH saturé pour ${serverAlias}, attente...`);
|
|
45
59
|
return await this.waitForConnection(serverAlias, serverConfig);
|
|
46
60
|
}
|
|
47
|
-
|
|
48
|
-
// Créer une nouvelle connexion
|
|
61
|
+
|
|
49
62
|
async createConnection(serverAlias, serverConfig) {
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
readyTimeout: this.config.connectionTimeout,
|
|
63
|
-
keepaliveInterval: this.config.keepAliveInterval,
|
|
64
|
-
keepaliveCountMax: 3
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
if (serverConfig.keyPath) {
|
|
68
|
-
config.privateKey = await fs.readFile(serverConfig.keyPath);
|
|
69
|
-
} else if (serverConfig.password) {
|
|
70
|
-
config.password = serverConfig.password;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
conn.on('ready', () => {
|
|
74
|
-
queue.log('info', `Nouvelle connexion SSH établie pour ${serverAlias}`);
|
|
75
|
-
|
|
76
|
-
// Ajouter au pool
|
|
77
|
-
if (!this.pools.has(serverAlias)) {
|
|
78
|
-
this.pools.set(serverAlias, []);
|
|
79
|
-
}
|
|
80
|
-
this.pools.get(serverAlias).push(connId);
|
|
81
|
-
|
|
82
|
-
// Enregistrer la connexion
|
|
83
|
-
this.activeConnections.set(connId, {
|
|
84
|
-
conn,
|
|
85
|
-
serverAlias,
|
|
86
|
-
inUse: true,
|
|
87
|
-
lastUsed: Date.now(),
|
|
88
|
-
config: serverConfig
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
// Ajouter un flag pour vérifier si la connexion est prête
|
|
92
|
-
conn.isReady = true;
|
|
93
|
-
|
|
94
|
-
resolve({ id: connId, client: conn });
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
conn.on('error', (err) => {
|
|
98
|
-
conn.isReady = false;
|
|
99
|
-
if (retries < this.config.retryAttempts) {
|
|
100
|
-
retries++;
|
|
101
|
-
queue.log('warn', `Tentative ${retries}/${this.config.retryAttempts} de connexion à ${serverAlias}`);
|
|
102
|
-
setTimeout(tryConnect, 2000 * retries);
|
|
103
|
-
} else {
|
|
104
|
-
this.removeConnection(connId);
|
|
105
|
-
reject(new Error(`Impossible de se connecter à ${serverAlias}: ${err.message}`));
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
conn.on('close', () => {
|
|
110
|
-
conn.isReady = false;
|
|
111
|
-
this.removeConnection(connId);
|
|
112
|
-
queue.log('info', `Connexion SSH fermée pour ${serverAlias}`);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
conn.connect(config);
|
|
116
|
-
} catch (err) {
|
|
117
|
-
reject(err);
|
|
63
|
+
const connId = `${serverAlias}_${Date.now()}_${Math.random().toString(36).slice(2, 11)}`;
|
|
64
|
+
let lastError = null;
|
|
65
|
+
|
|
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));
|
|
118
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)));
|
|
119
96
|
};
|
|
120
|
-
|
|
121
|
-
|
|
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 });
|
|
118
|
+
};
|
|
119
|
+
|
|
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
|
+
}
|
|
122
161
|
});
|
|
123
162
|
}
|
|
124
|
-
|
|
125
|
-
// Libérer une connexion
|
|
163
|
+
|
|
126
164
|
releaseConnection(connId) {
|
|
127
165
|
const connInfo = this.activeConnections.get(connId);
|
|
128
166
|
if (connInfo) {
|
|
@@ -131,56 +169,46 @@ class SSHConnectionPool {
|
|
|
131
169
|
queue.log('debug', `Connexion ${connId} libérée`);
|
|
132
170
|
}
|
|
133
171
|
}
|
|
134
|
-
|
|
135
|
-
// Fermer une connexion spécifique
|
|
172
|
+
|
|
136
173
|
closeConnection(connId) {
|
|
137
174
|
const connInfo = this.activeConnections.get(connId);
|
|
138
175
|
if (connInfo) {
|
|
139
176
|
try {
|
|
177
|
+
connInfo.ready = false;
|
|
140
178
|
connInfo.conn.end();
|
|
141
|
-
} catch
|
|
142
|
-
// Ignorer les erreurs de fermeture
|
|
143
|
-
}
|
|
179
|
+
} catch { /* ignore */ }
|
|
144
180
|
this.removeConnection(connId);
|
|
145
181
|
}
|
|
146
182
|
}
|
|
147
|
-
|
|
148
|
-
// Retirer une connexion du pool
|
|
183
|
+
|
|
149
184
|
removeConnection(connId) {
|
|
150
185
|
const connInfo = this.activeConnections.get(connId);
|
|
151
186
|
if (connInfo) {
|
|
152
187
|
const pool = this.pools.get(connInfo.serverAlias);
|
|
153
188
|
if (pool) {
|
|
154
189
|
const index = pool.indexOf(connId);
|
|
155
|
-
if (index > -1)
|
|
156
|
-
|
|
157
|
-
}
|
|
158
|
-
if (pool.length === 0) {
|
|
159
|
-
this.pools.delete(connInfo.serverAlias);
|
|
160
|
-
}
|
|
190
|
+
if (index > -1) pool.splice(index, 1);
|
|
191
|
+
if (pool.length === 0) this.pools.delete(connInfo.serverAlias);
|
|
161
192
|
}
|
|
162
193
|
this.activeConnections.delete(connId);
|
|
163
194
|
}
|
|
164
195
|
}
|
|
165
|
-
|
|
166
|
-
// Attendre qu'une connexion se libère
|
|
196
|
+
|
|
167
197
|
async waitForConnection(serverAlias, serverConfig, timeout = 30000) {
|
|
168
198
|
const startTime = Date.now();
|
|
169
|
-
|
|
199
|
+
|
|
170
200
|
return new Promise((resolve, reject) => {
|
|
171
|
-
const checkInterval = setInterval(
|
|
172
|
-
// Vérifier le timeout
|
|
201
|
+
const checkInterval = setInterval(() => {
|
|
173
202
|
if (Date.now() - startTime > timeout) {
|
|
174
203
|
clearInterval(checkInterval);
|
|
175
204
|
reject(new Error(`Timeout en attendant une connexion pour ${serverAlias}`));
|
|
176
205
|
return;
|
|
177
206
|
}
|
|
178
|
-
|
|
179
|
-
// Essayer d'obtenir une connexion
|
|
207
|
+
|
|
180
208
|
const pool = this.pools.get(serverAlias) || [];
|
|
181
209
|
for (const connId of pool) {
|
|
182
210
|
const connInfo = this.activeConnections.get(connId);
|
|
183
|
-
if (connInfo && !connInfo.inUse &&
|
|
211
|
+
if (connInfo && !connInfo.inUse && this.isConnectionReady(connId)) {
|
|
184
212
|
clearInterval(checkInterval);
|
|
185
213
|
connInfo.inUse = true;
|
|
186
214
|
connInfo.lastUsed = Date.now();
|
|
@@ -188,53 +216,52 @@ class SSHConnectionPool {
|
|
|
188
216
|
return;
|
|
189
217
|
}
|
|
190
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
|
+
}
|
|
191
225
|
}, 500);
|
|
192
226
|
});
|
|
193
227
|
}
|
|
194
|
-
|
|
195
|
-
// Nettoyer les connexions inactives
|
|
228
|
+
|
|
196
229
|
startCleanupInterval() {
|
|
197
230
|
setInterval(() => {
|
|
198
231
|
const now = Date.now();
|
|
199
|
-
|
|
200
|
-
for (const [connId, connInfo] of this.activeConnections) {
|
|
201
|
-
// Fermer les connexions inactives depuis trop longtemps
|
|
232
|
+
for (const [connId, connInfo] of [...this.activeConnections.entries()]) {
|
|
202
233
|
if (!connInfo.inUse && (now - connInfo.lastUsed) > this.config.idleTimeout) {
|
|
203
234
|
const pool = this.pools.get(connInfo.serverAlias) || [];
|
|
204
|
-
|
|
205
|
-
// Garder au moins minConnections
|
|
206
235
|
if (pool.length > this.config.minConnections) {
|
|
207
236
|
queue.log('info', `Fermeture connexion inactive: ${connId}`);
|
|
208
237
|
this.closeConnection(connId);
|
|
238
|
+
continue;
|
|
209
239
|
}
|
|
210
240
|
}
|
|
211
|
-
|
|
212
|
-
// Vérifier que la connexion est toujours vivante
|
|
213
|
-
if (!connInfo.conn.isReady && !connInfo.inUse) {
|
|
241
|
+
if (!this.isConnectionReady(connId) && !connInfo.inUse) {
|
|
214
242
|
this.removeConnection(connId);
|
|
215
243
|
}
|
|
216
244
|
}
|
|
217
|
-
}, 60000);
|
|
245
|
+
}, 60000);
|
|
218
246
|
}
|
|
219
|
-
|
|
220
|
-
// Obtenir les statistiques du pool
|
|
247
|
+
|
|
221
248
|
getStats() {
|
|
222
249
|
const stats = {
|
|
223
250
|
totalConnections: this.activeConnections.size,
|
|
224
251
|
byServer: {}
|
|
225
252
|
};
|
|
226
|
-
|
|
253
|
+
|
|
227
254
|
for (const [serverAlias, pool] of this.pools) {
|
|
228
255
|
const connections = pool.map(connId => {
|
|
229
256
|
const info = this.activeConnections.get(connId);
|
|
230
257
|
return {
|
|
231
258
|
id: connId,
|
|
232
259
|
inUse: info?.inUse || false,
|
|
233
|
-
ready:
|
|
260
|
+
ready: this.isConnectionReady(connId),
|
|
234
261
|
lastUsed: info?.lastUsed
|
|
235
262
|
};
|
|
236
263
|
});
|
|
237
|
-
|
|
264
|
+
|
|
238
265
|
stats.byServer[serverAlias] = {
|
|
239
266
|
total: connections.length,
|
|
240
267
|
inUse: connections.filter(c => c.inUse).length,
|
|
@@ -242,31 +269,17 @@ class SSHConnectionPool {
|
|
|
242
269
|
connections
|
|
243
270
|
};
|
|
244
271
|
}
|
|
245
|
-
|
|
272
|
+
|
|
246
273
|
return stats;
|
|
247
274
|
}
|
|
248
|
-
|
|
249
|
-
// Fermer toutes les connexions
|
|
275
|
+
|
|
250
276
|
closeAll() {
|
|
251
277
|
queue.log('info', 'Fermeture de toutes les connexions SSH...');
|
|
252
|
-
for (const connId of this.activeConnections.keys()) {
|
|
278
|
+
for (const connId of [...this.activeConnections.keys()]) {
|
|
253
279
|
this.closeConnection(connId);
|
|
254
280
|
}
|
|
255
281
|
}
|
|
256
282
|
}
|
|
257
283
|
|
|
258
|
-
// Instance singleton
|
|
259
284
|
const sshPool = new SSHConnectionPool();
|
|
260
|
-
|
|
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
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 };
|