@mmmbuto/nexuscrew 0.8.46 → 0.8.48

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.
@@ -17,6 +17,48 @@ const { signHop, HOP_HEADER } = require('./hop-proof.js');
17
17
  const MAX_HOPS = 4;
18
18
  const ROUTE_DELIMITER = '_';
19
19
  const TOPOLOGY_PEER_TIMEOUT_MS = 1500;
20
+ // Finestra di attesa del reverse channel su Share ON: limitata per non
21
+ // diventare un retry storm, ma sufficiente al bind subito dopo un pairing.
22
+ const SHARE_HEALTH_ATTEMPTS = 10;
23
+ const SHARE_HEALTH_DELAY_MS = 300;
24
+ const SHARE_NOT_READY_CODE = 'share-channel-not-ready';
25
+ // 500/502/503/504 sono indisponibilita' che passano; 501 e 505 no.
26
+ const TRANSIENT_HTTP_STATUS = new Set([500, 502, 503, 504]);
27
+ // Esiti della prova di slot che significano "non ottenuta" e non "sbagliata".
28
+ const SLOT_PROOF_UNAVAILABLE = 'reverse-slot-proof-unavailable';
29
+
30
+ // Classificazione dell'esito di Share ON. La regola e' fail-closed sul
31
+ // RITENTATIVO: e' ritentabile solo cio' che e' dimostrabilmente transitorio —
32
+ // il canale non ancora salito. Tutto il resto (credenziale non valida, nodo
33
+ // sbagliato in fondo al tunnel, prova di slot non corrispondente) e' un guasto
34
+ // che il tempo non ripara, e ritentarlo nasconde all'operatore la vera causa.
35
+ function classifyShareFailure(health) {
36
+ const detail = (health && health.detail) || 'reverse SSH non pronto';
37
+ if (!health) return { code: 'share-peer-unreachable', detail };
38
+ // Credenziale non valida: serve un re-pair, il tempo non la ripara.
39
+ if (health.auth === 'failed') return { code: 'share-peer-unauthorized', detail };
40
+ if (health.slotProof === true) {
41
+ // Attenzione: `probeReverseSlot` marca "non posseduta" anche quando la
42
+ // prova non e' stata OTTENUTA — timeout, connessione rifiutata, risposta
43
+ // non 200 — che e' un canale non ancora su, non una prova sbagliata.
44
+ // Confonderli renderebbe finale proprio il caso che vogliamo attendere.
45
+ return health.code === SLOT_PROOF_UNAVAILABLE
46
+ ? { code: SHARE_NOT_READY_CODE, detail }
47
+ : { code: 'share-slot-proof-failed', detail };
48
+ }
49
+ if (health.transport === 'down') return { code: SHARE_NOT_READY_CODE, detail };
50
+ // Il peer risponde ma con un errore server: indisponibilita' temporanea, non
51
+ // il nodo sbagliato in fondo al tunnel. Allowlist esplicita invece di "ogni
52
+ // 5xx": 501 e 505 dicono che quella richiesta non sara' MAI servita, e
53
+ // ritentarle sarebbe rumore.
54
+ if (TRANSIENT_HTTP_STATUS.has(health.httpStatus)) {
55
+ return { code: SHARE_NOT_READY_CODE, detail };
56
+ }
57
+ // Qui il peer ha risposto 200 ma non e' quello atteso (instanceId o payload):
58
+ // ritentare non lo trasforma nel nodo giusto.
59
+ if (health.reachability === 'failed') return { code: 'share-peer-mismatch', detail };
60
+ return { code: 'share-peer-unreachable', detail };
61
+ }
20
62
 
21
63
  function peerFromToken(nodesPath, token) {
22
64
  const st = store.loadStore(nodesPath);
@@ -381,7 +423,18 @@ async function notifyHubShare({ node, shared, fetchImpl = fetch, timeoutMs = 500
381
423
  }, budget);
382
424
  });
383
425
  const response = await Promise.race([request, timeout]);
384
- if (!response || !response.ok) throw new Error(`hub Share HTTP ${response && response.status || 'unknown'}`);
426
+ if (!response || !response.ok) {
427
+ const failure = new Error(`hub Share HTTP ${response && response.status || 'unknown'}`);
428
+ failure.status = response && response.status;
429
+ // Si estrae SOLO il codice tipizzato: il corpo remoto non entra mai nel
430
+ // messaggio d'errore ne' nei log. Un corpo non-JSON lascia il codice
431
+ // assente e l'errore resta definitivo, che e' il default sicuro.
432
+ try {
433
+ const body = typeof response.json === 'function' ? await response.json() : null;
434
+ if (body && typeof body.code === 'string' && body.code.length <= 64) failure.code = body.code;
435
+ } catch (_) { /* corpo assente o non-JSON: nessun codice */ }
436
+ throw failure;
437
+ }
385
438
  return { shared };
386
439
  } finally { clearTimeout(timer); }
387
440
  }
