@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.
@@ -3,7 +3,7 @@
3
3
  //
4
4
  // Contratto duro:
5
5
  // - read-only: GET /api/settings (roles, firstRun, port, platform, service,
6
- // rendezvous redatto, version).
6
+ // version).
7
7
  // - mutanti (LISTA CHIUSA §4b(6)), tutti dietro requireToken (montati sotto il
8
8
  // router /api gia' autenticato) + gate READONLY route-level (pattern
9
9
  // lib/fleet/routes.js, 403 esplicito):
@@ -11,7 +11,8 @@
11
11
  // POST /token/rotate riusa rotateToken(); token MAI in risposta (§4b(3))
12
12
  // POST /nodes nodes add (riusa lib/nodes/commands.js)
13
13
  // DELETE /nodes/:name nodes remove
14
- // POST /node-role node on/off (rendezvous config)
14
+ // PATCH /nodes/:name/share publish/revoke the local node on its hub
15
+ // POST /node-role retired compatibility endpoint (410)
15
16
  // POST /service/regenerate rigenera unit service (NO restart automatico)
16
17
  // - NON gate READONLY (lifecycle di PROCESSO, non mutazione di config — decisione
17
18
  // B0 documentata in lib/nodes/commands.js: READONLY blocca le mutazioni di
@@ -27,9 +28,9 @@
27
28
  // 403 readonly, 404 nodo, 409 conflitto, 500 con messaggio). Niente stack trace.
28
29
  // - validazione input strict fail-closed: schema chiuso per ogni body, garbage
29
30
  // -> 400 con causa, mai guess.
30
- // - NIENTE reimplementazione della logica B0: i mutanti nodes/node-role
31
- // incapsulano le funzioni CLI di lib/nodes/commands.js (log catturato per
32
- // estrarre esito/authorized_keys); config.json scritto col pattern atomico
31
+ // - NIENTE reimplementazione della logica B0: i mutanti nodes incapsulano le
32
+ // funzioni CLI di lib/nodes/commands.js (log catturato per estrarre l'esito);
33
+ // config.json scritto col pattern atomico
33
34
  // tmp+rename di lib/nodes/store.js.
34
35
  //
35
36
  // Nota lifecycle: i tunnel avviati da /nodes/:name/up sono spawn DETACHED+unref
@@ -59,7 +60,6 @@ const { scrubError } = require('../update/core.js');
59
60
  const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
60
61
  const ROLE_KEYS = new Set(['client', 'node']);
61
62
  const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
62
- const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
63
63
 
64
64
  // Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
65
65
  // NON usa ciecamente hostname: se l'hostname e' vuoto o 'localhost' (tipico di
@@ -155,6 +155,7 @@ function settingsRoutes(deps = {}) {
155
155
  const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
156
156
  const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
157
157
  const platform = seams.platform || detectPlatform();
158
+ const runtimePort = typeof deps.runtimePort === 'function' ? deps.runtimePort : () => cfg.port;
158
159
 
159
160
  const r = express.Router();
160
161
  r.use(express.json({ limit: '8kb' }));
@@ -176,6 +177,7 @@ function settingsRoutes(deps = {}) {
176
177
  spawnImpl: seams.spawnImpl, sshBin: seams.sshBin, logFd: seams.logFd,
177
178
  httpProbe: seams.httpProbe, sshVersion: seams.sshVersion,
178
179
  spawnSyncImpl: seams.spawnSyncImpl,
180
+ localAppPort: runtimePort(),
179
181
  });
180
182
 
181
183
  const validName = (name) => typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name);
@@ -207,13 +209,6 @@ function settingsRoutes(deps = {}) {
207
209
  deviceName: defaultDeviceName(),
208
210
  };
209
211
  if (updateStatus) out.update = updateStatus;
210
- // rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
211
- // la si prende comunque SOLO dalla vista redatta.
212
- const st = nodesStore.loadStore(nodesPath);
213
- if (st) {
214
- const view = nodesStore.redactStore(st);
215
- if (view.rendezvous) out.rendezvous = view.rendezvous;
216
- }
217
212
  send(res, 200, out);
218
213
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
219
214
  });
