@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/tunnels.js CHANGED
@@ -5,6 +5,8 @@ import config from './config.js';
5
5
  import queue from './queue.js';
6
6
  import history from './history.js';
7
7
  import ssh from './ssh.js';
8
+ import utils from './utils.js';
9
+ import jsonStore from './atomicJsonStore.js';
8
10
 
9
11
 
10
12
  const TUNNELS_PATH = path.join(config.dataDir, 'tunnels.json');
@@ -15,27 +17,38 @@ const DEFAULT_ALLOWLIST = [80, 443, 3000, 8080, 8443, 9090, 3002, 3100, 3102, 45
15
17
  const LOCAL_TUNNELS = new Map();
16
18
 
17
19
  async function loadJson(p, def) {
18
- try { await fs.access(p); return JSON.parse(await fs.readFile(p, 'utf-8')); }
19
- catch { return def; }
20
+ return jsonStore.readJson(p, def);
20
21
  }
21
22
  async function saveJson(p, data) {
22
- await fs.writeFile(p, JSON.stringify(data, null, 2));
23
+ await jsonStore.writeJsonAtomic(p, data);
23
24
  }
24
25
 
25
26
  async function loadAllowlist() {
26
- return await loadJson(ALLOWLIST_PATH, DEFAULT_ALLOWLIST);
27
+ return loadJson(ALLOWLIST_PATH, DEFAULT_ALLOWLIST);
27
28
  }
28
29
  async function saveAllowlist(list) {
29
30
  await saveJson(ALLOWLIST_PATH, list);
30
31
  }
31
32
 
32
33
  async function loadRegistry() {
33
- return await loadJson(TUNNELS_PATH, {});
34
+ return loadJson(TUNNELS_PATH, {});
34
35
  }
35
36
  async function saveRegistry(reg) {
36
37
  await saveJson(TUNNELS_PATH, reg);
37
38
  }
38
39
 
40
+ const tunnelLocks = new Map();
41
+ async function withTunnelLock(name, fn) {
42
+ const prev = tunnelLocks.get(name) || Promise.resolve();
43
+ let release;
44
+ const gate = new Promise(r => { release = r; });
45
+ const tail = prev.catch(() => {}).then(() => gate);
46
+ tunnelLocks.set(name, tail);
47
+ await prev.catch(() => {});
48
+ try { return await fn(); }
49
+ finally { release(); if (tunnelLocks.get(name) === tail) tunnelLocks.delete(name); }
50
+ }
51
+
39
52
  async function getSshTarget(alias, serverManager) {
40
53
  const cfg = await serverManager.getServer(alias);
41
54
  return { host: cfg.host, port: cfg.port || 22, user: cfg.user, keyPath: cfg.keyPath };
@@ -67,6 +80,7 @@ function buildArgs(type, listenPort, target, via, remoteKeyPath) {
67
80
 
68
81
  export default {
69
82
  async create(params, serverManager) {
83
+ return withTunnelLock(params.name, async () => {
70
84
  const { name, type, listen_port, target, via, source, key_path } = params;
71
85
  if (!name) throw new Error("Le paramètre 'name' est requis.");
72
86
  if (!['local', 'remote', 'socks'].includes(type)) throw new Error("type doit être 'local', 'remote' ou 'socks'");
@@ -99,8 +113,8 @@ export default {
99
113
  }
100
114
 
101
115
  const sessionName = `tunnel-${name}`;
102
- const sshCmd = 'ssh ' + args.join(' ');
103
- const createCmd = `sh -c "tmux new-session -d -s ${sessionName} '${sshCmd}'"`;
116
+ const sshCmd = ['ssh', ...args].map(utils.escapeShellArg).join(' ');
117
+ const createCmd = `tmux new-session -d -s ${utils.escapeShellArg(sessionName)} -- sh -lc ${utils.escapeShellArg(sshCmd)}`;
104
118
 
105
119
  const job = queue.addJob({ type: 'ssh', alias: source, cmd: createCmd, pty: true, timeout: 20, skip_policy: true, status: 'pending' });
106
120
  history.logTask(job);
@@ -130,6 +144,12 @@ export default {
130
144
  ? `Proxy SOCKS5 sur ${source}:${listen_port} (via ${via})`
131
145
  : `${source}:${listen_port} → ${via}:${target}`;
132
146
  return `Tunnel '${name}' créé sur ${source} (session tmux: ${sessionName}). ${desc}\n✅ Persistant (survit au redémarrage du MCP).`;
147
+ });
148
+ },
149
+
150
+ async get(name) {
151
+ const registry = await loadRegistry();
152
+ return registry[name] || null;
133
153
  },
134
154
 
135
155
  async list() {
@@ -142,7 +162,7 @@ export default {
142
162
  const local = LOCAL_TUNNELS.get(name);
143
163
  status = local && local.process && !local.process.killed ? 'actif' : 'mort (MCP relancé, relancez le tunnel)';
144
164
  } else {
145
- const ckJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux has-session -t ${info.tmux_session} 2>/dev/null && echo actif || echo mort`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
165
+ const ckJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux has-session -t ${utils.escapeShellArg(info.tmux_session)} 2>/dev/null && echo actif || echo mort`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
146
166
  ssh.executeCommand(ckJob.id);
147
167
  await new Promise(r => { const p = () => { const j = queue.getJob(ckJob.id); if (!j || j.status === 'completed' || j.status === 'failed') r(j); else setTimeout(p, 200); }; p(); });
148
168
  const j = queue.getJob(ckJob.id);
@@ -156,6 +176,7 @@ export default {
156
176
  },
157
177
 
158
178
  async close(name, serverManager) {
179
+ return withTunnelLock(name, async () => {
159
180
  const registry = await loadRegistry();
160
181
  const info = registry[name];
161
182
  if (!info) throw new Error(`Tunnel '${name}' introuvable.`);
@@ -168,13 +189,14 @@ export default {
168
189
  }
169
190
  LOCAL_TUNNELS.delete(name);
170
191
  } else {
171
- const killJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux kill-session -t ${info.tmux_session} 2>/dev/null; echo OK`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
192
+ const killJob = queue.addJob({ type: 'ssh', alias: info.source, cmd: `tmux kill-session -t ${utils.escapeShellArg(info.tmux_session)} 2>/dev/null; echo OK`, timeout: 10, skip_policy: true, streaming: false, status: 'pending' });
172
193
  ssh.executeCommand(killJob.id);
173
194
  }
174
195
 
175
196
  delete registry[name];
176
197
  await saveRegistry(registry);
177
198
  return `Tunnel '${name}' fermé.`;
199
+ });
178
200
  },
179
201
 
180
202
  async allowlistList() {
@@ -182,17 +204,14 @@ export default {
182
204
  },
183
205
 
184
206
  async allowlistAdd(port) {
185
- const list = await loadAllowlist();
186
- if (!list.includes(port)) list.push(port);
187
- await saveAllowlist(list);
188
- return list;
207
+ return jsonStore.updateJson(ALLOWLIST_PATH, DEFAULT_ALLOWLIST, list => {
208
+ if (!list.includes(port)) list.push(port);
209
+ return list;
210
+ });
189
211
  },
190
212
 
191
213
  async allowlistRemove(port) {
192
- const list = await loadAllowlist();
193
- const filtered = list.filter(p => p !== port);
194
- await saveAllowlist(filtered);
195
- return filtered;
214
+ return jsonStore.updateJson(ALLOWLIST_PATH, DEFAULT_ALLOWLIST, list => list.filter(p => p !== port));
196
215
  },
197
216
 
198
217
  async restore(servers) {
package/utils.js CHANGED
@@ -1,6 +1,215 @@
1
+ /**
2
+ * Utilitaires partagés (échappement shell, masquage secrets, timeouts, statuts).
3
+ * Extrait pour tests unitaires sans charger le serveur MCP.
4
+ */
5
+
6
+ const SENSITIVE_KEYS = new Set([
7
+ 'password',
8
+ 'pass',
9
+ 'api_key',
10
+ 'apikey',
11
+ 'apiKey',
12
+ 'htpasswd_pass',
13
+ 'htpasswdPass',
14
+ 'secret',
15
+ 'token',
16
+ 'access_token',
17
+ 'privateKey',
18
+ 'private_key',
19
+ 'authorization'
20
+ ]);
21
+
1
22
  function escapeShellArg(arg) {
2
23
  if (typeof arg !== 'string') return String(arg);
3
24
  return "'" + arg.replace(/'/g, "'\\''") + "'";
4
25
  }
5
26
 
6
- export default { escapeShellArg };
27
+ /**
28
+ * Masque un secret : garde les `visible` derniers caractères.
29
+ * null/undefined/non-string → inchangé.
30
+ */
31
+ function maskSecret(value, visible = 4) {
32
+ if (value == null) return value;
33
+ const s = String(value);
34
+ if (s.length === 0) return s;
35
+ if (s.length <= visible) return '***';
36
+ return '***' + s.slice(-visible);
37
+ }
38
+
39
+ /**
40
+ * Clone profond léger et masque les champs sensibles (récursif).
41
+ * Ne mute pas l'objet d'origine.
42
+ */
43
+ function redactSensitiveObject(obj, options = {}) {
44
+ const visible = options.visible ?? 4;
45
+ const extraKeys = options.extraKeys || [];
46
+ const keys = new Set([...SENSITIVE_KEYS, ...extraKeys.map(k => k.toLowerCase())]);
47
+
48
+ function walk(value, keyName) {
49
+ if (value == null) return value;
50
+ if (Array.isArray(value)) return value.map(v => walk(v, null));
51
+ if (typeof value === 'object') {
52
+ const out = {};
53
+ for (const [k, v] of Object.entries(value)) {
54
+ const lower = k.toLowerCase();
55
+ if (keys.has(lower) || keys.has(k)) {
56
+ out[k] = typeof v === 'string' || typeof v === 'number'
57
+ ? maskSecret(v, visible)
58
+ : (v == null ? v : '***');
59
+ } else {
60
+ out[k] = walk(v, k);
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ return value;
66
+ }
67
+
68
+ return walk(obj, null);
69
+ }
70
+
71
+ /**
72
+ * Convertit un timeout "secondes utilisateur" en ms pour waitForJobCompletion.
73
+ * - undefined/null → fallbackMs (déjà en ms, ex: config.syncTimeout)
74
+ * - 0 → 0 (attente infinie côté wait)
75
+ * - n > 0 → n * 1000
76
+ */
77
+ function toWaitTimeoutMs(timeoutSeconds, fallbackMs) {
78
+ if (timeoutSeconds === 0) return 0;
79
+ if (timeoutSeconds == null || timeoutSeconds === undefined || timeoutSeconds === '') {
80
+ return fallbackMs;
81
+ }
82
+ const n = Number(timeoutSeconds);
83
+ if (!Number.isFinite(n) || n < 0) return fallbackMs;
84
+ return n * 1000;
85
+ }
86
+
87
+ /** Statuts pour lesquels un job n'évoluera plus (fin d'attente hybride). */
88
+ function isTerminalJobStatus(status) {
89
+ return status === 'completed'
90
+ || status === 'failed'
91
+ || status === 'partial'
92
+ || status === 'crashed';
93
+ }
94
+
95
+ /**
96
+ * Résout le port SSH depuis une config serveur.
97
+ */
98
+ function resolveSshPort(serverConfig) {
99
+ if (!serverConfig) return 22;
100
+ const p = serverConfig.port;
101
+ if (p == null || p === '') return 22;
102
+ const n = Number(p);
103
+ return Number.isFinite(n) && n > 0 && n <= 65535 ? n : 22;
104
+ }
105
+
106
+ /** Patterns heuristiques de commandes destructives (avertissement / dry-run). */
107
+ const DESTRUCTIVE_PATTERNS = [
108
+ /\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b/i,
109
+ /\brm\s+-rf\b/i,
110
+ /\bmkfs\b/i,
111
+ /\bdd\s+if=/i,
112
+ /\bdrop\s+(database|table)\b/i,
113
+ /\btruncate\s+table\b/i,
114
+ /\bshutdown\b/i,
115
+ /\breboot\b/i,
116
+ /\buserdel\b/i,
117
+ /\bpasswd\b/i,
118
+ /\bchmod\s+-R\s+000\b/i,
119
+ /\b>\s*\/dev\/sd/i,
120
+ /\biptables\s+-F\b/i,
121
+ /\bsystemctl\s+(stop|disable|mask)\b/i,
122
+ /\bdocker\s+(system\s+prune|rmi|rm\s+-f)\b/i,
123
+ /\bpm2\s+(delete|del|kill)\b/i
124
+ ];
125
+
126
+ function isDestructiveCommand(cmd) {
127
+ if (!cmd || typeof cmd !== 'string') return false;
128
+ return DESTRUCTIVE_PATTERNS.some(re => re.test(cmd));
129
+ }
130
+
131
+ /**
132
+ * Refuse l'opération si MCP_READONLY=true.
133
+ * @throws Error
134
+ */
135
+ function assertWritable(actionLabel = 'cette opération') {
136
+ // Lazy import-free: caller passes config.readOnly or we read env
137
+ const ro = process.env.MCP_READONLY === 'true' || process.env.MCP_READONLY === '1';
138
+ if (ro) {
139
+ throw new Error(
140
+ `Mode lecture seule (MCP_READONLY=1) : ${actionLabel} est refusé. ` +
141
+ `Relancez le MCP sans MCP_READONLY pour écrire.`
142
+ );
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Compacte un objet pour l'agent (tronque gros champs).
148
+ * options = { maxString=500, maxArray=30, dropKeys=[] }
149
+ */
150
+ function compactResult(obj, options = {}) {
151
+ const maxString = options.maxString ?? 500;
152
+ const maxArray = options.maxArray ?? 30;
153
+ const dropKeys = new Set(options.dropKeys || ['diff', 'raw_output', 'content']);
154
+
155
+ function walk(v, key) {
156
+ if (v == null) return v;
157
+ if (typeof v === 'string') {
158
+ if (dropKeys.has(key)) return v.length > 80 ? `[omitted ${v.length} chars]` : v;
159
+ return v.length > maxString ? v.slice(0, maxString) + `…[+${v.length - maxString}]` : v;
160
+ }
161
+ if (Array.isArray(v)) {
162
+ const sliced = v.slice(0, maxArray).map((item) => walk(item, null));
163
+ if (v.length > maxArray) sliced.push(`…+${v.length - maxArray} items`);
164
+ return sliced;
165
+ }
166
+ if (typeof v === 'object') {
167
+ const out = {};
168
+ for (const [k, val] of Object.entries(v)) {
169
+ if (dropKeys.has(k) && typeof val === 'string' && val.length > 100) {
170
+ out[k] = `[omitted ${val.length} chars]`;
171
+ } else {
172
+ out[k] = walk(val, k);
173
+ }
174
+ }
175
+ return out;
176
+ }
177
+ return v;
178
+ }
179
+
180
+ return walk(obj, null);
181
+ }
182
+
183
+ function wantsCompact(params = {}) {
184
+ if (params && params.compact === true) return true;
185
+ if (params && params.compact === false) return false;
186
+ return process.env.MCP_COMPACT === 'true' || process.env.MCP_COMPACT === '1';
187
+ }
188
+
189
+ export {
190
+ escapeShellArg,
191
+ maskSecret,
192
+ redactSensitiveObject,
193
+ toWaitTimeoutMs,
194
+ isTerminalJobStatus,
195
+ resolveSshPort,
196
+ isDestructiveCommand,
197
+ assertWritable,
198
+ compactResult,
199
+ wantsCompact,
200
+ DESTRUCTIVE_PATTERNS,
201
+ SENSITIVE_KEYS
202
+ };
203
+
204
+ export default {
205
+ escapeShellArg,
206
+ maskSecret,
207
+ redactSensitiveObject,
208
+ toWaitTimeoutMs,
209
+ isTerminalJobStatus,
210
+ resolveSshPort,
211
+ isDestructiveCommand,
212
+ assertWritable,
213
+ compactResult,
214
+ wantsCompact
215
+ };
package/workSession.js ADDED
@@ -0,0 +1,139 @@
1
+ /**
2
+ * workSession.js — Sessions de travail (journal d'intervention).
3
+ * Stockage atomique: <dataDir>/work_sessions.json
4
+ */
5
+ import path from 'path';
6
+ import crypto from 'crypto';
7
+ import config from './config.js';
8
+ import notes from './notes.js';
9
+ import snapshotManager from './snapshotManager.js';
10
+ import jsonStore from './atomicJsonStore.js';
11
+
12
+ const SESSIONS_PATH = path.join(config.dataDir, 'work_sessions.json');
13
+ const EMPTY = { active: {}, history: [] };
14
+
15
+ const load = () => jsonStore.readJson(SESSIONS_PATH, EMPTY);
16
+
17
+ function trimHistory(data) {
18
+ data.active ||= {};
19
+ data.history ||= [];
20
+ if (data.history.length > 100) data.history = data.history.slice(-100);
21
+ return data;
22
+ }
23
+
24
+ export default {
25
+ async start({ alias = null, project = null, tag = null, message = null, snapshot = false, paths = [] } = {}) {
26
+ const id = `ws_${Date.now()}_${crypto.randomBytes(3).toString('hex')}`;
27
+ const session = {
28
+ id,
29
+ alias,
30
+ project,
31
+ tag: tag || `work-${new Date().toISOString().slice(0, 10)}`,
32
+ message: message || null,
33
+ startedAt: new Date().toISOString(),
34
+ events: [],
35
+ snapshotId: null,
36
+ status: 'active'
37
+ };
38
+
39
+ // Effet externe avant publication de la session. S'il échoue, on garde
40
+ // l'information dans les events mais on ne laisse aucun état partiel.
41
+ if (snapshot && alias && paths.length) {
42
+ try {
43
+ const snap = await snapshotManager.createSnapshot(
44
+ { type: 'remote', alias }, paths,
45
+ { tag: session.tag, message: message || `work_start ${id}` }
46
+ );
47
+ session.snapshotId = snap.snapshotId;
48
+ } catch (e) {
49
+ session.events.push({ at: new Date().toISOString(), type: 'snapshot_error', detail: e.message });
50
+ }
51
+ }
52
+
53
+ await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
54
+ trimHistory(data);
55
+ data.active[id] = session;
56
+ return data;
57
+ });
58
+ return session;
59
+ },
60
+
61
+ async log(id, event) {
62
+ let count = 0;
63
+ await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
64
+ trimHistory(data);
65
+ const session = data.active[id];
66
+ if (!session) throw new Error(`Session '${id}' introuvable ou déjà fermée.`);
67
+ session.events.push({ at: new Date().toISOString(), ...event });
68
+ count = session.events.length;
69
+ return data;
70
+ });
71
+ return { id, events: count };
72
+ },
73
+
74
+ async get(id) {
75
+ const data = trimHistory(await load());
76
+ if (data.active[id]) return data.active[id];
77
+ const hist = data.history.find(s => s.id === id);
78
+ if (hist) return hist;
79
+ throw new Error(`Session '${id}' introuvable.`);
80
+ },
81
+
82
+ async list({ includeHistory = false } = {}) {
83
+ const data = trimHistory(await load());
84
+ const active = Object.values(data.active);
85
+ if (!includeHistory) return { active, historyCount: data.history.length };
86
+ return { active, history: data.history };
87
+ },
88
+
89
+ async end(id, { summary = null, note = true } = {}) {
90
+ let closed;
91
+ // Commit principal d'abord : une panne de notes ne doit jamais laisser
92
+ // la session active alors que l'appel end a été accepté.
93
+ await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
94
+ trimHistory(data);
95
+ const session = data.active[id];
96
+ if (!session) throw new Error(`Session '${id}' introuvable ou déjà fermée.`);
97
+
98
+ session.status = 'closed';
99
+ session.endedAt = new Date().toISOString();
100
+ session.summary = summary || session.message || `Session ${id} fermée`;
101
+ session.events.push({ at: session.endedAt, type: 'end', detail: session.summary });
102
+ if (note && session.alias) session.noteUpdated = null; // pending
103
+
104
+ delete data.active[id];
105
+ data.history.push(session);
106
+ trimHistory(data);
107
+ closed = structuredClone(session);
108
+ return data;
109
+ });
110
+
111
+ if (note && closed.alias) {
112
+ let noteUpdated = true;
113
+ let noteError = null;
114
+ try {
115
+ await notes.set(closed.alias, {
116
+ intervention: `[${closed.tag}] ${closed.summary} (${closed.events.length} events)`
117
+ });
118
+ } catch (e) {
119
+ noteUpdated = false;
120
+ noteError = e.message;
121
+ }
122
+
123
+ // Compensation: enrichit l'historique sans rouvrir la session.
124
+ await jsonStore.updateJson(SESSIONS_PATH, EMPTY, data => {
125
+ trimHistory(data);
126
+ const hist = data.history.find(s => s.id === id);
127
+ if (hist) {
128
+ hist.noteUpdated = noteUpdated;
129
+ if (noteError) hist.noteError = noteError;
130
+ else delete hist.noteError;
131
+ closed = structuredClone(hist);
132
+ }
133
+ return data;
134
+ });
135
+ }
136
+
137
+ return closed;
138
+ }
139
+ };
package/.gencodedoc.yaml DELETED
@@ -1,76 +0,0 @@
1
- project_name: sftp-mcp
2
- ignore:
3
- dirs:
4
- - node_modules
5
- - venv
6
- - .venv
7
- - env
8
- - __pycache__
9
- - .git
10
- - dist
11
- - build
12
- - .next
13
- - coverage
14
- - node_modules
15
- - dist
16
- - .next
17
- - venv
18
- - .venv
19
- - __pycache__
20
- - dist
21
- - build
22
- files:
23
- - .DS_Store
24
- - Thumbs.db
25
- - package-lock.json
26
- - yarn.lock
27
- extensions:
28
- - .log
29
- - .pyc
30
- - .pyo
31
- - .exe
32
- - .bin
33
- - .jpg
34
- - .png
35
- - .gif
36
- - .mp4
37
- - .pdf
38
- - .zip
39
- - .pyc
40
- - .pyo
41
- - .pyd
42
- patterns: []
43
- autosave:
44
- enabled: false
45
- mode: hybrid
46
- timer:
47
- interval: 300
48
- diff_threshold:
49
- threshold: 0.05
50
- check_interval: 60
51
- ignore_whitespace: true
52
- ignore_comments: false
53
- hybrid:
54
- min_interval: 180
55
- max_interval: 600
56
- threshold: 0.03
57
- retention:
58
- max_autosaves: 50
59
- compress_after_days: 7
60
- delete_after_days: 30
61
- keep_manual: true
62
- diff_format:
63
- default: unified
64
- unified_context: 3
65
- json_include_content: true
66
- ast_enabled: false
67
- output:
68
- default_name: '{project}_doc_{date}.md'
69
- include_tree: true
70
- include_code: true
71
- tree_full_code_select: false
72
- language_detection: true
73
- max_file_size: 1000000
74
- storage_path: .gencodedoc
75
- compression_enabled: true
76
- compression_level: 3
package/ROADMAP.md DELETED
@@ -1,21 +0,0 @@
1
- # Roadmap MCP Orchestrator
2
-
3
- ## v11.2.0 (actuelle)
4
- - ✅ Command Blocklist (policies.js) — sécurité des commandes
5
- - ✅ Multi-host execution (alias array / "all")
6
- - ✅ tmux sessions persistantes (create/exec/read/list/kill)
7
- - ✅ Tunnels SSH (local/remote/SOCKS5) avec allowlist
8
- - ✅ Guide IA intégré (audit, security, cheatsheet)
9
- - ✅ Help avec schémas JSON des paramètres
10
- - ✅ Snapshots versionnés avec déduplication
11
- - ✅ File edit chirurgical avec protection hash
12
- - ✅ Diff multi-serveur (compare_all_sources)
13
- - ✅ get_services_status graceful fallback
14
-
15
- ## v12.x (planifiée)
16
- - [ ] Tunnels auto-setup de clés entre serveurs
17
- - [ ] Webhook on tunnel up/down (n8n compatible)
18
- - [ ] Dashboard web statique pour l'état du parc
19
- - [ ] Mode "agent" : exécution planifiée (cron-like)
20
- - [ ] Intégration Vaultwarden pour les secrets
21
- - [ ] Tests automatisés CI/CD