@@ -593,7 +646,14 @@ async function collectTopologyDetailed({
593
646
  if (!n.nodeId || seen.has(n.nodeId)
594
647
  || (!ingress && n.direction === 'inbound' && n.shared !== true)
595
648
  || (ingress && !canTransit(ingress, n))) continue;
596
- out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
649
+ // `name` e' lo slug locale con cui si indirizza; `label` e' il nome
650
+ // leggibile. Senza quest'ultimo un nodo raggiunto in transito arriva agli
651
+ // altri come uno slug scelto da qualcun altro, e cinque installazioni
652
+ // diverse si presentano con lo stesso nome.
653
+ out.push({
654
+ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true,
655
+ label: store.validLabel(n.label) ? n.label.trim() : '',
656
+ });
597
657
  if (ttl <= 1 || !n.token) continue;
598
658
  probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs }) });
599
659
  }
@@ -611,7 +671,15 @@ async function collectTopologyDetailed({
611
671
  || new Set(child.route).size !== child.route.length
612
672
  || child.name !== child.route[child.route.length - 1]
613
673
  || child.route.includes(n.name)) continue;
614
- out.push({ instanceId: child.instanceId, name: child.name, route: [n.name, ...child.route], direct: false });
674
+ // La label di un nodo in transito e' testo AUTO-DICHIARATO da un altro
675
+ // nodo: si accetta solo se rispetta la stessa forma di una label locale,
676
+ // altrimenti si resta senza nome invece di propagare qualcosa di
677
+ // arbitrario. Non e' mai una prova di identita': quella e' l'instanceId.
678
+ out.push({
679
+ instanceId: child.instanceId, name: child.name,
680
+ route: [n.name, ...child.route], direct: false,
681
+ label: store.validLabel(child.label) ? child.label.trim() : '',
682
+ });
615
683
  }
616
684
  }
617
685
  const ids = new Set(); const routes = new Set(); const unique = [];
@@ -653,7 +721,15 @@ async function collectLocalTopology({
653
721
  next.set(entry.instanceId, entry);
654
722
  }
655
723
  for (const n of live.nodes) {
656
- if (n.route.length > 1) next.set(n.instanceId, { instanceId: n.instanceId, name: n.name, route: [...n.route], lastSeen: now });
724
+ // La cache conserva anche la label: senza, un nodo che torna stale perde il
725
+ // nome e ricompare come slug, cioe' esattamente il difetto che si voleva
726
+ // togliere. Resta un dato riferito, non una prova.
727
+ if (n.route.length > 1) {
728
+ next.set(n.instanceId, {
729
+ instanceId: n.instanceId, name: n.name, route: [...n.route],
730
+ ...(n.label ? { label: n.label } : {}), lastSeen: now,
731
+ });
732
+ }
657
733
  }
658
734
  const cached = [...next.values()].sort((a, b) => a.route.join('/').localeCompare(b.route.join('/'))).slice(0, topologyCache.MAX_ENTRIES);
659
735
  const serialized = { schemaVersion: topologyCache.SCHEMA_VERSION, nodes: cached };
@@ -895,12 +971,19 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
895
971
  }
896
972
  try {
897
973
  if (body.shared) {
974
+ // Il reverse channel viene stabilito dal peer subito prima di questa
975
+ // chiamata: appena dopo un pairing il bind non e' ancora pronto e la
976
+ // finestra breve trasformava un'attesa in un fallimento definitivo,
977
+ // con rollback dell'intera transazione lato peer. La finestra resta
978
+ // limitata (nessun retry storm) ma copre il caso reale.
898
979
  let health;
899
980
  if (req.peer.reversePool) {
900
- for (let attempt = 0; attempt < 6; attempt += 1) {
981
+ for (let attempt = 0; attempt < SHARE_HEALTH_ATTEMPTS; attempt += 1) {
901
982
  health = await probeReverseOwner(req.peer, fetchImpl || fetch);
902
983
  if (health.status === 'healthy') break;
903
- if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 200));
984
+ if (attempt < SHARE_HEALTH_ATTEMPTS - 1) {
985
+ await new Promise((resolve) => setTimeout(resolve, SHARE_HEALTH_DELAY_MS));
986
+ }
904
987
  }
905
988
  } else {
906
989
  health = await waitForHealthyPeer({
@@ -908,14 +991,19 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
908
991
  token: req.peer.token,
909
992
  expectedInstanceId: req.peer.nodeId || null,
910
993
  fetchImpl: fetchImpl || fetch,
911
- attempts: 6,
912
- delayMs: 200,
994
+ attempts: SHARE_HEALTH_ATTEMPTS,
995
+ delayMs: SHARE_HEALTH_DELAY_MS,
913
996
  });
914
997
  }
915
998
  if (health.status !== 'healthy') {
999
+ // Codice tipizzato: chi chiama deve poter distinguere un'attesa da un
1000
+ // guasto definitivo senza interpretare una stringa. Solo il canale
1001
+ // non ancora salito e' ritentabile.
1002
+ const failure = classifyShareFailure(health);
916
1003
  return res.status(409).json({
917
1004
  error: 'canale share non raggiungibile',
918
- detail: health.detail || 'reverse SSH non pronto',
1005
+ code: failure.code,
1006
+ detail: failure.detail,
919
1007
  });
920
1008
  }
921
1009
  }
