@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/policies.js
CHANGED
|
@@ -1,76 +1,8 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
1
|
import path from 'path';
|
|
3
2
|
import config from './config.js';
|
|
4
|
-
|
|
5
|
-
const POLICIES_PATH
|
|
6
|
-
|
|
7
|
-
const DEFAULT_POLICIES
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"rm -rf /*",
|
|
11
|
-
"mkfs*",
|
|
12
|
-
":(){:|:&};:",
|
|
13
|
-
"dd if=/dev/zero",
|
|
14
|
-
"> /dev/sda",
|
|
15
|
-
"chmod -R 000 /",
|
|
16
|
-
"mv / /dev/null",
|
|
17
|
-
"wget -O- http://* | sh",
|
|
18
|
-
"curl http://* | sh",
|
|
19
|
-
"eval $(curl",
|
|
20
|
-
"eval $(wget",
|
|
21
|
-
"git clone http://* | sh"
|
|
22
|
-
]
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
async function loadPolicies() {
|
|
26
|
-
try {
|
|
27
|
-
await fs.access(POLICIES_PATH);
|
|
28
|
-
const data = await fs.readFile(POLICIES_PATH, 'utf-8');
|
|
29
|
-
return JSON.parse(data);
|
|
30
|
-
} catch {
|
|
31
|
-
await fs.writeFile(POLICIES_PATH, JSON.stringify(DEFAULT_POLICIES, null, 2));
|
|
32
|
-
return { ...DEFAULT_POLICIES };
|
|
33
|
-
}
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
async function savePolicies(policies) {
|
|
37
|
-
await fs.writeFile(POLICIES_PATH, JSON.stringify(policies, null, 2));
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function checkCommand(cmd, blocklist) {
|
|
41
|
-
const cmdTrimmed = cmd.trim();
|
|
42
|
-
for (const pattern of blocklist) {
|
|
43
|
-
const regex = pattern.includes('*')
|
|
44
|
-
? new RegExp('^' + pattern.replace(/\*/g, '.*') + '$', 'i')
|
|
45
|
-
: new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i');
|
|
46
|
-
if (regex.test(cmdTrimmed)) {
|
|
47
|
-
return { blocked: true, pattern };
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return { blocked: false };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export default {
|
|
54
|
-
async list() {
|
|
55
|
-
return await loadPolicies();
|
|
56
|
-
},
|
|
57
|
-
async add(pattern) {
|
|
58
|
-
const policies = await loadPolicies();
|
|
59
|
-
if (!policies.command_blocklist.includes(pattern)) {
|
|
60
|
-
policies.command_blocklist.push(pattern);
|
|
61
|
-
await savePolicies(policies);
|
|
62
|
-
}
|
|
63
|
-
return policies;
|
|
64
|
-
},
|
|
65
|
-
async remove(pattern) {
|
|
66
|
-
const policies = await loadPolicies();
|
|
67
|
-
policies.command_blocklist = policies.command_blocklist.filter(p => p !== pattern);
|
|
68
|
-
await savePolicies(policies);
|
|
69
|
-
return policies;
|
|
70
|
-
},
|
|
71
|
-
async check(cmd) {
|
|
72
|
-
const policies = await loadPolicies();
|
|
73
|
-
return checkCommand(cmd, policies.command_blocklist);
|
|
74
|
-
},
|
|
75
|
-
checkCommand
|
|
76
|
-
};
|
|
3
|
+
import jsonStore from './atomicJsonStore.js';
|
|
4
|
+
const POLICIES_PATH=path.join(config.dataDir,'policies.json');
|
|
5
|
+
const DEFAULT_POLICIES={command_blocklist:["rm -rf /","rm -rf /*","mkfs*",":(){:|:&};:","dd if=/dev/zero","> /dev/sda","chmod -R 000 /","mv / /dev/null","wget -O- http://* | sh","curl http://* | sh","eval $(curl","eval $(wget","git clone http://* | sh"]};
|
|
6
|
+
const loadPolicies=()=>jsonStore.readJson(POLICIES_PATH,DEFAULT_POLICIES);
|
|
7
|
+
function checkCommand(cmd,blocklist){ const c=(cmd||'').trim(); if(!c)return{blocked:false}; for(const pattern of blocklist){ if(!pattern)continue; if(pattern.includes('*')){const r=new RegExp('^'+pattern.replace(/[.+^${}()|[\]\\]/g,'\\$&').replace(/\*/g,'.*')+'$','i'); if(r.test(c))return{blocked:true,pattern}; continue;} const e=pattern.replace(/[.*+?^${}()|[\]\\]/g,'\\$&'); const r=/\/$/.test(pattern)?new RegExp(e+'(?=$|\\s|\\*)','i'):new RegExp(e,'i'); if(r.test(c))return{blocked:true,pattern}; } return{blocked:false}; }
|
|
8
|
+
export default { async list(){return loadPolicies();}, async add(pattern){return jsonStore.updateJson(POLICIES_PATH,DEFAULT_POLICIES,p=>{if(!p.command_blocklist.includes(pattern))p.command_blocklist.push(pattern);return p;});}, async remove(pattern){return jsonStore.updateJson(POLICIES_PATH,DEFAULT_POLICIES,p=>{p.command_blocklist=p.command_blocklist.filter(x=>x!==pattern);return p;});}, async check(cmd){const p=await loadPolicies();return checkCommand(cmd,p.command_blocklist);}, checkCommand };
|
package/projects.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* projects.js — Registre sémantique projet ↔ serveur(s) ↔ paths local/remote.
|
|
3
|
+
* Stockage atomique: <dataDir>/projects.json
|
|
4
|
+
*/
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import config from './config.js';
|
|
7
|
+
import servers from './servers.js';
|
|
8
|
+
import jsonStore from './atomicJsonStore.js';
|
|
9
|
+
|
|
10
|
+
const PROJECTS_PATH = path.join(config.dataDir, 'projects.json');
|
|
11
|
+
const EMPTY = {};
|
|
12
|
+
|
|
13
|
+
const load = () => jsonStore.readJson(PROJECTS_PATH, EMPTY);
|
|
14
|
+
|
|
15
|
+
function validateName(name) {
|
|
16
|
+
if (!name || !/^[a-zA-Z0-9_./-]+$/.test(name)) {
|
|
17
|
+
throw new Error("Nom de projet invalide (alphanumérique, _ . / -).");
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default {
|
|
22
|
+
async list() {
|
|
23
|
+
return load();
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async get(name) {
|
|
27
|
+
const all = await load();
|
|
28
|
+
if (!all[name]) throw new Error(`Projet '${name}' introuvable. project_list pour voir.`);
|
|
29
|
+
return { name, ...all[name] };
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async set(name, fields = {}) {
|
|
33
|
+
validateName(name);
|
|
34
|
+
|
|
35
|
+
// Valider d'abord les aliases hors verrou projets afin de ne pas garder
|
|
36
|
+
// le lock pendant une I/O vers le registre serveurs.
|
|
37
|
+
let known = null;
|
|
38
|
+
if (fields.servers !== undefined) known = await servers.listServers();
|
|
39
|
+
|
|
40
|
+
let merged;
|
|
41
|
+
await jsonStore.updateJson(PROJECTS_PATH, EMPTY, async (all) => {
|
|
42
|
+
const existing = all[name] || {};
|
|
43
|
+
merged = { ...existing };
|
|
44
|
+
for (const key of ['description', 'ignore', 'local', 'servers', 'tags', 'network']) {
|
|
45
|
+
if (fields[key] !== undefined) merged[key] = fields[key];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (merged.servers && typeof merged.servers === 'object') {
|
|
49
|
+
const serverMap = known || await servers.listServers();
|
|
50
|
+
for (const [role, cfg] of Object.entries(merged.servers)) {
|
|
51
|
+
if (!cfg || !cfg.alias) throw new Error(`servers.${role}: 'alias' requis`);
|
|
52
|
+
if (!serverMap[cfg.alias]) throw new Error(`servers.${role}: alias '${cfg.alias}' inconnu (server_list)`);
|
|
53
|
+
if (!cfg.path) throw new Error(`servers.${role}: 'path' requis`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
merged.updatedAt = new Date().toISOString();
|
|
58
|
+
all[name] = merged;
|
|
59
|
+
return all;
|
|
60
|
+
});
|
|
61
|
+
return { name, ...merged };
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
async remove(name) {
|
|
65
|
+
await jsonStore.updateJson(PROJECTS_PATH, EMPTY, (all) => {
|
|
66
|
+
if (!all[name]) throw new Error(`Projet '${name}' introuvable.`);
|
|
67
|
+
delete all[name];
|
|
68
|
+
return all;
|
|
69
|
+
});
|
|
70
|
+
return { success: true, message: `Projet '${name}' supprimé.` };
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
async resolve(name, role = null) {
|
|
74
|
+
const project = await this.get(name);
|
|
75
|
+
const ignore = project.ignore || ['node_modules', '.git', 'dist', 'coverage', '.env'];
|
|
76
|
+
let remote = null;
|
|
77
|
+
let runtime = null;
|
|
78
|
+
let url = null;
|
|
79
|
+
const serverEntries = project.servers || {};
|
|
80
|
+
const roles = Object.keys(serverEntries);
|
|
81
|
+
|
|
82
|
+
if (roles.length) {
|
|
83
|
+
const useRole = role || (serverEntries.prod ? 'prod' : roles[0]);
|
|
84
|
+
const cfg = serverEntries[useRole];
|
|
85
|
+
if (!cfg) throw new Error(`Rôle '${useRole}' absent. Rôles: ${roles.join(', ')}`);
|
|
86
|
+
remote = { type: 'remote', alias: cfg.alias, path: cfg.path, label: `${name}:${useRole}` };
|
|
87
|
+
runtime = cfg.runtime || null;
|
|
88
|
+
url = cfg.url || null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const local = project.local?.path
|
|
92
|
+
? { type: 'local', path: project.local.path, label: `${name}:local` }
|
|
93
|
+
: null;
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
name,
|
|
97
|
+
role: role || (serverEntries.prod ? 'prod' : roles[0] || null),
|
|
98
|
+
local,
|
|
99
|
+
remote,
|
|
100
|
+
ignore,
|
|
101
|
+
runtime,
|
|
102
|
+
url,
|
|
103
|
+
description: project.description || null,
|
|
104
|
+
network: project.network || null,
|
|
105
|
+
project
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
};
|
package/queue.js
CHANGED
|
@@ -5,8 +5,8 @@ import config from './config.js';
|
|
|
5
5
|
|
|
6
6
|
const QUEUE_FILE = path.join(config.dataDir, 'queue.json');
|
|
7
7
|
const QUEUE_BACKUP = path.join(config.dataDir, 'queue.backup.json');
|
|
8
|
-
const SAVE_INTERVAL = 5000;
|
|
9
|
-
const MAX_QUEUE_SIZE = 1000;
|
|
8
|
+
const SAVE_INTERVAL = config.saveInterval || 5000;
|
|
9
|
+
const MAX_QUEUE_SIZE = config.maxQueueSize || 1000;
|
|
10
10
|
|
|
11
11
|
// ✅ Mode silencieux par défaut (logs désactivés sauf si MCP_DEBUG=true)
|
|
12
12
|
const SILENT_MODE = process.env.MCP_DEBUG !== 'true';
|
|
@@ -17,6 +17,12 @@ const MAX_LOGS = 500;
|
|
|
17
17
|
|
|
18
18
|
let saveTimer = null;
|
|
19
19
|
let isDirty = false;
|
|
20
|
+
let dirtyRevision = 0;
|
|
21
|
+
|
|
22
|
+
function markDirty() {
|
|
23
|
+
isDirty = true;
|
|
24
|
+
dirtyRevision++;
|
|
25
|
+
}
|
|
20
26
|
|
|
21
27
|
// Charger la queue au démarrage
|
|
22
28
|
async function loadQueue() {
|
|
@@ -63,11 +69,17 @@ let saveLock = null;
|
|
|
63
69
|
async function saveQueue() {
|
|
64
70
|
if (!isDirty) return;
|
|
65
71
|
|
|
66
|
-
// Attendre le
|
|
67
|
-
|
|
72
|
+
// Attendre le save précédent puis revalider : il a peut-être déjà persisté
|
|
73
|
+
// toutes les mutations connues au moment de cet appel.
|
|
74
|
+
if (saveLock) {
|
|
75
|
+
await saveLock;
|
|
76
|
+
if (!isDirty) return;
|
|
77
|
+
}
|
|
68
78
|
|
|
69
79
|
saveLock = (async () => {
|
|
70
80
|
isSaving = true;
|
|
81
|
+
let tmpPath = null;
|
|
82
|
+
const revisionAtStart = dirtyRevision;
|
|
71
83
|
try {
|
|
72
84
|
try {
|
|
73
85
|
await fs.copyFile(QUEUE_FILE, QUEUE_BACKUP);
|
|
@@ -88,14 +100,19 @@ async function saveQueue() {
|
|
|
88
100
|
}
|
|
89
101
|
}
|
|
90
102
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
103
|
+
// Écriture atomique : un crash ne peut plus laisser queue.json tronqué.
|
|
104
|
+
tmpPath = `${QUEUE_FILE}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
105
|
+
await fs.writeFile(tmpPath, JSON.stringify(filteredQueue, null, 2));
|
|
106
|
+
await fs.rename(tmpPath, QUEUE_FILE);
|
|
107
|
+
tmpPath = null;
|
|
95
108
|
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
// Une mutation a pu arriver pendant les awaits ci-dessus. Ne jamais
|
|
110
|
+
// effacer son dirty flag : le prochain tick la persistera.
|
|
111
|
+
isDirty = dirtyRevision !== revisionAtStart;
|
|
112
|
+
log('debug', `Queue sauvegardée (${Object.keys(filteredQueue).length} tâches, dirty=${isDirty})`);
|
|
98
113
|
} catch (err) {
|
|
114
|
+
isDirty = true;
|
|
115
|
+
if (tmpPath) await fs.rm(tmpPath, { force: true }).catch(() => {});
|
|
99
116
|
log('error', `Erreur lors de la sauvegarde de la queue: ${err.message}`);
|
|
100
117
|
} finally {
|
|
101
118
|
isSaving = false;
|
|
@@ -166,7 +183,7 @@ function addJob(details) {
|
|
|
166
183
|
}
|
|
167
184
|
|
|
168
185
|
jobQueue[id] = job;
|
|
169
|
-
|
|
186
|
+
markDirty();
|
|
170
187
|
log('info', `Nouvelle tâche ${id} (${job.type}) ajoutée.`);
|
|
171
188
|
return jobQueue[id];
|
|
172
189
|
}
|
|
@@ -192,7 +209,7 @@ function updateJobStatus(id, status, data = {}) {
|
|
|
192
209
|
log('info', `Tâche ${id}: ${oldStatus} -> ${status}`);
|
|
193
210
|
}
|
|
194
211
|
|
|
195
|
-
|
|
212
|
+
markDirty();
|
|
196
213
|
}
|
|
197
214
|
}
|
|
198
215
|
|
|
@@ -250,7 +267,7 @@ function cleanOldJobs() {
|
|
|
250
267
|
|
|
251
268
|
if (toDelete.length > 0) {
|
|
252
269
|
log('info', `${toDelete.length} vieilles tâches supprimées de la queue`);
|
|
253
|
-
|
|
270
|
+
markDirty();
|
|
254
271
|
}
|
|
255
272
|
}
|
|
256
273
|
|
|
@@ -285,7 +302,7 @@ async function retryJob(id) {
|
|
|
285
302
|
delete newJob.completedAt;
|
|
286
303
|
|
|
287
304
|
jobQueue[newJob.id] = newJob;
|
|
288
|
-
|
|
305
|
+
markDirty();
|
|
289
306
|
|
|
290
307
|
log('info', `Tâche ${id} réessayée -> nouvelle tâche ${newJob.id} (tentative ${newJob.retryCount}/${newJob.maxRetries})`);
|
|
291
308
|
|
|
@@ -300,6 +317,56 @@ function getCrashedJobs() {
|
|
|
300
317
|
);
|
|
301
318
|
}
|
|
302
319
|
|
|
320
|
+
function getRetryableJobs(statusFilter = null) {
|
|
321
|
+
return Object.values(jobQueue).filter(job => {
|
|
322
|
+
if (!['failed', 'crashed'].includes(job.status)) return false;
|
|
323
|
+
if (statusFilter && job.status !== statusFilter) return false;
|
|
324
|
+
if ((job.retryCount || 0) >= (job.maxRetries || 3)) return false;
|
|
325
|
+
return true;
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Purge des jobs terminés/crashés.
|
|
331
|
+
* options = { status?: 'crashed'|'failed'|'completed'|'all_terminal', olderThanDays?: number, dryRun?: bool }
|
|
332
|
+
*/
|
|
333
|
+
function purgeJobs(options = {}) {
|
|
334
|
+
const olderThanDays = options.olderThanDays ?? 0;
|
|
335
|
+
const dryRun = options.dryRun === true;
|
|
336
|
+
const status = options.status || 'crashed';
|
|
337
|
+
const now = Date.now();
|
|
338
|
+
const maxAge = olderThanDays > 0 ? olderThanDays * 86400000 : 0;
|
|
339
|
+
|
|
340
|
+
const terminal = new Set(['completed', 'failed', 'crashed', 'partial']);
|
|
341
|
+
const toDelete = [];
|
|
342
|
+
|
|
343
|
+
for (const [id, job] of Object.entries(jobQueue)) {
|
|
344
|
+
let match = false;
|
|
345
|
+
if (status === 'all_terminal') {
|
|
346
|
+
match = terminal.has(job.status);
|
|
347
|
+
} else {
|
|
348
|
+
match = job.status === status;
|
|
349
|
+
}
|
|
350
|
+
if (!match) continue;
|
|
351
|
+
|
|
352
|
+
if (maxAge > 0) {
|
|
353
|
+
const created = job.createdAt ? new Date(job.createdAt).getTime() : now;
|
|
354
|
+
if (now - created < maxAge) continue;
|
|
355
|
+
}
|
|
356
|
+
toDelete.push(id);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (!dryRun) {
|
|
360
|
+
for (const id of toDelete) delete jobQueue[id];
|
|
361
|
+
if (toDelete.length) {
|
|
362
|
+
markDirty();
|
|
363
|
+
log('info', `Purge: ${toDelete.length} tâche(s) supprimée(s) (status=${status})`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
return { purged: toDelete.length, ids: toDelete, dryRun };
|
|
368
|
+
}
|
|
369
|
+
|
|
303
370
|
function getStats() {
|
|
304
371
|
const stats = {
|
|
305
372
|
total: Object.keys(jobQueue).length,
|
|
@@ -355,6 +422,8 @@ export default {
|
|
|
355
422
|
log,
|
|
356
423
|
retryJob,
|
|
357
424
|
getCrashedJobs,
|
|
425
|
+
getRetryableJobs,
|
|
426
|
+
purgeJobs,
|
|
358
427
|
getStats,
|
|
359
428
|
cleanOldJobs,
|
|
360
429
|
saveQueue,
|