@mmmbuto/nexuscrew 0.8.47 → 0.8.49

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.
@@ -52,7 +52,9 @@ const audioGroups = require('../audio/groups.js');
52
52
  const nodesCmds = require('../nodes/commands.js');
53
53
  const nodesTunnel = require('../nodes/tunnel.js');
54
54
  const peering = require('../nodes/peering.js');
55
- const { waitForHealthyPeer, preflightHubReverse, notifyHubShare, reconcilePeerShare } = require('../proxy/federation.js');
55
+ const {
56
+ waitForHealthyPeer, preflightHubReverse, notifyHubShare, reconcilePeerShare, SHARE_NOT_READY_CODE,
57
+ } = require('../proxy/federation.js');
56
58
  const { rotateToken } = require('../auth/token.js');
57
59
  const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
58
60
  const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
@@ -597,8 +599,45 @@ function settingsRoutes(deps = {}) {
597
599
  const wasShared = node.shared === true;
598
600
  let persistedDesired = wasShared;
599
601
  let shareOnAttempted = false;
602
+ // Traccia se il rollback ha davvero spento il canale reverse. Resta true
603
+ // finche' non si tenta una chiusura che non riesce a dimostrarsi.
604
+ let reverseChannelClosed = true;
605
+ // `close` restituisce false quando non riesce a DIMOSTRARE la proprieta'
606
+ // del supervisor: il canale resta in quarantena, vivo. Una chiusura che
607
+ // LANCIA non e' una chiusura riuscita: stesso esito, altrimenti l'unico
608
+ // caso in cui non sappiamo nulla sarebbe anche l'unico che tace.
609
+ const closeReverseChannel = async () => {
610
+ if (!node.reversePool || typeof reverseSlots?.close !== 'function') return;
611
+ try {
612
+ reverseChannelClosed = (await reverseSlots.close(name)) !== false;
613
+ } catch (_) {
614
+ reverseChannelClosed = false;
615
+ }
616
+ };
600
617
  const fetchImpl = seams.fetchImpl || fetch;
601
- const notifyHub = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
618
+ const notifyHubOnce = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
619
+ // Share ON stabilisce il reverse channel un istante prima di annunciarlo:
620
+ // subito dopo un pairing il bind sul hub non e' ancora pronto e l'hub
621
+ // risponde con un codice tipizzato ritentabile. Senza questo ritentativo
622
+ // limitato l'attesa diventava un fallimento definitivo, con rollback
623
+ // dell'intera transazione e Share che "non funziona" finche' l'operatore
624
+ // non riprova a mano. Qualunque altro errore resta definitivo.
625
+ const NOTIFY_RETRY_DELAYS_MS = [1000, 2000, 3000];
626
+ const notifyDelay = typeof seams.pairDelay === 'function'
627
+ ? seams.pairDelay
628
+ : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
629
+ const notifyHub = async (shared) => {
630
+ if (!shared) return notifyHubOnce(false);
631
+ let last;
632
+ for (let attempt = 0; attempt <= NOTIFY_RETRY_DELAYS_MS.length; attempt += 1) {
633
+ try { return await notifyHubOnce(true); } catch (e) {
634
+ last = e;
635
+ if (!e || e.code !== SHARE_NOT_READY_CODE) throw e;
636
+ if (attempt < NOTIFY_RETRY_DELAYS_MS.length) await notifyDelay(NOTIFY_RETRY_DELAYS_MS[attempt]);
637
+ }
638
+ }
639
+ throw last;
640
+ };
602
641
  const ensureLocal = async ({ restart = false } = {}) => {
603
642
  const stopForward = seams.stopTunnelImpl || nodesTunnel.stopTunnel;
604
643
  const startForward = seams.startForwardImpl || nodesTunnel.startForward;
@@ -639,7 +678,9 @@ function settingsRoutes(deps = {}) {
639
678
  nodesStore.atomicWriteStore(nodesPath, st);
640
679
  persistedDesired = shared;
641
680
  node = nodesStore.getNode(st, name);
642
- if (!shared && node.reversePool && typeof reverseSlots?.close === 'function') await reverseSlots.close(name);
681
+ // Lo stato persistito direbbe "privato" mentre il reverse pool e' ancora
682
+ // su: l'esito della chiusura va riportato a chi chiama, non ignorato.
683
+ if (!shared) await closeReverseChannel();
643
684
  await ensureLocal({ restart: true });
644
685
  };
645
686
  const revokeHub = () => reconcilePeerShare({
@@ -659,7 +700,10 @@ function settingsRoutes(deps = {}) {
659
700
  detail: scrubError(revokeErr),
660
701
  });
661
702
  }
662
- if (node.reversePool && typeof reverseSlots?.close === 'function') await reverseSlots.close(name);
703
+ // Stessa regola del rollback: una chiusura non dimostrabile lascia il
704
+ // canale in quarantena, vivo. Qui finisce in una risposta di SUCCESSO,
705
+ // quindi tacerlo e' peggio: dichiarerebbe revocato cio' che e' sospeso.
706
+ await closeReverseChannel();
663
707
  try {
664
708
  // A changed ON->OFF spec is restarted explicitly; same-state OFF uses
665
709
  // the idempotent spec-aware path, which only replaces a stale -R.
@@ -673,6 +717,7 @@ function settingsRoutes(deps = {}) {
673
717
  }
674
718
  return send(res, 200, {
675
719
  name, shared: false, revoked: true,
720
+ ...(reverseChannelClosed ? {} : { reversePoolPending: true }),
676
721
  ...(unchanged ? { unchanged: true, reconciled: true } : {}),
677
722
  });
678
723
  };
@@ -742,7 +787,11 @@ function settingsRoutes(deps = {}) {
742
787
  // Share-on is transactional: a failed hub acknowledgement returns to the
743
788
  // safe private -L-only state. Never include remote response bodies/tokens.
744
789
  if (body.shared && !wasShared && shareOnAttempted) {
745
- try { await applyLocal(false); } catch (_) { /* best-effort safe rollback */ }
790
+ // Il rollback resta best-effort non si puo' fallire due volte ma il
791
+ // suo esito NON si perde: se il canale reverse non e' stato spento in
792
+ // modo dimostrabile, chi chiama deve saperlo invece di leggere solo
793
+ // "Share non attivato" e credere che tutto sia tornato privato.
794
+ try { await applyLocal(false); } catch (_) { reverseChannelClosed = false; }
746
795
  }
747
796
  const offPersisted = body.shared === false && persistedDesired === false;
748
797
  const redact = (value) => String(value || '').replace(/Bearer\s+\S+/gi, 'Bearer ***');
@@ -753,6 +802,10 @@ function settingsRoutes(deps = {}) {
753
802
  : 'Share non attivato')
754
803
  : offPersisted ? 'Share disattivato localmente; hub non riconciliato' : 'Share non disattivato',
755
804
  ...(offPersisted ? { shared: false, reconcilePending: true } : {}),
805
+ // Segnale esplicito quando lo store dice privato ma il canale reverse
806
+ // non risulta spento in modo dimostrabile: e' una quarantena, non una
807
+ // chiusura, e va diagnosticata invece che dedotta.
808
+ ...(reverseChannelClosed ? {} : { reversePoolPending: true }),
756
809
  ...(e && typeof e.code === 'string' ? { code: e.code } : {}),
757
810
  detail: redact(e && e.message || e),
758
811
  ...(e && typeof e.hint === 'string' ? { hint: redact(e.hint) } : {}),
package/lib/ws/bridge.js CHANGED
@@ -10,6 +10,8 @@ function clamp(n, lo, hi, def) {
10
10
  return Math.max(lo, Math.min(hi, Math.round(n)));
11
11
  }
12
12
 
13
+ const ATTACH_TIMEOUT_MS = 15000;
14
+
13
15
  function bindWs(ws, deps) {
14
16
  const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {} } = deps;
15
17
  let pty = null;
@@ -17,10 +19,31 @@ function bindWs(ws, deps) {
17
19
  let session = null;
18
20
 
19
21
  function fail(code, reason) {
22
+ // Si spegne anche la scadenza pre-attach: un frame rifiutato (token o
23
+ // handshake non validi) chiude gia' il socket, e lasciare il timer vivo
24
+ // fino al close event tiene in piedi un handle senza scopo.
25
+ clearAttachTimer();
20
26
  try { ws.send(JSON.stringify({ type: 'error', reason })); } catch (_) {}
21
27
  try { ws.close(code, reason); } catch (_) {}
22
28
  }
23
29
 
30
+ // L'upgrade viene accettato prima dell'autenticazione: il token arriva nel
31
+ // primo frame. Senza una scadenza un socket che non manda MAI l'attach resta
32
+ // aperto e non autenticato a tempo indefinito, e il costo si moltiplica su
33
+ // ogni listener che serve l'app. La finestra e' generosa (un client reale
34
+ // manda l'attach all'apertura) ma non infinita.
35
+ const attachTimeoutMs = Number.isFinite(defaults.attachTimeoutMs)
36
+ ? Math.max(1000, defaults.attachTimeoutMs) : ATTACH_TIMEOUT_MS;
37
+ let attachTimer = setTimeout(() => {
38
+ attachTimer = null;
39
+ if (!attached) fail(4408, 'attach timeout');
40
+ }, attachTimeoutMs);
41
+ if (typeof attachTimer.unref === 'function') attachTimer.unref();
42
+ // Dichiarazione (hoisted): `fail` la chiama ed e' definita piu' sopra.
43
+ function clearAttachTimer() {
44
+ if (attachTimer) { clearTimeout(attachTimer); attachTimer = null; }
45
+ }
46
+
24
47
  function onMessage(data, isBinary) {
25
48
  if (!attached) {
26
49
  if (isBinary) return fail(1002, 'binary before attach');
@@ -30,6 +53,7 @@ function bindWs(ws, deps) {
30
53
  if (!verifyToken(msg.token)) return fail(4401, 'bad token');
31
54
  if (!isValidSession(msg.session)) return fail(4404, 'no such session');
32
55
  attached = true;
56
+ clearAttachTimer();
33
57
  session = msg.session;
34
58
  onAttach(session, ws);
35
59
  // Resize default: when nobody else is attached, drive the session size so a
@@ -83,7 +107,7 @@ function bindWs(ws, deps) {
83
107
  }
84
108
 
85
109
  ws.on('message', onMessage);
86
- ws.on('close', () => { if (pty) pty.kill(); pty = null; });
87
- ws.on('error', () => { if (pty) pty.kill(); pty = null; });
110
+ ws.on('close', () => { clearAttachTimer(); if (pty) pty.kill(); pty = null; });
111
+ ws.on('error', () => { clearAttachTimer(); if (pty) pty.kill(); pty = null; });
88
112
  }
89
113
  module.exports = { bindWs, clamp };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.47",
3
+ "version": "0.8.49",
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": {