@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/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
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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};
|
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 };
|