@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/ssh.js CHANGED
@@ -4,6 +4,7 @@ import queue from './queue.js';
4
4
  import serverManager from './servers.js';
5
5
  import sshPool from './sshPool.js';
6
6
  import config from './config.js';
7
+ import policies from './policies.js';
7
8
 
8
9
  const STREAMING_COMMANDS = [
9
10
  /^pm2\s+logs?\b/i,
@@ -25,32 +26,56 @@ function isStreamingCommand(cmd) {
25
26
 
26
27
  // Configuration des réponses automatiques pour les prompts interactifs
27
28
  const INTERACTIVE_RESPONSES = {
28
- // Patterns courants et leurs réponses
29
29
  'continue connecting': 'yes',
30
30
  'Are you sure': 'yes',
31
- 'password:': null, // Sera géré séparément
32
- 'Password:': null,
33
31
  'Do you want to continue': 'y',
32
+ '[y/n]': 'y',
33
+ '(yes/no)': 'yes',
34
+ '(y/N)': 'y',
35
+ '(Y/n)': 'y',
34
36
  'Overwrite': 'y',
35
- 'Save': 'y'
37
+ 'Save': 'y',
38
+ 'accept': 'yes',
39
+ 'confirm': 'yes',
40
+ 'password:': 'PASSWORD_REQUIRED',
41
+ 'Password:': 'PASSWORD_REQUIRED',
42
+ 'sudo': 'PASSWORD_REQUIRED'
36
43
  };
37
44
 
38
45
  // Détecte si une sortie contient un prompt interactif
39
- function detectInteractivePrompt(output) {
46
+ function detectInteractivePrompt(output, customResponses = {}) {
40
47
  const lastLine = output.split('\n').pop().toLowerCase();
41
-
48
+ const lastLines = output.split('\n').slice(-5).join('\n').toLowerCase();
49
+
50
+ // 1. Check custom responses first (supporte regex)
51
+ for (const [pattern, response] of Object.entries(customResponses)) {
52
+ try {
53
+ const regex = new RegExp(pattern, 'i');
54
+ if (regex.test(lastLines)) {
55
+ return { pattern, response, needsInput: true };
56
+ }
57
+ } catch (e) {
58
+ if (lastLines.includes(pattern.toLowerCase())) {
59
+ return { pattern, response, needsInput: true };
60
+ }
61
+ }
62
+ }
63
+
64
+ // 2. Check built-in patterns
42
65
  for (const [pattern, response] of Object.entries(INTERACTIVE_RESPONSES)) {
43
- if (lastLine.includes(pattern.toLowerCase())) {
66
+ if (lastLines.includes(pattern.toLowerCase())) {
44
67
  return { pattern, response, needsInput: true };
45
68
  }
46
69
  }
47
-
48
- // Vérifier les patterns qui attendent une entrée
49
- if (lastLine.endsWith(':') || lastLine.endsWith('?') ||
50
- lastLine.includes('[y/n]') || lastLine.includes('(yes/no)')) {
70
+
71
+ // 3. Generic prompt detection
72
+ if (lastLine.endsWith(':') || lastLine.endsWith('?') ||
73
+ lastLine.includes('[y/n]') || lastLine.includes('(yes/no)') ||
74
+ lastLine.match(/^\s*\[\d+\]/) || lastLine.match(/^\s*\d+[.)]\s/) ||
75
+ lastLine.match(/choose|select|pick|enter|input/i)) {
51
76
  return { pattern: 'generic', response: null, needsInput: true };
52
77
  }
53
-
78
+
54
79
  return { needsInput: false };
55
80
  }
56
81
 
@@ -60,14 +85,23 @@ async function executeCommand(jobId) {
60
85
  if (!job) return queue.log('error', `Tâche introuvable: ${jobId}`);
61
86
 
62
87
  let connection = null;
63
-
88
+
64
89
  try {
65
90
  const serverConfig = await serverManager.getServer(job.alias);
91
+
92
+ if (!job.skip_policy) {
93
+ const policyCheck = await policies.check(job.cmd);
94
+ if (policyCheck.blocked) {
95
+ queue.updateJobStatus(jobId, 'failed', { error: `Commande bloquée par la politique de sécurité (pattern: "${policyCheck.pattern}"). Utilisez skip_policy:true pour forcer.` });
96
+ return;
97
+ }
98
+ }
99
+
66
100
  queue.updateJobStatus(jobId, 'running');
67
101
 
68
102
  // Utiliser le pool de connexions si pas de mode interactif
69
103
  const usePool = !job.interactive && job.persistent !== false;
70
-
104
+
71
105
  if (usePool) {
72
106
  // Obtenir une connexion du pool
73
107
  connection = await sshPool.getConnection(job.alias, serverConfig);
@@ -76,9 +110,10 @@ async function executeCommand(jobId) {
76
110
  // Créer une connexion dédiée pour les commandes interactives
77
111
  await executeWithNewConnection(serverConfig, job, jobId);
78
112
  }
79
-
113
+
80
114
  } catch (err) {
81
- queue.updateJobStatus(jobId, 'failed', { error: err.message });
115
+ const existing = queue.getJob(jobId) || {};
116
+ queue.updateJobStatus(jobId, 'failed', { ...existing, error: err.message });
82
117
  } finally {
83
118
  // Libérer la connexion si elle vient du pool
84
119
  if (connection) {
@@ -88,7 +123,7 @@ async function executeCommand(jobId) {
88
123
  }
89
124
 
90
125
  // Exécution avec une connexion du pool
91
- async function executeWithPooledConnection(connection, job, jobId) {
126
+ async function executeWithPooledConnection(connection, job, jobId, updateQueue = true) {
92
127
  return new Promise((resolve, reject) => {
93
128
  const { client } = connection;
94
129
 
@@ -119,38 +154,45 @@ async function executeWithPooledConnection(connection, job, jobId) {
119
154
  reject(err);
120
155
  return;
121
156
  }
122
-
157
+
123
158
  let output = '';
124
159
  let stderr = '';
125
160
  let lineCount = 0;
126
161
  const startTime = Date.now();
127
162
  const maxLines = job.maxLines || 1000;
128
- const timeout = job.timeout || config.defaultCommandTimeout;
129
-
130
- const timeoutId = setTimeout(() => {
131
- stream.write('\x03'); // Envoyer Ctrl+C
132
- setTimeout(() => {
133
- stream.close();
134
- const duration = Date.now() - startTime;
135
- const result = { output: output.trim(), stderr: stderr.trim(), exitCode: 124, duration, lineCount, timedOut: true };
136
- queue.updateJobStatus(jobId, 'completed', result);
137
- resolve(result);
138
- }, 500);
139
- }, timeout);
140
-
163
+ const userTimeout = job.timeout; // en secondes, 0 = infini
164
+ const timeoutMs = userTimeout === 0 ? 0 : (userTimeout ? userTimeout * 1000 : config.defaultCommandTimeout);
165
+
166
+ let timeoutId = null;
167
+ if (timeoutMs > 0) {
168
+ timeoutId = setTimeout(() => {
169
+ stream.write('\x03');
170
+ setTimeout(() => {
171
+ stream.close();
172
+ const duration = Date.now() - startTime;
173
+ const result = { output: output.trim(), stderr: stderr.trim(), exitCode: 124, duration, lineCount, timedOut: true };
174
+ if (updateQueue) queue.updateJobStatus(jobId, 'completed', result);
175
+ resolve(result);
176
+ }, 500);
177
+ }, timeoutMs);
178
+ }
179
+
141
180
  stream.on('close', (code, signal) => {
142
- clearTimeout(timeoutId);
181
+ if (timeoutId) clearTimeout(timeoutId);
143
182
  const duration = Date.now() - startTime;
144
183
  const result = { output: output.trim(), stderr: stderr.trim(), exitCode: code, signal, duration, lineCount };
184
+
185
+ // Important: clear any other timeouts if they exist (though timeoutId is the main one here)
186
+
145
187
  if (code === 0 || code === 124) {
146
- queue.updateJobStatus(jobId, 'completed', result);
188
+ if (updateQueue) queue.updateJobStatus(jobId, 'completed', result);
147
189
  resolve(result);
148
190
  } else {
149
- queue.updateJobStatus(jobId, 'failed', { ...result, error: `Commande terminée avec code ${code}` });
191
+ if (updateQueue) queue.updateJobStatus(jobId, 'failed', { ...result, error: `Commande terminée avec code ${code}` });
150
192
  reject(new Error(`Exit code: ${code}`));
151
193
  }
152
194
  });
153
-
195
+
154
196
  stream.on('data', (data) => {
155
197
  const chunk = data.toString();
156
198
  output += chunk;
@@ -160,14 +202,14 @@ async function executeWithPooledConnection(connection, job, jobId) {
160
202
  setTimeout(() => stream.close(), 500);
161
203
  }
162
204
  if (job.autoRespond) {
163
- const prompt = detectInteractivePrompt(output);
164
- if (prompt.needsInput && prompt.response) {
205
+ const prompt = detectInteractivePrompt(output, job.responses || {});
206
+ if (prompt.needsInput && prompt.response && prompt.response !== 'PASSWORD_REQUIRED') {
165
207
  stream.write(prompt.response + '\n');
166
208
  queue.log('info', `Réponse automatique au prompt: ${prompt.pattern}`);
167
209
  }
168
210
  }
169
211
  });
170
-
212
+
171
213
  stream.stderr.on('data', (data) => {
172
214
  stderr += data.toString();
173
215
  });
@@ -193,22 +235,27 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
193
235
  let responseSent = false;
194
236
 
195
237
  const cleanupAndResolve = (status = 'completed') => {
196
- clearTimeout(jobTimeout);
238
+ if (jobTimeout) clearTimeout(jobTimeout);
197
239
  output = output.replace(END_MARKER, '').trim();
198
240
  const result = { output, exitCode: 0 };
199
241
  queue.updateJobStatus(jobId, status, result);
200
242
  try {
201
243
  stream.end();
202
244
  conn.end();
203
- } catch(e) {/* ignore */}
245
+ } catch (e) {/* ignore */ }
204
246
  resolve(result);
205
247
  };
206
248
 
207
- const jobTimeout = setTimeout(() => {
208
- cleanupAndResolve('failed');
209
- }, (job.timeout ? job.timeout * 1000 : config.interactiveCommandTimeout));
249
+ const timeoutMs = job.timeout === 0 ? 0 : (job.timeout ? job.timeout * 1000 : config.interactiveCommandTimeout);
250
+ let jobTimeout = null;
251
+ if (timeoutMs > 0) {
252
+ jobTimeout = setTimeout(() => {
253
+ cleanupAndResolve('failed');
254
+ }, timeoutMs);
255
+ }
210
256
 
211
257
  stream.on('close', () => {
258
+ if (jobTimeout) clearTimeout(jobTimeout);
212
259
  if (!output.includes(END_MARKER)) {
213
260
  cleanupAndResolve('failed');
214
261
  }
@@ -226,7 +273,7 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
226
273
  if (!responseSent && job.autoRespond) {
227
274
  const lowerChunk = chunk.toLowerCase();
228
275
  let responseToSend = null;
229
-
276
+
230
277
  // Chercher une réponse personnalisée
231
278
  for (const [pattern, response] of Object.entries(responses)) {
232
279
  if (lowerChunk.includes(pattern.toLowerCase())) {
@@ -235,7 +282,7 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
235
282
  break;
236
283
  }
237
284
  }
238
-
285
+
239
286
  // Sinon, chercher une réponse par défaut
240
287
  if (!responseToSend) {
241
288
  for (const [pattern, response] of Object.entries(INTERACTIVE_RESPONSES)) {
@@ -270,11 +317,11 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
270
317
  reject(err);
271
318
  });
272
319
 
273
- const connectConfig = {
274
- host: serverConfig.host,
275
- port: 22,
276
- username: serverConfig.user,
277
- readyTimeout: 20000
320
+ const connectConfig = {
321
+ host: serverConfig.host,
322
+ port: 22,
323
+ username: serverConfig.user,
324
+ readyTimeout: 20000
278
325
  };
279
326
 
280
327
  (async () => {
@@ -302,17 +349,17 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
302
349
  async function executeCommandSequence(jobId) {
303
350
  const job = queue.getJob(jobId);
304
351
  if (!job) return queue.log('error', `Tâche introuvable: ${jobId}`);
305
-
352
+
306
353
  const results = [];
307
354
  let connection = null;
308
-
355
+
309
356
  try {
310
357
  const serverConfig = await serverManager.getServer(job.alias);
311
358
  queue.updateJobStatus(jobId, 'running');
312
-
359
+
313
360
  // Obtenir une connexion du pool
314
361
  connection = await sshPool.getConnection(job.alias, serverConfig);
315
-
362
+
316
363
  // Exécuter chaque commande en séquence
317
364
  for (let i = 0; i < job.commands.length; i++) {
318
365
  const cmd = job.commands[i];
@@ -322,36 +369,37 @@ async function executeCommandSequence(jobId) {
322
369
  timeout: cmd.timeout || job.timeout,
323
370
  continueOnError: cmd.continueOnError || job.continueOnError
324
371
  };
325
-
372
+
326
373
  try {
327
- queue.log('info', `Exécution étape ${i+1}/${job.commands.length}: ${stepJob.cmd}`);
328
- const result = await executeWithPooledConnection(connection, stepJob, `${jobId}_step_${i}`);
329
- results.push({
330
- step: i + 1,
331
- command: stepJob.cmd,
332
- success: true,
333
- ...result
374
+ queue.log('info', `Exécution étape ${i + 1}/${job.commands.length}: ${stepJob.cmd}`);
375
+ // Don't update the main queue for sub-steps with phantom IDs
376
+ const result = await executeWithPooledConnection(connection, stepJob, `${jobId}_step_${i}`, false);
377
+ results.push({
378
+ step: i + 1,
379
+ command: stepJob.cmd,
380
+ success: true,
381
+ ...result
334
382
  });
335
383
  } catch (err) {
336
- results.push({
337
- step: i + 1,
338
- command: stepJob.cmd,
339
- success: false,
340
- error: err.message
384
+ results.push({
385
+ step: i + 1,
386
+ command: stepJob.cmd,
387
+ success: false,
388
+ error: err.message
341
389
  });
342
-
390
+
343
391
  if (!stepJob.continueOnError) {
344
- throw new Error(`Échec à l'étape ${i+1}: ${err.message}`);
392
+ throw new Error(`Échec à l'étape ${i + 1}: ${err.message}`);
345
393
  }
346
394
  }
347
395
  }
348
-
396
+
349
397
  queue.updateJobStatus(jobId, 'completed', { results });
350
-
398
+
351
399
  } catch (err) {
352
- queue.updateJobStatus(jobId, 'failed', {
353
- error: err.message,
354
- results
400
+ queue.updateJobStatus(jobId, 'failed', {
401
+ error: err.message,
402
+ results
355
403
  });
356
404
  } finally {
357
405
  if (connection) {
@@ -419,7 +467,7 @@ function parseServicesStatus(output) {
419
467
 
420
468
  try {
421
469
  const sections = output.split(/---DOCKER---|---PM2---/);
422
- const systemdRaw = sections[0] || '';
470
+ const systemdRaw = (sections[0] || '').replace(/^---SYSTEMD---\s*/, '');
423
471
  const dockerRaw = sections[1] || '';
424
472
  const pm2Raw = sections[2] || '';
425
473
 
@@ -467,26 +515,62 @@ function parseServicesStatus(output) {
467
515
  }
468
516
 
469
517
  function parseApiHealth(output) {
518
+ if (!output || typeof output !== 'string') {
519
+ return {
520
+ status: 'ERROR',
521
+ http_code: 0,
522
+ response_time_ms: 0,
523
+ error: 'Sortie invalide ou vide'
524
+ };
525
+ }
526
+
470
527
  try {
471
- const [codeStr, timeStr] = output.split(':');
528
+ const parts = output.trim().split(':');
529
+ if (parts.length !== 2) {
530
+ return {
531
+ status: 'ERROR',
532
+ http_code: 0,
533
+ response_time_ms: 0,
534
+ error: 'Format de réponse invalide',
535
+ raw_output: output
536
+ };
537
+ }
538
+
539
+ const [codeStr, timeStr] = parts;
472
540
  const http_code = parseInt(codeStr, 10);
473
541
  const response_time_ms = parseFloat(timeStr) * 1000;
474
542
 
543
+ if (isNaN(http_code) || isNaN(response_time_ms)) {
544
+ return {
545
+ status: 'ERROR',
546
+ http_code: 0,
547
+ response_time_ms: 0,
548
+ error: 'Valeurs non numériques',
549
+ raw_output: output
550
+ };
551
+ }
552
+
475
553
  return {
476
554
  status: http_code >= 200 && http_code < 300 ? 'UP' : 'DOWN',
477
555
  http_code: http_code,
478
556
  response_time_ms: Math.round(response_time_ms)
479
557
  };
480
558
  } catch (e) {
481
- return { status: 'ERROR', http_code: 0, response_time_ms: 0, parsing_error: e.message, raw_output: output };
559
+ return {
560
+ status: 'ERROR',
561
+ http_code: 0,
562
+ response_time_ms: 0,
563
+ error: e.message,
564
+ raw_output: output
565
+ };
482
566
  }
483
567
  }
484
568
 
485
- export default {
486
- executeCommand,
487
- executeCommandSequence,
488
- getPoolStats,
489
- parseSystemResources,
490
- parseServicesStatus,
491
- parseApiHealth
569
+ export default {
570
+ executeCommand,
571
+ executeCommandSequence,
572
+ getPoolStats,
573
+ parseSystemResources,
574
+ parseServicesStatus,
575
+ parseApiHealth
492
576
  };