@mmmbuto/nexuscrew 0.8.6 → 0.8.9

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.
@@ -46,15 +46,17 @@ const nodesStore = require('../nodes/store.js');
46
46
  const nodesCmds = require('../nodes/commands.js');
47
47
  const nodesTunnel = require('../nodes/tunnel.js');
48
48
  const peering = require('../nodes/peering.js');
49
+ const { probeHealth } = require('../proxy/federation.js');
49
50
  const { rotateToken } = require('../auth/token.js');
50
51
  const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
51
52
  const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
52
53
  const { isServiceRunning, readRoles, bootState } = require('../cli/commands.js');
53
54
  const { configJsonPath } = require('../config.js');
54
55
  const VERSION = require('../../package.json').version;
56
+ const { scrubError } = require('../update/core.js');
55
57
 
56
58
  // Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
57
- const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone']);
59
+ const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
58
60
  const ROLE_KEYS = new Set(['client', 'node']);
59
61
  const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
60
62
  const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
@@ -140,6 +142,7 @@ function settingsRoutes(deps = {}) {
140
142
  // scrive solo il file (come prima), senza reload in-memory.
141
143
  const tokenStore = deps.tokenStore || null;
142
144
  const closeSessions = deps.closeSessions || null;
145
+ const updater = deps.updater || null;
143
146
  const home = cfg.home || os.homedir();
144
147
  const configDir = cfg.configDir || path.join(home, '.nexuscrew');
145
148
  // configJsonPath() rispetta NEXUSCREW_CONFIG_FILE (stessa risoluzione di
@@ -190,6 +193,7 @@ function settingsRoutes(deps = {}) {
190
193
  active: isServiceRunning({ platform, execImpl: seams.execImpl, uid: seams.uid, home }),
191
194
  boot: bootState({ platform, execImpl: seams.execImpl, uid: seams.uid, home }).enabled,
192
195
  };
196
+ const updateStatus = updater && typeof updater.status === 'function' ? updater.status() : null;
193
197
  const out = {
194
198
  roles: readRoles(configPath),
195
199
  firstRun,
@@ -197,10 +201,12 @@ function settingsRoutes(deps = {}) {
197
201
  platform,
198
202
  service,
199
203
  version: VERSION,
204
+ autoUpdate: updateStatus ? updateStatus.enabled : fileCfg.autoUpdate !== false,
200
205
  // Nome dispositivo proposto per i form di pairing (etichetta umana, non
201
206
  // lo slug). La UI lo precompila e lascia editing libero.
202
207
  deviceName: defaultDeviceName(),
203
208
  };
209
+ if (updateStatus) out.update = updateStatus;
204
210
  // rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
205
211
  // la si prende comunque SOLO dalla vista redatta.
206
212
  const st = nodesStore.loadStore(nodesPath);
@@ -220,10 +226,10 @@ function settingsRoutes(deps = {}) {
220
226
  return send(res, 400, { error: 'body deve essere un oggetto JSON' });
221
227
  }
222
228
  for (const k of Object.keys(b)) {
223
- if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone)` });
229
+ if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate)` });
224
230
  }
225
231
  if (Object.keys(b).length === 0) {
226
- return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone)' });
232
+ return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate)' });
227
233
  }
228
234
  if (b.roles !== undefined) {
229
235
  if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
@@ -240,6 +246,9 @@ function settingsRoutes(deps = {}) {
240
246
  if (b.wizardDone !== undefined && typeof b.wizardDone !== 'boolean') {
241
247
  return send(res, 400, { error: 'wizardDone deve essere boolean' });
242
248
  }
249
+ if (b.autoUpdate !== undefined && typeof b.autoUpdate !== 'boolean') {
250
+ return send(res, 400, { error: 'autoUpdate deve essere boolean' });
251
+ }
243
252
 
244
253
  // merge sul config esistente (preserva le chiavi non gestite qui)
245
254
  const { cfg: current } = readConfigFile(configPath);
@@ -250,11 +259,15 @@ function settingsRoutes(deps = {}) {
250
259
  }
251
260
  if (b.port !== undefined) next.port = b.port;
252
261
  if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
262
+ if (b.autoUpdate !== undefined) next.autoUpdate = b.autoUpdate;
253
263
  atomicWriteConfig(configPath, next);
264
+ if (b.autoUpdate !== undefined && updater && typeof updater.setEnabled === 'function') {
265
+ updater.setEnabled(b.autoUpdate);
266
+ }
254
267
 
255
268
  const out = {
256
269
  saved: true,
257
- config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone },
270
+ config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone, autoUpdate: next.autoUpdate !== false },
258
271
  };
