@mmmbuto/nexuscrew 0.8.7 → 0.8.10

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
@@ -53,12 +54,12 @@ const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js')
53
54
  const { isServiceRunning, readRoles, bootState } = require('../cli/commands.js');
54
55
  const { configJsonPath } = require('../config.js');
55
56
  const VERSION = require('../../package.json').version;
57
+ const { scrubError } = require('../update/core.js');
56
58
 
57
59
  // Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
58
- const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone']);
60
+ const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
59
61
  const ROLE_KEYS = new Set(['client', 'node']);
60
62
  const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
61
- const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
62
63
 
63
64
  // Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
64
65
  // NON usa ciecamente hostname: se l'hostname e' vuoto o 'localhost' (tipico di
@@ -141,6 +142,7 @@ function settingsRoutes(deps = {}) {
141
142
  // scrive solo il file (come prima), senza reload in-memory.
142
143
  const tokenStore = deps.tokenStore || null;
143
144
  const closeSessions = deps.closeSessions || null;
145
+ const updater = deps.updater || null;
144
146
  const home = cfg.home || os.homedir();
145
147
  const configDir = cfg.configDir || path.join(home, '.nexuscrew');
146
148
  // configJsonPath() rispetta NEXUSCREW_CONFIG_FILE (stessa risoluzione di
@@ -153,6 +155,7 @@ function settingsRoutes(deps = {}) {
153
155
  const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
154
156
  const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
155
157
  const platform = seams.platform || detectPlatform();
158
+ const runtimePort = typeof deps.runtimePort === 'function' ? deps.runtimePort : () => cfg.port;
156
159
 
157
160
  const r = express.Router();
158
161
  r.use(express.json({ limit: '8kb' }));
@@ -174,6 +177,7 @@ function settingsRoutes(deps = {}) {
174
177
  spawnImpl: seams.spawnImpl, sshBin: seams.sshBin, logFd: seams.logFd,
175
178
  httpProbe: seams.httpProbe, sshVersion: seams.sshVersion,
176
179
  spawnSyncImpl: seams.spawnSyncImpl,
180
+ localAppPort: runtimePort(),
177
181
  });
178
182
 
179
183
  const validName = (name) => typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name);
@@ -191,6 +195,7 @@ function settingsRoutes(deps = {}) {
191
195
  active: isServiceRunning({ platform, execImpl: seams.execImpl, uid: seams.uid, home }),
192
196
  boot: bootState({ platform, execImpl: seams.execImpl, uid: seams.uid, home }).enabled,
193
197
  };
198
+ const updateStatus = updater && typeof updater.status === 'function' ? updater.status() : null;
194
199
  const out = {
195
200
  roles: readRoles(configPath),
196
201
  firstRun,
@@ -198,17 +203,12 @@ function settingsRoutes(deps = {}) {
198
203
  platform,
199
204
  service,
200
205
  version: VERSION,
206
+ autoUpdate: updateStatus ? updateStatus.enabled : fileCfg.autoUpdate !== false,
201
207
  // Nome dispositivo proposto per i form di pairing (etichetta umana, non
202
208
  // lo slug). La UI lo precompila e lascia editing libero.
203
209
  deviceName: defaultDeviceName(),
204
210
  };
205
- // rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
206
- // la si prende comunque SOLO dalla vista redatta.
207
- const st = nodesStore.loadStore(nodesPath);
208
- if (st) {
209
- const view = nodesStore.redactStore(st);
210
- if (view.rendezvous) out.rendezvous = view.rendezvous;
211
- }
211
+ if (updateStatus) out.update = updateStatus;
212
212
  send(res, 200, out);
213
213
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
214
214
  });
@@ -221,10 +221,10 @@ function settingsRoutes(deps = {}) {
221
221
  return send(res, 400, { error: 'body deve essere un oggetto JSON' });
222
222
  }
223
223
  for (const k of Object.keys(b)) {
224
- if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone)` });
224
+ if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate)` });
225
225
  }
226
226
  if (Object.keys(b).length === 0) {
227
- return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone)' });
227
+ return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate)' });
228
228
  }