@@ -243,6 +238,16 @@ function settingsRoutes(deps = {}) {
243
238
  if (b.port !== undefined && !nodesStore.isPort(b.port)) {
244
239
  return send(res, 400, { error: 'port deve essere un intero 1..65535' });
245
240
  }
241
+ if (b.port !== undefined && b.port !== runtimePort()) {
242
+ const peers = nodesStore.loadStore(nodesPath);
243
+ if (nodesStore.hasPairedPeers(peers)) {
244
+ return send(res, 409, {
245
+ error: 'porta non modificata: esistono nodi già collegati',
246
+ code: 'paired-port-change-refused',
247
+ hint: 'libera la porta corrente oppure rimuovi e ricollega intenzionalmente i peer',
248
+ });
249
+ }
250
+ }
246
251
  if (b.wizardDone !== undefined && typeof b.wizardDone !== 'boolean') {
247
252
  return send(res, 400, { error: 'wizardDone deve essere boolean' });
248
253
  }
@@ -307,7 +312,7 @@ function settingsRoutes(deps = {}) {
307
312
  if (typeof closeSessions === 'function') closeSessions();
308
313
  send(res, 200, {
309
314
  rotated: true,
310
- note: 'token ruotato: sessioni attive chiuse, vecchio token invalidato (401) — recupera il nuovo con `nexuscrew url`',
315
+ note: 'token ruotato: sessioni attive chiuse, vecchio token invalidato (401) — recupera il nuovo con `nexuscrew show token`',
311
316
  });
312
317
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
313
318
  });
@@ -324,29 +329,22 @@ function settingsRoutes(deps = {}) {
324
329
  const b = req.body || {};
325
330
  const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
326
331
  const st = nodesStore.loadOrInitStore(nodesPath);
327
- const rendezvous = st.rendezvous || null;
328
332
  const extra = {};
329
333
  if (typeof b.ssh === 'string' && b.ssh.trim()) {
330
334
  const ssh = nodesStore.parseSshTarget(b.ssh.trim());
331
335
  if (!ssh) return send(res, 400, { error: 'ssh non valido (atteso user@host o alias)' });
332
336
  extra.ssh = ssh.value;
333
- } else if (rendezvous && rendezvous.ssh) {
334
- // Zero-field creator path: the rendezvous SSH target is reachable by
335
- // definition. Its publishedPort is the REMOTE NEXUSCREW HTTP port,
336
- // never the OpenSSH transport port.
337
- extra.ssh = rendezvous.ssh;
338
337
  }
339
338
  if (b.sshPort !== undefined && b.sshPort !== null && b.sshPort !== '') {
340
339
  if (!nodesStore.isPort(Number(b.sshPort))) return send(res, 400, { error: 'sshPort non valida (1..65535)' });
341
340
  extra.sshPort = Number(b.sshPort);
342
341
  }
343
342
  if (extra.sshPort && !extra.ssh) return send(res, 400, { error: 'sshPort richiede un target SSH' });
344
- let remotePort = cfg.port;
343
+ if (!extra.ssh) return send(res, 400, { error: 'indica l’Host SSH pubblico/alias con cui gli altri dispositivi raggiungono questo hub' });
344
+ let remotePort = runtimePort();
345
345
  if (b.remotePort !== undefined && b.remotePort !== null && b.remotePort !== '') {
346
346
  if (!nodesStore.isPort(Number(b.remotePort))) return send(res, 400, { error: 'remotePort non valida (1..65535)' });
347
347
  remotePort = Number(b.remotePort);
348
- } else if (rendezvous && extra.ssh === rendezvous.ssh) {
349
- remotePort = rendezvous.publishedPort;
350
348
  }
351
349
  if (typeof b.name === 'string' && b.name.trim()) {
352
350
  if (!nodesStore.NODE_NAME_RE.test(b.name.trim())) return send(res, 400, { error: 'name non valido (a-z 0-9 -, max 32)' });
@@ -354,7 +352,7 @@ function settingsRoutes(deps = {}) {
354
352
  }
355
353
  if (extra.ssh && !extra.name) extra.name = nodesStore.toSlug(label);
356
354
  send(res, 200, peering.createInvite({
357
- invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: cfg.port, label, ...extra,
355
+ invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: runtimePort(), label, ...extra,
358
356
  }));
359
357
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
360
358
  });