259
272
  // Il server legge la porta SOLO allo startup: il cambio vale al prossimo
260
273
  // restart (contratto dichiarato nella risposta, la UI avvisa).
@@ -265,6 +278,20 @@ function settingsRoutes(deps = {}) {
265
278
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
266
279
  });
267
280
 
281
+ // Auto-update npm: check e apply sono autenticati, READONLY-gated e non
282
+ // accettano mai package/versione dal browser. Il manager usa esclusivamente
283
+ // @mmmbuto/nexuscrew@latest, confronta semver e installa la versione esatta.
284
+ r.post('/update/check', mutGate, async (_req, res) => {
285
+ if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
286
+ try { send(res, 200, await updater.check()); }
287
+ catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
288
+ });
289
+ r.post('/update/apply', mutGate, async (_req, res) => {
290
+ if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
291
+ try { send(res, 202, await updater.apply()); }
292
+ catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
293
+ });
294
+
268
295
  // --- POST /token/rotate — atomico + reload live + chiusura WS; token MAI in risposta
269
296
  // Contratto §4b(3): "scrittura atomica del token file + chiusura delle sessioni
270
297
  // WS/API attive locali + reload credenziali proxy". Audit F7: prima questa route
@@ -296,90 +323,231 @@ function settingsRoutes(deps = {}) {
296
323
  try {
297
324
  const b = req.body || {};
298
325
  const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
326
+ const st = nodesStore.loadOrInitStore(nodesPath);
327
+ const rendezvous = st.rendezvous || null;
299
328
  const extra = {};
300
329
  if (typeof b.ssh === 'string' && b.ssh.trim()) {
301
330
  const ssh = nodesStore.parseSshTarget(b.ssh.trim());
302
331
  if (!ssh) return send(res, 400, { error: 'ssh non valido (atteso user@host o alias)' });
303
332
  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;
304
338
  }
305
339
  if (b.sshPort !== undefined && b.sshPort !== null && b.sshPort !== '') {
306
340
  if (!nodesStore.isPort(Number(b.sshPort))) return send(res, 400, { error: 'sshPort non valida (1..65535)' });
307
341
  extra.sshPort = Number(b.sshPort);
308
342
  }
309
343
  if (extra.sshPort && !extra.ssh) return send(res, 400, { error: 'sshPort richiede un target SSH' });
344
+ let remotePort = cfg.port;
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
+ } else if (rendezvous && extra.ssh === rendezvous.ssh) {
349
+ remotePort = rendezvous.publishedPort;
350
+ }
310
351
  if (typeof b.name === 'string' && b.name.trim()) {
311
352
  if (!nodesStore.NODE_NAME_RE.test(b.name.trim())) return send(res, 400, { error: 'name non valido (a-z 0-9 -, max 32)' });
312
353
  extra.name = b.name.trim();
313
354
  }
314
355
  if (extra.ssh && !extra.name) extra.name = nodesStore.toSlug(label);
315
- const st = nodesStore.loadOrInitStore(nodesPath);
316
356
  send(res, 200, peering.createInvite({
317
- invitesPath, instanceId: st.nodeId, port: cfg.port, label, ...extra,
357
+ invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: cfg.port, label, ...extra,
318
358
  }));
319
359
  } catch (e) { send(res, 500, { error: String(e.message || e) }); }
320
360
  });
321
361
 
