@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/servers.js
CHANGED
|
@@ -1,49 +1,35 @@
|
|
|
1
|
-
import fs from 'fs/promises';
|
|
2
1
|
import path from 'path';
|
|
3
2
|
import config from './config.js';
|
|
3
|
+
import jsonStore from './atomicJsonStore.js';
|
|
4
|
+
|
|
4
5
|
const SERVERS_FILE_PATH = path.join(config.dataDir, 'servers.json');
|
|
6
|
+
const EMPTY = {};
|
|
5
7
|
|
|
6
8
|
async function readServers() {
|
|
7
|
-
|
|
8
|
-
await fs.access(SERVERS_FILE_PATH);
|
|
9
|
-
const data = await fs.readFile(SERVERS_FILE_PATH, 'utf-8');
|
|
10
|
-
return JSON.parse(data);
|
|
11
|
-
} catch (error) {
|
|
12
|
-
// Si le fichier n'existe pas, on retourne un objet vide
|
|
13
|
-
return {};
|
|
14
|
-
}
|
|
9
|
+
return jsonStore.readJson(SERVERS_FILE_PATH, EMPTY);
|
|
15
10
|
}
|
|
16
11
|
|
|
17
|
-
async function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
servers[alias] = { ...servers[alias], ...config };
|
|
26
|
-
await writeServers(servers);
|
|
27
|
-
return { success: true, message: `Serveur '${alias}' mis à jour avec succès.` };
|
|
28
|
-
}
|
|
29
|
-
// L'alias n'existe pas, on le crée
|
|
30
|
-
servers[alias] = config;
|
|
31
|
-
await writeServers(servers);
|
|
32
|
-
return { success: true, message: `Serveur '${alias}' ajouté avec succès.` };
|
|
12
|
+
async function addServer(alias, serverConfig) {
|
|
13
|
+
let existed = false;
|
|
14
|
+
await jsonStore.updateJson(SERVERS_FILE_PATH, EMPTY, (servers) => {
|
|
15
|
+
existed = !!servers[alias];
|
|
16
|
+
servers[alias] = existed ? { ...servers[alias], ...serverConfig } : serverConfig;
|
|
17
|
+
return servers;
|
|
18
|
+
});
|
|
19
|
+
return { success: true, message: `Serveur '${alias}' ${existed ? 'mis à jour' : 'ajouté'} avec succès.` };
|
|
33
20
|
}
|
|
34
21
|
|
|
35
22
|
async function removeServer(alias) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
await writeServers(servers);
|
|
23
|
+
await jsonStore.updateJson(SERVERS_FILE_PATH, EMPTY, (servers) => {
|
|
24
|
+
if (!servers[alias]) throw new Error(`L'alias '${alias}' n'existe pas.`);
|
|
25
|
+
delete servers[alias];
|
|
26
|
+
return servers;
|
|
27
|
+
});
|
|
42
28
|
return { success: true, message: `Serveur '${alias}' supprimé.` };
|
|
43
29
|
}
|
|
44
30
|
|
|
45
31
|
async function listServers() {
|
|
46
|
-
return
|
|
32
|
+
return readServers();
|
|
47
33
|
}
|
|
48
34
|
|
|
49
35
|
async function getServer(alias) {
|
|
@@ -55,4 +41,15 @@ async function getServer(alias) {
|
|
|
55
41
|
return serverConfig;
|
|
56
42
|
}
|
|
57
43
|
|
|
58
|
-
|
|
44
|
+
/** true si l'alias est marqué readonly:true dans servers.json */
|
|
45
|
+
async function isAliasReadOnly(alias) {
|
|
46
|
+
if (!alias) return false;
|
|
47
|
+
try {
|
|
48
|
+
const sc = await getServer(alias);
|
|
49
|
+
return sc.readonly === true || sc.readOnly === true;
|
|
50
|
+
} catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default { addServer, removeServer, listServers, getServer, isAliasReadOnly };
|
package/sftp.js
CHANGED
|
@@ -7,6 +7,7 @@ import serverManager from './servers.js';
|
|
|
7
7
|
import sourceAdapter from './sourceAdapter.js';
|
|
8
8
|
import fileOps from './fileOps.js';
|
|
9
9
|
import micromatch from 'micromatch';
|
|
10
|
+
import utils from './utils.js';
|
|
10
11
|
|
|
11
12
|
// Fonction utilitaire pour créer un dossier parent si nécessaire
|
|
12
13
|
async function ensureLocalDir(filePath) {
|
|
@@ -72,15 +73,94 @@ async function expandFileList(pattern, basePath = '') {
|
|
|
72
73
|
// Si c'est un pattern glob
|
|
73
74
|
if (pattern.includes('*') || pattern.includes('?') || pattern.includes('[')) {
|
|
74
75
|
const fullPattern = path.resolve(normalizedBasePath, pattern);
|
|
76
|
+
sourceAdapter.assertLocalPathAllowed(fullPattern);
|
|
75
77
|
const files = await glob(fullPattern, { nodir: false });
|
|
78
|
+
for (const f of files) sourceAdapter.assertLocalPathAllowed(f);
|
|
76
79
|
return files;
|
|
77
80
|
}
|
|
78
81
|
|
|
79
82
|
// Sinon c'est un fichier/dossier simple
|
|
80
83
|
const fullPath = path.resolve(normalizedBasePath, pattern);
|
|
84
|
+
sourceAdapter.assertLocalPathAllowed(fullPath);
|
|
81
85
|
return [fullPath];
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Copie une entrée remote -> remote via le pool SFTP partagé.
|
|
90
|
+
*
|
|
91
|
+
* - fichier : respecte force:false et append le basename si la cible est un dossier ;
|
|
92
|
+
* - dossier : copie récursivement les fichiers sous la racine cible ; force:false
|
|
93
|
+
* refuse une cible déjà existante afin d'éviter les merges/écrasements implicites.
|
|
94
|
+
*
|
|
95
|
+
* Note : les symlinks inclus dans un arbre sont volontairement ignorés par
|
|
96
|
+
* listFilesRecursive pour l'instant. La future stratégie direct/rsync devra les
|
|
97
|
+
* préserver ; le résultat expose un warning pour ne pas promettre une copie bit-à-bit.
|
|
98
|
+
*/
|
|
99
|
+
async function transferServerToServerEntry(file, job, force) {
|
|
100
|
+
const srcAlias = file.source_alias || job.source_alias;
|
|
101
|
+
const srcPath = file.source_path || file.local || file.remote;
|
|
102
|
+
const tgtAlias = job.alias;
|
|
103
|
+
const tgtPath = file.remote;
|
|
104
|
+
|
|
105
|
+
if (!srcAlias) throw new Error("source_alias requis pour server_to_server.");
|
|
106
|
+
if (!srcPath) throw new Error("Chemin source requis (local/source_path) pour server_to_server.");
|
|
107
|
+
if (!tgtPath) throw new Error("Chemin destination remote requis pour server_to_server.");
|
|
108
|
+
|
|
109
|
+
const srcKind = await sourceAdapter.exists({ type: 'remote', alias: srcAlias, path: srcPath });
|
|
110
|
+
if (!srcKind) throw new Error(`Source introuvable: ${srcAlias}:${srcPath}`);
|
|
111
|
+
|
|
112
|
+
if (srcKind === 'd') {
|
|
113
|
+
const dstKind = await sourceAdapter.exists({ type: 'remote', alias: tgtAlias, path: tgtPath });
|
|
114
|
+
if (dstKind && dstKind !== 'd') {
|
|
115
|
+
throw new Error(`La destination ${tgtAlias}:${tgtPath} existe et n'est pas un dossier.`);
|
|
116
|
+
}
|
|
117
|
+
if (dstKind && !force) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`Le dossier destination ${tgtAlias}:${tgtPath} existe déjà. ` +
|
|
120
|
+
`Utilisez force:true pour fusionner/écraser explicitement.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const rels = await sourceAdapter.listFilesRecursive(
|
|
125
|
+
{ type: 'remote', alias: srcAlias, path: srcPath },
|
|
126
|
+
{ recursive: true, ignorePatterns: [] }
|
|
127
|
+
);
|
|
128
|
+
let success = 0;
|
|
129
|
+
const failures = [];
|
|
130
|
+
for (const rel of rels) {
|
|
131
|
+
const from = path.posix.join(srcPath, rel);
|
|
132
|
+
const to = path.posix.join(tgtPath, rel);
|
|
133
|
+
try {
|
|
134
|
+
const buf = await sourceAdapter.readFile({ type: 'remote', alias: srcAlias, path: from });
|
|
135
|
+
await sourceAdapter.writeFile({ type: 'remote', alias: tgtAlias, path: to }, buf.content, { createDirs: true });
|
|
136
|
+
success++;
|
|
137
|
+
} catch (e) {
|
|
138
|
+
failures.push({ file: from, target: to, error: e.message });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
total: rels.length, success, failures,
|
|
143
|
+
warning: 'Copie dossier SFTP: symlinks et dossiers vides non préservés; utiliser une future stratégie rsync/tar pour une réplication bit-à-bit.'
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let finalTarget = tgtPath;
|
|
148
|
+
const dstKind = await sourceAdapter.exists({ type: 'remote', alias: tgtAlias, path: tgtPath });
|
|
149
|
+
if (dstKind === 'd') {
|
|
150
|
+
finalTarget = path.posix.join(tgtPath, path.posix.basename(srcPath));
|
|
151
|
+
const nestedKind = await sourceAdapter.exists({ type: 'remote', alias: tgtAlias, path: finalTarget });
|
|
152
|
+
if (nestedKind && !force) {
|
|
153
|
+
throw new Error(`Le fichier distant ${tgtAlias}:${finalTarget} existe déjà. Utilisez force:true.`);
|
|
154
|
+
}
|
|
155
|
+
} else if (dstKind && !force) {
|
|
156
|
+
throw new Error(`Le fichier distant ${tgtAlias}:${tgtPath} existe déjà. Utilisez force:true.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const buf = await sourceAdapter.readFile({ type: 'remote', alias: srcAlias, path: srcPath });
|
|
160
|
+
await sourceAdapter.writeFile({ type: 'remote', alias: tgtAlias, path: finalTarget }, buf.content, { createDirs: true });
|
|
161
|
+
return { total: 1, success: 1, failures: [] };
|
|
162
|
+
}
|
|
163
|
+
|
|
84
164
|
// Fonction principale de transfert avec support multi-fichiers
|
|
85
165
|
async function executeTransfer(jobId) {
|
|
86
166
|
const job = queue.getJob(jobId);
|
|
@@ -88,32 +168,8 @@ async function executeTransfer(jobId) {
|
|
|
88
168
|
|
|
89
169
|
let sftp = null;
|
|
90
170
|
try {
|
|
91
|
-
const serverConfig = await serverManager.getServer(job.alias);
|
|
92
171
|
queue.updateJobStatus(jobId, 'running');
|
|
93
172
|
|
|
94
|
-
sftp = new SftpClient();
|
|
95
|
-
|
|
96
|
-
// Configuration de la connexion
|
|
97
|
-
const config = {
|
|
98
|
-
host: serverConfig.host,
|
|
99
|
-
port: 22,
|
|
100
|
-
username: serverConfig.user,
|
|
101
|
-
readyTimeout: 20000,
|
|
102
|
-
retries: 3,
|
|
103
|
-
retry_factor: 2,
|
|
104
|
-
retry_minTimeout: 2000
|
|
105
|
-
};
|
|
106
|
-
|
|
107
|
-
if (serverConfig.keyPath) {
|
|
108
|
-
config.privateKey = await fs.readFile(serverConfig.keyPath);
|
|
109
|
-
} else if (serverConfig.password) {
|
|
110
|
-
config.password = serverConfig.password;
|
|
111
|
-
} else {
|
|
112
|
-
throw new Error(`Aucune méthode d'authentification pour '${job.alias}'.`);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
await sftp.connect(config);
|
|
116
|
-
|
|
117
173
|
// Déterminer si on traite plusieurs fichiers
|
|
118
174
|
const files = job.files || [{ local: job.local, remote: job.remote }];
|
|
119
175
|
const isMultiple = Array.isArray(job.files) && job.files.length > 1;
|
|
@@ -123,45 +179,90 @@ async function executeTransfer(jobId) {
|
|
|
123
179
|
let failedFiles = [];
|
|
124
180
|
let totalFiles = 0;
|
|
125
181
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
182
|
+
// server_to_server : 100% pool via sourceAdapter (fichiers + dossiers)
|
|
183
|
+
if (job.direction === 'server_to_server') {
|
|
184
|
+
const warnings = [];
|
|
185
|
+
for (let i = 0; i < files.length; i++) {
|
|
186
|
+
const file = files[i];
|
|
187
|
+
const progress = isMultiple ? ` (${i + 1}/${files.length})` : '';
|
|
188
|
+
const srcAlias = file.source_alias || job.source_alias;
|
|
189
|
+
const srcPath = file.source_path || file.local || file.remote;
|
|
190
|
+
try {
|
|
191
|
+
queue.log('info', `Transfert server_to_server${progress}: ${srcAlias}:${srcPath} → ${job.alias}:${file.remote}`);
|
|
192
|
+
const result = await transferServerToServerEntry(file, job, force);
|
|
193
|
+
totalFiles += result.total;
|
|
194
|
+
successCount += result.success;
|
|
195
|
+
if (result.failures?.length) failedFiles.push(...result.failures);
|
|
196
|
+
if (result.warning && !warnings.includes(result.warning)) warnings.push(result.warning);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
totalFiles += 1; // l'item lui-même a été tenté, même si son contenu n'a pas pu être énuméré
|
|
199
|
+
queue.log('error', `Échec transfert ${srcPath || file.remote}: ${err.message}`);
|
|
200
|
+
failedFiles.push({ file: srcPath || file.remote, error: err.message });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
job.transferWarnings = warnings;
|
|
204
|
+
} else {
|
|
205
|
+
// upload/download : SftpClient (dirs/glob) avec port configurable
|
|
206
|
+
const serverConfig = await serverManager.getServer(job.alias);
|
|
207
|
+
sftp = new SftpClient();
|
|
208
|
+
const sftpConfig = {
|
|
209
|
+
host: serverConfig.host,
|
|
210
|
+
port: utils.resolveSshPort(serverConfig),
|
|
211
|
+
username: serverConfig.user,
|
|
212
|
+
readyTimeout: 20000,
|
|
213
|
+
retries: 3,
|
|
214
|
+
retry_factor: 2,
|
|
215
|
+
retry_minTimeout: 2000
|
|
216
|
+
};
|
|
217
|
+
if (serverConfig.keyPath) {
|
|
218
|
+
sftpConfig.privateKey = await fs.readFile(serverConfig.keyPath);
|
|
219
|
+
} else if (serverConfig.password) {
|
|
220
|
+
sftpConfig.password = serverConfig.password;
|
|
221
|
+
} else {
|
|
222
|
+
throw new Error(`Aucune méthode d'authentification pour '${job.alias}'.`);
|
|
223
|
+
}
|
|
224
|
+
await sftp.connect(sftpConfig);
|
|
225
|
+
|
|
226
|
+
for (let i = 0; i < files.length; i++) {
|
|
227
|
+
const file = files[i];
|
|
228
|
+
const progress = isMultiple ? ` (${i + 1}/${files.length})` : '';
|
|
229
|
+
|
|
230
|
+
try {
|
|
231
|
+
if (job.direction === 'upload') {
|
|
232
|
+
const localFiles = await expandFileList(file.local);
|
|
233
|
+
totalFiles += localFiles.length;
|
|
234
|
+
for (const localFile of localFiles) {
|
|
235
|
+
queue.log('info', `Transfert${progress}: ${localFile}`);
|
|
236
|
+
// Fichier simple (pas dir) → pool sourceAdapter quand possible
|
|
237
|
+
let isDir = false;
|
|
238
|
+
try {
|
|
239
|
+
const st = await fs.stat(localFile);
|
|
240
|
+
isDir = st.isDirectory();
|
|
241
|
+
} catch { /* handled in handleUpload */ }
|
|
242
|
+
if (!isDir && !hasGlobPattern(file.remote || '')) {
|
|
243
|
+
const content = await fs.readFile(localFile);
|
|
244
|
+
let remotePath = file.remote;
|
|
245
|
+
// Si remote est un dossier existant, append basename — handleUpload fait ça ;
|
|
246
|
+
// pour pool on délègue à handleUpload si exists dir, sinon writeFile
|
|
247
|
+
await handleUpload(sftp, localFile, file.remote, force);
|
|
248
|
+
} else {
|
|
249
|
+
await handleUpload(sftp, localFile, file.remote, force);
|
|
250
|
+
}
|
|
251
|
+
successCount++;
|
|
252
|
+
}
|
|
253
|
+
} else if (job.direction === 'download') {
|
|
254
|
+
const downloadedCount = await handleDownload(sftp, file.remote, file.local, force);
|
|
255
|
+
totalFiles += downloadedCount;
|
|
256
|
+
successCount += downloadedCount;
|
|
138
257
|
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
successCount += downloadedCount;
|
|
143
|
-
} else if (job.direction === 'server_to_server') {
|
|
144
|
-
// Transfert direct entre deux serveurs distants (ou local↔remote inversé)
|
|
145
|
-
const srcAlias = file.source_alias || job.source_alias;
|
|
146
|
-
const srcPath = file.source_path || file.local || file.remote;
|
|
147
|
-
const tgtAlias = job.alias;
|
|
148
|
-
const tgtPath = file.remote;
|
|
149
|
-
|
|
150
|
-
queue.log('info', `Transfert server_to_server${progress}: ${srcAlias}:${srcPath} → ${tgtAlias}:${tgtPath}`);
|
|
151
|
-
const buf = await sourceAdapter.readFile({ type: 'remote', alias: srcAlias, path: srcPath });
|
|
152
|
-
await sourceAdapter.writeFile({ type: 'remote', alias: tgtAlias, path: tgtPath }, buf.content, { createDirs: true });
|
|
153
|
-
successCount++;
|
|
154
|
-
totalFiles++;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
queue.log('error', `Échec transfert ${file.local || file.remote}: ${err.message}`);
|
|
260
|
+
failedFiles.push({ file: file.local || file.remote, error: err.message });
|
|
155
261
|
}
|
|
156
|
-
} catch (err) {
|
|
157
|
-
queue.log('error', `Échec transfert ${file.local || file.remote}: ${err.message}`);
|
|
158
|
-
failedFiles.push({ file: file.local || file.remote, error: err.message });
|
|
159
262
|
}
|
|
160
263
|
}
|
|
161
264
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// Génération du rapport
|
|
265
|
+
// Génération du rapport (end() uniquement dans finally pour éviter double close)
|
|
165
266
|
let status = successCount === totalFiles ? 'completed' : 'partial';
|
|
166
267
|
let output = `Transfert ${job.direction}: ${successCount}/${totalFiles} fichiers réussis`;
|
|
167
268
|
|
|
@@ -170,7 +271,10 @@ async function executeTransfer(jobId) {
|
|
|
170
271
|
if (successCount === 0) status = 'failed';
|
|
171
272
|
}
|
|
172
273
|
|
|
173
|
-
|
|
274
|
+
if (job.transferWarnings?.length) {
|
|
275
|
+
output += `\nAvertissements: ${job.transferWarnings.join(' | ')}`;
|
|
276
|
+
}
|
|
277
|
+
queue.updateJobStatus(jobId, status, { output, failedFiles, warnings: job.transferWarnings || [] });
|
|
174
278
|
|
|
175
279
|
} catch (err) {
|
|
176
280
|
queue.updateJobStatus(jobId, 'failed', { error: err.message });
|
|
@@ -187,6 +291,7 @@ async function executeTransfer(jobId) {
|
|
|
187
291
|
|
|
188
292
|
// Gestion spécifique de l'upload
|
|
189
293
|
async function handleUpload(sftp, localPath, remotePath, force = false) {
|
|
294
|
+
sourceAdapter.assertLocalPathAllowed(localPath);
|
|
190
295
|
let localStats;
|
|
191
296
|
try {
|
|
192
297
|
localStats = await fs.stat(localPath);
|
|
@@ -242,6 +347,7 @@ async function handleUpload(sftp, localPath, remotePath, force = false) {
|
|
|
242
347
|
|
|
243
348
|
// Gestion spécifique du download
|
|
244
349
|
async function handleDownload(sftp, remotePath, localPath, force = false) {
|
|
350
|
+
sourceAdapter.assertLocalPathAllowed(localPath);
|
|
245
351
|
if (hasGlobPattern(remotePath)) {
|
|
246
352
|
const parentDir = path.dirname(remotePath);
|
|
247
353
|
const pattern = path.basename(remotePath);
|
|
@@ -257,8 +363,9 @@ async function handleDownload(sftp, remotePath, localPath, force = false) {
|
|
|
257
363
|
await fs.mkdir(localPath, { recursive: true });
|
|
258
364
|
|
|
259
365
|
for (const fileName of matchingFiles) {
|
|
260
|
-
const remoteFile = path.join(parentDir, fileName);
|
|
366
|
+
const remoteFile = path.posix.join(parentDir, fileName);
|
|
261
367
|
const localFile = path.join(localPath, fileName);
|
|
368
|
+
sourceAdapter.assertLocalPathAllowed(localFile);
|
|
262
369
|
|
|
263
370
|
const localFileExists = await fs.access(localFile).then(() => true).catch(() => false);
|
|
264
371
|
if (localFileExists && !force) {
|
|
@@ -319,6 +426,7 @@ async function handleDownload(sftp, remotePath, localPath, force = false) {
|
|
|
319
426
|
}
|
|
320
427
|
}
|
|
321
428
|
|
|
429
|
+
sourceAdapter.assertLocalPathAllowed(finalLocalPath);
|
|
322
430
|
const localDir = path.dirname(finalLocalPath);
|
|
323
431
|
await fs.mkdir(localDir, { recursive: true });
|
|
324
432
|
await sftp.get(remotePath, finalLocalPath);
|
package/shellSessions.js
CHANGED
|
@@ -2,6 +2,8 @@ import { Client } from 'ssh2';
|
|
|
2
2
|
import fs from 'fs/promises';
|
|
3
3
|
import crypto from 'crypto';
|
|
4
4
|
import serverManager from './servers.js';
|
|
5
|
+
import policies from './policies.js';
|
|
6
|
+
import utils from './utils.js';
|
|
5
7
|
|
|
6
8
|
/**
|
|
7
9
|
* shellSessions — Sessions shell PTY PERSISTANTES sur serveurs distants.
|
|
@@ -34,7 +36,7 @@ class ShellSessionManager {
|
|
|
34
36
|
const sc = await serverManager.getServer(alias);
|
|
35
37
|
const config = {
|
|
36
38
|
host: sc.host,
|
|
37
|
-
port: sc
|
|
39
|
+
port: utils.resolveSshPort(sc),
|
|
38
40
|
username: sc.user,
|
|
39
41
|
readyTimeout: 20000,
|
|
40
42
|
keepaliveInterval: 30000,
|
|
@@ -168,8 +170,17 @@ class ShellSessionManager {
|
|
|
168
170
|
|
|
169
171
|
/**
|
|
170
172
|
* Exécute une commande dans une session existante (état persistant).
|
|
173
|
+
* options = { timeoutSec, skip_policy }
|
|
171
174
|
*/
|
|
172
|
-
async execInSession(id, command, timeoutSec) {
|
|
175
|
+
async execInSession(id, command, timeoutSec, options = {}) {
|
|
176
|
+
// Rétrocompat : 3e arg peut être un number (timeout) ou un objet options
|
|
177
|
+
let opts = options;
|
|
178
|
+
let timeout = timeoutSec;
|
|
179
|
+
if (typeof timeoutSec === 'object' && timeoutSec !== null) {
|
|
180
|
+
opts = timeoutSec;
|
|
181
|
+
timeout = opts.timeoutSec ?? opts.timeout;
|
|
182
|
+
}
|
|
183
|
+
|
|
173
184
|
const session = this.sessions.get(id);
|
|
174
185
|
if (!session) throw new Error(`Session '${id}' introuvable. Utilisez shell_list pour voir les sessions actives.`);
|
|
175
186
|
if (session.closed) {
|
|
@@ -178,16 +189,33 @@ class ShellSessionManager {
|
|
|
178
189
|
}
|
|
179
190
|
if (session.busy) throw new Error(`Session '${id}' occupée : une seule commande à la fois par session.`);
|
|
180
191
|
|
|
192
|
+
if (!opts.skip_policy) {
|
|
193
|
+
const policyCheck = await policies.check(command);
|
|
194
|
+
if (policyCheck.blocked) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`Commande bloquée par la politique de sécurité (pattern: "${policyCheck.pattern}"). ` +
|
|
197
|
+
`Utilisez skip_policy:true pour forcer.`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
181
202
|
session.busy = true;
|
|
182
203
|
session.lastUsed = Date.now();
|
|
183
204
|
session.commandCount++;
|
|
184
205
|
try {
|
|
185
|
-
return await this._exec(session, command,
|
|
206
|
+
return await this._exec(session, command, timeout ?? session.cmdTimeout);
|
|
186
207
|
} finally {
|
|
187
208
|
session.busy = false;
|
|
188
209
|
}
|
|
189
210
|
}
|
|
190
211
|
|
|
212
|
+
/** Retourne les métadonnées minimales d'une session sans exposer la connexion. */
|
|
213
|
+
getSessionInfo(id) {
|
|
214
|
+
const s = this.sessions.get(id);
|
|
215
|
+
if (!s) return null;
|
|
216
|
+
return { id: s.id, alias: s.alias, closed: s.closed, busy: s.busy };
|
|
217
|
+
}
|
|
218
|
+
|
|
191
219
|
/**
|
|
192
220
|
* Liste les sessions actives avec leur état.
|
|
193
221
|
*/
|
package/snapshotManager.js
CHANGED
|
@@ -4,6 +4,7 @@ import crypto from 'crypto';
|
|
|
4
4
|
import { createTwoFilesPatch } from 'diff';
|
|
5
5
|
import config from './config.js';
|
|
6
6
|
import sourceAdapter from './sourceAdapter.js';
|
|
7
|
+
import jsonStore from './atomicJsonStore.js';
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* snapshotManager — Versioning d'infrastructure (style gencodedoc).
|
|
@@ -37,17 +38,12 @@ async function ensureDirs() {
|
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
async function readIndex() {
|
|
40
|
-
|
|
41
|
-
const data = await fs.readFile(INDEX_FILE, 'utf8');
|
|
42
|
-
return JSON.parse(data);
|
|
43
|
-
} catch {
|
|
44
|
-
return { snapshots: [] };
|
|
45
|
-
}
|
|
41
|
+
return jsonStore.readJson(INDEX_FILE, { snapshots: [] });
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
async function writeIndex(index) {
|
|
49
45
|
await ensureDirs();
|
|
50
|
-
await
|
|
46
|
+
await jsonStore.writeJsonAtomic(INDEX_FILE, index);
|
|
51
47
|
}
|
|
52
48
|
|
|
53
49
|
function objectPath(hash) {
|
package/sourceAdapter.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
|
+
import fsSync from 'fs';
|
|
2
3
|
import path from 'path';
|
|
3
4
|
import crypto from 'crypto';
|
|
4
5
|
import micromatch from 'micromatch';
|
|
5
6
|
import serverManager from './servers.js';
|
|
6
7
|
import sshPool from './sshPool.js';
|
|
7
8
|
import { z } from 'zod';
|
|
9
|
+
import config from './config.js';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* sourceAdapter — Abstraction unifiée local/remote.
|
|
@@ -148,6 +150,52 @@ function describe(source) {
|
|
|
148
150
|
};
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
/**
|
|
154
|
+
* Politique transversale des chemins locaux.
|
|
155
|
+
*
|
|
156
|
+
* Historiquement MCP_ALLOWED_ROOTS n'était vérifié que dans fileOps.js, ce qui
|
|
157
|
+
* laissait snapshots/transferts contourner la restriction. La validation vit
|
|
158
|
+
* désormais au niveau sourceAdapter, porte d'entrée commune des accès locaux.
|
|
159
|
+
*
|
|
160
|
+
* La vérification combine :
|
|
161
|
+
* - contrôle lexical du chemin absolu ;
|
|
162
|
+
* - realpath du chemin (ou du parent existant le plus proche) pour empêcher
|
|
163
|
+
* un symlink situé dans une racine autorisée de sortir de cette racine.
|
|
164
|
+
*/
|
|
165
|
+
function assertLocalPathAllowed(sourcePath) {
|
|
166
|
+
if (!config.allowedRoots || config.allowedRoots.length === 0) return;
|
|
167
|
+
if (!sourcePath || typeof sourcePath !== 'string') {
|
|
168
|
+
throw new Error("Chemin local invalide.");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const requested = path.resolve(sourcePath);
|
|
172
|
+
|
|
173
|
+
function realExistingAnchor(p) {
|
|
174
|
+
let cur = p;
|
|
175
|
+
while (!fsSync.existsSync(cur)) {
|
|
176
|
+
const parent = path.dirname(cur);
|
|
177
|
+
if (parent === cur) break;
|
|
178
|
+
cur = parent;
|
|
179
|
+
}
|
|
180
|
+
try { return fsSync.realpathSync.native(cur); } catch { return path.resolve(cur); }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const requestedAnchorReal = realExistingAnchor(requested);
|
|
184
|
+
const allowed = config.allowedRoots.some((root) => {
|
|
185
|
+
const rootAbs = path.resolve(root);
|
|
186
|
+
const lexical = requested === rootAbs || requested.startsWith(rootAbs + path.sep);
|
|
187
|
+
if (!lexical) return false;
|
|
188
|
+
const rootReal = realExistingAnchor(rootAbs);
|
|
189
|
+
return requestedAnchorReal === rootReal || requestedAnchorReal.startsWith(rootReal + path.sep);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
if (!allowed) {
|
|
193
|
+
throw new Error(
|
|
194
|
+
`Accès local refusé : '${requested}' est hors MCP_ALLOWED_ROOTS (${config.allowedRoots.join(', ')}).`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
151
199
|
// Valide la structure d'une source
|
|
152
200
|
function validateSource(source) {
|
|
153
201
|
if (!source || typeof source !== 'object') {
|
|
@@ -162,11 +210,16 @@ function validateSource(source) {
|
|
|
162
210
|
if (source.type === 'remote' && !source.alias) {
|
|
163
211
|
throw new Error("Source remote invalide : 'alias' est requis quand type='remote'.");
|
|
164
212
|
}
|
|
213
|
+
if (source.type === 'local') {
|
|
214
|
+
assertLocalPathAllowed(source.path);
|
|
215
|
+
}
|
|
165
216
|
}
|
|
166
217
|
|
|
167
|
-
export { sourceSchema };
|
|
218
|
+
export { sourceSchema, assertLocalPathAllowed };
|
|
168
219
|
|
|
169
220
|
export default {
|
|
221
|
+
assertLocalPathAllowed,
|
|
222
|
+
|
|
170
223
|
/**
|
|
171
224
|
* Décrit une source : { server, type, path, label }.
|
|
172
225
|
* server = 'localhost' ou alias serveur. Utile pour indiquer clairement
|
package/ssh.js
CHANGED
|
@@ -5,6 +5,7 @@ import serverManager from './servers.js';
|
|
|
5
5
|
import sshPool from './sshPool.js';
|
|
6
6
|
import config from './config.js';
|
|
7
7
|
import policies from './policies.js';
|
|
8
|
+
import utils from './utils.js';
|
|
8
9
|
|
|
9
10
|
const STREAMING_COMMANDS = [
|
|
10
11
|
/^pm2\s+logs?\b/i,
|
|
@@ -319,7 +320,7 @@ async function executeWithNewConnection(serverConfig, job, jobId) {
|
|
|
319
320
|
|
|
320
321
|
const connectConfig = {
|
|
321
322
|
host: serverConfig.host,
|
|
322
|
-
port:
|
|
323
|
+
port: utils.resolveSshPort(serverConfig),
|
|
323
324
|
username: serverConfig.user,
|
|
324
325
|
readyTimeout: 20000
|
|
325
326
|
};
|
|
@@ -366,10 +367,23 @@ async function executeCommandSequence(jobId) {
|
|
|
366
367
|
const stepJob = {
|
|
367
368
|
...job,
|
|
368
369
|
cmd: typeof cmd === 'string' ? cmd : cmd.command,
|
|
369
|
-
timeout: cmd.timeout
|
|
370
|
-
continueOnError: cmd.continueOnError
|
|
370
|
+
timeout: (typeof cmd === 'object' && cmd.timeout != null) ? cmd.timeout : job.timeout,
|
|
371
|
+
continueOnError: (typeof cmd === 'object' && cmd.continueOnError != null)
|
|
372
|
+
? cmd.continueOnError
|
|
373
|
+
: job.continueOnError
|
|
371
374
|
};
|
|
372
375
|
|
|
376
|
+
// Policy par étape (sauf skip_policy)
|
|
377
|
+
if (!job.skip_policy) {
|
|
378
|
+
const policyCheck = await policies.check(stepJob.cmd);
|
|
379
|
+
if (policyCheck.blocked) {
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Étape ${i + 1} bloquée par la politique de sécurité (pattern: "${policyCheck.pattern}"). ` +
|
|
382
|
+
`Utilisez skip_policy:true pour forcer.`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
373
387
|
try {
|
|
374
388
|
queue.log('info', `Exécution étape ${i + 1}/${job.commands.length}: ${stepJob.cmd}`);
|
|
375
389
|
// Don't update the main queue for sub-steps with phantom IDs
|