@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/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, Connection[]>
8
- this.activeConnections = new Map(); // Map<connectionId, {conn, serverAlias, inUse, lastUsed}>
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: 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
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
- // Obtenir ou créer une connexion
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 && 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
  }
47
+ // Connexion morte dans le pool → purge
48
+ if (connInfo && !connInfo.inUse && !this.isConnectionReady(connId)) {
49
+ this.removeConnection(connId);
50
+ }
35
51
  }
36
-
37
- // Si pas de connexion disponible, en créer une nouvelle
38
- if (pool.length < this.config.maxConnections) {
39
- const newConn = await this.createConnection(serverAlias, serverConfig);
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 conn = new Client();
51
- const connId = `${serverAlias}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
52
-
53
- return new Promise((resolve, reject) => {
54
- let retries = 0;
55
-
56
- const tryConnect = async () => {
57
- try {
58
- const config = {
59
- host: serverConfig.host,
60
- port: 22,
61
- username: serverConfig.user,
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
- tryConnect();
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 (e) {
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
- pool.splice(index, 1);
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(async () => {
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 && connInfo.conn.isReady) {
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); // Vérifier toutes les minutes
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: info?.conn?.isReady || false,
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 };