@@ -391,6 +389,8 @@ function settingsRoutes(deps = {}) {
391
389
  const localLabel = nodesStore.sanitizeLabel(b.localLabel, defaultDeviceName());
392
390
  const fetchImpl = seams.fetchImpl || fetch;
393
391
  const sleep = typeof seams.pairDelay === 'function' ? seams.pairDelay : undefined;
392
+ const transportProbe = typeof seams.probeTransportReady === 'function'
393
+ ? seams.probeTransportReady : peering.probeTransportReady;
394
394
  const requestTimeoutMs = Number.isInteger(seams.pairRequestTimeoutMs) && seams.pairRequestTimeoutMs > 0
395
395
  ? seams.pairRequestTimeoutMs : 6000;
396
396
  // Every protocol request must terminate. Readiness and federation health
@@ -404,6 +404,7 @@ function settingsRoutes(deps = {}) {
404
404
  };
405
405
 
406
406
  let provisionalPort = null;
407
+ let portReservation = null;
407
408
  let rollbackCredential = null;
408
409
  let created = false;
409
410
  let rolledBack = false;
@@ -411,6 +412,10 @@ function settingsRoutes(deps = {}) {
411
412
  // credenziale provvisoria sul peer (se emessa) e rimuove nodo+tunnel locali.
412
413
  const rollback = async () => {
413
414
  if (rolledBack) return; rolledBack = true;
415
+ if (portReservation) {
416
+ try { await portReservation.release(); } catch (_) { /* best-effort */ }
417
+ portReservation = null;
418
+ }
414
419
  if (rollbackCredential && provisionalPort) {
415
420
  try {
416
421
  await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
@@ -452,7 +457,18 @@ function settingsRoutes(deps = {}) {
452
457
  hint: 'usa il nodo esistente oppure rimuovilo prima di rifare il pairing',
453
458
  });
454
459
  }
455
- const localPort = nodesCmds.assignLocalPort(st);
460
+ let localPort;
461
+ try {
462
+ portReservation = await nodesCmds.reserveLocalPort(st, {
463
+ createServerImpl: seams.createPortServer,
464
+ });
465
+ localPort = portReservation.port;
466
+ } catch (e) {
467
+ return fail(502, 'ssh-start', 'local-port-unavailable',
468
+ `nessuna porta locale disponibile per il tunnel: ${String((e && e.message) || e)}`, {
469
+ retryable: true, hint: 'chiudi il processo che occupa le porte locali e riprova',
470
+ });
471
+ }
456
472
  provisionalPort = localPort;
457
473
  const acceptToken = crypto.randomBytes(32).toString('base64url');
458
474
  try {
@@ -465,15 +481,20 @@ function settingsRoutes(deps = {}) {
465
481
  } catch (e) {
466
482
  const msg = String((e && e.message) || e);
467
483
  const isDup = msg.includes('duplicato') || msg.includes('self-reference');
468
- return fail(isDup ? 409 : 400, isDup ? 'conflict' : 'validation', 'node-rejected', msg, { retryable: !isDup });
484
+ return failRolledBack(isDup ? 409 : 400, isDup ? 'conflict' : 'validation', 'node-rejected', msg, { retryable: !isDup });
469
485
  }
470
486
  nodesStore.atomicWriteStore(nodesPath, st);
471
487
  created = true;
472
488
  const node = nodesStore.getNode(st, b.name);
473
489
 
474
490
  // --- ssh-start: supervisor del tunnel -L provvisorio --------------------
491
+ // Il bind OS-aware ha protetto la scelta fino a questo punto. SSH deve
492
+ // prendere la stessa porta, quindi rilasciamo immediatamente prima dello
493
+ // spawn (eventuali race residue emergono come local-forward-bind).
494
+ await portReservation.release();
495
+ portReservation = null;
475
496
  const started = nodesTunnel.startForward({
476
- home, node, localAppPort: cfg.port,
497
+ home, node, localAppPort: runtimePort(),
477
498
  spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
478
499
  sshBin: seams.sshBin, logFd: seams.logFd,
479
500
  });
@@ -485,13 +506,19 @@ function settingsRoutes(deps = {}) {
485
506
  }
486
507
 
487
508
  // --- ssh-ready: readiness bounded PRIMA di consumare l'invite -----------
488
- const ready = await peering.probeTransportReady({ port: localPort, fetchImpl, sleep });
509
+ const ready = await transportProbe({
510
+ port: localPort, capability: pair.invite, expectedInstanceId: pair.instanceId,
511
+ fetchImpl, sleep,
512
+ });
489
513
  if (!ready.ready) {
490
- return failRolledBack(502, 'ssh-ready', 'transport-not-ready',
491
- `il peer non risponde attraverso il tunnel SSH (${ready.attempts} tentativi${ready.lastError ? `: ${ready.lastError}` : ''})`, {
492
- retryable: true,
493
- hint: 'controlla target SSH, porta e chiavi; il link NON e\' stato consumato, puoi riprovare',
494
- });
514
+ const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, b.name, pair.port);
515
+ return failRolledBack(502, 'ssh-ready', (diagnosis && diagnosis.code) || ready.code || 'transport-not-ready',
516
+ (diagnosis && diagnosis.detail)
517
+ || `il peer non risponde attraverso il tunnel SSH (${ready.attempts} tentativi${ready.lastError ? `: ${ready.lastError}` : ''})`, {
518
+ retryable: true,
519
+ hint: (diagnosis && diagnosis.hint)
520
+ || 'controlla target SSH, porta e chiavi; il link NON e\' stato consumato, puoi riprovare',
521
+ });
495
522
  }
496
523
 
497
524
  // --- join: consuma l'invite one-time (UNA volta, mai replay) ------------
@@ -504,8 +531,11 @@ function settingsRoutes(deps = {}) {
504
531
  instanceId: st.nodeId,
505
532
  name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
506
533
  label: localLabel,
507
- port: cfg.port,
534
+ port: runtimePort(),
508
535
  acceptToken,
536
+ // Pairing establishes a private client-to-hub connection. Sharing
537
+ // this device back through the hub is a separate explicit action.
538
+ shared: false,
509
539
  roles: readRoles(configPath),
510
540
  }),
511
541
  });