322
- // Hydra join: add once from the PWA. A provisional -L reaches the invite
323
- // endpoint, then the negotiated -R makes the same SSH link reciprocal.
362
+ // Hydra join in stadi espliciti: la PWA riceve un contratto strutturato
363
+ // {error, code, stage, detail?, hint?, retryable?} MAI credenziali, token,
364
+ // header Authorization o contenuto di chiavi nel payload. Stadi distinti:
365
+ // validation | conflict | ssh-start | ssh-ready | join | tunnel-final |
366
+ // confirm | health (+ internal per gli inattesi)
367
+ // Lo sleep fisso 900ms e' sostituito da un probe di readiness bounded
368
+ // (peering.probeTransportReady) eseguito PRIMA di consumare l'invite one-time;
369
+ // una risposta join ambigua (rete morta dopo l'invio) non viene mai rigiocata.
370
+ // Su qualunque fallimento post-provisioning il rollback locale/remoto gira
371
+ // esattamente una volta e lo stage fallito arriva al client.
324
372
  r.post('/nodes/pair', mutGate, async (req, res) => {
325
373
  const b = req.body || {};
326
- if (!validName(b.name) || !nodesStore.parseSshTarget(b.ssh)) {
327
- return send(res, 400, { error: 'name o alias SSH non valido' });
328
- }
374
+ const redactSecrets = (s) => String(s || '')
375
+ .replace(/Bearer\s+\S+/gi, 'Bearer ***')
376
+ .replace(/[A-Za-z0-9_-]{40,}/g, '***');
377
+ const fail = (status, stage, code, detail, extra = {}) => send(res, status, {
378
+ error: redactSecrets(detail), stage, code, detail: redactSecrets(detail), retryable: false, ...extra,
379
+ });
380
+ if (!validName(b.name)) return fail(400, 'validation', 'bad-name', 'name non valido (slug a-z, 0-9, -, max 32)', { retryable: true });
381
+ if (!nodesStore.parseSshTarget(b.ssh)) return fail(400, 'validation', 'bad-ssh', 'alias SSH non valido (atteso user@host o Host alias)', { retryable: true });
329
382
  const pair = peering.parsePairingUrl(b.pairingUrl);
330
- if (!pair) return send(res, 400, { error: 'link di pairing non valido' });
331
- if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return send(res, 400, { error: 'sshPort non valida' });
332
- if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return send(res, 400, { error: 'identityFile non valido' });
333
- if (b.label !== undefined && !nodesStore.validLabel(b.label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
334
- if (b.localLabel !== undefined && !nodesStore.validLabel(b.localLabel)) return send(res, 400, { error: 'localLabel non valida (max 64 char, niente a capo)' });
383
+ if (!pair) return fail(400, 'validation', 'bad-link', 'link di pairing non valido o corrotto', { retryable: true, hint: 'rigenera il link sul dispositivo che invita' });
384
+ if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return fail(400, 'validation', 'bad-ssh-port', 'sshPort non valida (1..65535)', { retryable: true });
385
+ if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return fail(400, 'validation', 'bad-identity-file', 'identityFile non valido (path assoluto)', { retryable: true });
386
+ if (b.label !== undefined && !nodesStore.validLabel(b.label)) return fail(400, 'validation', 'bad-label', 'label non valida (max 64 char, niente a capo)', { retryable: true });
387
+ if (b.localLabel !== undefined && !nodesStore.validLabel(b.localLabel)) return fail(400, 'validation', 'bad-label', 'localLabel non valida (max 64 char, niente a capo)', { retryable: true });
335
388
  // label umana del peer come lo vedro' io (display); se assente usa lo slug.
336
389
  const peerLabel = nodesStore.sanitizeLabel(b.label, b.name);
337
390
  // etichetta umana con cui il peer vedra' questo dispositivo; default sensato.
338
391
  const localLabel = nodesStore.sanitizeLabel(b.localLabel, defaultDeviceName());
392
+ const fetchImpl = seams.fetchImpl || fetch;
393
+ const sleep = typeof seams.pairDelay === 'function' ? seams.pairDelay : undefined;
394
+ const requestTimeoutMs = Number.isInteger(seams.pairRequestTimeoutMs) && seams.pairRequestTimeoutMs > 0
395
+ ? seams.pairRequestTimeoutMs : 6000;
396
+ // Every protocol request must terminate. Readiness and federation health
397
+ // already have their own bounded probes; join/confirm/cancel use this
398
+ // wrapper so a half-open peer cannot leave the PWA waiting forever.
399
+ const pairFetch = async (url, opts = {}, timeoutMs = requestTimeoutMs) => {
400
+ const ctrl = new AbortController();
401
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
402
+ try { return await fetchImpl(url, { ...opts, signal: ctrl.signal }); }
403
+ finally { clearTimeout(timer); }
404
+ };
405
+
339
406
  let provisionalPort = null;
340
407
  let rollbackCredential = null;
341
408
  let created = false;
409
+ let rolledBack = false;
410
+ // Rollback locale/remoto ESATTAMENTE una volta, best-effort: cancella la
411
+ // credenziale provvisoria sul peer (se emessa) e rimuove nodo+tunnel locali.
412
+ const rollback = async () => {
413
+ if (rolledBack) return; rolledBack = true;
414
+ if (rollbackCredential && provisionalPort) {
415
+ try {
416
+ await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
417
+ method: 'POST', headers: { 'content-type': 'application/json' },
418
+ body: JSON.stringify({ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId, credential: rollbackCredential }),
419
+ }, Math.min(requestTimeoutMs, 3000));
420
+ } catch (_) { /* best-effort */ }
421
+ }
422
+ try {
423
+ const st = nodesStore.loadStore(nodesPath);
424
+ const n = st && nodesStore.getNode(st, b.name);
425
+ if (n && created) {
426
+ nodesTunnel.stopTunnel({ home, name: b.name });
427
+ nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, b.name));
428
+ }
429
+ } catch (_) { /* best-effort */ }
430
+ };
431
+ const failRolledBack = async (status, stage, code, detail, extra = {}) => {
432
+ await rollback();
433
+ return fail(status, stage, code, detail, extra);
434
+ };
435
+
342
436
  try {
437
+ // --- conflict: il nome non deve gia' esistere --------------------------
343
438
  let st = nodesStore.loadOrInitStore(nodesPath);
439
+ if (st.nodes.some((n) => n.name === b.name)) {
440
+ return fail(409, 'conflict', 'name-exists', `nodo "${b.name}" gia' presente`, {
441
+ retryable: true, hint: 'scegli un altro nome nelle opzioni avanzate e riprova',
442
+ });
443
+ }
444
+ if (st.nodeId === pair.instanceId) {
445
+ return fail(409, 'conflict', 'self-pairing', 'il link appartiene a questa stessa installazione', {
446
+ hint: 'genera il link sul nodo remoto che vuoi collegare',
447
+ });
448
+ }
449
+ const knownPeer = st.nodes.find((n) => n.nodeId === pair.instanceId);
450
+ if (knownPeer) {
451
+ return fail(409, 'conflict', 'peer-exists', `questa installazione e' gia' collegata come "${knownPeer.name}"`, {
452
+ hint: 'usa il nodo esistente oppure rimuovilo prima di rifare il pairing',
453
+ });
454
+ }
344
455
  const localPort = nodesCmds.assignLocalPort(st);
345
456
  provisionalPort = localPort;
346
457
  const acceptToken = crypto.randomBytes(32).toString('base64url');
347
- st = nodesStore.addNode(st, {
348
- name: b.name, ssh: b.ssh, sshPort: b.sshPort,
349
- remotePort: pair.port, localPort, identityFile: b.identityFile,
350
- transport: 'auto', autostart: true, visibility: 'network',
351
- direction: 'outbound', acceptToken, label: peerLabel,
352
- });
458
+ try {
459
+ st = nodesStore.addNode(st, {
460
+ name: b.name, ssh: b.ssh, sshPort: b.sshPort,
461
+ remotePort: pair.port, localPort, identityFile: b.identityFile,
462
+ transport: 'auto', autostart: true, visibility: 'network',
463
+ direction: 'outbound', acceptToken, label: peerLabel,
464
+ });
465
+ } catch (e) {
466
+ const msg = String((e && e.message) || e);
467
+ const isDup = msg.includes('duplicato') || msg.includes('self-reference');
468
+ return fail(isDup ? 409 : 400, isDup ? 'conflict' : 'validation', 'node-rejected', msg, { retryable: !isDup });
469
+ }
353
470
  nodesStore.atomicWriteStore(nodesPath, st);
354
471
  created = true;
355
472
  const node = nodesStore.getNode(st, b.name);
473
+
474
+ // --- ssh-start: supervisor del tunnel -L provvisorio --------------------
356
475
  const started = nodesTunnel.startForward({
357
476
  home, node, localAppPort: cfg.port,
358
477
  spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
359
478
  sshBin: seams.sshBin, logFd: seams.logFd,
360
479
  });
361
- if (!started.started && started.reason !== 'already running') throw new Error(started.reason || 'tunnel start failed');
362
- if (typeof seams.pairDelay === 'function') await seams.pairDelay();
363
- else await new Promise((resolve) => setTimeout(resolve, 900));
364
- const fetchImpl = seams.fetchImpl || fetch;
365
- const jr = await fetchImpl(`http://127.0.0.1:${localPort}/pair/join`, {
366
- method: 'POST', headers: { 'content-type': 'application/json' },
367
- body: JSON.stringify({
368
- invite: pair.invite,
369
- instanceId: st.nodeId,
370
- name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
371
- label: localLabel,
372
- port: cfg.port,
373
- acceptToken,
374
- }),
375
- });
480
+ if (!started.started && started.reason !== 'already running') {
481
+ return failRolledBack(502, 'ssh-start', 'tunnel-start-failed', started.reason || 'avvio del tunnel SSH fallito', {
482
+ retryable: true,
483
+ hint: 'verifica ssh e target/alias su questo dispositivo; il link NON e\' stato consumato e puoi riprovare',
484
+ });
485
+ }
486
+
487
+ // --- ssh-ready: readiness bounded PRIMA di consumare l'invite -----------
488
+ const ready = await peering.probeTransportReady({ port: localPort, fetchImpl, sleep });
489
+ 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
+ });
495
+ }
496
+
497
+ // --- join: consuma l'invite one-time (UNA volta, mai replay) ------------
498
+ let jr;
499
+ try {
500
+ jr = await pairFetch(`http://127.0.0.1:${localPort}/pair/join`, {
501
+ method: 'POST', headers: { 'content-type': 'application/json' },
502
+ body: JSON.stringify({
503
+ invite: pair.invite,
504
+ instanceId: st.nodeId,
505
+ name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
506
+ label: localLabel,
507
+ port: cfg.port,
508
+ acceptToken,
509
+ roles: readRoles(configPath),
510
+ }),
511
+ });
512
+ } catch (e) {
513
+ // Risposta persa DOPO l'invio: l'invite potrebbe essere stato consumato.
514
+ // Un join ambiguo non si rigioca mai.
515
+ return failRolledBack(502, 'join', 'join-ambiguous',
516
+ 'risposta di join persa: l\'invito potrebbe essere gia\' stato consumato', {
517
+ hint: 'rigenera un nuovo link sul dispositivo che invita e riprova',
518
+ });
519
+ }
376
520
  const joined = await jr.json().catch(() => ({}));
