@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,425 @@
1
+ 'use strict';
2
+ // lib/nodes/store.js — nodes.json secret store (design §4, §4b(4)).
3
+ //
4
+ // nodes.json e' un SECRET STORE, non config qualunque: contiene i token dei nodi
5
+ // remoti (iniettati dal proxy in B1). Percio' riusa lo stesso hardening di
6
+ // lib/fleet/definitions.js e del token file:
7
+ // - permessi 0600, scrittura atomica (tmp stessa dir + rename), rifiuto symlink
8
+ // - schema STRICT validato al load: garbage -> errore esplicito, MAI guess
9
+ // - nessun token in output redatto (redactStore/redactNode)
10
+ //
11
+ // Modulo PURO al parse: parseStore/parseNode non toccano il filesystem.
12
+ // Tutto l'I/O vive in loadStore/atomicWriteStore/loadOrInitStore/migrateLegacyNodes.
13
+ // Principio fail-closed: qualunque dato malformato -> null (parse) o throw
14
+ // esplicito (mutazioni/write), mai un default silenzioso.
15
+ const fs = require('node:fs');
16
+ const os = require('node:os');
17
+ const path = require('node:path');
18
+ const crypto = require('node:crypto');
19
+
20
+ const SCHEMA_VERSION = 2;
21
+ const LEGACY_SCHEMA_VERSION = 1;
22
+ const MAX_NODES = 64;
23
+ const MAX_TOKEN_LEN = 4096;
24
+ const MAX_KEYPATH_LEN = 4096;
25
+ const MAX_SSH_LEN = 320; // user(<=32) + '@' + host(<=255)
26
+
27
+ // name: chiave strict usata anche come segmento path/route in B1 -> niente '.',
28
+ // '/', maiuscole. Allineato al contratto §4b(2) (^[a-z0-9-]{1,32}$).
29
+ const NODE_NAME_RE = /^[a-z0-9-]{1,32}$/;
30
+ // nodeId: id stabile per-installazione, hex casuale (crypto.randomBytes(16)).
31
+ const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
32
+ // user@host: entrambe le parti argv-safe (no spazi, no leading '-' -> mai
33
+ // interpretabile come opzione ssh). Host: hostname/FQDN/IPv4 (niente ':' -> IPv6
34
+ // va usato via Host alias in ~/.ssh/config, fuori scope B0).
35
+ const SSH_USER_RE = /^[A-Za-z0-9._-]{1,32}$/;
36
+ const SSH_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
37
+
38
+ const PORT_MIN = 1;
39
+ const PORT_MAX = 65535;
40
+
41
+ function isPort(n) {
42
+ return Number.isInteger(n) && n >= PORT_MIN && n <= PORT_MAX;
43
+ }
44
+
45
+ // user@host -> {user, host, value} | null. Strict: no whitespace/null, host non
46
+ // inizia con '-' (garanzia argv-safe, non diventa mai un flag ssh).
47
+ function parseSsh(s) {
48
+ if (typeof s !== 'string' || !s || s.length > MAX_SSH_LEN) return null;
49
+ if (s.includes('\0') || /\s/.test(s)) return null;
50
+ const at = s.indexOf('@');
51
+ if (at <= 0 || at !== s.lastIndexOf('@')) return null; // esattamente un '@'
52
+ const user = s.slice(0, at);
53
+ const host = s.slice(at + 1);
54
+ if (!SSH_USER_RE.test(user) || !SSH_HOST_RE.test(host)) return null;
55
+ return { user, host, value: `${user}@${host}` };
56
+ }
57
+
58
+ // OpenSSH target/Host alias. It is passed as one argv item (never through a
59
+ // shell), therefore the safety boundary is: non-empty, no whitespace/control
60
+ // characters and no leading '-'. user@host remains accepted as a subset.
61
+ function parseSshTarget(s) {
62
+ if (typeof s !== 'string' || !s || s.length > MAX_SSH_LEN) return null;
63
+ if (s.startsWith('-') || /[\0-\x20\x7f]/.test(s)) return null;
64
+ if (s.includes('@')) return parseSsh(s);
65
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,254}$/.test(s)) return null;
66
+ return { value: s };
67
+ }
68
+
69
+ // keyPath: path assoluto, no null/newline (la chiave potrebbe non esistere ancora
70
+ // al momento dell'add: viene generata dopo). L'esistenza si verifica al lancio ssh.
71
+ function isAbsPath(p) {
72
+ return typeof p === 'string' && p.length > 0 && p.length <= MAX_KEYPATH_LEN
73
+ && !p.includes('\0') && !/[\n\r]/.test(p) && path.isAbsolute(p);
74
+ }
75
+
76
+ // token remoto: segreto opaco, single-line, cap. Vuoto -> assente (non salvato).
77
+ // Charset ristretto a header-safe (VCHAR + spazio/tab): il token viene iniettato
78
+ // in `Authorization: Bearer <t>` verso l'upstream; un char fuori range farebbe
79
+ // lanciare setHeader in modo sincrono (ERR_INVALID_CHAR). (hardening audit).
80
+ function validToken(t) {
81
+ return typeof t === 'string' && t.length > 0 && t.length <= MAX_TOKEN_LEN
82
+ && /^[\x20-\x7e\t]+$/.test(t);
83
+ }
84
+
85
+ // roles per-nodo (default {client:true, node:false}): STRICT, nessuna chiave extra.
86
+ function parseRoles(r) {
87
+ if (r === undefined) return { client: true, node: false };
88
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
89
+ for (const k of Object.keys(r)) { if (k !== 'client' && k !== 'node') return null; }
90
+ const client = r.client === undefined ? true : r.client;
91
+ const node = r.node === undefined ? false : r.node;
92
+ if (typeof client !== 'boolean' || typeof node !== 'boolean') return null;
93
+ return { client, node };
94
+ }
95
+
96
+ // parseNode(n) -> nodo normalizzato | null (fail-closed). Nessun campo extra
97
+ // tollerato oltre a quelli noti (schema chiuso: garbage -> null, non guess).
98
+ const NODE_KEYS = new Set([
99
+ 'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
100
+ 'roles', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
101
+ 'direction', 'reversePort',
102
+ ]);
103
+ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
104
+ if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
105
+ for (const k of Object.keys(n)) { if (!NODE_KEYS.has(k)) return null; } // schema chiuso
106
+ if (typeof n.name !== 'string' || !NODE_NAME_RE.test(n.name)) return null;
107
+ const direction = n.direction || 'outbound';
108
+ if (!['outbound', 'inbound'].includes(direction)) return null;
109
+ const ssh = n.ssh === undefined && direction === 'inbound' ? null
110
+ : (schemaVersion === LEGACY_SCHEMA_VERSION ? parseSsh(n.ssh) : parseSshTarget(n.ssh));
111
+ if (direction === 'outbound' && !ssh) return null;
112
+ if (n.sshPort !== undefined && !isPort(n.sshPort)) return null;
113
+ if (!isPort(n.remotePort)) return null;
114
+ if (!isPort(n.localPort)) return null;
115
+ const identityFile = n.identityFile || n.keyPath;
116
+ if (identityFile !== undefined && !isAbsPath(identityFile)) return null;
117
+ if (schemaVersion === LEGACY_SCHEMA_VERSION && !identityFile) return null;
118
+ const roles = parseRoles(n.roles);
119
+ if (!roles) return null;
120
+
121
+ const out = {
122
+ name: n.name,
123
+ ...(ssh ? { ssh: ssh.value } : {}),
124
+ remotePort: n.remotePort,
125
+ localPort: n.localPort,
126
+ roles,
127
+ direction,
128
+ transport: n.transport || (direction === 'inbound' ? 'inbound' : (schemaVersion === LEGACY_SCHEMA_VERSION ? 'ssh' : 'auto')),
129
+ autostart: n.autostart === undefined ? schemaVersion !== LEGACY_SCHEMA_VERSION : n.autostart,
130
+ visibility: n.visibility || 'network',
131
+ };
132
+ if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
133
+ if (direction === 'inbound' && out.transport !== 'inbound') return null;
134
+ if (direction === 'outbound' && out.transport === 'inbound') return null;
135
+ if (typeof out.autostart !== 'boolean') return null;
136
+ if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
137
+ if (identityFile) out.identityFile = identityFile;
138
+ // Keep the old public field while reading v1 so old callers/tests and a
139
+ // running 0.8.1 service can coexist during the atomic upgrade.
140
+ if (n.keyPath) out.keyPath = n.keyPath;
141
+ if (n.reversePort !== undefined) {
142
+ if (!isPort(n.reversePort)) return null;
143
+ out.reversePort = n.reversePort;
144
+ }
145
+ if (n.selected !== undefined) {
146
+ if (!Array.isArray(n.selected) || n.selected.length > MAX_NODES) return null;
147
+ const selected = [...new Set(n.selected)];
148
+ if (selected.some((id) => typeof id !== 'string' || !NODE_ID_RE.test(id))) return null;
149
+ out.selected = selected;
150
+ }
151
+ // Campo opzionale per compatibilita' con gli store 0.8.0: se assente, ssh
152
+ // continua a usare la porta risolta da ~/.ssh/config o il default OpenSSH.
153
+ if (n.sshPort !== undefined) out.sshPort = n.sshPort;
154
+ if (n.token !== undefined) {
155
+ if (!validToken(n.token)) return null;
156
+ out.token = n.token;
157
+ }
158
+ if (n.acceptToken !== undefined) {
159
+ if (!validToken(n.acceptToken)) return null;
160
+ out.acceptToken = n.acceptToken;
161
+ }
162
+ if (n.nodeId !== undefined) {
163
+ if (typeof n.nodeId !== 'string' || !NODE_ID_RE.test(n.nodeId)) return null;
164
+ out.nodeId = n.nodeId;
165
+ }
166
+ return out;
167
+ }
168
+
169
+ // rendezvous (ruolo node/reverse): dove questa installazione si pubblica.
170
+ const RDV_KEYS = new Set(['ssh', 'publishedPort', 'localPort', 'keyPath']);
171
+ function parseRendezvous(r) {
172
+ if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
173
+ for (const k of Object.keys(r)) { if (!RDV_KEYS.has(k)) return null; }
174
+ const ssh = parseSsh(r.ssh);
175
+ if (!ssh) return null;
176
+ if (!isPort(r.publishedPort)) return null;
177
+ if (!isPort(r.localPort)) return null;
178
+ if (!isAbsPath(r.keyPath)) return null;
179
+ return { ssh: ssh.value, publishedPort: r.publishedPort, localPort: r.localPort, keyPath: r.keyPath };
180
+ }
181
+
182
+ // parseStore(raw) -> store normalizzato | null. Accetta stringa JSON o oggetto.
183
+ function parseStore(raw) {
184
+ try {
185
+ let d;
186
+ if (typeof raw === 'string') {
187
+ try { d = JSON.parse(raw); } catch (_) { return null; }
188
+ } else if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
189
+ d = raw;
190
+ } else {
191
+ return null;
192
+ }
193
+
194
+ if (d.schemaVersion !== SCHEMA_VERSION && d.schemaVersion !== LEGACY_SCHEMA_VERSION) return null;
195
+ if (typeof d.nodeId !== 'string' || !NODE_ID_RE.test(d.nodeId)) return null;
196
+ if (!Array.isArray(d.nodes) || d.nodes.length > MAX_NODES) return null;
197
+
198
+ const names = new Set();
199
+ const ids = new Set();
200
+ const nodes = [];
201
+ for (const raw2 of d.nodes) {
202
+ const node = parseNode(raw2, d.schemaVersion);
203
+ if (!node) return null;
204
+ if (names.has(node.name)) return null; // name univoco
205
+ names.add(node.name);
206
+ if (node.nodeId) {
207
+ if (node.nodeId === d.nodeId) return null; // self-reference nei dati salvati
208
+ if (ids.has(node.nodeId)) return null; // nodeId remoto univoco
209
+ ids.add(node.nodeId);
210
+ }
211
+ nodes.push(node);
212
+ }
213
+
214
+ const out = { schemaVersion: d.schemaVersion, nodeId: d.nodeId, nodes };
215
+
216
+ if (d.rendezvous !== undefined && d.rendezvous !== null) {
217
+ const rdv = parseRendezvous(d.rendezvous);
218
+ if (!rdv) return null;
219
+ out.rendezvous = rdv;
220
+ }
221
+ return out;
222
+ } catch (_) {
223
+ return null; // fail-closed: qualunque eccezione inattesa -> null, MAI throw
224
+ }
225
+ }
226
+
227
+ // --- I/O -------------------------------------------------------------------
228
+
229
+ function defaultNodesPath(home) {
230
+ return path.join(home || os.homedir(), '.nexuscrew', 'nodes.json');
231
+ }
232
+
233
+ // loadStore(p): legge rifiutando i symlink; parse strict. null se assente/invalido.
234
+ function loadStore(p) {
235
+ try {
236
+ let st;
237
+ try { st = fs.lstatSync(p); } catch (_) { return null; } // missing -> null
238
+ if (st.isSymbolicLink()) return null; // no symlink
239
+ if (!st.isFile()) return null;
240
+ return parseStore(fs.readFileSync(p, 'utf8'));
241
+ } catch (_) { return null; }
242
+ }
243
+
244
+ // atomicWriteStore(p, data): valida PRIMA di scrivere (fail-closed). Rifiuta di
245
+ // scrivere attraverso un symlink. tmp stessa dir -> chmod 0600 -> rename atomico.
246
+ function atomicWriteStore(p, data) {
247
+ try {
248
+ if (fs.lstatSync(p).isSymbolicLink()) {
249
+ throw new Error('refuse to write: nodes.json target e\' un symlink');
250
+ }
251
+ } catch (e) {
252
+ if (e.code === 'ENOENT') { /* nuovo file, ok */ }
253
+ else throw e; // inclusi i nostri 'refuse to write'
254
+ }
255
+
256
+ const parsed = parseStore(data);
257
+ if (!parsed) throw new Error('nodes.json non valido: validazione fallita (schema strict)');
258
+
259
+ const dir = path.dirname(p);
260
+ fs.mkdirSync(dir, { recursive: true });
261
+ const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
262
+ try {
263
+ fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
264
+ fs.chmodSync(tmp, 0o600); // forza 0600 a prescindere da umask
265
+ fs.renameSync(tmp, p); // atomico sullo stesso filesystem
266
+ } catch (e) {
267
+ try { fs.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
268
+ throw e;
269
+ }
270
+ return parsed;
271
+ }
272
+
273
+ function newNodeId() { return crypto.randomBytes(16).toString('hex'); }
274
+
275
+ function emptyStore(nodeId) {
276
+ return { schemaVersion: SCHEMA_VERSION, nodeId: nodeId || newNodeId(), nodes: [] };
277
+ }
278
+
279
+ // loadOrInitStore(p): store esistente, oppure ne crea uno vuoto (nodeId fresco,
280
+ // STABILE una volta scritto). Se il file esiste ma e' invalido -> throw esplicito
281
+ // (mai sovrascrivere/mascherare corruzione o un file altrui).
282
+ function loadOrInitStore(p) {
283
+ let st;
284
+ try { st = fs.lstatSync(p); }
285
+ catch (e) {
286
+ if (e.code === 'ENOENT') return atomicWriteStore(p, emptyStore());
287
+ throw e;
288
+ }
289
+ if (st.isSymbolicLink()) throw new Error('nodes.json e\' un symlink (rifiutato)');
290
+ const parsed = loadStore(p);
291
+ if (!parsed) throw new Error('nodes.json presente ma invalido (schema strict): correggi o rimuovi il file');
292
+ return parsed;
293
+ }
294
+
295
+ // --- Mutazioni pure (ritornano un nuovo store; il caller lo scrive) ---------
296
+
297
+ function getNode(store, name) {
298
+ return store.nodes.find((n) => n.name === name) || null;
299
+ }
300
+
301
+ function addNode(store, entry) {
302
+ const node = parseNode(entry, SCHEMA_VERSION);
303
+ if (!node) throw new Error('nodo non valido (schema strict): controlla name/ssh/remotePort/localPort');
304
+ if (store.nodes.some((n) => n.name === node.name)) {
305
+ throw new Error(`nodo duplicato: name "${node.name}" gia' presente`);
306
+ }
307
+ if (node.nodeId) {
308
+ if (node.nodeId === store.nodeId) throw new Error('self-reference: il nodeId coincide con questa installazione');
309
+ if (store.nodes.some((n) => n.nodeId === node.nodeId)) {
310
+ throw new Error(`nodo duplicato: nodeId "${node.nodeId}" gia' presente`);
311
+ }
312
+ }
313
+ return { ...store, schemaVersion: SCHEMA_VERSION, nodes: store.nodes.concat([node]) };
314
+ }
315
+
316
+ function removeNode(store, name) {
317
+ const idx = store.nodes.findIndex((n) => n.name === name);
318
+ if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
319
+ const nodes = store.nodes.slice();
320
+ nodes.splice(idx, 1);
321
+ return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
322
+ }
323
+
324
+ function setNodeToken(store, name, token) {
325
+ const idx = store.nodes.findIndex((n) => n.name === name);
326
+ if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
327
+ if (!validToken(token)) throw new Error('token non valido (vuoto, multilinea o troppo lungo)');
328
+ const nodes = store.nodes.slice();
329
+ nodes[idx] = { ...nodes[idx], token };
330
+ return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
331
+ }
332
+
333
+ function updateNode(store, name, patch) {
334
+ const idx = store.nodes.findIndex((n) => n.name === name);
335
+ if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
336
+ const parsed = parseNode({ ...store.nodes[idx], ...patch }, SCHEMA_VERSION);
337
+ if (!parsed) throw new Error('aggiornamento nodo non valido');
338
+ const nodes = store.nodes.slice();
339
+ nodes[idx] = parsed;
340
+ return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
341
+ }
342
+
343
+ function setRendezvous(store, rdv) {
344
+ const parsed = parseRendezvous(rdv);
345
+ if (!parsed) throw new Error('rendezvous non valido: controlla ssh/publishedPort/localPort/keyPath');
346
+ return { ...store, schemaVersion: SCHEMA_VERSION, rendezvous: parsed };
347
+ }
348
+
349
+ function clearRendezvous(store) {
350
+ const out = { ...store, schemaVersion: SCHEMA_VERSION };
351
+ delete out.rendezvous;
352
+ return out;
353
+ }
354
+
355
+ // --- Redazione (view sicura per status/list: MAI il token) ------------------
356
+
357
+ function redactNode(n) {
358
+ const out = {
359
+ name: n.name,
360
+ ...(n.ssh ? { ssh: n.ssh } : {}),
361
+ remotePort: n.remotePort,
362
+ localPort: n.localPort,
363
+ direction: n.direction || 'outbound',
364
+ transport: n.transport || 'ssh',
365
+ autostart: !!n.autostart,
366
+ visibility: n.visibility || 'network',
367
+ hasToken: !!n.token, // presenza, non il valore
368
+ paired: !!(n.token && n.acceptToken),
369
+ };
370
+ if (n.identityFile || n.keyPath) out.hasIdentity = true;
371
+ if (n.visibility === 'selected') out.selected = [...(n.selected || [])];
372
+ if (n.sshPort !== undefined) out.sshPort = n.sshPort;
373
+ if (n.nodeId) out.nodeId = n.nodeId;
374
+ return out;
375
+ }
376
+
377
+ function redactStore(store) {
378
+ const out = {
379
+ schemaVersion: store.schemaVersion,
380
+ nodeId: store.nodeId,
381
+ nodes: store.nodes.map(redactNode),
382
+ };
383
+ // rendezvous non contiene token: sicuro da esporre integralmente.
384
+ if (store.rendezvous) out.rendezvous = { ...store.rendezvous };
385
+ return out;
386
+ }
387
+
388
+ // --- Migrazione esplicita da config.json (guarded, no-op se assente) ---------
389
+ // Se config.json contiene un array `nodes` legacy (vecchio formato pre-B0),
390
+ // lo importa in nodes.json. Guarded: no-op se config.json non ha `nodes`, o se
391
+ // nodes.json ha gia' dei nodi (mai overwrite). Strict: un nodo legacy malformato
392
+ // -> throw esplicito (non importa silenziosamente spazzatura).
393
+ function migrateLegacyNodes(configPath, nodesPath) {
394
+ let cfg;
395
+ try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
396
+ catch (_) { return { migrated: false, count: 0, reason: 'config.json assente/illeggibile' }; }
397
+ if (!cfg || typeof cfg !== 'object' || !Array.isArray(cfg.nodes) || cfg.nodes.length === 0) {
398
+ return { migrated: false, count: 0, reason: 'nessun campo nodes legacy in config.json' };
399
+ }
400
+ const store = loadOrInitStore(nodesPath);
401
+ if (store.nodes.length > 0) {
402
+ return { migrated: false, count: 0, reason: 'nodes.json gia\' popolato (no overwrite)' };
403
+ }
404
+ let next = store;
405
+ for (const legacy of cfg.nodes) {
406
+ next = addNode(next, legacy); // strict: garbage legacy -> throw esplicito
407
+ }
408
+ const written = atomicWriteStore(nodesPath, next);
409
+ return { migrated: true, count: written.nodes.length, reason: 'migrato da config.json' };
410
+ }
411
+
412
+ module.exports = {
413
+ // parse/validate
414
+ parseStore, parseNode, parseRendezvous, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
415
+ // I/O
416
+ defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
417
+ // mutazioni
418
+ getNode, addNode, removeNode, setNodeToken, updateNode, setRendezvous, clearRendezvous,
419
+ // redazione
420
+ redactNode, redactStore,
421
+ // migrazione
422
+ migrateLegacyNodes,
423
+ // costanti
424
+ SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
425
+ };
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ // Detached supervisor for one SSH tunnel. The parent NexusCrew process can exit;
4
+ // this process keeps the tunnel alive and retries failures with bounded backoff.
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const { spawn } = require('node:child_process');
8
+ const { backoffDelay } = require('./tunnel.js');
9
+
10
+ const sshBin = process.argv[2];
11
+ const sshArgs = process.argv.slice(3);
12
+ const statePath = process.env.NEXUSCREW_TUNNEL_STATE;
13
+ if (!sshBin || !statePath) process.exit(2);
14
+
15
+ let child = null;
16
+ let stopping = false;
17
+ let attempt = 0;
18
+ let retryTimer = null;
19
+ let upTimer = null;
20
+
21
+ function writeState(status, extra = {}) {
22
+ const tmp = `${statePath}.tmp.${process.pid}`;
23
+ const data = { status, supervisorPid: process.pid, attempt, updatedAt: Date.now(), ...extra };
24
+ try {
25
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
26
+ fs.writeFileSync(tmp, `${JSON.stringify(data)}\n`, { mode: 0o600 });
27
+ fs.chmodSync(tmp, 0o600);
28
+ fs.renameSync(tmp, statePath);
29
+ } catch (_) {
30
+ try { fs.unlinkSync(tmp); } catch (_e) {}
31
+ }
32
+ }
33
+
34
+ function scheduleRetry(detail) {
35
+ if (stopping) return finish();
36
+ const delayMs = backoffDelay(attempt, { baseMs: 1000, capMs: 60000 });
37
+ writeState('retrying', { delayMs, detail });
38
+ attempt += 1;
39
+ retryTimer = setTimeout(run, delayMs);
40
+ }
41
+
42
+ function run() {
43
+ if (stopping) return finish();
44
+ writeState('starting');
45
+ try {
46
+ child = spawn(sshBin, sshArgs, { stdio: 'inherit' });
47
+ } catch (e) {
48
+ child = null;
49
+ return scheduleRetry(String(e && e.message || e));
50
+ }
51
+
52
+ let failureHandled = false;
53
+ const handleFailure = (detail) => {
54
+ if (failureHandled) return;
55
+ failureHandled = true;
56
+ clearTimeout(upTimer);
57
+ child = null;
58
+ scheduleRetry(detail);
59
+ };
60
+ child.once('spawn', () => {
61
+ // ExitOnForwardFailure makes bind/auth failures exit. Surviving this short
62
+ // grace window is the best portable readiness signal available for ssh -N.
63
+ upTimer = setTimeout(() => {
64
+ if (!stopping && child && child.exitCode == null) {
65
+ attempt = 0;
66
+ writeState('up', { sshPid: child.pid });
67
+ }
68
+ }, 750);
69
+ });
70
+ child.once('error', (e) => {
71
+ handleFailure(String(e && e.message || e));
72
+ });
73
+ child.once('exit', (code, signal) => {
74
+ if (stopping) return finish();
75
+ handleFailure(`ssh exited code=${code} signal=${signal || ''}`);
76
+ });
77
+ }
78
+
79
+ function finish() {
80
+ clearTimeout(retryTimer);
81
+ clearTimeout(upTimer);
82
+ writeState('down');
83
+ try { fs.unlinkSync(statePath); } catch (_) {}
84
+ process.exit(0);
85
+ }
86
+
87
+ function stop() {
88
+ if (stopping) return;
89
+ stopping = true;
90
+ clearTimeout(retryTimer);
91
+ clearTimeout(upTimer);
92
+ if (child && child.exitCode == null) {
93
+ try { child.kill('SIGTERM'); } catch (_) {}
94
+ setTimeout(() => { try { if (child && child.exitCode == null) child.kill('SIGKILL'); } catch (_) {} finish(); }, 1500).unref();
95
+ } else {
96
+ finish();
97
+ }
98
+ }
99
+
100
+ process.on('SIGTERM', stop);
101
+ process.on('SIGINT', stop);
102
+ run();