@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/guide.js ADDED
@@ -0,0 +1,236 @@
1
+ /**
2
+ * guide — Manuel intégré pour l'IA pilote d'orchestrator.
3
+ *
4
+ * Retourne des sections structurées (workflows, cheatsheet, pièges) pour que
5
+ * l'agent charge les bonnes pratiques en début de session, sans avoir à
6
+ * connaître les ~44 outils par cœur. Inspiré de gencodedoc_guide.
7
+ */
8
+
9
+ const SECTIONS = {
10
+ index: `# Guide Orchestrator — Index
11
+
12
+ Serveur MCP d'orchestration d'infrastructure (SSH/SFTP, fichiers, diff, sessions, snapshots).
13
+
14
+ ## Catégories d'outils
15
+ - **Serveurs** : server_add/list/remove, server_note_* (contexte du parc), infra_overview
16
+ - **Exécution** : task_exec (one-shot), task_exec_sequence, shell_* (sessions persistantes)
17
+ - **Fichiers** : file_read, file_write, file_edit (local + remote unifiés)
18
+ - **Comparaison** : diff_files, diff_folders, compare_all_sources
19
+ - **Versioning** : snapshot_create/list/diff/restore/delete
20
+ - **Monitoring** : get_system_resources, get_services_status, get_pm2_logs, get_docker_logs, tail_file
21
+ - **Transferts** : task_transfer, task_transfer_multi
22
+
23
+ ## Sections détaillées (guide section:<nom>)
24
+ - workflows : recettes copier-coller
25
+ - file-editing : éditer un fichier en sécurité
26
+ - sources : concept local vs remote
27
+ - cheatsheet : tableau outil → usage
28
+ - audit : audit complet du parc (recette copier-coller)
29
+ - security : politique de sécurité (blocklist + tunnels)
30
+ - pitfalls : pièges à éviter
31
+
32
+ Astuce : appelle 'infra_overview' en début de session pour situer le parc.`,
33
+
34
+ sources: `# Concept clé : les "sources" (local vs remote)
35
+
36
+ Tous les outils fichiers/diff/snapshot acceptent une source unifiée :
37
+
38
+ { type: 'local', path: '/chemin' } → PC hôte du MCP, SANS SSH
39
+ { type: 'remote', alias: 'vpsparfait', path: '/chemin' } → serveur distant via SFTP (pool SSH)
40
+
41
+ - 'local' = agit sur la machine où tourne le MCP (fs direct, rapide)
42
+ - 'remote' = agit sur un serveur configuré (server_list pour les alias)
43
+ - label optionnel : nom lisible pour un affichage clair quand les chemins varient
44
+
45
+ Puissance : on peut mixer (diff local↔remote, snapshot remote→restore local).`,
46
+
47
+ 'file-editing': `# Éditer un fichier en sécurité
48
+
49
+ ## Workflow recommandé
50
+ 1. file_read { source } → récupère 'content' + 'hash'
51
+ 2. file_edit { source, oldString, newString, expectedHash: <hash> }
52
+ - mode chirurgical : remplace un bout précis (économe en tokens)
53
+ - expectedHash : refuse l'édition si le fichier a changé entre-temps (HASH_MISMATCH)
54
+
55
+ ## Options utiles
56
+ - dryRun:true → prévisualise le diff SANS écrire (valider avant d'appliquer)
57
+ - backup:true → snapshot auto avant modif → undo via snapshot_restore
58
+ - replaceAll:true → remplace toutes les occurrences (sinon erreur si multiples)
59
+
60
+ ## Mode complet
61
+ file_edit { source, newContent: "<tout le fichier>" } → remplace tout.
62
+
63
+ ## Erreurs
64
+ - OLDSTRING_NOT_FOUND : le texte exact n'existe pas (vérifie espaces/indentation)
65
+ - MULTIPLE_MATCHES : oldString présent N fois → ajoute du contexte ou replaceAll
66
+ - HASH_MISMATCH : fichier modifié depuis la lecture → relire, réanalyser`,
67
+
68
+ workflows: `# Recettes
69
+
70
+ ## Éditer une config distante en sécurité
71
+ file_read {source:{type:'remote',alias:'vpsparfait',path:'/etc/nginx/nginx.conf'}}
72
+ → file_edit {source, oldString:'...', newString:'...', expectedHash, backup:true}
73
+
74
+ ## Détecter un drift de config sur le parc
75
+ compare_all_sources { sources:[
76
+ {type:'local', path:'/ref.conf', label:'ref'},
77
+ {type:'remote', alias:'vps1', path:'/etc/x.conf', label:'vps1'},
78
+ {type:'remote', alias:'vps2', path:'/etc/x.conf', label:'vps2'}
79
+ ]}
80
+ → drift:true + diff référence→divergences
81
+
82
+ ## Workflow multi-étapes avec état (build)
83
+ shell_create {alias:'vps1', workdir:'/app'} → sessionId
84
+ shell_exec {sessionId, command:'npm install'} # cd/env persistent
85
+ shell_exec {sessionId, command:'npm run build'}
86
+ shell_close {sessionId}
87
+
88
+ ## Sécuriser avant une modif risquée
89
+ snapshot_create {source:{type:'remote',alias:'vps1'}, paths:['/etc/nginx/'], tag:'before-fix'}
90
+ ... modifs ...
91
+ snapshot_restore {snapshotId, target:{type:'remote',alias:'vps1'}, dryRun:true} # preview
92
+ snapshot_restore {snapshotId, target:{type:'remote',alias:'vps1'}, dryRun:false, force:true}`,
93
+
94
+ cheatsheet: `# Cheatsheet
95
+
96
+ | Besoin | Outil |
97
+ |--------|-------|
98
+ | Lire un fichier | file_read |
99
+ | Modifier un bout précis | file_edit (oldString/newString) |
100
+ | Créer/écraser | file_write |
101
+ | Comparer 2 fichiers | diff_files |
102
+ | Comparer 2 dossiers | diff_folders |
103
+ | Vérifier cohérence parc | compare_all_sources |
104
+ | Commande one-shot | task_exec |
105
+ | Suite de commandes avec état | shell_create + shell_exec |
106
+ | Sauvegarder avant modif | snapshot_create / backup:true |
107
+ | Annuler une modif | snapshot_restore |
108
+ | Contexte d'un serveur | server_note_get |
109
+ | Vue du parc | infra_overview |
110
+ | CPU/RAM/Disque | get_system_resources |
111
+ | Logs PM2/Docker | get_pm2_logs / get_docker_logs |`,
112
+
113
+ audit: `# Audit complet du parc — recette copier-coller
114
+
115
+ ## 1. Vue d'ensemble
116
+ \`\`\`
117
+ infra_overview → liste des serveurs + rôles + services + warnings
118
+ \`\`\`
119
+
120
+ ## 2. Ressources de chaque serveur
121
+ \`\`\`
122
+ get_system_resources {alias:'vps_contabo'}
123
+ get_system_resources {alias:'vpsparfait_fkomp'}
124
+ get_system_resources {alias:'vpsparfait2_fkomp'}
125
+ get_system_resources {alias:'fkomprodmini1_prod'}
126
+ get_system_resources {alias:'fkomprodmini2_prod'}
127
+ get_system_resources {alias:'pc2'}
128
+ \`\`\`
129
+
130
+ ## 3. Services (systemd, Docker, PM2) par serveur
131
+ \`\`\`
132
+ get_services_status {alias:'vps_contabo'}
133
+ # ... idem pour chaque alias
134
+ \`\`\`
135
+
136
+ ## 4. Scan Nginx (domaines et proxy_pass)
137
+ \`\`\`
138
+ task_exec {alias:'<alias>', cmd: "for f in /etc/nginx/sites-enabled/*; do echo \"--- \$f ---\"; cat \"\$f\" 2>/dev/null | grep -E \"server_name|listen|proxy_pass|return \" | head -20; done"}
139
+ \`\`\`
140
+
141
+ ## 5. Ports ouverts
142
+ \`\`\`
143
+ task_exec {alias:'<alias>', cmd: "ss -tlnp 2>/dev/null | sort -n -k4"}
144
+ \`\`\`
145
+
146
+ ## 6. Contexte enregistré
147
+ \`\`\`
148
+ server_note_get {alias:'<alias>'} → avant d'intervenir sur un serveur
149
+ \`\`\`
150
+
151
+ ## 7. Notes de tout le parc
152
+ \`\`\`
153
+ server_note_list
154
+ \`\`\`
155
+
156
+ ## 8. Après une modification risquée, vérifier les drifts
157
+ \`\`\`
158
+ compare_all_sources {sources: [
159
+ {type:'remote', alias:'vps1', path:'/etc/nginx/nginx.conf', label:'vps1'},
160
+ {type:'remote', alias:'vps2', path:'/etc/nginx/nginx.conf', label:'vps2'}
161
+ ]}
162
+ \`\`\`
163
+
164
+ Astuce: lancer les appels des étapes 2-5 en parallèle pour un audit complet en ~30s.`,
165
+
166
+ security: `# Politique de sécurité
167
+
168
+ ## Command Blocklist
169
+ Les commandes dangereuses sont bloquées par défaut avant exécution SSH.
170
+
171
+ ## Gérer la blocklist
172
+ \`\`\`
173
+ policy_blocklist_list → lister les patterns
174
+ policy_blocklist_add {pattern:"rm -rf /"} → ajouter un pattern
175
+ policy_blocklist_remove {pattern:"rm -rf /"} → retirer un pattern
176
+ \`\`\`
177
+
178
+ ## Contournement conscient
179
+ \`\`\`
180
+ task_exec {alias:"vps", cmd:"...", skip_policy:true}
181
+ \`\`\`
182
+
183
+ ## Patterns supportés
184
+ - Littéral : "rm -rf /" bloque exactement cette commande
185
+ - Wildcard : "wget * | sh" bloque tout wget pipé vers sh
186
+ - Les patterns sont testés en regex insensible à la casse
187
+
188
+ La blocklist est stockée dans data/policies.json et persiste entre les redémarrages.
189
+
190
+ ## Tunnels SSH
191
+ \`\`\`
192
+ tunnel_create {name:"crm", type:"local", listen_port:8080, target:"127.0.0.1:3100", via:"vps_contabo"}
193
+ → http://localhost:8080 → vps_contabo:3100
194
+
195
+ tunnel_create {name:"proxy", type:"socks", listen_port:1080, via:"vpsparfait"}
196
+ → Proxy SOCKS5 sur localhost:1080 via vpsparfait
197
+
198
+ tunnel_create {name:"reverse", type:"remote", listen_port:9090, target:"127.0.0.1:3000", via:"vps_contabo", source:"fkomprodmini2"}
199
+ → fkomprodmini2:9090 → localhost:3000 (reverse)
200
+ \`\`\`
201
+
202
+ ## Allowlist de ports
203
+ Seuls les ports autorisés peuvent être utilisés pour les tunnels.
204
+ \`\`\`
205
+ tunnel_allowlist_add {port:3306} → ouvrir un port
206
+ tunnel_allowlist_remove {port:3306} → le fermer
207
+ tunnel_list → voir les ports autorisés
208
+ \`\`\`
209
+
210
+ ## Sécurité des tunnels
211
+ - Ports < 1024 bloqués (root requis)
212
+ - Allowlist obligatoire : un port doit être explicitement autorisé
213
+ - Les tunnels via tmux survivent au redémarrage du MCP
214
+ - Les tunnels locaux (source=null) meurent avec le MCP et sont auto-restaurés au redémarrage
215
+ - Warning émis à la création`,
216
+
217
+ pitfalls: `# Pièges à éviter
218
+
219
+ 1. **Toujours dryRun avant snapshot_restore réel** (écrase des fichiers).
220
+ 2. **expectedHash sur file_edit** : sans lui, pas de protection contre écrasement concurrent.
221
+ 3. **type:'local' agit sur l'hôte du MCP**, pas sur un serveur — vérifie bien le type.
222
+ 4. **Gros fichiers** : file_read avec offset/limit pour consulter sans tout charger.
223
+ 5. **Binaires** : détection auto (base64), mais file_edit ne s'applique qu'au texte.
224
+ 6. **shell_exec** : une commande à la fois par session ; ferme les sessions (shell_close).
225
+ 7. **task_exec ≠ shell_exec** : task_exec isole chaque commande (cd perdu), shell_exec persiste.
226
+ 8. **Consulte server_note_get avant d'intervenir** sur un serveur inconnu.`
227
+ };
228
+
229
+ export default {
230
+ get(section = 'index') {
231
+ return SECTIONS[section] || `Section '${section}' inconnue. Sections : ${Object.keys(SECTIONS).join(', ')}.`;
232
+ },
233
+ sections() {
234
+ return Object.keys(SECTIONS);
235
+ }
236
+ };
package/history.js CHANGED
@@ -1,44 +1,5 @@
1
- import fs from 'fs/promises';
2
- import path from 'path';
3
- import config from './config.js';
4
- const HISTORY_FILE_PATH = path.join(config.dataDir, 'history.json');
5
-
6
- async function readHistory() {
7
- try {
8
- await fs.access(HISTORY_FILE_PATH);
9
- const data = await fs.readFile(HISTORY_FILE_PATH, 'utf-8');
10
- return JSON.parse(data);
11
- } catch (error) {
12
- return [];
13
- }
14
- }
15
-
16
- async function writeHistory(history) {
17
- await fs.writeFile(HISTORY_FILE_PATH, JSON.stringify(history, null, 2));
18
- }
19
-
20
- async function logTask(jobDetails) {
21
- const history = await readHistory();
22
- const logEntry = {
23
- jobId: jobDetails.id,
24
- timestamp: new Date().toISOString(),
25
- type: jobDetails.type,
26
- alias: jobDetails.alias,
27
- command: jobDetails.type === 'ssh' ? jobDetails.cmd : `${jobDetails.direction} ${jobDetails.local} -> ${jobDetails.remote}`
28
- };
29
- history.unshift(logEntry); // Ajoute au début
30
- if (history.length > 500) { // Garde les 500 dernières commandes
31
- history.pop();
32
- }
33
- await writeHistory(history);
34
- }
35
-
36
- async function getHistory(filters = {}) {
37
- let history = await readHistory();
38
- if (filters.alias) {
39
- history = history.filter(log => log.alias === filters.alias);
40
- }
41
- return history;
42
- }
43
-
44
- export default { logTask, getHistory };
1
+ import path from 'path'; import config from './config.js'; import jsonStore from './atomicJsonStore.js';
2
+ const HISTORY_FILE_PATH=path.join(config.dataDir,'history.json');
3
+ async function logTask(jobDetails){const logEntry={jobId:jobDetails.id,timestamp:new Date().toISOString(),type:jobDetails.type,alias:jobDetails.alias,command:jobDetails.type==='ssh'?jobDetails.cmd:`${jobDetails.direction} ${jobDetails.local} -> ${jobDetails.remote}`}; try{await jsonStore.updateJson(HISTORY_FILE_PATH,[],h=>{h.unshift(logEntry);if(h.length>500)h.length=500;return h;});}catch(error){if(process.env.MCP_DEBUG==='true')console.error(`[ERROR] Failed to write history: ${error.message}`);}}
4
+ async function getHistory(filters={}){let h=await jsonStore.readJson(HISTORY_FILE_PATH,[]);if(filters.alias)h=h.filter(x=>x.alias===filters.alias);return h;}
5
+ export default {logTask,getHistory};
@@ -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 };