377
- if (!jr.ok || !nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
378
- || !nodesStore.NODE_ID_RE.test(joined.instanceId)) throw new Error(joined.error || `pairing HTTP ${jr.status}`);
521
+ if (jr.status === 410) {
522
+ return failRolledBack(502, 'join', 'invite-expired', 'invito scaduto o gia\' usato (one-time)', {
523
+ hint: 'rigenera un nuovo link sul dispositivo che invita',
524
+ });
525
+ }
526
+ if (!jr.ok) {
527
+ return failRolledBack(502, 'join', 'join-rejected', joined.error || `join rifiutato dal peer (HTTP ${jr.status})`, {
528
+ hint: 'rigenera un nuovo link e riprova',
529
+ });
530
+ }
531
+ const joinedRoles = joined.roles === undefined ? null : nodesStore.parseRoles(joined.roles);
532
+ if (!nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
533
+ || !nodesStore.NODE_ID_RE.test(joined.instanceId) || (joined.roles !== undefined && !joinedRoles)) {
534
+ return failRolledBack(502, 'join', 'join-invalid-response', 'risposta di join non valida dal peer', {
535
+ hint: 'versioni NexusCrew incompatibili? aggiorna entrambi i nodi',
536
+ });
537
+ }
379
538
  rollbackCredential = joined.credential;