229
229
  if (b.roles !== undefined) {
230
230
  if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
@@ -238,9 +238,22 @@ function settingsRoutes(deps = {}) {
238
238
  if (b.port !== undefined && !nodesStore.isPort(b.port)) {
239
239
  return send(res, 400, { error: 'port deve essere un intero 1..65535' });
240
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
+ }
241
251
  if (b.wizardDone !== undefined && typeof b.wizardDone !== 'boolean') {
242
252
  return send(res, 400, { error: 'wizardDone deve essere boolean' });
243
253
  }
254
+ if (b.autoUpdate !== undefined && typeof b.autoUpdate !== 'boolean') {
255
+ return send(res, 400, { error: 'autoUpdate deve essere boolean' });
256
+ }
244
257
 
245
258
  // merge sul config esistente (preserva le chiavi non gestite qui)
246
259
  const { cfg: current } = readConfigFile(configPath);
@@ -251,11 +264,15 @@ function settingsRoutes(deps = {}) {
251
264
  }
252
265
  if (b.port !== undefined) next.port = b.port;
253
266
  if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
267
+ if (b.autoUpdate !== undefined) next.autoUpdate = b.autoUpdate;
254
268
  atomicWriteConfig(configPath, next);
269
+ if (b.autoUpdate !== undefined && updater && typeof updater.setEnabled === 'function') {
270
+ updater.setEnabled(b.autoUpdate);
271
+ }
255
272
 
256
273
  const out = {
257
274
  saved: true,
258
- config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone },
275
+ config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone, autoUpdate: next.autoUpdate !== false },
259
276
  };
260
277
  // Il server legge la porta SOLO allo startup: il cambio vale al prossimo
261
278
  // restart (contratto dichiarato nella risposta, la UI avvisa).
@@ -266,6 +283,20 @@ function settingsRoutes(deps = {}) {
266
283
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
267
284
  });
268
285
 
286
+ // Auto-update npm: check e apply sono autenticati, READONLY-gated e non
287
+ // accettano mai package/versione dal browser. Il manager usa esclusivamente
288
+ // @mmmbuto/nexuscrew@latest, confronta semver e installa la versione esatta.
289
+ r.post('/update/check', mutGate, async (_req, res) => {
290
+ if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
291
+ try { send(res, 200, await updater.check()); }
292
+ catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
293
+ });
294
+ r.post('/update/apply', mutGate, async (_req, res) => {
295
+ if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
296
+ try { send(res, 202, await updater.apply()); }
297
+ catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
298
+ });
299
+
269
300
  // --- POST /token/rotate — atomico + reload live + chiusura WS; token MAI in risposta
270
301
  // Contratto §4b(3): "scrittura atomica del token file + chiusura delle sessioni
271
302
  // WS/API attive locali + reload credenziali proxy". Audit F7: prima questa route