@@ -543,26 +573,36 @@ function settingsRoutes(deps = {}) {
543
573
  });
544
574
  }
545
575
 
546
- // --- tunnel-final: riavvio con la spec negoziata (-R inclusa) -----------
576
+ // --- tunnel-final: connessione privata, solo -L -------------------------
577
+ // reversePort resta negoziata per un futuro Share opt-in, ma il builder
578
+ // non emette -R finche' shared non diventa true.
547
579
  st = nodesStore.loadOrInitStore(nodesPath);
548
580
  st = nodesStore.updateNode(st, b.name, {
549
581
  token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
582
+ shared: false,
550
583
  ...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
551
584
  });
552
585
  nodesStore.atomicWriteStore(nodesPath, st);
553
586
  nodesTunnel.stopTunnel({ home, name: b.name });
554
587
  const finalStart = nodesTunnel.startForward({
555
- home, node: nodesStore.getNode(st, b.name), localAppPort: cfg.port,
588
+ home, node: nodesStore.getNode(st, b.name), localAppPort: runtimePort(),
556
589
  spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
557
590
  sshBin: seams.sshBin, logFd: seams.logFd,
558
591
  });
559
592
  if (!finalStart.started && finalStart.reason !== 'already running') {
560
593
  return failRolledBack(502, 'tunnel-final', 'tunnel-restart-failed', finalStart.reason || 'riavvio del tunnel negoziato fallito', {});
561
594
  }
562
- const readyFinal = await peering.probeTransportReady({ port: localPort, fetchImpl, sleep });
595
+ const readyFinal = await transportProbe({
596
+ port: localPort, capability: joined.credential, expectedInstanceId: joined.instanceId,
597
+ fetchImpl, sleep,
598
+ });
563
599
  if (!readyFinal.ready) {
564
- return failRolledBack(502, 'tunnel-final', 'transport-not-ready',
565
- `il tunnel negoziato non risponde (${readyFinal.attempts} tentativi)`, {});
600
+ const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, b.name, pair.port);
601
+ return failRolledBack(502, 'tunnel-final', (diagnosis && diagnosis.code) || readyFinal.code || 'transport-not-ready',
602
+ (diagnosis && diagnosis.detail)
603
+ || `il tunnel negoziato non risponde o non corrisponde al peer atteso (${readyFinal.attempts} tentativi)`, {
604
+ hint: (diagnosis && diagnosis.hint) || 'verifica il target SSH e riprova con un nuovo link',
605
+ });
566
606
  }
567
607
 
568
608
  // --- confirm: idempotente lato peer -> bounded retry ---------------------
@@ -696,7 +736,10 @@ function settingsRoutes(deps = {}) {
696
736
  const out = await nodesCmds.nodesTest({ ...cliOpts(cap), name });
697
737
  if (out.result === 'unknown-node') return send(res, 404, { error: `nodo sconosciuto "${name}"` });
698
738
  // result distingue: ok | tunnel-down | health-ko | token-missing | token-ko
699
- send(res, 200, { ok: out.result === 'ok', result: out.result, detail: lastLine(cap, '') });
739
+ send(res, 200, {
740
+ ok: out.result === 'ok', result: out.result, detail: lastLine(cap, ''),
741
+ ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}),
742
+ });
700
743
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
701
744
  });