539
+ if (joined.instanceId !== pair.instanceId) {
540
+ return failRolledBack(502, 'join', 'peer-identity-mismatch',
541
+ 'l\'identita\' del peer raggiunto non coincide con quella contenuta nel link', {
542
+ hint: 'controlla che il target SSH punti al nodo che ha generato il link',
543
+ });
544
+ }
545
+
546
+ // --- tunnel-final: riavvio con la spec negoziata (-R inclusa) -----------
380
547
  st = nodesStore.loadOrInitStore(nodesPath);
381
548
  st = nodesStore.updateNode(st, b.name, {
382
549
  token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
550
+ ...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
383
551
  });
384
552
  nodesStore.atomicWriteStore(nodesPath, st);
385
553
  nodesTunnel.stopTunnel({ home, name: b.name });
@@ -388,44 +556,51 @@ function settingsRoutes(deps = {}) {
388
556
  spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
389
557
  sshBin: seams.sshBin, logFd: seams.logFd,
390
558
  });
391
- if (!finalStart.started && finalStart.reason !== 'already running') throw new Error(finalStart.reason || 'final tunnel start failed');
392
- if (typeof seams.pairDelay === 'function') await seams.pairDelay();
393
- else await new Promise((resolve) => setTimeout(resolve, 900));
559
+ if (!finalStart.started && finalStart.reason !== 'already running') {
560
+ return failRolledBack(502, 'tunnel-final', 'tunnel-restart-failed', finalStart.reason || 'riavvio del tunnel negoziato fallito', {});
561
+ }
562
+ const readyFinal = await peering.probeTransportReady({ port: localPort, fetchImpl, sleep });
563
+ if (!readyFinal.ready) {
564
+ return failRolledBack(502, 'tunnel-final', 'transport-not-ready',
565
+ `il tunnel negoziato non risponde (${readyFinal.attempts} tentativi)`, {});
566
+ }
567
+
568
+ // --- confirm: idempotente lato peer -> bounded retry ---------------------
394
569
  let confirmed = null;