@@ -281,7 +312,7 @@ function settingsRoutes(deps = {}) {
281
312
  if (typeof closeSessions === 'function') closeSessions();
282
313
  send(res, 200, {
283
314
  rotated: true,
284
- 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`',
285
316
  });
286
317
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
287
318
  });
@@ -297,6 +328,7 @@ function settingsRoutes(deps = {}) {
297
328
  try {
298
329
  const b = req.body || {};
299
330
  const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
331
+ const st = nodesStore.loadOrInitStore(nodesPath);
300
332
  const extra = {};
301
333
  if (typeof b.ssh === 'string' && b.ssh.trim()) {
302
334
  const ssh = nodesStore.parseSshTarget(b.ssh.trim());
@@ -308,14 +340,19 @@ function settingsRoutes(deps = {}) {
308
340
  extra.sshPort = Number(b.sshPort);
309
341
  }
310
342
  if (extra.sshPort && !extra.ssh) return send(res, 400, { error: 'sshPort richiede un target SSH' });
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
+ if (b.remotePort !== undefined && b.remotePort !== null && b.remotePort !== '') {
346
+ if (!nodesStore.isPort(Number(b.remotePort))) return send(res, 400, { error: 'remotePort non valida (1..65535)' });
347
+ remotePort = Number(b.remotePort);
348
+ }
311
349
  if (typeof b.name === 'string' && b.name.trim()) {
312
350
  if (!nodesStore.NODE_NAME_RE.test(b.name.trim())) return send(res, 400, { error: 'name non valido (a-z 0-9 -, max 32)' });
313
351
  extra.name = b.name.trim();
314
352
  }
315
353
  if (extra.ssh && !extra.name) extra.name = nodesStore.toSlug(label);
316
- const st = nodesStore.loadOrInitStore(nodesPath);
317
354
  send(res, 200, peering.createInvite({
318
- invitesPath, instanceId: st.nodeId, port: cfg.port, label, ...extra,
355
+ invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: runtimePort(), label, ...extra,
319
356
  }));
320
357
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
321
358
  });
@@ -352,6 +389,8 @@ function settingsRoutes(deps = {}) {
352
389
  const localLabel = nodesStore.sanitizeLabel(b.localLabel, defaultDeviceName());
353
390
  const fetchImpl = seams.fetchImpl || fetch;
354
391
  const sleep = typeof seams.pairDelay === 'function' ? seams.pairDelay : undefined;
392
+ const transportProbe = typeof seams.probeTransportReady === 'function'
393
+ ? seams.probeTransportReady : peering.probeTransportReady;
355
394
  const requestTimeoutMs = Number.isInteger(seams.pairRequestTimeoutMs) && seams.pairRequestTimeoutMs > 0
356
395
  ? seams.pairRequestTimeoutMs : 6000;
357
396
  // Every protocol request must terminate. Readiness and federation health
@@ -365,6 +404,7 @@ function settingsRoutes(deps = {}) {
365
404
  };
366
405
 
367
406
  let provisionalPort = null;
407
+ let portReservation = null;
368
408
  let rollbackCredential = null;
369
409
  let created = false;
370
410
  let rolledBack = false;
@@ -372,6 +412,10 @@ function settingsRoutes(deps = {}) {
372
412
  // credenziale provvisoria sul peer (se emessa) e rimuove nodo+tunnel locali.
373
413
  const rollback = async () => {
374
414
  if (rolledBack) return; rolledBack = true;
415
+ if (portReservation) {
416
+ try { await portReservation.release(); } catch (_) { /* best-effort */ }
417
+ portReservation = null;
418
+ }
375
419
  if (rollbackCredential && provisionalPort) {
376
420
  try {
377
421
  await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
@@ -413,7 +457,18 @@ function settingsRoutes(deps = {}) {
413
457
  hint: 'usa il nodo esistente oppure rimuovilo prima di rifare il pairing',
414
458
  });
415
459
  }
416
- 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
+ }
417
472
  provisionalPort = localPort;
418
473
  const acceptToken = crypto.randomBytes(32).toString('base64url');
419
474
  try {
@@ -426,15 +481,20 @@ function settingsRoutes(deps = {}) {
426
481
  } catch (e) {
427
482
  const msg = String((e && e.message) || e);
428
483
  const isDup = msg.includes('duplicato') || msg.includes('self-reference');
429
- 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 });
430
485
  }
431
486
  nodesStore.atomicWriteStore(nodesPath, st);
432
487
  created = true;
433
488
  const node = nodesStore.getNode(st, b.name);
434
489
 
435
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;
436
496
  const started = nodesTunnel.startForward({
437
- home, node, localAppPort: cfg.port,
497
+ home, node, localAppPort: runtimePort(),
438
498
  spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
439
499
  sshBin: seams.sshBin, logFd: seams.logFd,
440
500
  });
@@ -446,13 +506,19 @@ function settingsRoutes(deps = {}) {
446
506
  }
447
507
 
448
508
  // --- ssh-ready: readiness bounded PRIMA di consumare l'invite -----------
449
- 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
+ });
450
513
  if (!ready.ready) {
451
- return failRolledBack(502, 'ssh-ready', 'transport-not-ready',
452
- `il peer non risponde attraverso il tunnel SSH (${ready.attempts} tentativi${ready.lastError ? `: ${ready.lastError}` : ''})`, {
453
- retryable: true,
454
- hint: 'controlla target SSH, porta e chiavi; il link NON e\' stato consumato, puoi riprovare',
455
- });
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
+ });
456
522
  }
457
523
 
458
524
  // --- join: consuma l'invite one-time (UNA volta, mai replay) ------------
@@ -465,8 +531,12 @@ function settingsRoutes(deps = {}) {
465
531
  instanceId: st.nodeId,
466
532
  name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
467
533
  label: localLabel,
468
- port: cfg.port,
534
+ port: runtimePort(),
469
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,
539
+ roles: readRoles(configPath),
470
540
  }),
471
541
  });
472
542
  } catch (e) {
@@ -488,8 +558,9 @@ function settingsRoutes(deps = {}) {
488
558
  hint: 'rigenera un nuovo link e riprova',
489
559
  });
490
560
  }
561
+ const joinedRoles = joined.roles === undefined ? null : nodesStore.parseRoles(joined.roles);
491
562
  if (!nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
492
- || !nodesStore.NODE_ID_RE.test(joined.instanceId)) {
563
+ || !nodesStore.NODE_ID_RE.test(joined.instanceId) || (joined.roles !== undefined && !joinedRoles)) {
493
564
  return failRolledBack(502, 'join', 'join-invalid-response', 'risposta di join non valida dal peer', {
494
565
  hint: 'versioni NexusCrew incompatibili? aggiorna entrambi i nodi',
495
566
  });
@@ -502,25 +573,36 @@ function settingsRoutes(deps = {}) {
502
573
  });
503
574
  }
504
575
 
505
- // --- 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.
506
579
  st = nodesStore.loadOrInitStore(nodesPath);
507
580
  st = nodesStore.updateNode(st, b.name, {
508
581
  token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
582
+ shared: false,
583
+ ...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
509
584
  });
510
585
  nodesStore.atomicWriteStore(nodesPath, st);
511
586
  nodesTunnel.stopTunnel({ home, name: b.name });
512
587
  const finalStart = nodesTunnel.startForward({
513
- home, node: nodesStore.getNode(st, b.name), localAppPort: cfg.port,
588
+ home, node: nodesStore.getNode(st, b.name), localAppPort: runtimePort(),
514
589
  spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
515
590
  sshBin: seams.sshBin, logFd: seams.logFd,
516
591
  });
517
592
  if (!finalStart.started && finalStart.reason !== 'already running') {
518
593
  return failRolledBack(502, 'tunnel-final', 'tunnel-restart-failed', finalStart.reason || 'riavvio del tunnel negoziato fallito', {});
519
594
  }
520
- 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
+ });
521
599
  if (!readyFinal.ready) {
522
- return failRolledBack(502, 'tunnel-final', 'transport-not-ready',
523
- `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
+ });
524
606
  }