702
745
 
@@ -715,21 +758,103 @@ function settingsRoutes(deps = {}) {
715
758
  const fn = action === 'up' ? nodesCmds.nodesUp
716
759
  : action === 'down' ? nodesCmds.nodesDown
717
760
  : nodesCmds.nodesRestart;
718
- const out = fn({ ...cliOpts(cap), name });
761
+ const out = fn({ ...cliOpts(cap), name, persistAutostart: action === 'up' || action === 'down' });
719
762
  if (out.code !== 0) {
720
763
  const msg = lastLine(cap, `nodes ${action} fallito`);
721
764
  const status = /sconosciuto/.test(msg) ? 404 : 500;
722
- return send(res, status, { error: msg });
765
+ return send(res, status, { error: msg, ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}) });
723
766
  }
724
- if (action === 'up') return send(res, 200, { name, started: out.started, pid: out.pid });
767
+ if (action === 'up') return send(res, 200, { name, started: out.started, pid: out.pid, diagnostic: out.diagnostic });
725
768
  if (action === 'down') return send(res, 200, { name, stopped: out.stopped });
726
- return send(res, 200, { name, restarted: true, pid: out.pid });
769
+ return send(res, 200, { name, restarted: true, pid: out.pid, diagnostic: out.diagnostic });
727
770
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
728
771
  };
729
772
  }
730
773
  r.post('/nodes/:name/up', mutGate, lifecycleHandler('up'));
731
774
  r.post('/nodes/:name/down', mutGate, lifecycleHandler('down'));
732
775
  r.post('/nodes/:name/restart', mutGate, lifecycleHandler('restart'));