395
570
  for (let attempt = 0; attempt < 3; attempt += 1) {
396
571
  try {
397
- confirmed = await fetchImpl(`http://127.0.0.1:${localPort}/pair/confirm`, {
572
+ confirmed = await pairFetch(`http://127.0.0.1:${localPort}/pair/confirm`, {
398
573
  method: 'POST', headers: { 'content-type': 'application/json' },
399
574
  body: JSON.stringify({ credential: joined.credential }),
400
- });
575
+ }, Math.min(requestTimeoutMs, 3500));
401
576
  if (confirmed.ok) break;
402
- } catch (_) {}
403
- await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
577
+ } catch (_) { /* retry: confirm e' idempotente lato peer */ }
578
+ if (attempt < 2) await (sleep ? sleep() : new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))));
404
579
  }
405
580
  if (!confirmed || !confirmed.ok) {
406
581
  const x = confirmed ? await confirmed.json().catch(() => ({})) : {};
407
- throw new Error(x.error || `pair confirm HTTP ${confirmed ? confirmed.status : 'unreachable'}`);
582
+ return failRolledBack(502, 'confirm', 'confirm-failed',
583
+ x.error || `conferma pairing fallita (HTTP ${confirmed ? confirmed.status : 'irraggiungibile'})`, {
584
+ hint: 'rigenera un nuovo link e riprova',
585
+ });
408
586
  }
409
- send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto' });
410
- } catch (e) {
411
- if (rollbackCredential && provisionalPort) {
412
- try {
413
- const fetchImpl = seams.fetchImpl || fetch;
414
- await fetchImpl(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
415
- method: 'POST', headers: { 'content-type': 'application/json' },
416
- body: JSON.stringify({ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId, credential: rollbackCredential }),
587
+
588
+ // --- health: federazione AUTENTICATA verificata prima di paired:true ----
589
+ const health = await probeHealth({
590
+ port: localPort, token: joined.credential, expectedInstanceId: joined.instanceId,
591
+ fetchImpl, now: Date.now(),
592
+ });
593
+ if (!health || health.status !== 'healthy') {
594
+ return failRolledBack(502, 'health', 'federation-health-failed',
595
+ (health && health.detail) || 'health federato non verificabile dopo la conferma', {
596
+ hint: 'pairing annullato e ripulito: rigenera il link e riprova',
417
597
  });
418
- } catch (_) {}
419
598
  }
420
- try {
421
- const st = nodesStore.loadStore(nodesPath);
422
- const n = st && nodesStore.getNode(st, b.name);
423
- if (n && created) {
424
- nodesTunnel.stopTunnel({ home, name: b.name });
425
- nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, b.name));
426
- }
427
- } catch (_) {}
428
- send(res, 502, { error: String(e.message || e) });
599
+
600
+ send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto', health: { status: health.status } });
601
+ } catch (e) {
602
+ await rollback();
603
+ fail(502, 'internal', 'unexpected', String((e && e.message) || e), {});
429
604
  }
430
605
  });
431
606
 
