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