@@ -980,7 +1068,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
980
1068
  function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
981
1069
 
982
1070
  module.exports = {
983
- MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
1071
+ MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure,
984
1072
  peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
985
1073
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
986
1074
  probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
package/lib/server.js CHANGED
@@ -227,15 +227,19 @@ function createServer(opts = {}) {
227
227
  }
228
228
  const selected = [...entries.values()].filter((entry) => (remotePort === null || entry.remotePort === remotePort)
229
229
  && (generation === null || entry.generation === generation));
230
- let stopped = false;
230
+ // Il valore di ritorno significa "TUTTE le entry selezionate sono spente in
231
+ // modo dimostrabile". Durante la grace di una rotazione ne coesistono due:
232
+ // dichiarare successo perche' UNA e' stata chiusa lascerebbe l'altra viva
233
+ // mentre chi chiama registra il canale come privato.
234
+ const outcomes = [];
231
235
  for (const existing of selected) {
232
236
  const result = nodesTunnel.stopTunnel({ home: cfg.home || os.homedir(), name: nodesTunnel.reverseTunnelName(name, existing.remotePort, existing.generation) });
237
+ outcomes.push(result);
233
238
  // If we cannot prove ownership we neither signal nor close the listener:
234
239
  // the channel is quarantined for diagnostics rather than broken by us.
235
- if (!result.stopped && !['no pidfile', 'stale (pid dead)'].includes(result.reason)) continue;
240
+ if (!reverseRotation.stopWasDemonstrated(result)) continue;
236
241
  entries.delete(reverseKey(existing.remotePort, existing.generation));
237
242
  await reverseSlotListeners?.closePort(existing.localPort);
238
- stopped = true;
239
243
  }
240
244
  if (!entries.size) rotatableReverse.delete(name);
241
245
  if (remotePort === null && generation === null) {
@@ -243,7 +247,11 @@ function createServer(opts = {}) {
243
247
  if (watcher) clearInterval(watcher);
244
248
  reverseWatchers.delete(name);
245
249
  }
246
- return stopped;
250
+ // La decisione vive in `summarizeStops` (funzione pura, testabile senza
251
+ // processi): successo solo se qualcosa e' stato spento E niente e' rimasto
252
+ // in quarantena. Il caso "nessuna entry" resta false come prima: non si
253
+ // puo' dimostrare nulla.
254
+ return reverseRotation.summarizeStops(outcomes).allClosed;
247
255
  }
248
256
  async function verifyRotatablePool(node) {
249
257
  const pool = node?.reversePool;
@@ -473,7 +481,12 @@ function createServer(opts = {}) {
473
481
  }
474
482
 
475
483
  const app = express();
476
- reverseSlotListeners = createReverseSlotListeners({ app, diagnostics, createServerImpl: cfg.reverseSlotCreateServerImpl });
484
+ reverseSlotListeners = createReverseSlotListeners({
485
+ app, diagnostics, createServerImpl: cfg.reverseSlotCreateServerImpl,
486
+ // `routeUpgrade` e' hoisted: qui si cattura solo il riferimento, la
487
+ // chiamata avviene quando arriva un upgrade, a server avviato.
488
+ attachUpgrade: (slotServer) => slotServer.on('upgrade', routeUpgrade),
489
+ });
477
490
  const distDir = path.join(__dirname, '..', 'frontend', 'dist');
478
491
  // no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
479
492
  app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
@@ -795,7 +808,13 @@ function createServer(opts = {}) {
795
808
  }, opts.wsHeartbeatMs || 30000);
796
809
  if (typeof heartbeat.unref === 'function') heartbeat.unref();
797
810
  server.on('close', () => clearInterval(heartbeat));
798
- server.on('upgrade', (req, socket, head) => {
811
+ // Routing dell'upgrade WS. Dichiarata come funzione (hoisted nello scope di
812
+ // createServer) perche' i listener per-slot nascono PRIMA di questo punto e
813
+ // devono ricevere lo STESSO routing: un listener che serve `app` senza
814
+ // handler di upgrade fa cadere l'upgrade su Express, che non ha una rotta
815
+ // HTTP `/ws` e risponde con la SPA (200) invece di 101 -> terminale nero su
816
+ // ogni peer raggiunto via reverse pool.
817
+ function routeUpgrade(req, socket, head) {
799
818
  let pathname;
800
819
  try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
801
820
  catch (_) { try { socket.destroy(); } catch (_e) {} return; }
@@ -826,7 +845,8 @@ function createServer(opts = {}) {
826
845
  return;
827
846
  }
828
847
  try { socket.destroy(); } catch (_) {}
829
- });
848
+ }
849
+ server.on('upgrade', routeUpgrade);
830
850
  wss.on('connection', (ws, req) => {
831
851
  ws.isAlive = true;
832
852
  ws.on('pong', () => { ws.isAlive = true; });
@@ -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.46",
3
+ "version": "0.8.48",
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": {