@@ -685,6 +860,7 @@ function settingsRoutes(deps = {}) {
685
860
  function publicPeeringRoutes(deps = {}) {
686
861
  const cfg = deps.cfg || {};
687
862
  const home = cfg.home || os.homedir();
863
+ const configPath = cfg.configPath || configJsonPath();
688
864
  const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
689
865
  const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
690
866
  const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
@@ -698,8 +874,10 @@ function publicPeeringRoutes(deps = {}) {
698
874
  recent.push(now); attempts.set(key, recent);
699
875
  if (recent.length > 10) return res.status(429).json({ error: 'troppi tentativi di pairing' });
700
876
  const b = req.body || {};
877
+ const peerRoles = b.roles === undefined ? null : nodesStore.parseRoles(b.roles);
701
878
  if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
702
- || !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)) {
879
+ || !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
880
+ || (b.roles !== undefined && !peerRoles)) {
703
881
  return res.status(400).json({ error: 'pairing request non valida' });
704
882
  }
705
883
  if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
@@ -715,8 +893,9 @@ function publicPeeringRoutes(deps = {}) {
715
893
  const credential = peering.createPending({ pendingPath, data: {
716
894
  name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
717
895
  label: nodesStore.sanitizeLabel(b.label, name),
896
+ ...(peerRoles ? { roles: peerRoles, rolesKnown: true } : { rolesKnown: false }),
718
897
  } });
719
- res.json({ paired: true, instanceId: st.nodeId, reversePort, credential });
898
+ res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
720
899
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
721
900
  });
