@mmmbuto/nexuscrew 0.8.9 → 0.8.11

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.
@@ -1,20 +1,22 @@
1
1
  'use strict';
2
- // lib/nodes/commands.js — subcomandi CLI `nodes` e `node` (design §3, §4, §4b).
2
+ // lib/nodes/commands.js — implementation helpers for the authenticated PWA.
3
3
  //
4
- // nodes add/list/remove/test/up/down/restart/set-token + node on/off.
4
+ // Nodes add/list/remove/test/up/down/restart/set-token. These helpers are not
5
+ // advertised as public CLI commands.
5
6
  // Invarianti:
6
7
  // - token per-nodo MAI loggati/stampati (redazione sempre; set-token li legge
7
8
  // da stdin/env, mai da argv -> niente segreti in `ps`).
8
9
  // - 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
+ // configuration), while the Settings routes separately gate process lifecycle.
10
11
  // - niente shell interpolation: ssh-keygen via execFile (argv), tunnel via spawn argv.
11
12
  const fs = require('node:fs');
13
+ const net = require('node:net');
12
14
  const os = require('node:os');
13
15
  const path = require('node:path');
14
16
  const { execFileSync } = require('node:child_process');
15
17
  const store = require('./store.js');
16
18
  const tunnel = require('./tunnel.js');
17
- const { resolvePaths, loadPort, DEFAULT_PORT } = require('../cli/url.js');
19
+ const { resolvePaths, DEFAULT_PORT } = require('../cli/url.js');
18
20
 
19
21
  const LOCAL_PORT_BASE = 43001; // porte locali stabili per i forward (design: stabili da nodes.json)
20
22
 
@@ -38,6 +40,49 @@ function assignLocalPort(st) {
38
40
  return p;
39
41
  }
40
42
 
43
+ function bindLocalPort(port, createServerImpl = net.createServer) {
44
+ return new Promise((resolve, reject) => {
45
+ const server = createServerImpl();
46
+ const onError = (error) => {
47
+ server.removeListener('listening', onListening);
48
+ reject(error);
49
+ };
50
+ const onListening = () => {
51
+ server.removeListener('error', onError);
52
+ if (typeof server.unref === 'function') server.unref();
53
+ let released = false;
54
+ resolve({
55
+ port,
56
+ release: () => new Promise((done) => {
57
+ if (released) return done();
58
+ released = true;
59
+ try { server.close(() => done()); } catch (_) { done(); }
60
+ }),
61
+ });
62
+ };
63
+ server.once('error', onError);
64
+ server.once('listening', onListening);
65
+ server.listen({ host: '127.0.0.1', port, exclusive: true });
66
+ });
67
+ }
68
+
69
+ // Selezione OS-aware per il pairing: nodes.json evita collisioni logiche, il
70
+ // bind reale evita porte gia' occupate da processi estranei. La socket resta
71
+ // riservata fino all'avvio del supervisor SSH e viene poi rilasciata una volta.
72
+ async function reserveLocalPort(st, opts = {}) {
73
+ const used = new Set((st.nodes || []).map((n) => n.localPort));
74
+ const createServerImpl = opts.createServerImpl || net.createServer;
75
+ for (let port = opts.start || LOCAL_PORT_BASE; port <= 65535; port += 1) {
76
+ if (used.has(port)) continue;
77
+ try { return await bindLocalPort(port, createServerImpl); }
78
+ catch (error) {
79
+ if (error && (error.code === 'EADDRINUSE' || error.code === 'EACCES')) continue;
80
+ throw error;
81
+ }
82
+ }
83
+ throw new Error('nessuna porta locale disponibile per il tunnel');
84
+ }
85
+
41
86
  function defaultKeyPath(home, name) {
42
87
  return path.join(home, '.nexuscrew', 'keys', `${name}_ed25519`);
43
88
  }
@@ -59,24 +104,6 @@ function ensureKey(keyPath, name, opts = {}) {
59
104
  return fs.readFileSync(pubPath, 'utf8').trim();
60
105
  }
61
106
 
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
107
  // Legge il token remoto da fonte NON-argv: opts.token (test) > env > stdin.
