@mmmbuto/nexuscrew 0.7.7 → 0.8.2

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.
Files changed (51) hide show
  1. package/README.md +153 -25
  2. package/bin/nexuscrew.js +10 -4
  3. package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
  4. package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
  5. package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
  6. package/frontend/dist/index.html +2 -2
  7. package/frontend/dist/sw.js +29 -0
  8. package/frontend/dist/version.json +1 -0
  9. package/lib/auth/middleware.js +7 -1
  10. package/lib/auth/token.js +31 -1
  11. package/lib/cli/commands.js +502 -43
  12. package/lib/cli/doctor.js +160 -0
  13. package/lib/cli/fleet-service.js +16 -3
  14. package/lib/cli/init.js +69 -11
  15. package/lib/cli/path.js +24 -0
  16. package/lib/cli/service.js +14 -8
  17. package/lib/cli/url.js +63 -0
  18. package/lib/config.js +5 -0
  19. package/lib/decks/routes.js +81 -0
  20. package/lib/decks/store.js +123 -0
  21. package/lib/files/routes.js +57 -1
  22. package/lib/files/store.js +16 -1
  23. package/lib/fleet/builtin.js +151 -24
  24. package/lib/fleet/definitions.js +26 -0
  25. package/lib/fleet/managed.js +381 -0
  26. package/lib/fleet/routes.js +5 -4
  27. package/lib/mcp/server.js +381 -0
  28. package/lib/nodes/commands.js +411 -0
  29. package/lib/nodes/peering.js +116 -0
  30. package/lib/nodes/store.js +425 -0
  31. package/lib/nodes/tunnel-supervisor.js +102 -0
  32. package/lib/nodes/tunnel.js +315 -0
  33. package/lib/notify/asks.js +150 -0
  34. package/lib/notify/events.js +59 -0
  35. package/lib/notify/notifier.js +37 -0
  36. package/lib/notify/persist.js +73 -0
  37. package/lib/notify/push.js +289 -0
  38. package/lib/notify/routes.js +260 -0
  39. package/lib/proxy/federation.js +217 -0
  40. package/lib/proxy/node-proxy.js +305 -0
  41. package/lib/pty/attach.js +34 -9
  42. package/lib/pty/provider.js +21 -6
  43. package/lib/server.js +291 -14
  44. package/lib/settings/routes.js +685 -0
  45. package/lib/ws/bridge.js +10 -1
  46. package/package.json +8 -2
  47. package/skills/nexuscrew-agent/SKILL.md +83 -0
  48. package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
  49. package/skills/nexuscrew-agent/bin/nc-send +48 -0
  50. package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
  51. package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