722
901
  r.post('/confirm', (req, res) => {
@@ -736,6 +915,8 @@ function publicPeeringRoutes(deps = {}) {
736
915
  direction: 'inbound', transport: 'inbound', autostart: true,
737
916
  visibility: 'network', nodeId: pending.instanceId,
738
917
  token: pending.acceptToken, acceptToken: b.credential,
918
+ ...(pending.roles ? { roles: pending.roles } : {}),
919
+ rolesKnown: pending.rolesKnown === true,
739
920
  ...(pending.label ? { label: pending.label } : {}),
740
921
  });
741
922
  nodesStore.atomicWriteStore(nodesPath, st);
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+
7
+ const PACKAGE_NAME = require('../../package.json').name;
8
+ const VERSION_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
9
+
10
+ function parseVersion(value) {
11
+ const m = VERSION_RE.exec(String(value || '').trim());
12
+ if (!m) return null;
13
+ return {
14
+ raw: m[0],
15
+ major: Number(m[1]),
16
+ minor: Number(m[2]),
17
+ patch: Number(m[3]),
18
+ prerelease: m[4] ? m[4].split('.') : [],
19
+ };
20
+ }
21
+
22
+ function compareIdentifier(a, b) {
23
+ const an = /^[0-9]+$/.test(a);
24
+ const bn = /^[0-9]+$/.test(b);
25
+ if (an && bn) return Number(a) === Number(b) ? 0 : Number(a) > Number(b) ? 1 : -1;
26
+ if (an !== bn) return an ? -1 : 1;
27
+ return a === b ? 0 : a > b ? 1 : -1;
28
+ }
29
+
30
+ function compareVersions(a, b) {
31
+ const av = parseVersion(a);
32
+ const bv = parseVersion(b);
33
+ if (!av || !bv) throw new Error('versione semver non valida');
34
+ for (const key of ['major', 'minor', 'patch']) {
35
+ if (av[key] !== bv[key]) return av[key] > bv[key] ? 1 : -1;
36
+ }
37
+ if (!av.prerelease.length && !bv.prerelease.length) return 0;
38
+ if (!av.prerelease.length) return 1;
39
+ if (!bv.prerelease.length) return -1;
40
+ const n = Math.max(av.prerelease.length, bv.prerelease.length);
41
+ for (let i = 0; i < n; i += 1) {
42
+ if (av.prerelease[i] === undefined) return -1;
43
+ if (bv.prerelease[i] === undefined) return 1;
44
+ const c = compareIdentifier(av.prerelease[i], bv.prerelease[i]);
45
+ if (c) return c;
46
+ }
47
+ return 0;
48
+ }
49
+
50
+ function registryVersion(stdout) {
51
+ const raw = String(stdout || '').trim();
52
+ let value = raw;
53
+ try {
54
+ const decoded = JSON.parse(raw);
55
+ if (typeof decoded === 'string') value = decoded;
56
+ } catch (_) { /* npm senza --json o output gia' plain */ }
57
+ const parsed = parseVersion(value);
58
+ if (!parsed) throw new Error('npm ha restituito una versione non valida');
59
+ return parsed.raw;
60
+ }
61
+
62
+ function scrubError(error) {
63
+ return String((error && error.message) || error || 'errore sconosciuto')
64
+ .replace(/[\r\n\t]+/g, ' ')
65
+ .replace(/https?:\/\/[^\s/@:]+:[^\s/@]+@/gi, 'https://***@')
66
+ .replace(/\/(?:home|Users|data\/data\/com\.termux\/files\/home)\/[^\s:]+/g, '<local-path>')
67
+ .replace(/(?:Bearer\s+)?[A-Za-z0-9_-]{40,}/gi, '***')
68
+ .slice(0, 300);
69
+ }
70
+
71
+ function pidAlive(pid) {
72
+ if (!Number.isInteger(pid) || pid < 1) return false;
73
+ try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
74
+ }
75
+
76
+ function readLock(file) {
77
+ try {
78
+ const st = fs.lstatSync(file);
79
+ if (!st.isFile() || st.isSymbolicLink()) return null;
80
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
81
+ return value && Number.isInteger(value.pid) && typeof value.token === 'string' ? value : null;
82
+ } catch (_) { return null; }
83
+ }
84
+
85
+ function writeLock(file, value, flags = 'wx') {
86
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
87
+ fs.writeFileSync(file, `${JSON.stringify(value)}\n`, { flag: flags, mode: 0o600 });
88
+ fs.chmodSync(file, 0o600);
89
+ }
90
+
91
+ function acquireUpdateLock(file, pid = process.pid, token = crypto.randomBytes(16).toString('hex')) {
92
+ for (let attempt = 0; attempt < 2; attempt += 1) {
93
+ try { writeLock(file, { pid, token, createdAt: Date.now() }); return { ok: true, token }; }
94
+ catch (e) {
95
+ if (e.code !== 'EEXIST') throw e;
96
+ const current = readLock(file);
97
+ if (current && pidAlive(current.pid)) return { ok: false, current };
98
+ try { fs.unlinkSync(file); } catch (unlinkError) { if (unlinkError.code !== 'ENOENT') throw unlinkError; }
99
+ }
100
+ }
101
+ return { ok: false, current: readLock(file) };
102
+ }
103
+
104
+ function adoptUpdateLock(file, token, pid = process.pid) {
105
+ const current = readLock(file);
106
+ if (!current || current.token !== token) return false;
107
+ const tmp = `${file}.${pid}.tmp`;
108
+ try {
109
+ writeLock(tmp, { ...current, pid, adoptedAt: Date.now() });
110
+ fs.renameSync(tmp, file);
111
+ return true;
112
+ } catch (e) {
113
+ try { fs.unlinkSync(tmp); } catch (_) {}
114
+ throw e;
115
+ }
116
+ }
117
+
118
+ function releaseUpdateLock(file, token) {
119
+ const current = readLock(file);
120
+ if (!current || current.token !== token) return false;
121
+ try { fs.unlinkSync(file); return true; } catch (_) { return false; }
122
+ }
123
+
124
+ function readState(file) {
125
+ try {
126
+ const st = fs.lstatSync(file);
127
+ if (!st.isFile() || st.isSymbolicLink()) return {};
128
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
129
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
130
+ } catch (_) { return {}; }
131
+ }
132
+
133
+ function writeState(file, value) {
134
+ try {
135
+ const st = fs.lstatSync(file);
136
+ if (st.isSymbolicLink() || !st.isFile()) throw new Error('update state target non sicuro');
137
+ } catch (e) {
138
+ if (e.code !== 'ENOENT') throw e;
139
+ }
140
+ const dir = path.dirname(file);
141
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
142
+ const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
143
+ try {
144
+ fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
145
+ fs.chmodSync(tmp, 0o600);
146
+ fs.renameSync(tmp, file);
147
+ } catch (e) {
148
+ try { fs.unlinkSync(tmp); } catch (_) {}
149
+ throw e;
150
+ }
151
+ }
152
+
153
+ module.exports = {
154
+ PACKAGE_NAME, VERSION_RE, parseVersion, compareVersions, registryVersion,
155
+ scrubError, pidAlive, readLock, acquireUpdateLock, adoptUpdateLock, releaseUpdateLock,
156
+ readState, writeState,
157
+ };