@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/inventory.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inventory.js — Inventaire léger d'un serveur (cache TTL).
|
|
3
|
+
*/
|
|
4
|
+
import queue from './queue.js';
|
|
5
|
+
import servers from './servers.js';
|
|
6
|
+
import sshPool from './sshPool.js';
|
|
7
|
+
import utils from './utils.js';
|
|
8
|
+
|
|
9
|
+
const cache = new Map(); // alias → { at, data }
|
|
10
|
+
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
11
|
+
|
|
12
|
+
const INVENTORY_CMD = [
|
|
13
|
+
'echo "===HOST==="',
|
|
14
|
+
'hostname; whoami; date -u +%Y-%m-%dT%H:%M:%SZ',
|
|
15
|
+
'echo "===UPTIME==="',
|
|
16
|
+
'uptime',
|
|
17
|
+
'echo "===DISK==="',
|
|
18
|
+
'df -h / 2>/dev/null | tail -1',
|
|
19
|
+
'echo "===MEM==="',
|
|
20
|
+
'free -h 2>/dev/null | head -2 || true',
|
|
21
|
+
'echo "===PM2==="',
|
|
22
|
+
'command -v pm2 >/dev/null && pm2 jlist 2>/dev/null | head -c 8000 || echo "[]"',
|
|
23
|
+
'echo "===DOCKER==="',
|
|
24
|
+
'command -v docker >/dev/null && docker ps --format "{{.Names}}|{{.Status}}|{{.Ports}}" 2>/dev/null | head -40 || true',
|
|
25
|
+
'echo "===HOME_TOP==="',
|
|
26
|
+
'ls -1 "$HOME" 2>/dev/null | head -40',
|
|
27
|
+
'echo "===TAILSCALE==="',
|
|
28
|
+
'command -v tailscale >/dev/null && tailscale status --json 2>/dev/null | head -c 2000 || echo "no-tailscale"'
|
|
29
|
+
].join('; ');
|
|
30
|
+
|
|
31
|
+
function parseInventory(raw) {
|
|
32
|
+
const sections = {};
|
|
33
|
+
let current = 'raw';
|
|
34
|
+
for (const line of (raw || '').split('\n')) {
|
|
35
|
+
const m = line.match(/^===([A-Z_]+)===\s*$/);
|
|
36
|
+
if (m) {
|
|
37
|
+
current = m[1].toLowerCase();
|
|
38
|
+
sections[current] = [];
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (!sections[current]) sections[current] = [];
|
|
42
|
+
sections[current].push(line);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const join = (k) => (sections[k] || []).join('\n').trim();
|
|
46
|
+
|
|
47
|
+
let pm2 = [];
|
|
48
|
+
try {
|
|
49
|
+
const pm2Raw = join('pm2');
|
|
50
|
+
if (pm2Raw && pm2Raw !== '[]') {
|
|
51
|
+
const list = JSON.parse(pm2Raw);
|
|
52
|
+
if (Array.isArray(list)) {
|
|
53
|
+
pm2 = list.map(p => ({
|
|
54
|
+
name: p.name,
|
|
55
|
+
status: p.pm2_env?.status,
|
|
56
|
+
pid: p.pid,
|
|
57
|
+
pm_id: p.pm_id
|
|
58
|
+
})).slice(0, 40);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
pm2 = [{ parse_error: true, raw: join('pm2').slice(0, 200) }];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const docker = join('docker')
|
|
66
|
+
.split('\n')
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
.map(line => {
|
|
69
|
+
const [name, status, ports] = line.split('|');
|
|
70
|
+
return { name, status, ports };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const home_top = join('home_top').split('\n').filter(Boolean);
|
|
74
|
+
const hasTailscale = !join('tailscale').includes('no-tailscale');
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
host: join('host'),
|
|
78
|
+
uptime: join('uptime'),
|
|
79
|
+
disk: join('disk'),
|
|
80
|
+
mem: join('mem'),
|
|
81
|
+
pm2,
|
|
82
|
+
docker,
|
|
83
|
+
home_top,
|
|
84
|
+
tailscale: hasTailscale ? 'present' : 'absent',
|
|
85
|
+
tailscale_raw_len: join('tailscale').length
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function execProbe(alias, timeoutMs = 20000) {
|
|
90
|
+
const serverConfig = await servers.getServer(alias);
|
|
91
|
+
let connection = null;
|
|
92
|
+
try {
|
|
93
|
+
connection = await sshPool.getConnection(alias, serverConfig);
|
|
94
|
+
const output = await new Promise((resolve, reject) => {
|
|
95
|
+
let settled = false;
|
|
96
|
+
const timer = setTimeout(() => {
|
|
97
|
+
if (!settled) {
|
|
98
|
+
settled = true;
|
|
99
|
+
reject(new Error(`inventory timeout ${timeoutMs}ms`));
|
|
100
|
+
}
|
|
101
|
+
}, timeoutMs);
|
|
102
|
+
|
|
103
|
+
connection.client.exec(INVENTORY_CMD, (err, stream) => {
|
|
104
|
+
if (err) {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
if (!settled) { settled = true; reject(err); }
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
let out = '';
|
|
110
|
+
stream.on('data', d => { out += d.toString(); });
|
|
111
|
+
stream.stderr.on('data', d => { out += d.toString(); });
|
|
112
|
+
stream.on('close', () => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
if (!settled) { settled = true; resolve(out); }
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
return parseInventory(output);
|
|
119
|
+
} finally {
|
|
120
|
+
if (connection) sshPool.releaseConnection(connection.id);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export default {
|
|
125
|
+
async get(alias, { force = false, ttlMs = DEFAULT_TTL_MS } = {}) {
|
|
126
|
+
const cached = cache.get(alias);
|
|
127
|
+
if (!force && cached && (Date.now() - cached.at) < ttlMs) {
|
|
128
|
+
return { alias, cached: true, ageSeconds: Math.round((Date.now() - cached.at) / 1000), ...cached.data };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
queue.log('info', `inventory: probe ${alias}`);
|
|
132
|
+
const sc = await servers.getServer(alias);
|
|
133
|
+
const data = await execProbe(alias);
|
|
134
|
+
const payload = {
|
|
135
|
+
alias,
|
|
136
|
+
host: sc.host,
|
|
137
|
+
user: sc.user,
|
|
138
|
+
port: utils.resolveSshPort(sc),
|
|
139
|
+
...data,
|
|
140
|
+
fetchedAt: new Date().toISOString()
|
|
141
|
+
};
|
|
142
|
+
cache.set(alias, { at: Date.now(), data: payload });
|
|
143
|
+
return { ...payload, cached: false, ageSeconds: 0 };
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
clear(alias = null) {
|
|
147
|
+
if (alias) cache.delete(alias);
|
|
148
|
+
else cache.clear();
|
|
149
|
+
return { cleared: true, alias };
|
|
150
|
+
}
|
|
151
|
+
};
|
package/lhm.plugin.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"identifier": "fkom13-mcp-sftp-orchestrator",
|
|
3
|
+
"name": "MCP SFTP/SSH Orchestrator",
|
|
4
|
+
"version": "11.8.0",
|
|
5
|
+
"description": "82 outils SSH/SFTP et orchestration infra pour agents IA : multi-host, fichiers hash-safe, tmux, tunnels, snapshots, projets, work sessions, topologie, blocklist et allowed_roots",
|
|
6
|
+
"tags": "ssh,sftp,mcp,devops,server-management,infrastructure"
|
|
7
|
+
}
|
package/notes.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import path from 'path'; import config from './config.js'; import jsonStore from './atomicJsonStore.js';
|
|
2
|
+
const NOTES_FILE=path.join(config.dataDir,'server_notes.json'), EMPTY={}; const readAll=()=>jsonStore.readJson(NOTES_FILE,EMPTY);
|
|
3
|
+
export default { async set(alias,fields={}){let merged; await jsonStore.updateJson(NOTES_FILE,EMPTY,notes=>{merged={...(notes[alias]||{})}; for(const key of ['description','services','warnings','conventions','note'])if(fields[key]!==undefined)merged[key]=fields[key]; if(fields.intervention)merged.last_intervention={at:new Date().toISOString(),summary:fields.intervention}; merged.updatedAt=new Date().toISOString(); notes[alias]=merged; return notes;}); return{alias,note:merged};}, async get(alias){const n=await readAll();return n[alias]||null;}, async list(){return readAll();}, async remove(alias){let removed=false; await jsonStore.updateJson(NOTES_FILE,EMPTY,n=>{removed=!!n[alias]; if(removed)delete n[alias]; return n;}); return{removed,alias};} };
|
package/package.json
CHANGED
|
@@ -1,40 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fkom13/mcp-sftp-orchestrator",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.8.0",
|
|
4
4
|
"description": "Un serveur MCP stdio pour l'orchestration de tâches distantes avec exécution hybride (synchrone/asynchrone) et configuration flexible.",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"type": "module",
|
|
7
|
-
"
|
|
8
|
-
"mcp-sftp-orchestrator": "server.js"
|
|
9
|
-
},
|
|
7
|
+
"mcpName": "io.github.fkom13/mcp-sftp-orchestrator",
|
|
10
8
|
"scripts": {
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
"
|
|
15
|
-
"url": "git+https://github.com/fkom13/mcp-sftp-orchestrator.git"
|
|
16
|
-
},
|
|
17
|
-
"keywords": [
|
|
18
|
-
"mcp",
|
|
19
|
-
"orchestrator",
|
|
20
|
-
"ssh",
|
|
21
|
-
"sftp",
|
|
22
|
-
"automation"
|
|
23
|
-
],
|
|
24
|
-
"author": "fkom13",
|
|
25
|
-
"license": "MIT",
|
|
26
|
-
"bugs": {
|
|
27
|
-
"url": "https://github.com/fkom13/mcp-sftp-orchestrator/issues"
|
|
9
|
+
"start": "node server.js",
|
|
10
|
+
"test": "node test_p0_unit.js && node test_p1_unit.js && node test_p16_unit.js && node test_hardening_1161.js && node test_atomic_store_1161.js && node test_queue_atomic_1161.js && node test_aix_117.js && node test_mcp_hardening_integration.js && node test_mcp.js && node test_features.js",
|
|
11
|
+
"test:unit": "node test_p0_unit.js && node test_p1_unit.js && node test_p16_unit.js",
|
|
12
|
+
"test:integration": "node test_features.js"
|
|
28
13
|
},
|
|
29
|
-
"homepage": "https://github.com/fkom13/mcp-sftp-orchestrator#readme",
|
|
30
14
|
"dependencies": {
|
|
31
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
16
|
+
"diff": "^5.2.2",
|
|
32
17
|
"dotenv": "^16.4.5",
|
|
33
|
-
"glob": "^
|
|
18
|
+
"glob": "^13.0.6",
|
|
34
19
|
"micromatch": "^4.0.5",
|
|
35
20
|
"ssh2": "^1.15.0",
|
|
36
21
|
"ssh2-sftp-client": "^9.1.0",
|
|
37
|
-
"uuid": "^
|
|
22
|
+
"uuid": "^11.1.1",
|
|
38
23
|
"zod": "^3.23.8"
|
|
39
24
|
}
|
|
40
|
-
}
|
|
25
|
+
}
|
package/policies.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import config from './config.js';
|
|
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
|
+
};
|