525
607
 
526
608
  // --- confirm: idempotente lato peer -> bounded retry ---------------------
@@ -654,7 +736,10 @@ function settingsRoutes(deps = {}) {
654
736
  const out = await nodesCmds.nodesTest({ ...cliOpts(cap), name });
655
737
  if (out.result === 'unknown-node') return send(res, 404, { error: `nodo sconosciuto "${name}"` });
656
738
  // result distingue: ok | tunnel-down | health-ko | token-missing | token-ko
657
- 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
+ });
658
743
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
659
744
  });
660
745
 
@@ -673,21 +758,103 @@ function settingsRoutes(deps = {}) {
673
758
  const fn = action === 'up' ? nodesCmds.nodesUp
674
759
  : action === 'down' ? nodesCmds.nodesDown
675
760
  : nodesCmds.nodesRestart;
676
- const out = fn({ ...cliOpts(cap), name });
761
+ const out = fn({ ...cliOpts(cap), name, persistAutostart: action === 'up' || action === 'down' });
677
762
  if (out.code !== 0) {
678
763
  const msg = lastLine(cap, `nodes ${action} fallito`);
679
764
  const status = /sconosciuto/.test(msg) ? 404 : 500;
680
- return send(res, status, { error: msg });
765
+ return send(res, status, { error: msg, ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}) });
681
766
  }
682
- 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 });
683
768
  if (action === 'down') return send(res, 200, { name, stopped: out.stopped });
684
- return send(res, 200, { name, restarted: true, pid: out.pid });
769
+ return send(res, 200, { name, restarted: true, pid: out.pid, diagnostic: out.diagnostic });
685
770
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
686
771
  };
687
772
  }
688
773
  r.post('/nodes/:name/up', mutGate, lifecycleHandler('up'));
689
774
  r.post('/nodes/:name/down', mutGate, lifecycleHandler('down'));