@@ -0,0 +1,411 @@
1
+ 'use strict';
2
+ // lib/nodes/commands.js — subcomandi CLI `nodes` e `node` (design §3, §4, §4b).
3
+ //
4
+ // nodes add/list/remove/test/up/down/restart/set-token + node on/off.
5
+ // Invarianti:
6
+ // - token per-nodo MAI loggati/stampati (redazione sempre; set-token li legge
7
+ // da stdin/env, mai da argv -> niente segreti in `ps`).
8
+ // - NEXUSCREW_READONLY blocca le MUTAZIONI DI CONFIG (add/remove/set-token/
9
+ // on/off), non list/test/status/up/down/restart (lifecycle tunnel, non config).
10
+ // - niente shell interpolation: ssh-keygen via execFile (argv), tunnel via spawn argv.
11
+ const fs = require('node:fs');
12
+ const os = require('node:os');
13
+ const path = require('node:path');
14
+ const { execFileSync } = require('node:child_process');
15
+ const store = require('./store.js');
16
+ const tunnel = require('./tunnel.js');
17
+ const { resolvePaths, loadPort, DEFAULT_PORT } = require('../cli/url.js');
18
+
19
+ const LOCAL_PORT_BASE = 43001; // porte locali stabili per i forward (design: stabili da nodes.json)
20
+
21
+ function isReadonly(opts) {
22
+ return process.env.NEXUSCREW_READONLY === '1' || !!opts.readonly;
23
+ }
24
+
25
+ function resolveNodePaths(opts) {
26
+ const { home, configDir, configPath } = resolvePaths(opts);
27
+ const nodesPath = opts.nodesPath || path.join(configDir, 'nodes.json');
28
+ return { home, configDir, configPath, nodesPath };
29
+ }
30
+
31
+ // Prima localPort libera >= base, evitando le porte gia' assegnate (porta STABILE:
32
+ // una volta scelta resta in nodes.json, non si ricicla).
33
+ function assignLocalPort(st) {
34
+ const used = new Set(st.nodes.map((n) => n.localPort));
35
+ let p = LOCAL_PORT_BASE;
36
+ while (used.has(p) && p <= 65535) p += 1;
37
+ if (p > 65535) throw new Error('nessuna porta locale libera per il tunnel');
38
+ return p;
39
+ }
40
+
41
+ function defaultKeyPath(home, name) {
42
+ return path.join(home, '.nexuscrew', 'keys', `${name}_ed25519`);
43
+ }
44
+
45
+ // Genera (o riusa) una chiave SSH dedicata per-tunnel; ritorna la pubkey (single-line).
46
+ // Seam opts.keygen(keyPath, name) -> pubkey per i test (niente ssh-keygen reale).
47
+ function ensureKey(keyPath, name, opts = {}) {
48
+ if (typeof opts.keygen === 'function') return opts.keygen(keyPath, name);
49
+ const execFileImpl = opts.execFileImpl || execFileSync;
50
+ const pubPath = `${keyPath}.pub`;
51
+ if (fs.existsSync(keyPath)) {
52
+ if (fs.existsSync(pubPath)) return fs.readFileSync(pubPath, 'utf8').trim();
53
+ return String(execFileImpl('ssh-keygen', ['-y', '-f', keyPath], { encoding: 'utf8' })).trim();
54
+ }
55
+ fs.mkdirSync(path.dirname(keyPath), { recursive: true, mode: 0o700 });
56
+ // chiave dedicata: ed25519, senza passphrase (BatchMode), commento identificante.
57
+ execFileImpl('ssh-keygen', ['-t', 'ed25519', '-f', keyPath, '-N', '', '-C', `nexuscrew-tunnel-${name}`], { stdio: 'ignore' });
58
+ try { fs.chmodSync(keyPath, 0o600); } catch (_) {}
59
+ return fs.readFileSync(pubPath, 'utf8').trim();
60
+ }
61
+
62
+ // Scrittura atomica di config.json preservando il resto (per roles).
63
+ function writeConfigRole(configPath, key, value) {
64
+ let cfg = {};
65
+ try { const c = JSON.parse(fs.readFileSync(configPath, 'utf8')); if (c && typeof c === 'object') cfg = c; } catch (_) {}
66
+ const roles = (cfg.roles && typeof cfg.roles === 'object') ? cfg.roles : {};
67
+ cfg.roles = { client: !!roles.client, node: !!roles.node };
68
+ cfg.roles[key] = value;
69
+ const dir = path.dirname(configPath);
70
+ fs.mkdirSync(dir, { recursive: true });
71
+ const tmp = path.join(dir, `.${path.basename(configPath)}.${process.pid}.tmp`);
72
+ try {
73
+ fs.writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
74
+ fs.chmodSync(tmp, 0o600);
75
+ fs.renameSync(tmp, configPath);
76
+ } catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} throw e; }
77
+ return cfg.roles;
78
+ }
79
+
80
+ // Legge il token remoto da fonte NON-argv: opts.token (test) > env > stdin.
81
+ // MAI da flag CLI: il token comparirebbe in `ps`/history.
82
+ function readSecretToken(opts) {
83
+ if (typeof opts.token === 'string') return opts.token.trim();
84
+ if (process.env.NEXUSCREW_NODE_TOKEN) return String(process.env.NEXUSCREW_NODE_TOKEN).trim();
85
+ try { return fs.readFileSync(0, 'utf8').trim(); } catch (_) { return ''; }
86
+ }
87
+
88
+ // --- nodes add -------------------------------------------------------------
89
+ function nodesAdd(opts) {
90
+ const log = opts.log || console.log;
91
+ if (isReadonly(opts)) { log('nodes add: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
92
+
93
+ const { home, nodesPath } = resolveNodePaths(opts);
94
+ const name = opts.name;
95
+ const ssh = opts.ssh;
96
+ if (!name) { log('usage: nexuscrew nodes add <name> --ssh user@host [--ssh-port N] [--remote-port N] [--key path] [--local-port N]'); return { code: 1, reason: 'name mancante' }; }
97
+ if (!ssh) { log('nodes add: --ssh user@host obbligatorio'); return { code: 1, reason: 'ssh mancante' }; }
98
+
99
+ const remotePort = opts.remotePort ? Number(opts.remotePort) : DEFAULT_PORT;
100
+ const sshPort = opts.sshPort === undefined ? undefined : Number(opts.sshPort);
101
+
102
+ let st;
103
+ try { st = store.loadOrInitStore(nodesPath); }
104
+ catch (e) { log(`nodes add: ${e.message}`); return { code: 1, reason: 'store invalido' }; }
105
+
106
+ const localPort = opts.localPort ? Number(opts.localPort) : assignLocalPort(st);
107
+ const keyPath = opts.identityFile || opts.key || (typeof opts.keygen === 'function' ? defaultKeyPath(home, name) : undefined);
108
+
109
+ const entry = {
110
+ name, ssh, remotePort, localPort,
111
+ direction: opts.direction || 'outbound',
112
+ transport: opts.transport || 'auto',
113
+ autostart: opts.autostart !== false,
114
+ visibility: opts.visibility || 'network',
115
+ roles: { client: true, node: false },
116
+ };
117
+ if (keyPath) entry.identityFile = keyPath;
118
+ if (opts.token) entry.token = opts.token;
119
+ if (opts.reversePort) entry.reversePort = Number(opts.reversePort);
120
+ if (sshPort !== undefined) entry.sshPort = sshPort;
121
+ if (opts.nodeId) entry.nodeId = opts.nodeId;
122
+
123
+ // Valida + inserisci in memoria (dup name/id, self-reference, schema) PRIMA di
124
+ // toccare la chiave: un errore qui non deve lasciare artefatti.
125
+ let next;
126
+ try { next = store.addNode(st, entry); }
127
+ catch (e) { log(`nodes add: ${e.message}`); return { code: 1, reason: 'add rifiutato' }; }
128
+
129
+ // chiave dedicata per-tunnel (genera o riusa)
130
+ let pub = null;
131
+ if (keyPath && typeof opts.keygen === 'function') {
132
+ try { pub = ensureKey(keyPath, name, opts); }
133
+ catch (e) { log(`nodes add: generazione chiave fallita (${e.message}) — nodo NON salvato`); return { code: 1, reason: 'keygen fallita' }; }
134
+ }
135
+
136
+ try { store.atomicWriteStore(nodesPath, next); }
137
+ catch (e) { log(`nodes add: scrittura nodes.json fallita: ${e.message}`); return { code: 1, reason: 'write fallita' }; }
138
+
139
+ log(`nodes add: nodo "${name}" aggiunto (ssh ${ssh}${sshPort ? `:${sshPort}` : ''}, nexus remoto ${remotePort} -> locale ${localPort})`);
140
+ log('Incolla nel ~/.ssh/authorized_keys del NODO (lato forward, chiave dedicata):');
141
+ // permitopen vincola i -L alla SOLA porta nexus remota; command=/bin/false + restrict.
142
+ if (pub) log(`restrict,port-forwarding,permitopen="127.0.0.1:${remotePort}",command="/bin/false" ${pub}`);
143
+ return { code: 0, name, sshPort, localPort, remotePort, transport: entry.transport };
144
+ }
145
+
146
+ // --- nodes list ------------------------------------------------------------
147
+ function nodesList(opts) {
148
+ const log = opts.log || console.log;
149
+ const { home, nodesPath } = resolveNodePaths(opts);
150
+
151
+ const st = store.loadStore(nodesPath);
152
+ if (!st) {
153
+ if (fs.existsSync(nodesPath)) { log('nodes list: nodes.json presente ma invalido (schema strict) — correggi o rimuovi il file'); return { code: 1, nodes: [] }; }
154
+ if (opts.json) { log(JSON.stringify({ nodeId: null, nodes: [] }, null, 2)); }
155
+ else log('nodes: (nessun nodo)');
156
+ return { code: 0, nodes: [] };
157
+ }
158
+
159
+ const view = store.redactStore(st); // MAI il token
160
+ const nodes = view.nodes.map((n) => ({ ...n, tunnel: tunnel.readTunnelState(home, n.name) }));
161
+ const out = { nodeId: view.nodeId, nodes };
162
+ if (view.rendezvous) out.rendezvous = view.rendezvous;
163
+
164
+ if (opts.json) { log(JSON.stringify(out, null, 2)); return { code: 0, nodes }; }
165
+ if (nodes.length === 0) { log('nodes: (nessun nodo)'); return { code: 0, nodes }; }
166
+ for (const n of nodes) {
167
+ log(`${n.name}\t${n.ssh}${n.sshPort ? `:${n.sshPort}` : ''}\tlocal:${n.localPort} -> nexus:${n.remotePort}\ttunnel:${n.tunnel.status}\ttoken:${n.hasToken ? 'set' : 'unset'}`);
168
+ }
169
+ return { code: 0, nodes };
170
+ }
171
+
172
+ // --- nodes remove ----------------------------------------------------------
173
+ function nodesRemove(opts) {
174
+ const log = opts.log || console.log;
175
+ if (isReadonly(opts)) { log('nodes remove: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
176
+ const { home, nodesPath } = resolveNodePaths(opts);
177
+ const name = opts.name;
178
+ if (!name) { log('usage: nexuscrew nodes remove <name>'); return { code: 1 }; }
179
+ const st = store.loadStore(nodesPath);
180
+ if (!st) { log('nodes remove: nessun nodes.json valido'); return { code: 1 }; }
181
+ let next;
182
+ try { next = store.removeNode(st, name); }
183
+ catch (e) { log(`nodes remove: ${e.message}`); return { code: 1 }; }
184
+ // Ferma un eventuale forward tunnel ATTIVO prima di togliere la config (audit F4):
185
+ // senza questo il ssh resterebbe orfano — porta locale aperta verso un nodo che non
186
+ // e' piu' in config, irraggiungibile e senza piu' uno stop pulito via CLI/API.
187
+ let stopped = false;
188
+ try {
189
+ const sr = tunnel.stopTunnel({ home, name });
190
+ stopped = !!sr.stopped;
191
+ const safeAbsent = ['no pidfile', 'stale (pid dead)', 'pid reuse (cmd mismatch)'].includes(sr.reason);
192
+ if (!stopped && !safeAbsent) {
193
+ log(`nodes remove: impossibile fermare il tunnel (${sr.reason || 'errore sconosciuto'}); config preservata`);
194
+ return { code: 1, reason: 'tunnel stop failed' };
195
+ }
196
+ } catch (e) {
197
+ log(`nodes remove: impossibile fermare il tunnel (${e.message}); config preservata`);
198
+ return { code: 1, reason: 'tunnel stop failed' };
199
+ }
200
+ store.atomicWriteStore(nodesPath, next);
201
+ log(`nodes remove: nodo "${name}" rimosso${stopped ? ' (tunnel attivo fermato)' : ''}`);
202
+ return { code: 0, name, stopped };
203
+ }
204
+
205
+ // --- nodes test (NON-mutante): distingue tunnel-down / health-ko / token-ko ---
206
+ async function nodesTest(opts) {
207
+ const log = opts.log || console.log;
208
+ const { home, nodesPath } = resolveNodePaths(opts);
209
+ const name = opts.name;
210
+ if (!name) { log('usage: nexuscrew nodes test <name>'); return { code: 1 }; }
211
+ const st = store.loadStore(nodesPath);
212
+ const node = st ? store.getNode(st, name) : null;
213
+ if (!node) { log(`nodes test: nodo sconosciuto "${name}"`); return { code: 1, result: 'unknown-node' }; }
214
+
215
+ const state = tunnel.readTunnelState(home, name);
216
+ if (state.status !== 'up') {
217
+ log(`nodes test [${name}]: TUNNEL DOWN — avvia con \`nexuscrew nodes up ${name}\``);
218
+ return { code: 1, result: 'tunnel-down' };
219
+ }
220
+
221
+ const httpProbe = opts.httpProbe || defaultHttpProbe;
222
+ const base = `http://127.0.0.1:${node.localPort}`;
223
+
224
+ // health: GET / non autenticato -> 2xx significa "server remoto raggiungibile via tunnel".
225
+ let health;
226
+ try { health = await httpProbe(`${base}/`, {}); }
227
+ catch (e) { health = { ok: false, error: e && e.message }; }
228
+ if (!health || !health.ok) {
229
+ log(`nodes test [${name}]: HEALTH KO — tunnel up ma il server remoto non risponde (${(health && health.error) || 'no 2xx'})`);
230
+ return { code: 1, result: 'health-ko' };
231
+ }
232
+
233
+ // token: GET /api/config con Bearer del nodo -> 200 ok, 401 token KO.
234
+ if (!node.token) {
235
+ log(`nodes test [${name}]: TOKEN ASSENTE — salva il token remoto con \`nexuscrew nodes set-token ${name}\``);
236
+ return { code: 1, result: 'token-missing' };
237
+ }
238
+ let authed;
239
+ try { authed = await httpProbe(`${base}/api/config`, { authorization: `Bearer ${node.token}` }); }
240
+ catch (e) { authed = { ok: false, error: e && e.message }; }
241
+ if (authed && authed.status === 200) {
242
+ log(`nodes test [${name}]: OK — tunnel up, health ok, token valido`);
243
+ return { code: 0, result: 'ok' };
244
+ }
245
+ log(`nodes test [${name}]: TOKEN KO — il token remoto salvato non e' valido (status ${(authed && authed.status) || '?'}); ruota con \`nexuscrew nodes set-token ${name}\``);
246
+ return { code: 1, result: 'token-ko' };
247
+ }
248
+
249
+ // probe HTTP di default (fetch built-in, Node >=18). Ritorna {ok, status}.
250
+ async function defaultHttpProbe(url, headers) {
251
+ const res = await fetch(url, { headers, redirect: 'manual' });
252
+ return { ok: res.status >= 200 && res.status < 400, status: res.status };
253
+ }
254
+
255
+ // --- nodes up/down/restart (lifecycle tunnel, NON tocca la config) ----------
256
+ function loadNodeOrFail(opts, log) {
257
+ const { home, nodesPath } = resolveNodePaths(opts);
258
+ const name = opts.name;
259
+ if (!name) { log('usage: nexuscrew nodes up|down|restart <name>'); return null; }
260
+ const st = store.loadStore(nodesPath);
261
+ const node = st ? store.getNode(st, name) : null;
262
+ if (!node) { log(`nodes: nodo sconosciuto "${name}"`); return null; }
263
+ return { home, node };
264
+ }
265
+
266
+ function nodesUp(opts) {
267
+ const log = opts.log || console.log;
268
+ const ctx = loadNodeOrFail(opts, log);
269
+ if (!ctx) return { code: 1 };
270
+ if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
271
+ const r = tunnel.startForward({ home: ctx.home, node: ctx.node, localAppPort: opts.localAppPort, spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd });
272
+ if (r.started) {
273
+ log(`nodes up [${ctx.node.name}]: tunnel avviato (pid ${r.pid}, local ${ctx.node.localPort})`);
274
+ return { code: 0, started: true, pid: r.pid };
275
+ }
276
+ if (r.reason === 'already running') {
277
+ log(`nodes up [${ctx.node.name}]: gia' attivo (pid ${r.pid})`);
278
+ return { code: 0, started: false, pid: r.pid };
279
+ }
280
+ // failure esplicita (ssh mancante / spawn error): surfacciata a CLI e Settings API.
281
+ log(`nodes up [${ctx.node.name}]: avvio tunnel fallito — ${r.reason}`);
282
+ return { code: 1, started: false, reason: r.reason };
283
+ }
284
+
285
+ function nodesDown(opts) {
286
+ const log = opts.log || console.log;
287
+ const { home, nodesPath } = resolveNodePaths(opts);
288
+ const name = opts.name;
289
+ if (!name) { log('usage: nexuscrew nodes down <name>'); return { code: 1 }; }
290
+ const st = store.loadStore(nodesPath);
291
+ if (!st || !store.getNode(st, name)) { log(`nodes down: nodo sconosciuto "${name}"`); return { code: 1 }; }
292
+ const r = tunnel.stopTunnel({ home, name });
293
+ log(`nodes down [${name}]: ${r.stopped ? `fermato (pid ${r.pid})` : r.reason}`);
294
+ return { code: 0, stopped: r.stopped };
295
+ }
296
+
297
+ function nodesRestart(opts) {
298
+ const log = opts.log || console.log;
299
+ const ctx = loadNodeOrFail(opts, log);
300
+ if (!ctx) return { code: 1 };
301
+ const args = tunnel.buildForwardArgs(ctx.node);
302
+ const r = tunnel.restartTunnel({ home: ctx.home, name: ctx.node.name, args, spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd });
303
+ if (r.started) {
304
+ log(`nodes restart [${ctx.node.name}]: tunnel riavviato (pid ${r.pid})`);
305
+ return { code: 0, started: true, pid: r.pid };
306
+ }
307
+ // dopo stop+start, 'already running' non e' atteso: qualunque !started e' un problema
308
+ // esplicito (ssh mancante / spawn error), surfacciato a CLI e Settings API.
309
+ log(`nodes restart [${ctx.node.name}]: riavvio tunnel fallito — ${r.reason || 'sconosciuto'}`);
310
+ return { code: 1, started: false, reason: r.reason || 'spawn failed' };
311
+ }
312
+
313
+ // --- nodes set-token (aggiorna il token remoto; MAI da argv) ----------------
314
+ function nodesSetToken(opts) {
315
+ const log = opts.log || console.log;
316
+ if (isReadonly(opts)) { log('nodes set-token: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
317
+ const { nodesPath } = resolveNodePaths(opts);
318
+ const name = opts.name;
319
+ if (!name) { log('usage: nexuscrew nodes set-token <name> (token da stdin o env NEXUSCREW_NODE_TOKEN)'); return { code: 1 }; }
320
+ const st = store.loadStore(nodesPath);
321
+ if (!st || !store.getNode(st, name)) { log(`nodes set-token: nodo sconosciuto "${name}"`); return { code: 1 }; }
322
+ const token = readSecretToken(opts);
323
+ if (!token) { log('nodes set-token: nessun token fornito (stdin/env vuoti)'); return { code: 1, reason: 'token vuoto' }; }
324
+ let next;
325
+ try { next = store.setNodeToken(st, name, token); }
326
+ catch (e) { log(`nodes set-token: ${e.message}`); return { code: 1 }; }
327
+ store.atomicWriteStore(nodesPath, next);
328
+ log(`nodes set-token: token del nodo "${name}" aggiornato (valore redatto)`); // MAI il token
329
+ return { code: 0, name };
330
+ }
331
+
332
+ // --- node on|off (ruolo "nodo raggiungibile", reverse tunnel) ---------------
333
+ function nodeOn(opts) {
334
+ const log = opts.log || console.log;
335
+ if (isReadonly(opts)) { log('node on: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
336
+ const { home, configPath, nodesPath } = resolveNodePaths(opts);
337
+
338
+ // Gate permitlisten (§7 advisory a): il ruolo node richiede reverse tunnel con
339
+ // permitlisten sul rendezvous (OpenSSH >=7.8). Se la versione e' nota ed e'
340
+ // troppo vecchia -> rifiuta PRIMA di abilitare. Ignota -> warn, non blocca.
341
+ const v = (opts.sshVersion || tunnel.readSshVersion)(opts.spawnSyncImpl);
342
+ const supp = tunnel.sshSupportsPermitlisten(v);
343
+ if (supp === false) {
344
+ log(`node on: OpenSSH ${v.major}.${v.minor} < 7.8 — permitlisten non supportato dal client; il ruolo node NON e' abilitabile con questa toolchain`);
345
+ return { code: 1, reason: 'permitlisten' };
346
+ }
347
+ if (supp === null) log('node on: versione OpenSSH non determinabile — verifica che il rendezvous supporti permitlisten (>=7.8)');
348
+
349
+ // Configurazione rendezvous opzionale (flags) o gia' presente in nodes.json.
350
+ let st;
351
+ try { st = store.loadOrInitStore(nodesPath); }
352
+ catch (e) { log(`node on: ${e.message}`); return { code: 1 }; }
353
+
354
+ if (opts.rendezvousSsh) {
355
+ const localPort = loadPort(opts); // porta nexus locale da esporre
356
+ const publishedPort = opts.publishedPort ? Number(opts.publishedPort) : localPort;
357
+ const keyPath = opts.key || path.join(home, '.nexuscrew', 'keys', 'rendezvous_ed25519');
358
+ let pub;
359
+ try { pub = ensureKey(keyPath, 'rendezvous', opts); }
360
+ catch (e) { log(`node on: generazione chiave rendezvous fallita (${e.message})`); return { code: 1 }; }
361
+ let next;
362
+ try { next = store.setRendezvous(st, { ssh: opts.rendezvousSsh, publishedPort, localPort, keyPath }); }
363
+ catch (e) { log(`node on: ${e.message}`); return { code: 1 }; }
364
+ store.atomicWriteStore(nodesPath, next);
365
+ st = next;
366
+ log(`node on: rendezvous configurato (${opts.rendezvousSsh}, published ${publishedPort} <- local ${localPort})`);
367
+ log('Incolla nel ~/.ssh/authorized_keys del RENDEZVOUS (lato reverse, chiave dedicata):');
368
+ // permitlisten vincola i -R alla SOLA porta pubblicata (loopback esplicito).
369
+ log(`restrict,port-forwarding,permitlisten="127.0.0.1:${publishedPort}",command="/bin/false" ${pub}`);
370
+ } else if (!st.rendezvous) {
371
+ log('node on: nessun rendezvous configurato — passa --rendezvous user@host [--published-port N] [--key path]');
372
+ return { code: 1, reason: 'no rendezvous' };
373
+ }
374
+
375
+ // Avvia un supervisor detached per il reverse tunnel. Il supervisor ritenta
376
+ // con backoff: al primo setup può partire prima che la pubkey sia stata
377
+ // incollata sul rendezvous e convergerà automaticamente appena autorizzata.
378
+ const tr = tunnel.startReverse({
379
+ home, rendezvous: st.rendezvous,
380
+ spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl,
381
+ sshBin: opts.sshBin, logFd: opts.logFd,
382
+ });
383
+ if (!tr.started && tr.reason !== 'already running') {
384
+ log(`node on: reverse tunnel non avviato — ${tr.reason || 'errore sconosciuto'}; ruolo NON abilitato`);
385
+ return { code: 1, reason: 'reverse tunnel failed' };
386
+ }
387
+
388
+ const roles = writeConfigRole(configPath, 'node', true);
389
+ log(`node on: ruolo node ABILITATO (roles: client=${roles.client} node=${roles.node}, reverse pid=${tr.pid})`);
390
+ return { code: 0, roles, tunnel: { started: !!tr.started, pid: tr.pid } };
391
+ }
392
+
393
+ function nodeOff(opts) {
394
+ const log = opts.log || console.log;
395
+ if (isReadonly(opts)) { log('node off: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
396
+ const { home, configPath } = resolveNodePaths(opts);
397
+ const roles = writeConfigRole(configPath, 'node', false);
398
+ // Ferma un eventuale reverse tunnel attivo (best-effort, non tocca la config rendezvous).
399
+ try { tunnel.stopTunnel({ home, name: tunnel.REVERSE_NAME }); } catch (_) {}
400
+ log(`node off: ruolo node DISABILITATO (roles: client=${roles.client} node=${roles.node})`);
401
+ return { code: 0, roles };
402
+ }
403
+
404
+ module.exports = {
405
+ nodesAdd, nodesList, nodesRemove, nodesTest,
406
+ nodesUp, nodesDown, nodesRestart, nodesSetToken,
407
+ nodeOn, nodeOff,
408
+ // helper esposti per test/riuso
409
+ assignLocalPort, defaultKeyPath, ensureKey, writeConfigRole, readSecretToken,
410
+ resolveNodePaths, defaultHttpProbe,
411
+ };
@@ -0,0 +1,116 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('node:crypto');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const store = require('./store.js');
7
+
8
+ const INVITE_TTL_MS = 10 * 60 * 1000;
9
+ const REVERSE_PORT_BASE = 44001;
10
+
11
+ function defaultInvitesPath(home) { return path.join(home, '.nexuscrew', 'invites.json'); }
12
+ function defaultPendingPath(home) { return path.join(home, '.nexuscrew', 'pairing-pending.json'); }
13
+
14
+ function safeEqual(a, b) {
15
+ const aa = Buffer.from(String(a || ''));
16
+ const bb = Buffer.from(String(b || ''));
17
+ return aa.length === bb.length && crypto.timingSafeEqual(aa, bb);
18
+ }
19
+
20
+ function readInvites(p, now = Date.now()) {
21
+ try {
22
+ const st = fs.lstatSync(p);
23
+ if (!st.isFile() || st.isSymbolicLink()) return [];
24
+ const rows = JSON.parse(fs.readFileSync(p, 'utf8'));
25
+ return Array.isArray(rows) ? rows.filter((x) => x && x.expiresAt > now && !x.usedAt) : [];
26
+ } catch (_) { return []; }
27
+ }
28
+
29
+ function writeInvites(p, rows) {
30
+ try { if (fs.lstatSync(p).isSymbolicLink()) throw new Error('invites target is symlink'); }
31
+ catch (e) { if (e.code !== 'ENOENT') throw e; }
32
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
33
+ const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
34
+ fs.writeFileSync(tmp, `${JSON.stringify(rows)}\n`, { mode: 0o600 });
35
+ fs.chmodSync(tmp, 0o600);
36
+ fs.renameSync(tmp, p);
37
+ }
38
+
39
+ function encodePairing(data) {
40
+ return Buffer.from(JSON.stringify(data)).toString('base64url');
41
+ }
42
+
43
+ function decodePairing(value) {
44
+ try {
45
+ const x = JSON.parse(Buffer.from(String(value || ''), 'base64url').toString('utf8'));
46
+ if (!x || x.v !== 1 || !store.NODE_ID_RE.test(x.instanceId) || !store.isPort(x.port)
47
+ || !store.validToken(x.invite) || typeof x.label !== 'string') return null;
48
+ return x;
49
+ } catch (_) { return null; }
50
+ }
51
+
52
+ function parsePairingUrl(value) {
53
+ try {
54
+ const u = new URL(String(value));
55
+ const payload = new URLSearchParams(u.hash.replace(/^#/, '')).get('pair');
56
+ return decodePairing(payload);
57
+ } catch (_) { return null; }
58
+ }
59
+
60
+ function createInvite({ invitesPath, instanceId, port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes }) {
61
+ const invite = randomBytes(32).toString('base64url');
62
+ const expiresAt = now + INVITE_TTL_MS;
63
+ const rows = readInvites(invitesPath, now);
64
+ rows.push({ hash: crypto.createHash('sha256').update(invite).digest('hex'), expiresAt, usedAt: null });
65
+ writeInvites(invitesPath, rows.slice(-32));
66
+ const pair = encodePairing({ v: 1, instanceId, port, label: String(label).slice(0, 64), invite });
67
+ return { pairingUrl: `http://127.0.0.1:${port}/#pair=${pair}`, expiresAt };
68
+ }
69
+
70
+ function consumeInvite({ invitesPath, invite, now = Date.now() }) {
71
+ const all = readInvites(invitesPath, 0);
72
+ const hash = crypto.createHash('sha256').update(String(invite || '')).digest('hex');
73
+ const idx = all.findIndex((x) => !x.usedAt && x.expiresAt > now && safeEqual(x.hash, hash));
74
+ if (idx < 0) return false;
75
+ all[idx] = { ...all[idx], usedAt: now };
76
+ writeInvites(invitesPath, all.slice(-32));
77
+ return true;
78
+ }
79
+
80
+ function allocateReversePort(nodes) {
81
+ const used = new Set();
82
+ for (const n of nodes || []) {
83
+ if (store.isPort(n.localPort)) used.add(n.localPort);
84
+ if (store.isPort(n.reversePort)) used.add(n.reversePort);
85
+ }
86
+ let p = REVERSE_PORT_BASE;
87
+ while (used.has(p) && p < 65535) p += 1;
88
+ if (p >= 65535) throw new Error('no reverse port available');
89
+ return p;
90
+ }
91
+
92
+ function createPending({ pendingPath, data, now = Date.now() }) {
93
+ const rows = readInvites(pendingPath, now);
94
+ const credential = crypto.randomBytes(32).toString('base64url');
95
+ rows.push({ ...data, hash: crypto.createHash('sha256').update(credential).digest('hex'), expiresAt: now + INVITE_TTL_MS, usedAt: null });
96
+ writeInvites(pendingPath, rows.slice(-32));
97
+ return credential;
98
+ }
99
+
100
+ function consumePending({ pendingPath, credential, now = Date.now() }) {
101
+ const rows = readInvites(pendingPath, 0);
102
+ const hash = crypto.createHash('sha256').update(String(credential || '')).digest('hex');
103
+ const idx = rows.findIndex((x) => !x.usedAt && x.expiresAt > now && safeEqual(x.hash, hash));
104
+ if (idx < 0) return null;
105
+ const found = { ...rows[idx] };
106
+ rows.splice(idx, 1);
107
+ writeInvites(pendingPath, rows.slice(-32));
108
+ delete found.hash; delete found.expiresAt; delete found.usedAt;
109
+ return found;
110
+ }
111
+
112
+ module.exports = {
113
+ INVITE_TTL_MS, REVERSE_PORT_BASE, defaultInvitesPath, defaultPendingPath, safeEqual,
114
+ readInvites, writeInvites, encodePairing, decodePairing, parsePairingUrl,
115
+ createInvite, consumeInvite, allocateReversePort, createPending, consumePending,
116
+ };