81
108
  // MAI da flag CLI: il token comparirebbe in `ps`/history.
82
109
  function readSecretToken(opts) {
@@ -93,7 +120,7 @@ function nodesAdd(opts) {
93
120
  const { home, nodesPath } = resolveNodePaths(opts);
94
121
  const name = opts.name;
95
122
  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' }; }
123
+ if (!name) { log('nodes add: name mancante (configura il nodo dalla PWA)'); return { code: 1, reason: 'name mancante' }; }
97
124
  if (!ssh) { log('nodes add: --ssh user@host obbligatorio'); return { code: 1, reason: 'ssh mancante' }; }
98
125
 
99
126
  const remotePort = opts.remotePort ? Number(opts.remotePort) : DEFAULT_PORT;
@@ -159,7 +186,6 @@ function nodesList(opts) {
159
186
  const view = store.redactStore(st); // MAI il token
160
187
  const nodes = view.nodes.map((n) => ({ ...n, tunnel: tunnel.readTunnelState(home, n.name) }));
161
188
  const out = { nodeId: view.nodeId, nodes };
162
- if (view.rendezvous) out.rendezvous = view.rendezvous;
163
189
 
164
190
  if (opts.json) { log(JSON.stringify(out, null, 2)); return { code: 0, nodes }; }
165
191
  if (nodes.length === 0) { log('nodes: (nessun nodo)'); return { code: 0, nodes }; }
@@ -175,7 +201,7 @@ function nodesRemove(opts) {
175
201
  if (isReadonly(opts)) { log('nodes remove: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
176
202
  const { home, nodesPath } = resolveNodePaths(opts);
177
203
  const name = opts.name;
178
- if (!name) { log('usage: nexuscrew nodes remove <name>'); return { code: 1 }; }
204
+ if (!name) { log('nodes remove: name mancante'); return { code: 1 }; }
179
205
  const st = store.loadStore(nodesPath);
180
206
  if (!st) { log('nodes remove: nessun nodes.json valido'); return { code: 1 }; }
181
207
  let next;
@@ -207,15 +233,16 @@ async function nodesTest(opts) {
207
233
  const log = opts.log || console.log;
208
234
  const { home, nodesPath } = resolveNodePaths(opts);
209
235
  const name = opts.name;
210
- if (!name) { log('usage: nexuscrew nodes test <name>'); return { code: 1 }; }
236
+ if (!name) { log('nodes test: name mancante'); return { code: 1 }; }
211
237
  const st = store.loadStore(nodesPath);
212
238
  const node = st ? store.getNode(st, name) : null;
213
239
  if (!node) { log(`nodes test: nodo sconosciuto "${name}"`); return { code: 1, result: 'unknown-node' }; }
214
240
 
215
241
  const state = tunnel.readTunnelState(home, name);
216
242
  if (state.status !== 'up') {
217
- log(`nodes test [${name}]: TUNNEL DOWN — avvia con \`nexuscrew nodes up ${name}\``);
218
- return { code: 1, result: 'tunnel-down' };
243
+ const diagnostic = tunnel.diagnoseTunnel(home, node, state);
244
+ log(`nodes test [${name}]: ${diagnostic.code.toUpperCase()} ${diagnostic.detail}${diagnostic.hint ? ` · ${diagnostic.hint}` : ''}`);
245
+ return { code: 1, result: 'tunnel-down', diagnostic };
219
246
  }
220
247
 
221
248
  const httpProbe = opts.httpProbe || defaultHttpProbe;
@@ -226,13 +253,17 @@ async function nodesTest(opts) {
226
253
  try { health = await httpProbe(`${base}/`, {}); }
227
254
  catch (e) { health = { ok: false, error: e && e.message }; }
228
255
  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' };
256
+ const diagnostic = tunnel.diagnoseTunnel(home, node);
257
+ const realCause = diagnostic.code !== 'transport-ready' ? diagnostic.detail : ((health && health.error) || 'server HTTP non raggiungibile');
258
+ log(`nodes test [${name}]: HEALTH KO — ${realCause}${diagnostic.hint ? ` · ${diagnostic.hint}` : ''}`);
259
+ return { code: 1, result: 'health-ko', diagnostic: diagnostic.code === 'transport-ready'
260
+ ? { stage: 'http', code: 'peer-http-unreachable', detail: realCause, hint: 'verifica la porta NexusCrew contenuta nel link' }
261
+ : diagnostic };
231
262
  }
232
263
 
233
264
  // token: GET /api/config con Bearer del nodo -> 200 ok, 401 token KO.
234
265
  if (!node.token) {
235
- log(`nodes test [${name}]: TOKEN ASSENTEsalva il token remoto con \`nexuscrew nodes set-token ${name}\``);
266
+ log(`nodes test [${name}]: ASSOCIAZIONE INCOMPLETArimuovi il nodo e ripeti il pairing dalla PWA`);
236
267
  return { code: 1, result: 'token-missing' };
237
268
  }
238
269
  let authed;
@@ -242,7 +273,7 @@ async function nodesTest(opts) {
242
273
  log(`nodes test [${name}]: OK — tunnel up, health ok, token valido`);
243
274
  return { code: 0, result: 'ok' };
244
275
  }
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}\``);
276
+ log(`nodes test [${name}]: CREDENZIALE KO — il pairing non e' piu' valido (status ${(authed && authed.status) || '?'}); ripeti il pairing dalla PWA`);
246
277
  return { code: 1, result: 'token-ko' };
247
278
  }
248
279
 
@@ -256,11 +287,18 @@ async function defaultHttpProbe(url, headers) {
256
287
  function loadNodeOrFail(opts, log) {
257
288
  const { home, nodesPath } = resolveNodePaths(opts);
258
289
  const name = opts.name;
259
- if (!name) { log('usage: nexuscrew nodes up|down|restart <name>'); return null; }
290
+ if (!name) { log('nodes: name mancante'); return null; }
260
291
  const st = store.loadStore(nodesPath);
261
292
  const node = st ? store.getNode(st, name) : null;
262
293
  if (!node) { log(`nodes: nodo sconosciuto "${name}"`); return null; }
263
- return { home, node };
294
+ return { home, nodesPath, store: st, node };
295
+ }
296
+
297
+ function persistAutostart(ctx, enabled) {
298
+ const next = store.updateNode(ctx.store, ctx.node.name, { autostart: enabled });
299
+ store.atomicWriteStore(ctx.nodesPath, next);
300
+ ctx.store = next;
301
+ ctx.node = store.getNode(next, ctx.node.name);
264
302
  }
265
303
 
266
304
  function nodesUp(opts) {
@@ -268,29 +306,35 @@ function nodesUp(opts) {
268
306
  const ctx = loadNodeOrFail(opts, log);
269
307
  if (!ctx) return { code: 1 };
270
308
  if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
309
+ if (opts.persistAutostart === true && ctx.node.autostart !== true) {
310
+ try { persistAutostart(ctx, true); }
311
+ catch (e) { log(`nodes up [${ctx.node.name}]: impossibile salvare l'avvio automatico — ${e.message}`); return { code: 1, reason: 'autostart write failed' }; }
312
+ }
271
313
  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
314
  if (r.started) {
273
315
  log(`nodes up [${ctx.node.name}]: tunnel avviato (pid ${r.pid}, local ${ctx.node.localPort})`);
274
- return { code: 0, started: true, pid: r.pid };
316
+ return { code: 0, started: true, pid: r.pid, diagnostic: tunnel.diagnoseTunnel(ctx.home, ctx.node) };
275
317
  }
276
318
  if (r.reason === 'already running') {
277
319
  log(`nodes up [${ctx.node.name}]: gia' attivo (pid ${r.pid})`);
278
- return { code: 0, started: false, pid: r.pid };
320
+ return { code: 0, started: false, pid: r.pid, diagnostic: tunnel.diagnoseTunnel(ctx.home, ctx.node) };
279
321
  }
280
322
  // failure esplicita (ssh mancante / spawn error): surfacciata a CLI e Settings API.
281
323
  log(`nodes up [${ctx.node.name}]: avvio tunnel fallito — ${r.reason}`);
282
- return { code: 1, started: false, reason: r.reason };
324
+ return { code: 1, started: false, reason: r.reason, diagnostic: tunnel.diagnoseTunnel(ctx.home, ctx.node) };
283
325
  }
284
326
 
285
327
  function nodesDown(opts) {
286
328
  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}`);
329
+ const ctx = loadNodeOrFail(opts, log);
330
+ if (!ctx) return { code: 1 };
331
+ if (ctx.node.direction === 'inbound') return { code: 0, stopped: false, inbound: true };
332
+ if (opts.persistAutostart === true && ctx.node.autostart !== false) {
333
+ try { persistAutostart(ctx, false); }
334
+ catch (e) { log(`nodes down [${ctx.node.name}]: impossibile salvare lo stop — ${e.message}`); return { code: 1, reason: 'autostart write failed' }; }
335
+ }
336
+ const r = tunnel.stopTunnel({ home: ctx.home, name: ctx.node.name });
337
+ log(`nodes down [${ctx.node.name}]: ${r.stopped ? `fermato (pid ${r.pid})` : r.reason}`);
294
338
  return { code: 0, stopped: r.stopped };
295
339
  }
296
340
 
@@ -298,16 +342,17 @@ function nodesRestart(opts) {
298
342
  const log = opts.log || console.log;
299
343
  const ctx = loadNodeOrFail(opts, log);
300
344
  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 });
345
+ if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
346
+ tunnel.stopTunnel({ home: ctx.home, name: ctx.node.name });
347
+ 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 });
303
348
  if (r.started) {
304
349
  log(`nodes restart [${ctx.node.name}]: tunnel riavviato (pid ${r.pid})`);
305
- return { code: 0, started: true, pid: r.pid };
350
+ return { code: 0, started: true, pid: r.pid, diagnostic: tunnel.diagnoseTunnel(ctx.home, ctx.node) };
306
351
  }
307
352
  // dopo stop+start, 'already running' non e' atteso: qualunque !started e' un problema
308
353
  // esplicito (ssh mancante / spawn error), surfacciato a CLI e Settings API.
309
354
  log(`nodes restart [${ctx.node.name}]: riavvio tunnel fallito — ${r.reason || 'sconosciuto'}`);
310
- return { code: 1, started: false, reason: r.reason || 'spawn failed' };
355
+ return { code: 1, started: false, reason: r.reason || 'spawn failed', diagnostic: tunnel.diagnoseTunnel(ctx.home, ctx.node) };
311
356
  }
312
357
 
313
358
  // --- nodes set-token (aggiorna il token remoto; MAI da argv) ----------------
@@ -316,7 +361,7 @@ function nodesSetToken(opts) {
316
361
  if (isReadonly(opts)) { log('nodes set-token: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
317
362
  const { nodesPath } = resolveNodePaths(opts);
318
363
  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 }; }
364
+ if (!name) { log('nodes set-token: name mancante'); return { code: 1 }; }
320
365
  const st = store.loadStore(nodesPath);
321
366
  if (!st || !store.getNode(st, name)) { log(`nodes set-token: nodo sconosciuto "${name}"`); return { code: 1 }; }
322
367
  const token = readSecretToken(opts);
@@ -329,83 +374,10 @@ function nodesSetToken(opts) {
329
374
  return { code: 0, name };
330
375
  }
331
376
 
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
377
  module.exports = {
405
378
  nodesAdd, nodesList, nodesRemove, nodesTest,
406
379
  nodesUp, nodesDown, nodesRestart, nodesSetToken,
407
- nodeOn, nodeOff,
408
380
  // helper esposti per test/riuso
409
- assignLocalPort, defaultKeyPath, ensureKey, writeConfigRole, readSecretToken,
381
+ assignLocalPort, bindLocalPort, reserveLocalPort, defaultKeyPath, ensureKey, readSecretToken,
410
382
  resolveNodePaths, defaultHttpProbe,
411
383
  };
@@ -30,23 +30,29 @@ function clearHealthCache() { cache.clear(); }
30
30
  // onesto). outbound down/401 -> 'down'/'degraded'.
31
31
  function tunnelFromHealth(h) {
32
32
  if (!h) return { status: 'unknown', managed: false };
33
- if (h.status === 'passive') return { status: 'passive', managed: false };
34
- if (h.transport === 'up' && h.auth === 'ok') return { status: 'up', managed: h.managed !== false };
35
- if (h.transport === 'up' && h.auth === 'failed') return { status: 'degraded', managed: h.managed !== false };
36
- if (h.transport === 'up') return { status: 'degraded', managed: h.managed !== false };
37
- if (h.transport === 'down') return { status: 'down', managed: h.managed !== false };
33
+ const used = h.transportEngine ? { transport: h.transportEngine } : {};
34
+ if (h.status === 'passive') return { status: 'passive', managed: false, ...used };
35
+ if (h.transport === 'up' && h.auth === 'ok') return { status: 'up', managed: h.managed !== false, ...used };
36
+ if (h.transport === 'up' && h.auth === 'failed') return { status: 'degraded', managed: h.managed !== false, ...used };
37
+ if (h.transport === 'up') return { status: 'degraded', managed: h.managed !== false, ...used };
38
+ if (h.transport === 'down') return { status: 'down', managed: h.managed !== false, ...used };
38
39
  return { status: 'unknown', managed: false }; // inbound / unknown
39
40
  }
40
41
 
41
42
  async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false }) {
42
43
  if (!node || typeof node !== 'object') return null;
43
- const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}\0${node.rolesKnown === true}\0${node.roles?.node === true}`;
44
+ const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}\0${node.shared === true}\0${node.rolesKnown === true}\0${node.roles?.node === true}`;
44
45
  const cached = !force && cache.get(cacheKey);
45
46
  if (cached && (now - cached.at) < TTL_MS) return cached.health;
46
47
 
47
48
  let health;
48
49
  if (node.direction === 'inbound') {
49
- if (!node.token) {
50
+ if (node.shared !== true) {
51
+ health = {
52
+ transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'passive',
53
+ detail: 'client privato collegato (Share disattivato)', expected: true, managed: false, at: now,
54
+ };
55
+ } else if (!node.token) {
50
56
  health = {
51
57
  transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'degraded',
52
58
  detail: 'peer inbound senza credenziale federation — re-pair', managed: false, at: now,
@@ -70,22 +76,24 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
70
76
  } else {
71
77
  const ts = nodesTunnel.readTunnelState(home, node.name);
72
78
  if (ts.status !== 'up') {
73
- health = {
74
- transport: 'down', auth: 'unknown', reachability: 'unknown', status: 'down',
75
- detail: ts.reason ? `tunnel down (${ts.reason})` : 'tunnel down',
76
- managed: ts.managed !== false, at: now,
77
- };
79
+ const diagnostic = nodesTunnel.diagnoseTunnel(home, node, ts);
80
+ health = {
81
+ transport: 'down', auth: 'unknown', reachability: 'unknown', status: 'down',
82
+ detail: diagnostic.detail, code: diagnostic.code, stage: diagnostic.stage,
83
+ ...(diagnostic.hint ? { hint: diagnostic.hint } : {}),
84
+ transportEngine: ts.transport || 'ssh', managed: ts.managed !== false, at: now,
85
+ };
78
86
  } else if (!node.token) {
79
87
  health = {
80
88
  transport: 'up', auth: 'unknown', reachability: 'unknown', status: 'degraded',
81
89
  detail: 'tunnel up, credenziali federation assenti (token mancante) — re-pair',
82
- managed: true, at: now,
90
+ transportEngine: ts.transport || 'ssh', managed: true, at: now,
83
91
  };
84
92
  } else {
85
93
  const probed = await probeHealth({
86
94
  port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
87
95
  });
88
- health = { ...probed, managed: true };
96
+ health = { ...probed, transportEngine: ts.transport || 'ssh', managed: true };
89
97
  }
90
98
  }
91
99
  cache.set(cacheKey, { health, at: now });
@@ -7,6 +7,8 @@ const store = require('./store.js');
7
7
 
8
8
  const INVITE_TTL_MS = 10 * 60 * 1000;
9
9
  const REVERSE_PORT_BASE = 44001;
10
+ const CAPABILITY_ID_RE = /^[a-f0-9]{64}$/;
11
+ const CHALLENGE_RE = /^[A-Za-z0-9_-]{16,128}$/;
10
12
 
11
13
  function defaultInvitesPath(home) { return path.join(home, '.nexuscrew', 'invites.json'); }
12
14
  function defaultPendingPath(home) { return path.join(home, '.nexuscrew', 'pairing-pending.json'); }
@@ -162,21 +164,81 @@ function consumePending({ pendingPath, credential, now = Date.now() }) {
162
164
  return found;
163
165
  }
164
166
 
165
- // Probe di trasporto del tunnel -L provvisorio PRIMA di consumare l'invite
166
- // one-time: qualunque risposta HTTP dal peer attraverso la forward (anche un
167
- // 401 su /federation/health senza credenziali) dimostra che ssh+forward sono
168
- // vivi; un errore di rete no. Sostituisce lo sleep fisso 900ms: bounded, con
169
- // sleep iniettabile (deterministico nei test) e timeout per tentativo.
170
- async function probeTransportReady({ port, fetchImpl = fetch, attempts = 6, timeoutMs = 1500, sleep } = {}) {
167
+ // Prova pubblica ma capability-bound usata durante il pairing. Il client NON
168
+ // invia mai l'invito/credential: invia SHA256(SHA256(capability)) + challenge;
169
+ // il peer cerca il record attivo e firma il challenge con SHA256(capability).
170
+ // Un processo HTTP estraneo sulla stessa porta non puo' quindi diventare un
171
+ // falso positivo e non riceve materiale sufficiente per consumare l'invito.
172
+ function capabilityIdentity({ invitesPath, pendingPath, capabilityId, challenge, now = Date.now() } = {}) {
173
+ if (!CAPABILITY_ID_RE.test(String(capabilityId || '')) || !CHALLENGE_RE.test(String(challenge || ''))) return null;
174
+ const rows = [
175
+ ...readInvites(invitesPath, now),
176
+ ...readInvites(pendingPath, now),
177
+ ];
178
+ const row = rows.find((x) => {
179
+ if (!x || !CAPABILITY_ID_RE.test(String(x.hash || ''))) return false;
180
+ const id = crypto.createHash('sha256').update(Buffer.from(x.hash, 'hex')).digest('hex');
181
+ return safeEqual(id, capabilityId);
182
+ });
183
+ if (!row) return null;
184
+ return crypto.createHmac('sha256', Buffer.from(row.hash, 'hex')).update(challenge).digest('base64url');
185
+ }
186
+
187
+ function capabilityProbeMaterial(capability, randomBytes = crypto.randomBytes) {
188
+ if (!store.validToken(capability)) return null;
189
+ const key = crypto.createHash('sha256').update(capability).digest();
190
+ return {
191
+ key,
192
+ capabilityId: crypto.createHash('sha256').update(key).digest('hex'),
193
+ challenge: randomBytes(24).toString('base64url'),
194
+ };
195
+ }
196
+
197
+ // Probe di trasporto del tunnel -L PRIMA di consumare l'invito e dopo il
198
+ // restart negoziato. Oltre alla reachability verifica prova capability e
199
+ // instanceId atteso. Bounded, con sleep/random iniettabili per test.
200
+ async function probeTransportReady({
201
+ port, capability, expectedInstanceId, fetchImpl = fetch, attempts = 6,
202
+ timeoutMs = 1500, sleep, randomBytes,
203
+ } = {}) {
171
204
  const wait = typeof sleep === 'function' ? sleep : (ms) => new Promise((r) => setTimeout(r, ms));
205
+ const material = capabilityProbeMaterial(capability, randomBytes);
206
+ if (!store.isPort(port) || !material || !store.NODE_ID_RE.test(String(expectedInstanceId || ''))) {
207
+ return { ready: false, attempts: 0, code: 'identity-probe-invalid', lastError: 'parametri identity probe non validi' };
208
+ }
172
209
  let lastError = '';
210
+ let code = 'transport-not-ready';
173
211
  for (let i = 0; i < attempts; i += 1) {
174
212
  let timer;
175
213
  try {
176
214
  const ctrl = new AbortController();
177
215
  timer = setTimeout(() => ctrl.abort(), timeoutMs);
178
- await fetchImpl(`http://127.0.0.1:${port}/federation/health`, { signal: ctrl.signal });
179
- return { ready: true, attempts: i + 1 };
216
+ const response = await fetchImpl(`http://127.0.0.1:${port}/pair/identity`, {
217
+ method: 'POST',
218
+ headers: { 'content-type': 'application/json' },
219
+ body: JSON.stringify({ capabilityId: material.capabilityId, challenge: material.challenge }),
220
+ signal: ctrl.signal,
221
+ });
222
+ if (!response || response.status !== 200) {
223
+ code = 'identity-proof-rejected';
224
+ throw new Error(`identity probe HTTP ${(response && response.status) || '?'}`);
225
+ }
226
+ const body = await response.json().catch(() => null);
227
+ if (!body || body.ok !== true || !store.NODE_ID_RE.test(String(body.instanceId || ''))
228
+ || typeof body.proof !== 'string') {
229
+ code = 'identity-proof-invalid';
230
+ throw new Error('identity probe payload non valido');
231
+ }
232
+ if (body.instanceId !== expectedInstanceId) {
233
+ code = 'peer-identity-mismatch';
234
+ throw new Error('instanceId del peer non coincide con il link');
235
+ }
236
+ const expected = crypto.createHmac('sha256', material.key).update(material.challenge).digest('base64url');
237
+ if (!safeEqual(expected, body.proof)) {
238
+ code = 'identity-proof-invalid';
239
+ throw new Error('prova crittografica del peer non valida');
240
+ }
241
+ return { ready: true, attempts: i + 1, instanceId: body.instanceId };
180
242
  } catch (e) {
181
243
  lastError = String((e && e.message) || e);
182
244
  } finally {
@@ -184,12 +246,12 @@ async function probeTransportReady({ port, fetchImpl = fetch, attempts = 6, time
184
246
  }
185
247
  if (i < attempts - 1) await wait(250 * (i + 1));
186
248
  }
187
- return { ready: false, attempts, lastError };
249
+ return { ready: false, attempts, code, lastError };
188
250
  }
189
251
 
190
252
  module.exports = {
191
253
  INVITE_TTL_MS, REVERSE_PORT_BASE, defaultInvitesPath, defaultPendingPath, safeEqual,
192
254
  readInvites, writeInvites, encodePairing, decodePairing, parsePairingUrl,
193
255
  createInvite, consumeInvite, allocateReversePort, createPending, consumePending,
194
- probeTransportReady,
256
+ capabilityIdentity, capabilityProbeMaterial, probeTransportReady,
195
257
  };
@@ -104,7 +104,7 @@ const LABEL_MAX = 64;
104
104
  const NODE_KEYS = new Set([
105
105
  'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
106
106
  'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
107
- 'direction', 'reversePort', 'label',
107
+ 'direction', 'reversePort', 'shared', 'label',
108
108
  ]);
109
109
  function parseNode(n, schemaVersion = SCHEMA_VERSION) {
110
110
  if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
@@ -148,12 +148,18 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
148
148
  direction,
149
149
  transport: n.transport || (direction === 'inbound' ? 'inbound' : (schemaVersion === LEGACY_SCHEMA_VERSION ? 'ssh' : 'auto')),
150
150
  autostart: n.autostart === undefined ? schemaVersion !== LEGACY_SCHEMA_VERSION : n.autostart,
151
+ // A paired device is private by default. `shared` only controls whether the
152
+ // hub may advertise/route this peer and whether the outbound SSH session
153
+ // requests the optional reverse (-R) channel. Old stores therefore migrate
154
+ // safely to private without a schema bump.
155
+ shared: n.shared === undefined ? false : n.shared,
151
156
  visibility: n.visibility || 'network',
152
157
  };
153
158
  if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
154
159
  if (direction === 'inbound' && out.transport !== 'inbound') return null;
155
160
  if (direction === 'outbound' && out.transport === 'inbound') return null;
156
161
  if (typeof out.autostart !== 'boolean') return null;
162
+ if (typeof out.shared !== 'boolean') return null;
157
163
  if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
158
164
  if (identityFile) out.identityFile = identityFile;
159
165
  // Keep the old public field while reading v1 so old callers/tests and a
@@ -188,7 +194,9 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
188
194
  return out;
189
195
  }
190
196
 
191
- // rendezvous (ruolo node/reverse): dove questa installazione si pubblica.
197
+ // Legacy rendezvous record: read-only migration data from pre-0.8.10. It is
198
+ // parsed so an existing store remains loadable, but no new runtime path writes
199
+ // or starts it.
192
200
  const RDV_KEYS = new Set(['ssh', 'publishedPort', 'localPort', 'keyPath']);
193
201
  function parseRendezvous(r) {
194
202
  if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
@@ -362,18 +370,6 @@ function updateNode(store, name, patch) {
362
370
  return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
363
371
  }
364
372
 
365
- function setRendezvous(store, rdv) {
366
- const parsed = parseRendezvous(rdv);
367
- if (!parsed) throw new Error('rendezvous non valido: controlla ssh/publishedPort/localPort/keyPath');
368
- return { ...store, schemaVersion: SCHEMA_VERSION, rendezvous: parsed };
369
- }
370
-
371
- function clearRendezvous(store) {
372
- const out = { ...store, schemaVersion: SCHEMA_VERSION };
373
- delete out.rendezvous;
374
- return out;
375
- }
376
-
377
373
  // --- Redazione (view sicura per status/list: MAI il token) ------------------
378
374
 
379
375
  function redactNode(n) {
@@ -388,6 +384,7 @@ function redactNode(n) {
388
384
  rolesKnown: n.rolesKnown === true,
389
385
  transport: n.transport || 'ssh',
390
386
  autostart: !!n.autostart,
387
+ shared: n.shared === true,
391
388
  visibility: n.visibility || 'network',
392
389
  hasToken: !!n.token, // presenza, non il valore
393
390
  paired: !!(n.token && n.acceptToken),
@@ -405,11 +402,17 @@ function redactStore(store) {
405
402
  nodeId: store.nodeId,
406
403
  nodes: store.nodes.map(redactNode),
407
404
  };
408
- // rendezvous non contiene token: sicuro da esporre integralmente.
409
- if (store.rendezvous) out.rendezvous = { ...store.rendezvous };
410
405
  return out;
411
406
  }
412
407
 
408
+ // A peer with both scoped credentials has completed pairing. Its advertised
409
+ // HTTP port is part of the established SSH/federation contract; silently
410
+ // moving that port would strand the peer even though the local PWA still works.
411
+ function hasPairedPeers(store) {
412
+ return !!(store && Array.isArray(store.nodes)
413
+ && store.nodes.some((node) => validToken(node.token) && validToken(node.acceptToken)));
414
+ }
415
+
413
416
  // --- Migrazione esplicita da config.json (guarded, no-op se assente) ---------
414
417
  // Se config.json contiene un array `nodes` legacy (vecchio formato pre-B0),
415
418
  // lo importa in nodes.json. Guarded: no-op se config.json non ha `nodes`, o se
@@ -497,9 +500,9 @@ module.exports = {
497
500
  // I/O
498
501
  defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
499
502
  // mutazioni
500
- getNode, addNode, removeNode, setNodeToken, updateNode, setRendezvous, clearRendezvous,
503
+ getNode, addNode, removeNode, setNodeToken, updateNode,
501
504
  // redazione
502
- redactNode, redactStore,
505
+ redactNode, redactStore, hasPairedPeers,
503
506
  // migrazione
504
507
  migrateLegacyNodes,
505
508
  // label / slug