@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/infraTopology.js
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* infraTopology — découverte live read-only et corrélation sémantique
|
|
3
|
+
* domaine → reverse proxy → port → Docker/Compose/service.
|
|
4
|
+
*
|
|
5
|
+
* Aucune donnée secrète n'est collectée. Les probes utilisent uniquement des
|
|
6
|
+
* commandes d'inventaire. Cache court pour éviter de marteler les machines.
|
|
7
|
+
*/
|
|
8
|
+
import servers from './servers.js';
|
|
9
|
+
import sshPool from './sshPool.js';
|
|
10
|
+
import queue from './queue.js';
|
|
11
|
+
import utils from './utils.js';
|
|
12
|
+
|
|
13
|
+
const cache = new Map();
|
|
14
|
+
const DEFAULT_TTL_MS = 2 * 60 * 1000;
|
|
15
|
+
|
|
16
|
+
const PROBE_CMD = String.raw`
|
|
17
|
+
printf '===META===\n'; hostname 2>/dev/null || true; date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || true
|
|
18
|
+
printf '===LISTENERS===\n'; (ss -lntH 2>/dev/null || netstat -lnt 2>/dev/null || true) | head -250
|
|
19
|
+
printf '===DOCKER===\n'; if command -v docker >/dev/null 2>&1; then docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}\t{{.Label "com.docker.compose.project"}}\t{{.Label "com.docker.compose.service"}}' 2>/dev/null | head -250; fi
|
|
20
|
+
printf '===PM2===\n'; if command -v pm2 >/dev/null 2>&1; then pm2 jlist 2>/dev/null | head -c 40000; else printf '[]'; fi; printf '\n'
|
|
21
|
+
printf '===SYSTEMD===\n'; (systemctl list-units --type=service --state=running --no-legend --plain 2>/dev/null || true) | head -120
|
|
22
|
+
printf '===NGINX===\n'; if command -v nginx >/dev/null 2>&1; then (sudo -n nginx -T 2>&1 || nginx -T 2>&1 || true) | head -c 180000; fi
|
|
23
|
+
`;
|
|
24
|
+
|
|
25
|
+
function sectionsFromRaw(raw = '') {
|
|
26
|
+
const sections = {};
|
|
27
|
+
let current = 'raw';
|
|
28
|
+
for (const line of String(raw).split('\n')) {
|
|
29
|
+
const marker = line.match(/^===([A-Z0-9_]+)===\s*$/);
|
|
30
|
+
if (marker) {
|
|
31
|
+
current = marker[1].toLowerCase();
|
|
32
|
+
sections[current] = [];
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
(sections[current] ||= []).push(line);
|
|
36
|
+
}
|
|
37
|
+
return Object.fromEntries(Object.entries(sections).map(([k, v]) => [k, v.join('\n').trim()]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseListeners(text = '') {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const line of text.split('\n').map(s => s.trim()).filter(Boolean)) {
|
|
43
|
+
const cols = line.split(/\s+/);
|
|
44
|
+
// ss -lntH: State Recv-Q Send-Q Local Peer
|
|
45
|
+
let local = cols.length >= 4 ? cols[3] : null;
|
|
46
|
+
if (!local || !/[:.]\d+$/.test(local.replace(/\]$/, ''))) {
|
|
47
|
+
const candidate = cols.find(c => /:\d+$/.test(c));
|
|
48
|
+
if (candidate) local = candidate;
|
|
49
|
+
}
|
|
50
|
+
if (!local) continue;
|
|
51
|
+
const m = local.match(/^(.*):([0-9]+)$/);
|
|
52
|
+
if (!m) continue;
|
|
53
|
+
out.push({ address: m[1], port: Number(m[2]), raw: line });
|
|
54
|
+
}
|
|
55
|
+
return out.slice(0, 250);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parsePortMappings(ports = '') {
|
|
59
|
+
const mappings = [];
|
|
60
|
+
for (const part of ports.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
61
|
+
const m = part.match(/^(?:(.+):)?(\d+)->(\d+)\/(tcp|udp)$/);
|
|
62
|
+
if (m) {
|
|
63
|
+
mappings.push({ hostAddress: m[1] || null, hostPort: Number(m[2]), containerPort: Number(m[3]), protocol: m[4] });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const exposed = part.match(/^(\d+)\/(tcp|udp)$/);
|
|
67
|
+
if (exposed) mappings.push({ hostAddress: null, hostPort: null, containerPort: Number(exposed[1]), protocol: exposed[2] });
|
|
68
|
+
}
|
|
69
|
+
return mappings;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parseDocker(text = '') {
|
|
73
|
+
return text.split('\n').map(s => s.trim()).filter(Boolean).map(line => {
|
|
74
|
+
const [name = '', image = '', status = '', ports = '', composeProject = '', composeService = ''] = line.split('\t');
|
|
75
|
+
return {
|
|
76
|
+
name, image, status, ports,
|
|
77
|
+
composeProject: composeProject || null,
|
|
78
|
+
composeService: composeService || null,
|
|
79
|
+
portMappings: parsePortMappings(ports)
|
|
80
|
+
};
|
|
81
|
+
}).filter(x => x.name).slice(0, 250);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function parsePm2(text = '') {
|
|
85
|
+
if (!text || text === '[]') return [];
|
|
86
|
+
try {
|
|
87
|
+
const list = JSON.parse(text);
|
|
88
|
+
if (!Array.isArray(list)) return [];
|
|
89
|
+
return list.slice(0, 100).map(p => ({
|
|
90
|
+
name: p.name || null,
|
|
91
|
+
status: p.pm2_env?.status || null,
|
|
92
|
+
pid: p.pid ?? null,
|
|
93
|
+
pm_id: p.pm_id ?? null,
|
|
94
|
+
cwd: p.pm2_env?.pm_cwd || null,
|
|
95
|
+
port: Number(p.pm2_env?.env?.PORT ?? p.pm2_env?.PORT ?? NaN) || null
|
|
96
|
+
}));
|
|
97
|
+
} catch {
|
|
98
|
+
return [{ parseError: true, rawLength: text.length }];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseSystemd(text = '') {
|
|
103
|
+
return text.split('\n').map(s => s.trim()).filter(Boolean).map(line => {
|
|
104
|
+
const unit = line.split(/\s+/)[0];
|
|
105
|
+
return { unit, raw: line };
|
|
106
|
+
}).filter(x => x.unit).slice(0, 120);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function countBraces(line) {
|
|
110
|
+
let open = 0, close = 0, quote = null, escaped = false;
|
|
111
|
+
for (const ch of line) {
|
|
112
|
+
if (escaped) { escaped = false; continue; }
|
|
113
|
+
if (ch === '\\') { escaped = true; continue; }
|
|
114
|
+
if (quote) { if (ch === quote) quote = null; continue; }
|
|
115
|
+
if (ch === '"' || ch === "'") { quote = ch; continue; }
|
|
116
|
+
if (ch === '{') open++;
|
|
117
|
+
if (ch === '}') close++;
|
|
118
|
+
}
|
|
119
|
+
return { open, close };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function cleanDirectiveValues(line, directive) {
|
|
123
|
+
const idx = line.indexOf(directive);
|
|
124
|
+
if (idx < 0) return [];
|
|
125
|
+
return line.slice(idx + directive.length).replace(/;.*/, '').trim().split(/\s+/).filter(Boolean);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseNginx(text = '') {
|
|
129
|
+
const serverBlocks = [];
|
|
130
|
+
const upstreams = {};
|
|
131
|
+
const lines = text.split('\n');
|
|
132
|
+
let sourceFile = null;
|
|
133
|
+
let depth = 0;
|
|
134
|
+
let currentServer = null;
|
|
135
|
+
let currentUpstream = null;
|
|
136
|
+
|
|
137
|
+
for (const raw of lines) {
|
|
138
|
+
const fileMarker = raw.match(/^# configuration file ([^:]+):/);
|
|
139
|
+
if (fileMarker) sourceFile = fileMarker[1];
|
|
140
|
+
const line = raw.replace(/#.*$/, '').trim();
|
|
141
|
+
if (!line) continue;
|
|
142
|
+
|
|
143
|
+
const before = depth;
|
|
144
|
+
if (!currentServer) {
|
|
145
|
+
const sm = line.match(/^server\s*\{/);
|
|
146
|
+
if (sm) currentServer = { file: sourceFile, depth: before + 1, serverNames: [], listens: [], proxyPasses: [], returns: [] };
|
|
147
|
+
}
|
|
148
|
+
if (!currentUpstream) {
|
|
149
|
+
const um = line.match(/^upstream\s+([^\s{]+)\s*\{/);
|
|
150
|
+
if (um) currentUpstream = { name: um[1], depth: before + 1, servers: [], file: sourceFile };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (currentServer) {
|
|
154
|
+
if (/^server_name\s+/.test(line)) currentServer.serverNames.push(...cleanDirectiveValues(line, 'server_name'));
|
|
155
|
+
if (/^listen\s+/.test(line)) currentServer.listens.push(...cleanDirectiveValues(line, 'listen'));
|
|
156
|
+
const pm = line.match(/\bproxy_pass\s+([^;]+);/);
|
|
157
|
+
if (pm) currentServer.proxyPasses.push(pm[1].trim());
|
|
158
|
+
const rm = line.match(/^return\s+(\d{3})\s+([^;]+);/);
|
|
159
|
+
if (rm) currentServer.returns.push({ status: Number(rm[1]), target: rm[2].trim() });
|
|
160
|
+
}
|
|
161
|
+
if (currentUpstream) {
|
|
162
|
+
const us = line.match(/^server\s+([^;\s]+)(?:\s+[^;]*)?;/);
|
|
163
|
+
if (us) currentUpstream.servers.push(us[1]);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const bc = countBraces(line);
|
|
167
|
+
depth += bc.open - bc.close;
|
|
168
|
+
|
|
169
|
+
if (currentServer && depth < currentServer.depth) {
|
|
170
|
+
currentServer.serverNames = [...new Set(currentServer.serverNames)].filter(x => x && x !== '_');
|
|
171
|
+
currentServer.listens = [...new Set(currentServer.listens)];
|
|
172
|
+
currentServer.proxyPasses = [...new Set(currentServer.proxyPasses)];
|
|
173
|
+
serverBlocks.push(currentServer);
|
|
174
|
+
currentServer = null;
|
|
175
|
+
}
|
|
176
|
+
if (currentUpstream && depth < currentUpstream.depth) {
|
|
177
|
+
upstreams[currentUpstream.name] = { servers: [...new Set(currentUpstream.servers)], file: currentUpstream.file };
|
|
178
|
+
currentUpstream = null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { serverBlocks, upstreams };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function endpointFromTarget(target) {
|
|
185
|
+
if (!target) return null;
|
|
186
|
+
let normalized = target.trim();
|
|
187
|
+
try {
|
|
188
|
+
if (/^https?:\/\//i.test(normalized)) {
|
|
189
|
+
const u = new URL(normalized);
|
|
190
|
+
return { host: u.hostname, port: Number(u.port || (u.protocol === 'https:' ? 443 : 80)), raw: target };
|
|
191
|
+
}
|
|
192
|
+
} catch { /* fall through */ }
|
|
193
|
+
const simple = normalized.replace(/^https?:\/\//i, '').replace(/\/.*$/, '');
|
|
194
|
+
const m = simple.match(/^\[?([^\]]+?)\]?:([0-9]+)$/);
|
|
195
|
+
if (m) return { host: m[1], port: Number(m[2]), raw: target };
|
|
196
|
+
if (/^[a-zA-Z0-9_.-]+$/.test(simple)) return { host: simple, port: null, raw: target };
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function resolveContainer(endpoint, docker) {
|
|
201
|
+
if (!endpoint?.port) return [];
|
|
202
|
+
const localHosts = new Set(['127.0.0.1','localhost','0.0.0.0','::1','[::1]']);
|
|
203
|
+
const hits = [];
|
|
204
|
+
for (const c of docker) {
|
|
205
|
+
for (const m of c.portMappings) {
|
|
206
|
+
if (localHosts.has(endpoint.host) && m.hostPort === endpoint.port) hits.push({ container: c.name, image: c.image, composeProject: c.composeProject, composeService: c.composeService, via: 'hostPort', hostPort: m.hostPort, containerPort: m.containerPort });
|
|
207
|
+
if (endpoint.host === c.name && m.containerPort === endpoint.port) hits.push({ container: c.name, image: c.image, composeProject: c.composeProject, composeService: c.composeService, via: 'containerDns', containerPort: m.containerPort });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return hits;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function buildDomainGraph(nginx, docker) {
|
|
214
|
+
const byDomain = new Map();
|
|
215
|
+
for (const block of nginx.serverBlocks) {
|
|
216
|
+
for (const domain of block.serverNames) {
|
|
217
|
+
if (!domain || domain.includes('$')) continue;
|
|
218
|
+
let entry = byDomain.get(domain);
|
|
219
|
+
if (!entry) {
|
|
220
|
+
entry = { domain, listens: [], configFiles: [], routes: [], redirects: [] };
|
|
221
|
+
byDomain.set(domain, entry);
|
|
222
|
+
}
|
|
223
|
+
entry.listens.push(...block.listens);
|
|
224
|
+
if (block.file) entry.configFiles.push(block.file);
|
|
225
|
+
entry.redirects.push(...block.returns);
|
|
226
|
+
for (const proxyPass of block.proxyPasses) {
|
|
227
|
+
const ep = endpointFromTarget(proxyPass);
|
|
228
|
+
if (ep && nginx.upstreams[ep.host]) {
|
|
229
|
+
for (const upstreamTarget of nginx.upstreams[ep.host].servers) {
|
|
230
|
+
const upstreamEp = endpointFromTarget(upstreamTarget);
|
|
231
|
+
entry.routes.push({ proxyPass, upstream: ep.host, target: upstreamEp, containers: resolveContainer(upstreamEp, docker) });
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
entry.routes.push({ proxyPass, target: ep, containers: resolveContainer(ep, docker) });
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return [...byDomain.values()].map(entry => {
|
|
240
|
+
const uniqueRoutes = [];
|
|
241
|
+
const seenRoutes = new Set();
|
|
242
|
+
for (const route of entry.routes) {
|
|
243
|
+
const key = JSON.stringify([route.proxyPass, route.upstream || null, route.target?.host || null, route.target?.port || null]);
|
|
244
|
+
if (!seenRoutes.has(key)) { seenRoutes.add(key); uniqueRoutes.push(route); }
|
|
245
|
+
}
|
|
246
|
+
const uniqueRedirects = [];
|
|
247
|
+
const seenRedirects = new Set();
|
|
248
|
+
for (const redirect of entry.redirects) {
|
|
249
|
+
const key = JSON.stringify(redirect);
|
|
250
|
+
if (!seenRedirects.has(key)) { seenRedirects.add(key); uniqueRedirects.push(redirect); }
|
|
251
|
+
}
|
|
252
|
+
const configFiles = [...new Set(entry.configFiles)];
|
|
253
|
+
return {
|
|
254
|
+
domain: entry.domain,
|
|
255
|
+
listens: [...new Set(entry.listens)],
|
|
256
|
+
configFile: configFiles.length === 1 ? configFiles[0] : null,
|
|
257
|
+
configFiles,
|
|
258
|
+
routes: uniqueRoutes,
|
|
259
|
+
redirects: uniqueRedirects
|
|
260
|
+
};
|
|
261
|
+
}).sort((a, b) => a.domain.localeCompare(b.domain));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseProbe(raw) {
|
|
265
|
+
const sections = sectionsFromRaw(raw);
|
|
266
|
+
const docker = parseDocker(sections.docker || '');
|
|
267
|
+
const nginx = parseNginx(sections.nginx || '');
|
|
268
|
+
const listeners = parseListeners(sections.listeners || '');
|
|
269
|
+
const pm2 = parsePm2(sections.pm2 || '');
|
|
270
|
+
const systemd = parseSystemd(sections.systemd || '');
|
|
271
|
+
const domains = buildDomainGraph(nginx, docker);
|
|
272
|
+
return {
|
|
273
|
+
meta: (sections.meta || '').split('\n').filter(Boolean),
|
|
274
|
+
listeners,
|
|
275
|
+
docker,
|
|
276
|
+
pm2,
|
|
277
|
+
systemd,
|
|
278
|
+
nginx: { serverBlockCount: nginx.serverBlocks.length, upstreamCount: Object.keys(nginx.upstreams).length, upstreams: nginx.upstreams },
|
|
279
|
+
domains,
|
|
280
|
+
summary: {
|
|
281
|
+
listeners: listeners.length,
|
|
282
|
+
dockerContainers: docker.length,
|
|
283
|
+
composeProjects: new Set(docker.map(c => c.composeProject).filter(Boolean)).size,
|
|
284
|
+
pm2Processes: pm2.filter(x => !x.parseError).length,
|
|
285
|
+
runningSystemdServices: systemd.length,
|
|
286
|
+
nginxServerBlocks: nginx.serverBlocks.length,
|
|
287
|
+
domains: domains.length,
|
|
288
|
+
resolvedDomainRoutes: domains.reduce((n, d) => n + d.routes.filter(r => r.containers?.length).length, 0)
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function execProbe(alias, timeoutMs = 25000) {
|
|
294
|
+
const serverConfig = await servers.getServer(alias);
|
|
295
|
+
let connection = null;
|
|
296
|
+
try {
|
|
297
|
+
connection = await sshPool.getConnection(alias, serverConfig);
|
|
298
|
+
const output = await new Promise((resolve, reject) => {
|
|
299
|
+
let settled = false;
|
|
300
|
+
const timer = setTimeout(() => { if (!settled) { settled = true; reject(new Error(`infra topology timeout ${timeoutMs}ms`)); } }, timeoutMs);
|
|
301
|
+
connection.client.exec(PROBE_CMD, (err, stream) => {
|
|
302
|
+
if (err) { clearTimeout(timer); if (!settled) { settled = true; reject(err); } return; }
|
|
303
|
+
let out = '';
|
|
304
|
+
stream.on('data', d => { if (out.length < 300000) out += d.toString(); });
|
|
305
|
+
stream.stderr.on('data', d => { if (out.length < 300000) out += d.toString(); });
|
|
306
|
+
stream.on('close', () => { clearTimeout(timer); if (!settled) { settled = true; resolve(out); } });
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
return parseProbe(output);
|
|
310
|
+
} finally {
|
|
311
|
+
if (connection) sshPool.releaseConnection(connection.id);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function get(alias, { force = false, ttlMs = DEFAULT_TTL_MS, timeoutMs = 25000 } = {}) {
|
|
316
|
+
const cached = cache.get(alias);
|
|
317
|
+
if (!force && cached && Date.now() - cached.at < ttlMs) return { ...cached.data, cached: true, ageSeconds: Math.round((Date.now() - cached.at) / 1000) };
|
|
318
|
+
const sc = await servers.getServer(alias);
|
|
319
|
+
queue.log('info', `infra_topology: probe ${alias}`);
|
|
320
|
+
const parsed = await execProbe(alias, timeoutMs);
|
|
321
|
+
const payload = {
|
|
322
|
+
alias,
|
|
323
|
+
host: sc.host,
|
|
324
|
+
user: sc.user,
|
|
325
|
+
port: utils.resolveSshPort(sc),
|
|
326
|
+
fetchedAt: new Date().toISOString(),
|
|
327
|
+
...parsed
|
|
328
|
+
};
|
|
329
|
+
cache.set(alias, { at: Date.now(), data: payload });
|
|
330
|
+
return { ...payload, cached: false, ageSeconds: 0 };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function clear(alias = null) { if (alias) cache.delete(alias); else cache.clear(); return { cleared: true, alias }; }
|
|
334
|
+
|
|
335
|
+
export { sectionsFromRaw, parseListeners, parseDocker, parsePm2, parseSystemd, parseNginx, buildDomainGraph, parseProbe };
|
|
336
|
+
export default { get, clear, parseProbe };
|
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
CHANGED
|
@@ -1,87 +1,3 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* notes — Mémoire contextuelle par serveur (protocole/particularités).
|
|
7
|
-
*
|
|
8
|
-
* Permet à l'IA pilote de ne pas se perdre dans un parc de nombreux serveurs :
|
|
9
|
-
* chaque serveur peut porter une description, la liste de ses services critiques,
|
|
10
|
-
* des avertissements (ex: "RAM 1Go, ne pas rebuild en prod"), des conventions
|
|
11
|
-
* (chemins standards), et la trace de la dernière intervention.
|
|
12
|
-
*
|
|
13
|
-
* Stockage : <dataDir>/server_notes.json (même pattern que servers.json).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const NOTES_FILE = path.join(config.dataDir, 'server_notes.json');
|
|
17
|
-
|
|
18
|
-
async function readAll() {
|
|
19
|
-
try {
|
|
20
|
-
const data = await fs.readFile(NOTES_FILE, 'utf-8');
|
|
21
|
-
return JSON.parse(data);
|
|
22
|
-
} catch {
|
|
23
|
-
return {};
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function writeAll(notes) {
|
|
28
|
-
await fs.mkdir(path.dirname(NOTES_FILE), { recursive: true });
|
|
29
|
-
await fs.writeFile(NOTES_FILE, JSON.stringify(notes, null, 2));
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export default {
|
|
33
|
-
/**
|
|
34
|
-
* Définit/met à jour la note d'un serveur (merge partiel : seuls les champs
|
|
35
|
-
* fournis sont modifiés). Champs libres :
|
|
36
|
-
* description, services[], warnings[], conventions[], note (texte libre)
|
|
37
|
-
* `last_intervention` est mis à jour automatiquement (timestamp + résumé).
|
|
38
|
-
*/
|
|
39
|
-
async set(alias, fields = {}) {
|
|
40
|
-
const notes = await readAll();
|
|
41
|
-
const existing = notes[alias] || {};
|
|
42
|
-
const merged = { ...existing };
|
|
43
|
-
|
|
44
|
-
// Champs structurés (remplacés si fournis)
|
|
45
|
-
for (const key of ['description', 'services', 'warnings', 'conventions', 'note']) {
|
|
46
|
-
if (fields[key] !== undefined) merged[key] = fields[key];
|
|
47
|
-
}
|
|
48
|
-
// Trace d'intervention
|
|
49
|
-
if (fields.intervention) {
|
|
50
|
-
merged.last_intervention = {
|
|
51
|
-
at: new Date().toISOString(),
|
|
52
|
-
summary: fields.intervention
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
merged.updatedAt = new Date().toISOString();
|
|
56
|
-
|
|
57
|
-
notes[alias] = merged;
|
|
58
|
-
await writeAll(notes);
|
|
59
|
-
return { alias, note: merged };
|
|
60
|
-
},
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Récupère la note d'un serveur. Retourne null si absente.
|
|
64
|
-
*/
|
|
65
|
-
async get(alias) {
|
|
66
|
-
const notes = await readAll();
|
|
67
|
-
return notes[alias] || null;
|
|
68
|
-
},
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Liste toutes les notes (vue d'ensemble du parc).
|
|
72
|
-
*/
|
|
73
|
-
async list() {
|
|
74
|
-
return await readAll();
|
|
75
|
-
},
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Supprime la note d'un serveur.
|
|
79
|
-
*/
|
|
80
|
-
async remove(alias) {
|
|
81
|
-
const notes = await readAll();
|
|
82
|
-
if (!notes[alias]) return { removed: false, alias };
|
|
83
|
-
delete notes[alias];
|
|
84
|
-
await writeAll(notes);
|
|
85
|
-
return { removed: true, alias };
|
|
86
|
-
}
|
|
87
|
-
};
|
|
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,23 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fkom13/mcp-sftp-orchestrator",
|
|
3
|
-
"version": "11.
|
|
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
7
|
"mcpName": "io.github.fkom13/mcp-sftp-orchestrator",
|
|
8
8
|
"scripts": {
|
|
9
9
|
"start": "node server.js",
|
|
10
|
-
"test": "node test_mcp.js && node test_features.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",
|
|
11
12
|
"test:integration": "node test_features.js"
|
|
12
13
|
},
|
|
13
14
|
"dependencies": {
|
|
14
|
-
"@modelcontextprotocol/sdk": "^1.
|
|
15
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
16
|
+
"diff": "^5.2.2",
|
|
15
17
|
"dotenv": "^16.4.5",
|
|
16
|
-
"glob": "^
|
|
18
|
+
"glob": "^13.0.6",
|
|
17
19
|
"micromatch": "^4.0.5",
|
|
18
20
|
"ssh2": "^1.15.0",
|
|
19
21
|
"ssh2-sftp-client": "^9.1.0",
|
|
20
|
-
"uuid": "^
|
|
22
|
+
"uuid": "^11.1.1",
|
|
21
23
|
"zod": "^3.23.8"
|
|
22
24
|
}
|
|
23
|
-
}
|
|
25
|
+
}
|