690
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
+
691
858
  r.patch('/nodes/:name/visibility', mutGate, (req, res) => {
692
859
  try {
693
860
  const name = String(req.params.name || '');
@@ -717,53 +884,13 @@ function settingsRoutes(deps = {}) {
717
884
  } catch (e) { send(res, 400, { error: String(e.message || e) }); }
718
885
  });
719
886
 
720
- // --- POST /node-role — node on/off (rendezvous config) ---------------------
721
- r.post('/node-role', mutGate, (req, res) => {
722
- try {
723
- const b = req.body;
724
- if (!b || typeof b !== 'object' || Array.isArray(b)) {
725
- return send(res, 400, { error: 'body deve essere un oggetto JSON' });
726
- }
727
- for (const k of Object.keys(b)) {
728
- if (!NODE_ROLE_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (schema: enabled, rendezvousSsh?, publishedPort?, keyPath?)` });
729
- }
730
- if (typeof b.enabled !== 'boolean') return send(res, 400, { error: 'enabled deve essere boolean' });
731
- if (b.rendezvousSsh !== undefined && !nodesStore.parseSsh(b.rendezvousSsh)) {
732
- return send(res, 400, { error: 'rendezvousSsh non valido (atteso user@host strict)' });
733
- }
734
- if (b.publishedPort !== undefined && !nodesStore.isPort(b.publishedPort)) {
735
- return send(res, 400, { error: 'publishedPort deve essere un intero 1..65535' });
736
- }
737
- if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
738
- return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
739
- }
740
-
741
- const cap = capture();
742
- if (b.enabled === false) {
743
- const out = nodesCmds.nodeOff({ ...cliOpts(cap) });
744
- if (out.code === 0) return send(res, 200, { enabled: false, roles: out.roles });
745
- return send(res, 500, { error: lastLine(cap, 'node off fallito') });
746
- }
747
- const out = nodesCmds.nodeOn({
748
- ...cliOpts(cap),
749
- rendezvousSsh: b.rendezvousSsh,
750
- publishedPort: b.publishedPort,
751
- key: b.keyPath,
752
- port: cfg.port, // porta nexus locale da esporre = quella del server attivo
753
- });
754
- if (out.code === 0) {
755
- const resp = { enabled: true, roles: out.roles, tunnel: out.tunnel || null };
756
- const line = authorizedKeysLine(cap);
757
- if (line) resp.authorizedKeys = line; // pubkey con permitlisten: non un segreto
758
- return send(res, 200, resp);
759
- }
760
- const msg = lastLine(cap, 'node on fallito');
761
- if (out.reason === 'readonly') return send(res, 403, { error: msg });
762
- if (out.reason === 'no rendezvous') return send(res, 400, { error: msg });
763
- if (out.reason === 'permitlisten') return send(res, 409, { error: msg });
764
- send(res, 500, { error: msg });
765
- } catch (e) { send(res, 500, { error: String(e.message || e) }); }
766
- });
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
+ }));
767
894
 
768
895
  // --- POST /service/regenerate — rigenera l'unit service --------------------
769
896
  // Riusa generateService/installService (lib/cli/service.js: escaping per-platform,
@@ -818,21 +945,45 @@ function settingsRoutes(deps = {}) {
818
945
  function publicPeeringRoutes(deps = {}) {
819
946
  const cfg = deps.cfg || {};
820
947
  const home = cfg.home || os.homedir();
948
+ const configPath = cfg.configPath || configJsonPath();
821
949
  const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
822
950
  const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
823
951
  const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
824
952
  const r = express.Router();
825
953
  const attempts = new Map();
826
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
+ });
827
973
  r.post('/join', (req, res) => {
828
- const key = String(req.socket && req.socket.remoteAddress || 'local');
974
+ const key = `join:${String(req.socket && req.socket.remoteAddress || 'local')}`;
829
975
  const now = Date.now();
830
976
  const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
831
977
  recent.push(now); attempts.set(key, recent);
832
978
  if (recent.length > 10) return res.status(429).json({ error: 'troppi tentativi di pairing' });
833
979
  const b = req.body || {};
980
+ const peerRoles = b.roles === undefined ? null : nodesStore.parseRoles(b.roles);
834
981
  if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
835
- || !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)) {
982
+ || !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
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)) {
836
987
  return res.status(400).json({ error: 'pairing request non valida' });
837
988
  }
838
989
  if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
@@ -847,9 +998,11 @@ function publicPeeringRoutes(deps = {}) {
847
998
  const reversePort = peering.allocateReversePort(st.nodes);
848
999
  const credential = peering.createPending({ pendingPath, data: {
849
1000
  name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
1001
+ shared: false,
850
1002
  label: nodesStore.sanitizeLabel(b.label, name),
1003
+ ...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
851
1004
  } });
852
- res.json({ paired: true, instanceId: st.nodeId, reversePort, credential });
1005
+ res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
853
1006
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
854
1007
  });
855
1008
  r.post('/confirm', (req, res) => {
@@ -867,8 +1020,10 @@ function publicPeeringRoutes(deps = {}) {
867
1020
  st = nodesStore.addNode(st, {
868
1021
  name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
869
1022
  direction: 'inbound', transport: 'inbound', autostart: true,
870
- visibility: 'network', nodeId: pending.instanceId,
1023
+ visibility: 'network', shared: pending.shared === true, nodeId: pending.instanceId,
871
1024
  token: pending.acceptToken, acceptToken: b.credential,
1025
+ ...(pending.roles ? { roles: pending.roles } : {}),
1026
+ rolesKnown: pending.rolesKnown === true,
872
1027
  ...(pending.label ? { label: pending.label } : {}),
873
1028
  });
874
1029
  nodesStore.atomicWriteStore(nodesPath, st);