776
+
777
+ // Share is the only publication control exposed to the user. The normal
778
+ // paired connection is -L only; enabling Share restarts the same supervised
779
+ // SSH session with its negotiated -R and asks the hub to advertise it only
780
+ // after the reverse channel passes an authenticated health probe.
781
+ r.patch('/nodes/:name/share', mutGate, async (req, res) => {
782
+ const name = String(req.params.name || '');
783
+ const body = req.body || {};
784
+ if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
785
+ if (Object.keys(body).some((k) => k !== 'shared') || typeof body.shared !== 'boolean') {
786
+ return send(res, 400, { error: 'body non valido: atteso {shared:boolean}' });
787
+ }
788
+ let st = nodesStore.loadStore(nodesPath);
789
+ let node = st && nodesStore.getNode(st, name);
790
+ if (!node) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
791
+ if (node.direction !== 'outbound') return send(res, 409, { error: 'Share si attiva sul dispositivo che possiede la connessione SSH' });
792
+ if (!node.token || !node.nodeId) return send(res, 409, { error: 'nodo non associato: ripeti il pairing' });
793
+ if (body.shared && !nodesStore.isPort(node.reversePort)) {
794
+ return send(res, 409, { error: 'canale share non negoziato: ripeti il pairing' });
795
+ }
796
+ if (node.shared === body.shared) return send(res, 200, { name, shared: body.shared, unchanged: true });
797
+
798
+ const fetchImpl = seams.fetchImpl || fetch;
799
+ const notifyHub = async (shared) => {
800
+ const ctrl = new AbortController();
801
+ const timer = setTimeout(() => ctrl.abort(), 5000);
802
+ try {
803
+ const response = await fetchImpl(`http://127.0.0.1:${node.localPort}/federation/share`, {
804
+ method: 'POST', signal: ctrl.signal,
805
+ headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
806
+ body: JSON.stringify({ shared }),
807
+ });
808
+ if (!response.ok) throw new Error(`hub HTTP ${response.status}`);
809
+ } finally { clearTimeout(timer); }
810
+ };
811
+ const applyLocal = async (shared) => {
812
+ st = nodesStore.loadOrInitStore(nodesPath);
813
+ st = nodesStore.updateNode(st, name, { shared });
814
+ nodesStore.atomicWriteStore(nodesPath, st);
815
+ node = nodesStore.getNode(st, name);
816
+ nodesTunnel.stopTunnel({ home, name });
817
+ const started = nodesTunnel.startForward({
818
+ home, node, localAppPort: runtimePort(),
819
+ spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
820
+ sshBin: seams.sshBin, logFd: seams.logFd,
821
+ });
822
+ if (!started.started && started.reason !== 'already running') {
823
+ throw new Error(started.reason || 'avvio SSH fallito');
824
+ }
825
+ const ready = await probeHealth({
826
+ port: node.localPort, token: node.token, expectedInstanceId: node.nodeId,
827
+ fetchImpl,
828
+ });
829
+ if (!ready || ready.status !== 'healthy') {
830
+ const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, name, node.remotePort);
831
+ throw new Error((diagnosis && diagnosis.detail) || (ready && ready.detail) || 'hub non raggiungibile dopo il riavvio SSH');
832
+ }
833
+ };
834
+
835
+ try {
836
+ if (body.shared) {
837
+ await applyLocal(true);
838
+ await notifyHub(true);
839
+ } else {
840
+ // Revoke at the hub while -R is still alive, then remove -R locally.
841
+ await notifyHub(false);
842
+ await applyLocal(false);
843
+ }
844
+ return send(res, 200, { name, shared: body.shared });
845
+ } catch (e) {
846
+ // Share-on is transactional: a failed hub acknowledgement returns to the
847
+ // safe private -L-only state. Never include remote response bodies/tokens.
848
+ if (body.shared) {
849
+ try { await applyLocal(false); } catch (_) { /* best-effort safe rollback */ }
850
+ }
851
+ return send(res, 502, {
852
+ error: body.shared ? 'Share non attivato' : 'Share non disattivato',
853
+ detail: String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***'),
854
+ });
855
+ }
856
+ });
857
+
733
858
  r.patch('/nodes/:name/visibility', mutGate, (req, res) => {
734
859
  try {
735
860
  const name = String(req.params.name || '');
@@ -759,53 +884,13 @@ function settingsRoutes(deps = {}) {
759
884
  } catch (e) { send(res, 400, { error: String(e.message || e) }); }
760
885
  });
761
886
 
762
- // --- POST /node-role — node on/off (rendezvous config) ---------------------
763
- r.post('/node-role', mutGate, (req, res) => {
764
- try {
765
- const b = req.body;
766
- if (!b || typeof b !== 'object' || Array.isArray(b)) {
767
- return send(res, 400, { error: 'body deve essere un oggetto JSON' });
768
- }
769
- for (const k of Object.keys(b)) {
770
- if (!NODE_ROLE_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (schema: enabled, rendezvousSsh?, publishedPort?, keyPath?)` });
771
- }
772
- if (typeof b.enabled !== 'boolean') return send(res, 400, { error: 'enabled deve essere boolean' });
773
- if (b.rendezvousSsh !== undefined && !nodesStore.parseSsh(b.rendezvousSsh)) {
774
- return send(res, 400, { error: 'rendezvousSsh non valido (atteso user@host strict)' });
775
- }
776
- if (b.publishedPort !== undefined && !nodesStore.isPort(b.publishedPort)) {
777
- return send(res, 400, { error: 'publishedPort deve essere un intero 1..65535' });
778
- }
779
- if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
780
- return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
781
- }
782
-
783
- const cap = capture();
784
- if (b.enabled === false) {
785
- const out = nodesCmds.nodeOff({ ...cliOpts(cap) });
786
- if (out.code === 0) return send(res, 200, { enabled: false, roles: out.roles });
787
- return send(res, 500, { error: lastLine(cap, 'node off fallito') });
788
- }
789
- const out = nodesCmds.nodeOn({
790
- ...cliOpts(cap),
791
- rendezvousSsh: b.rendezvousSsh,
792
- publishedPort: b.publishedPort,
793
- key: b.keyPath,
794
- port: cfg.port, // porta nexus locale da esporre = quella del server attivo
795
- });
796
- if (out.code === 0) {
797
- const resp = { enabled: true, roles: out.roles, tunnel: out.tunnel || null };
798
- const line = authorizedKeysLine(cap);
799
- if (line) resp.authorizedKeys = line; // pubkey con permitlisten: non un segreto
800
- return send(res, 200, resp);
801
- }
802
- const msg = lastLine(cap, 'node on fallito');
803
- if (out.reason === 'readonly') return send(res, 403, { error: msg });
804
- if (out.reason === 'no rendezvous') return send(res, 400, { error: msg });
805
- if (out.reason === 'permitlisten') return send(res, 409, { error: msg });
806
- send(res, 500, { error: msg });
807
- } catch (e) { send(res, 500, { error: String(e.message || e) }); }
808
- });
887
+ // Legacy compatibility endpoint: the old node-role/rendezvous flow opened a
888
+ // second SSH process and is intentionally retired. Existing data is kept for
889
+ // migration, but all new publication goes through pairing + Share on the
890
+ // already-connected hub.
891
+ r.post('/node-role', mutGate, (_req, res) => send(res, 410, {
892
+ error: 'node-role/rendezvous ritirato: collega un hub e usa “Condividi questo nodo”',
893
+ }));
809
894
 
810
895
  // --- POST /service/regenerate — rigenera l'unit service --------------------
811
896
  // Riusa generateService/installService (lib/cli/service.js: escaping per-platform,
@@ -867,8 +952,26 @@ function publicPeeringRoutes(deps = {}) {
867
952
  const r = express.Router();
868
953
  const attempts = new Map();
869
954
  r.use(express.json({ limit: '8kb' }));
955
+ // Identity proof non consuma la capability e non riceve mai invite/token in
956
+ // chiaro. Serve a impedire che un qualunque listener HTTP sulla porta -L
957
+ // venga scambiato per il nodo contenuto nel link.
958
+ r.post('/identity', (req, res) => {
959
+ const key = `identity:${String(req.socket && req.socket.remoteAddress || 'local')}`;
960
+ const now = Date.now();
961
+ const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
962
+ recent.push(now); attempts.set(key, recent);
963
+ if (recent.length > 30) return res.status(429).json({ error: 'troppi tentativi' });
964
+ const b = req.body || {};
965
+ const proof = peering.capabilityIdentity({
966
+ invitesPath, pendingPath, capabilityId: b.capabilityId, challenge: b.challenge, now,
967
+ });
968
+ if (!proof) return res.status(404).json({ error: 'capability non valida' });
969
+ const st = nodesStore.loadStore(nodesPath);
970
+ if (!st || !nodesStore.NODE_ID_RE.test(st.nodeId)) return res.status(503).json({ error: 'identita nodo non disponibile' });
971
+ return res.json({ ok: true, instanceId: st.nodeId, proof });
972
+ });
870
973
  r.post('/join', (req, res) => {
871
- const key = String(req.socket && req.socket.remoteAddress || 'local');
974
+ const key = `join:${String(req.socket && req.socket.remoteAddress || 'local')}`;
872
975
  const now = Date.now();
873
976
  const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
874
977
  recent.push(now); attempts.set(key, recent);
@@ -877,7 +980,10 @@ function publicPeeringRoutes(deps = {}) {
877
980
  const peerRoles = b.roles === undefined ? null : nodesStore.parseRoles(b.roles);
878
981
  if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
879
982
  || !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
880
- || (b.roles !== undefined && !peerRoles)) {
983
+ || (b.roles !== undefined && !peerRoles)
984
+ // Pairing is always private. Publishing is a separate authenticated
985
+ // action after the reverse channel is live and health-checked.
986
+ || (b.shared !== undefined && b.shared !== false)) {
881
987
  return res.status(400).json({ error: 'pairing request non valida' });
882
988
  }
883
989
  if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
@@ -892,8 +998,9 @@ function publicPeeringRoutes(deps = {}) {
892
998
  const reversePort = peering.allocateReversePort(st.nodes);
893
999
  const credential = peering.createPending({ pendingPath, data: {
894
1000
  name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
1001
+ shared: false,
895
1002
  label: nodesStore.sanitizeLabel(b.label, name),
896
- ...(peerRoles ? { roles: peerRoles, rolesKnown: true } : { rolesKnown: false }),
1003
+ ...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
897
1004
  } });
898
1005
  res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
899
1006
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
@@ -913,7 +1020,7 @@ function publicPeeringRoutes(deps = {}) {
913
1020
  st = nodesStore.addNode(st, {
914
1021
  name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
915
1022
  direction: 'inbound', transport: 'inbound', autostart: true,
916
- visibility: 'network', nodeId: pending.instanceId,
1023
+ visibility: 'network', shared: pending.shared === true, nodeId: pending.instanceId,
917
1024
  token: pending.acceptToken, acceptToken: b.credential,
918
1025
  ...(pending.roles ? { roles: pending.roles } : {}),
919
1026
  rolesKnown: pending.rolesKnown === true,
@@ -29,7 +29,10 @@ async function restartRuntime(opts = {}) {
29
29
  const token = opts.token || url.readToken(url.resolvePaths({ home }).tokenPath);
30
30
  let mode = 'inactive';
31
31
  if (commands.isServiceRunning({ platform, home })) {
32
- commands.restart({ platform, home, log: () => {} });
32
+ const restarted = commands.restart({ platform, home, log: () => {} });
33
+ if (!restarted || restarted.restarted !== true) {
34
+ throw new Error(`restart service fallito: ${(restarted && restarted.reason) || 'esito non verificato'}`);
35
+ }
33
36
  mode = 'service';
34
37
  } else {
35
38
  const pidPath = pidf.defaultPidfilePath(home);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "start": "node bin/nexuscrew.js serve",
21
21
  "dev": "node bin/nexuscrew.js serve",
22
22
  "build": "cd frontend && npm install && npm run build && cd ..",
23
- "test": "node --test tests/*.test.js"
23
+ "test": "node tests/run-isolated.js"
24
24
  },
25
25
  "dependencies": {
26
26
  "express": "^4.21.0",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: nexuscrew-agent
3
- description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect NexusCrew session/fleet status, read its inbox, deliver a file (report, screenshot, export), send text/input to a tmux session, or recover from tmux send-keys messages that remain unsubmitted or arrive garbled. Prefer the NexusCrew MCP tools nc_notify, nc_ask, nc_status, nc_inbox, and nc_send_file when exposed; use the bundled tmux/file helpers as fallback.
3
+ description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect NexusCrew session/fleet status or its own deck membership, read its inbox, deliver a file (report, screenshot, export), send text/input to a tmux session, or recover from tmux send-keys messages that remain unsubmitted or arrive garbled. Prefer the NexusCrew MCP tools nc_notify, nc_ask, nc_status, nc_deck, nc_inbox, and nc_send_file when exposed; use the bundled tmux/file helpers as fallback.
4
4
  ---
5
5
 
6
6
  # NexusCrew Agent I/O
@@ -16,6 +16,7 @@ When the client exposes the NexusCrew MCP server, use these tools directly:
16
16
  | Notify the human about a result, blocker, or milestone | `nc_notify` |
17
17
  | Ask for a decision without blocking the agent | `nc_ask` |
18
18
  | Inspect live tmux sessions and fleet cells | `nc_status` |
19
+ | Read the caller's deck name(s) and member cells/tmux sessions | `nc_deck` |
19
20
  | List files received for the current session | `nc_inbox` |
20
21
  | Deliver an absolute file path under the user's home | `nc_send_file` |
21
22
 
@@ -24,7 +25,9 @@ Apply these rules:
24
25
  - Use `nc_notify` for meaningful asynchronous updates, failures requiring attention, and completion. Do not notify for every command or duplicate routine chat commentary.
25
26
  - Never include access tokens, credentials, private keys, push subscriptions, or other secrets in a notification, ask, file caption, or tool result.
26
27
  - Treat `nc_ask` as non-blocking: it returns an ask ID immediately. Continue safe independent work or wait normally; the human response arrives in the originating tmux session with a `[human reply · ask#<id>]` prefix by default.
27
- - Use `nc_status` instead of scraping NexusCrew state files. Use `nc_inbox` instead of guessing an inbox path when the tool is available.
28
+ - Use `nc_status` instead of scraping NexusCrew state files. Use `nc_deck` instead of reading `decks.json`: it returns every local or authorized shared-owner deck containing the caller, preserves visual member order, identifies each deck and member by stable owner ID, includes viewer-valid Hydra routes, and reports `cell: null` when no managed Fleet match is available.
29
+ - Treat `nc_deck` as discovery, not authorization to contact every member. Use the verified tmux-messaging flow below for actual delivery and confirm submission visually.
30
+ - Use `nc_inbox` instead of guessing an inbox path when the tool is available.
28
31
  - Pass `nc_send_file` an existing absolute regular-file path below the user's home. Let NexusCrew choose and sanitize the outbox name.
29
32
  - Do not treat an MCP notification as a substitute for the final response required by the active client.
30
33
 
@@ -68,6 +71,7 @@ tmux capture-pane -t <session> -p | tail -8 # see the text / a running state
68
71
  | Notify the human | `nc_notify` |
69
72
  | Ask the human without blocking | `nc_ask` |
70
73
  | Inspect NexusCrew runtime state | `nc_status` |
74
+ | Discover this session's deck neighbours | `nc_deck` |
71
75
  | Give the human a file | `nc_send_file` or fallback `nc-deliver <file>...` |
72
76
  | Read a file the human sent | `nc_inbox` or fallback to the path in the prompt |
73
77
  | Send a prompt/command to a session | `nc-send <session> "text"` |