@mmmbuto/nexuscrew 0.9.38 → 0.9.40

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  const http = require('node:http');
4
4
  const net = require('node:net');
5
+ const crypto = require('node:crypto');
5
6
  const express = require('express');
6
7
  const { WebSocketServer } = require('ws');
7
8
  const store = require('../nodes/store.js');
@@ -1497,7 +1498,7 @@ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecre
1497
1498
  return r;
1498
1499
  }
1499
1500
 
1500
- function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null, hopSecret = null }) {
1501
+ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null, hopSecret = null, hubAttaches = null }) {
1501
1502
  if (readonly()) return reject(socket, 403);
1502
1503
  const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
1503
1504
  // Due percorsi separati: questo NON passa da routeHandler e non ne condivide i
@@ -1516,6 +1517,16 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1516
1517
  if (!st) return reject(socket, 503);
1517
1518
  const visited = controlledVisited(req, ingress, st.nodeId);
1518
1519
  if (!visited) return reject(socket, 409);
1520
+ // L'id e la sessione viaggiano SOLO nella query (niente de-mascheramento dei
1521
+ // frame); il token si confronta per IMPRONTA e non entra mai in chiaro.
1522
+ const attachId = parseAttachId(queryParam(req.url, 'attachId'));
1523
+ const attachSession = parseAttachSession(queryParam(req.url, 'attachSession'));
1524
+ // Sul percorso del browser il token e' in query; su quello FEDERATO e' il
1525
+ // Bearer del peer che ha originato l'attach (la query non lo porta). Senza
1526
+ // questo, l'ingresso federato non entrava mai nel ramo trattenuto.
1527
+ const attachToken = queryParam(req.url, 'token') || bearerFrom(req) || null;
1528
+ const attachRoute = parsed.route.join('/');
1529
+ const hubMode = Boolean(hubAttaches && attachId && attachSession && attachToken);
1519
1530
  let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential();
1520
1531
  // Ultimo hop: `/ws` entra com'e', il pannello rientra nell'API locale.
1521
1532
  let path = parsed.resource === '/ws' ? '/ws' : `/api${parsed.resource}`;
@@ -1527,7 +1538,71 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1527
1538
  const rest = parsed.route.slice(1);
1528
1539
  path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}`;
1529
1540
  }
1541
+ // Aggancio alla attach trattenuta — SOLO dopo i gate d'ingresso appena
1542
+ // eseguiti (route/next/privateInbound/canTransit) e SOLO se il record e'
1543
+ // STACCATO: un secondo upgrade con la stessa identita' non deve rubare lo
1544
+ // stream a un client ancora connesso.
1545
+ if (hubMode) {
1546
+ const record = hubAttaches.take({
1547
+ attachId, tokenHash: tokenFingerprint(attachToken), route: attachRoute, session: attachSession,
1548
+ });
1549
+ if (record) {
1550
+ const handshake = wsHandshakeResponse(req);
1551
+ if (!handshake) return reject(socket, 403);
1552
+ // 101 a mano, poi passthrough: l'input del nuovo client passa com'e' e
1553
+ // l'upstream de-maschera per frame (la chiave di mascheramento puo'
1554
+ // cambiare), l'output va scritto grezzo (i frame li ha gia' incorniciati
1555
+ // l'upstream).
1556
+ try { socket.write(handshake); } catch (_) { /* morto */ }
1557
+ // Gli eventuali byte gia' arrivati dopo la richiesta di upgrade vanno
1558
+ // inoltrati come nel ramo normale, non persi.
1559
+ if (head && head.length) { try { record.up.write(head); } catch (_) { /* morto */ } }
1560
+ socket.pipe(record.up, { end: false });
1561
+ // Identity check: la caduta di QUESTO socket non deve staccare il record
1562
+ // se nel frattempo ne ha preso uno nuovo.
1563
+ const onGoneResume = () => { if (record.socket === socket) hubAttaches.detach(record); };
1564
+ socket.on('end', onGoneResume);
1565
+ socket.on('close', onGoneResume);
1566
+ socket.on('error', () => { /* il close segue */ });
1567
+ // Anche questo socket entra in activeSockets, o la chiusura ordinata
1568
+ // del server non lo vede.
1569
+ if (activeSockets && typeof activeSockets.add === 'function') {
1570
+ activeSockets.add(socket); activeSockets.add(record.up);
1571
+ const remove = () => { activeSockets.delete(socket); activeSockets.delete(record.up); };
1572
+ socket.once('close', remove); record.up.once('close', remove);
1573
+ }
1574
+ hubAttaches.attach(record, socket);
1575
+ return;
1576
+ }
1577
+ }
1530
1578
  const up = net.connect({ host: '127.0.0.1', port });
1579
+ // Osservatore NON consumante della testa di risposta dell'upstream: serve
1580
+ // solo a sapere se il 101 e' transitato (client in stato WebSocket) o se
1581
+ // l'hop e' morto prima. La pipe resta l'unico trasporto dei byte: il
1582
+ // listener non sottrae nulla, guarda gli stessi chunk.
1583
+ let saw101 = false;
1584
+ let headBuf = Buffer.alloc(0);
1585
+ let gone = false;
1586
+ up.on('data', (chunk) => {
1587
+ if (saw101 || headBuf.length >= 1024) return;
1588
+ headBuf = Buffer.concat([headBuf, chunk.subarray(0, Math.max(0, 1024 - headBuf.length))]);
1589
+ if (headBuf.includes(' 101')) saw101 = true;
1590
+ });
1591
+ // L'hop e' morto (errore di connessione o chiusura a stream avviato): il
1592
+ // client deve riceverlo come codice, non come troncamento.
1593
+ const upstreamGone = () => {
1594
+ if (gone) return;
1595
+ gone = true;
1596
+ if (saw101) {
1597
+ // Handshake gia' transitato: un frame di close e' cio' che il browser
1598
+ // sa leggere. Se l'upstream aveva gia' chiuso con un suo codice, quel
1599
+ // frame e' arrivato prima e vince (il browser si ferma al primo).
1600
+ try { socket.write(wsCloseFrame(4402, 'upstream gone')); } catch (_) { /* socket morto */ }
1601
+ try { socket.end(); } catch (_) { /* idem */ }
1602
+ return;
1603
+ }
1604
+ acceptWsThenClose(socket, req, 4402, 'upstream gone');
1605
+ };
1531
1606
  up.once('connect', () => {
1532
1607
  // Ultimo hop: l'upgrade rientra nell'API locale col Bearer locale e da li'
1533
1608
  // in poi sarebbe indistinguibile da un attach diretto di chi possiede quel
@@ -1554,9 +1629,49 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1554
1629
  const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
1555
1630
  socket.once('close', remove); up.once('close', remove);
1556
1631
  }
1557
- socket.pipe(up); up.pipe(socket);
1632
+ if (hubMode) {
1633
+ // Attach trattenuta: l'output passa dal ring quando il client non c'è,
1634
+ // e il record entra nello store sotto la sua identità.
1635
+ const record = hubAttaches.hold({
1636
+ attachId, tokenHash: tokenFingerprint(attachToken), route: attachRoute, session: attachSession, up,
1637
+ });
1638
+ up.on('data', (chunk) => hubAttaches.feed(record, chunk));
1639
+ up.on('close', () => hubAttaches.destroy(record));
1640
+ up.on('error', () => hubAttaches.destroy(record));
1641
+ // end:false: la caduta del CLIENT non deve chiudere l'upstream — è
1642
+ // esattamente ciò che la grazia deve trattenere.
1643
+ socket.pipe(up, { end: false });
1644
+ hubAttaches.attach(record, socket);
1645
+ // Senza un handler d'errore il RST del client morto resta senza 'close':
1646
+ // la caduta non verrebbe mai rilevata e la grazia non partirebbe.
1647
+ socket.on('error', () => { /* il close segue */ });
1648
+ // 'end' E 'close': con la pipe a end:false il socket resta HALF-OPEN
1649
+ // quando il browser se ne va, quindi il solo 'close' puo' non arrivare
1650
+ // mai e la grazia non partirebbe. Il FIN in lettura e' la caduta.
1651
+ const onGoneHeld = () => { if (record.socket === socket) hubAttaches.detach(record); };
1652
+ socket.on('end', onGoneHeld);
1653
+ socket.on('close', onGoneHeld);
1654
+ if (activeSockets && typeof activeSockets.add === 'function') {
1655
+ activeSockets.add(socket); activeSockets.add(up);
1656
+ const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
1657
+ socket.once('close', remove); up.once('close', remove);
1658
+ }
1659
+ return;
1660
+ }
1661
+ socket.pipe(up);
1662
+ // end:false: la chiusura verso il browser la governiamo noi, o la pipe
1663
+ // chiuderebbe il socket PRIMA che il frame di close 4402 possa partire.
1664
+ up.pipe(socket, { end: false });
1558
1665
  });
1559
- up.on('error', () => reject(socket, 502)); socket.on('error', () => up.destroy());
1666
+ if (!hubMode) {
1667
+ // Nel ramo trattenuto la morte dell'upstream la governa lo store (destroy
1668
+ // del record): qui il close leggibile verso il browser non ha più un
1669
+ // destinatario quando il client è già caduto.
1670
+ up.on('error', () => upstreamGone());
1671
+ up.on('end', () => upstreamGone());
1672
+ up.on('close', () => upstreamGone());
1673
+ }
1674
+ socket.on('error', () => up.destroy());
1560
1675
  }
1561
1676
 
1562
1677
  // Indistinguibile da «sessione inesistente» (lib/ws/bridge.js): le stesse
@@ -1577,13 +1692,220 @@ function rejectAsNoSuchSession(socket, req) {
1577
1692
  } catch (_) { try { socket.destroy(); } catch (_) { /* idem */ } }
1578
1693
  }
1579
1694
 
1695
+ // ---- attach trattenuta nell'hub --------------------------------------
1696
+ // Quando il client cade, l'upstream federato NON viene distrutto subito: resta
1697
+ // vivo per la grazia e il suo output va in un ring. Chi torna con la STESSA
1698
+ // identità (hash del token, stessa route, stessa sessione) riprende da qui
1699
+ // senza riaprire l'attach; chiunque altro fa una attach nuova.
1700
+ const HUB_ATTACH_MAX = 8;
1701
+ const HUB_ATTACH_GRACE_MS = 60000;
1702
+ const HUB_ATTACH_RING_BYTES = 256 * 1024;
1703
+
1704
+ function queryParam(url, name) {
1705
+ const q = String(url || '').split('?')[1];
1706
+ if (!q) return null;
1707
+ for (const part of q.split('&')) {
1708
+ const i = part.indexOf('=');
1709
+ if (i <= 0) continue;
1710
+ let key = part.slice(0, i);
1711
+ try { key = decodeURIComponent(key); } catch (_) { /* chiave grezza */ }
1712
+ if (key !== name) continue;
1713
+ try { return decodeURIComponent(part.slice(i + 1)); } catch (_) { return part.slice(i + 1); }
1714
+ }
1715
+ return null;
1716
+ }
1717
+ // 128 bit in esadecimale, e nient'altro: un id malformato non è un attachId.
1718
+ const ATTACH_ID_RE = /^[0-9a-f]{32}$/;
1719
+ const parseAttachId = (raw) => (typeof raw === 'string' && ATTACH_ID_RE.test(raw) ? raw : null);
1720
+ // Nome sessione con lo stesso alfabeto accettato dalle risorse federate.
1721
+ const ATTACH_SESSION_RE = /^[\w.@%:+-]{1,128}$/;
1722
+ const parseAttachSession = (raw) => (typeof raw === 'string' && ATTACH_SESSION_RE.test(raw) ? raw : null);
1723
+ // Il token non entra MAI in memoria in chiaro: si confronta la sua impronta.
1724
+ const tokenFingerprint = (token) => crypto.createHash('sha256').update(String(token)).digest('hex');
1725
+ // Diagnostica: mai l'id completo, mai il token.
1726
+ const shortId = (id) => (typeof id === 'string' ? id.slice(0, 6) : '-');
1727
+
1728
+ // Ricompone i confini di frame del protocollo WS leggendo SOLO la lunghezza
1729
+ // dall'header. Server->client non e' mascherato; se lo fosse, l'header e' piu'
1730
+ // lungo e i 4 byte di maschera si saltano senza toccare il payload.
1731
+ function createFrameSplitter() {
1732
+ let buf = Buffer.alloc(0);
1733
+ return {
1734
+ push(chunk) {
1735
+ buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
1736
+ const frames = [];
1737
+ let off = 0;
1738
+ while (off + 2 <= buf.length) {
1739
+ const b1 = buf[off + 1];
1740
+ let len = b1 & 0x7f;
1741
+ let head = 2;
1742
+ if (len === 126) {
1743
+ if (off + 4 > buf.length) break;
1744
+ len = buf.readUInt16BE(off + 2); head = 4;
1745
+ } else if (len === 127) {
1746
+ if (off + 10 > buf.length) break;
1747
+ len = Number(buf.readBigUInt64BE(off + 2)); head = 10;
1748
+ }
1749
+ if (b1 & 0x80) head += 4;
1750
+ const total = head + len;
1751
+ if (off + total > buf.length) break;
1752
+ frames.push(buf.subarray(off, off + total));
1753
+ off += total;
1754
+ }
1755
+ buf = off ? buf.subarray(off) : buf;
1756
+ return frames;
1757
+ },
1758
+ pending: () => buf,
1759
+ };
1760
+ }
1761
+
1762
+ function createHubAttachStore({ graceMs = HUB_ATTACH_GRACE_MS, maxAttaches = HUB_ATTACH_MAX, ringBytes = HUB_ATTACH_RING_BYTES } = {}) {
1763
+ const grace = Math.max(1, Number(graceMs) || HUB_ATTACH_GRACE_MS);
1764
+ const cap = Math.max(1, Math.floor(Number(maxAttaches) || HUB_ATTACH_MAX));
1765
+ const ringLimit = Math.max(1, Math.floor(Number(ringBytes) || HUB_ATTACH_RING_BYTES));
1766
+ const records = new Map();
1767
+
1768
+ const destroy = (record) => {
1769
+ if (!record || record.ended) return;
1770
+ record.ended = true;
1771
+ if (record.timer) { clearTimeout(record.timer); record.timer = null; }
1772
+ if (records.get(record.attachId) === record) records.delete(record.attachId);
1773
+ // Se un client e' ATTACCATO, la morte dell'upstream va DETTA subito: senza,
1774
+ // il browser resta appeso finche' la liveness non scade (10 s) — e in
1775
+ // 0.9.39 il 4402 arrivava invece immediatamente. Se nessuno e' attaccato
1776
+ // non c'e' nulla da avvisare.
1777
+ if (record.socket && record.socket.writable) {
1778
+ try { record.socket.write(wsCloseFrame(4402, 'upstream gone')); } catch (_) { /* morto */ }
1779
+ try { record.socket.end(); } catch (_) { /* idem */ }
1780
+ record.socket = null;
1781
+ }
1782
+ try { record.up.destroy(); } catch (_) { /* già chiuso */ }
1783
+ };
1784
+ // Il ring tiene FRAME INTERI: un ring di soli chunk TCP puo' consegnare un
1785
+ // replay che comincia a meta' frame, e il browser chiude con 1002. Il parser
1786
+ // legge SOLO la lunghezza dagli header (server->client non maschera) e non
1787
+ // tocca i payload: nessun de-mascheramento.
1788
+ const pushRing = (record, frame) => {
1789
+ record.ring.push(frame);
1790
+ record.ringBytes += frame.length;
1791
+ while (record.ringBytes > ringLimit && record.ring.length > 1) record.ringBytes -= record.ring.shift().length;
1792
+ };
1793
+ const hold = ({ attachId, tokenHash, route, session, up }) => {
1794
+ // Cap: si chiude la PIÙ VECCHIA, non si rifiuta la nuova.
1795
+ while (records.size >= cap) {
1796
+ const oldest = records.keys().next().value;
1797
+ const rec = oldest ? records.get(oldest) : null;
1798
+ if (!rec) break;
1799
+ destroy(rec);
1800
+ }
1801
+ const record = {
1802
+ attachId, tokenHash, route, session, up, socket: null,
1803
+ ring: [], ringBytes: 0, timer: null, ended: false, splitter: createFrameSplitter(),
1804
+ };
1805
+ records.set(attachId, record);
1806
+ return record;
1807
+ };
1808
+ // Scrive il pregresso al nuovo client: prima il ring, poi lo stream live.
1809
+ const attach = (record, socket) => {
1810
+ if (!record || record.ended) return false;
1811
+ if (record.timer) { clearTimeout(record.timer); record.timer = null; }
1812
+ record.socket = socket;
1813
+ for (const frame of record.ring) { try { socket.write(frame); } catch (_) { break; } }
1814
+ // I byte già letti ma non ancora completati in un frame vanno consegnati
1815
+ // ora: senza, il client perderebbe la coda dell'ultimo frame.
1816
+ const pending = record.splitter.pending();
1817
+ if (pending && pending.length) { try { socket.write(pending); } catch (_) { /* morto */ } }
1818
+ record.splitter = createFrameSplitter();
1819
+ record.ring = []; record.ringBytes = 0;
1820
+ return true;
1821
+ };
1822
+ // Il client è caduto: da qui l'output va nel ring, e parte la grazia.
1823
+ const detach = (record) => {
1824
+ if (!record || record.ended) return;
1825
+ record.socket = null;
1826
+ if (record.timer) clearTimeout(record.timer);
1827
+ record.timer = setTimeout(() => destroy(record), grace);
1828
+ if (typeof record.timer.unref === 'function') record.timer.unref();
1829
+ };
1830
+ const feed = (record, chunk) => {
1831
+ if (!record || record.ended) return;
1832
+ if (record.socket && record.socket.writable) {
1833
+ // Passthrough: i byte vanno al client come sono arrivati.
1834
+ try { record.socket.write(chunk); return; } catch (_) { /* cade: si passa al ring */ }
1835
+ }
1836
+ for (const frame of record.splitter.push(chunk)) pushRing(record, frame);
1837
+ };
1838
+ // Aggancio: SOLO identità identica (impronta token, route, sessione).
1839
+ const take = ({ attachId, tokenHash, route, session }) => {
1840
+ const record = records.get(attachId);
1841
+ if (!record || record.ended) return null;
1842
+ // Solo un record STACCATO (in grazia): agganciare un record ancora
1843
+ // attaccato vorrebbe dire rubare lo stream a un client vivo. Un socket
1844
+ // DITRUTTO non e' un client vivo — e il suo 'close' puo' arrivare dopo,
1845
+ // quindi lo stato del socket e' la verita' osservabile, non l'evento.
1846
+ if (record.socket && record.socket.destroyed !== true) return null;
1847
+ if (record.tokenHash !== tokenHash || record.route !== route || record.session !== session) return null;
1848
+ return record;
1849
+ };
1850
+ return {
1851
+ hold, attach, detach, feed, take, destroy,
1852
+ size: () => records.size,
1853
+ get: (id) => records.get(id) || null,
1854
+ };
1855
+ }
1856
+
1857
+ // Handshake WS del ramo di ripresa: identico a quello che scrive REJECT_WSS
1858
+ // (stesso Sec-WebSocket-Accept), ma SENZA creare un oggetto ws. Il socket del
1859
+ // nuovo client resta un PASSTHROUGH di frame come nel ramo normale: niente
1860
+ // de-mascheramento, niente re-incapsulamento.
1861
+ const WS_ACCEPT_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
1862
+ function wsHandshakeResponse(req) {
1863
+ const key = req && req.headers && req.headers['sec-websocket-key'];
1864
+ if (!key) return null;
1865
+ const accept = crypto.createHash('sha1').update(String(key) + WS_ACCEPT_GUID).digest('base64');
1866
+ return 'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n'
1867
+ + `Sec-WebSocket-Accept: ${accept}\r\n\r\n`;
1868
+ }
1869
+
1580
1870
  function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
1581
1871
 
1872
+ // Frame di close costruito a mano: sul ramo "hop morto DOPO il 101" non c'e'
1873
+ // piu' una richiesta di upgrade da consegnare a handleUpgrade (l'handshake e'
1874
+ // gia' transitato), quindi il frame va scritto direttamente.
1875
+ function wsCloseFrame(code, reason = '') {
1876
+ const text = String(reason).slice(0, 100);
1877
+ const payload = Buffer.alloc(2 + Buffer.byteLength(text));
1878
+ payload.writeUInt16BE((Number(code) || 1000) & 0xffff, 0);
1879
+ payload.write(text, 2, 'utf8');
1880
+ const len = payload.length;
1881
+ return len < 126
1882
+ ? Buffer.concat([Buffer.from([0x88, len]), payload])
1883
+ : Buffer.concat([Buffer.from([0x88, 126, (len >> 8) & 0xff, len & 0xff]), payload]);
1884
+ }
1885
+
1886
+ // Hop morto e il client NON ha ancora visto il 101: completiamo l'handshake
1887
+ // qui, cosi' il browser entra in stato WebSocket e puo' leggere un frame di
1888
+ // close LEGGIBILE (4402) invece di un 502 grezzo o di un EOF muto. L'accept
1889
+ // lo calcola la libreria (stesso REJECT_WSS del rifiuto "no such session"),
1890
+ // cosi' il GUID della RFC 6455 non vive in questo file. Per una richiesta che
1891
+ // non e' un vero upgrade resta il rifiuto HTTP di prima.
1892
+ function acceptWsThenClose(socket, req, code, reason) {
1893
+ const key = req && req.headers && req.headers['sec-websocket-key'];
1894
+ if (!key) return reject(socket, 502);
1895
+ try {
1896
+ REJECT_WSS.handleUpgrade(req, socket, Buffer.alloc(0), (ws) => {
1897
+ try { ws.send(JSON.stringify({ type: 'error', reason })); } catch (_) { /* socket morto */ }
1898
+ try { ws.close(code, reason); } catch (_) { /* idem */ }
1899
+ });
1900
+ } catch (_) { try { socket.destroy(); } catch (_) { /* idem */ } }
1901
+ }
1902
+
1903
+
1582
1904
  module.exports = {
1583
1905
  isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, routeHandler,
1584
1906
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
1585
1907
  peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
1586
- collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
1908
+ collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade, createHubAttachStore,
1587
1909
  probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
1588
1910
  hubPoolRequest, verifyHubPoolSlot, reserveHubPoolSlot, commitHubPoolSlot, settleHubPoolSlot, abortHubPoolSlot, getHubPoolStatus,
1589
1911
  };
package/lib/server.js CHANGED
@@ -110,8 +110,28 @@ function uiBuildVersion(distDir) {
110
110
  } catch (_) { return null; }
111
111
  }
112
112
 
113
+ // I defaults del bridge ws vengono dalla config del server. E' una funzione a
114
+ // se' perche' il cablaggio sia verificabile senza montare una sessione tmux.
115
+ function wsBridgeDefaults(cfg = {}) {
116
+ const terminal = cfg.terminal && typeof cfg.terminal === 'object' ? cfg.terminal : {};
117
+ return {
118
+ readonlyDefault: cfg.readonlyDefault,
119
+ tmuxBin: cfg.tmuxBin,
120
+ outputRingBytes: terminal.outputRingBytes || undefined,
121
+ captureLines: terminal.captureLines || undefined,
122
+ };
123
+ }
124
+
113
125
  function createServer(opts = {}) {
114
126
  const cfg = loadConfig(opts);
127
+ // UNA istanza per server: trattiene le attach federate quando il client cade,
128
+ // cosi' chi torna con la stessa identita' riprende dal buffer. I parametri
129
+ // vengono dalla config del server, non da valori cablati.
130
+ const hubAttaches = federation.createHubAttachStore({
131
+ graceMs: cfg.terminal && cfg.terminal.hubGraceMs,
132
+ ringBytes: cfg.terminal && cfg.terminal.outputRingBytes,
133
+ maxAttaches: 8,
134
+ });
115
135
  assertLoopback(cfg.bind);
116
136
  // Token holder LIVE (da revisione di sicurezza): requireToken/verify leggono tokenStore.get()
117
137
  // ad ogni richiesta, cosi' una rotazione via Settings API invalida il VECCHIO token
@@ -1035,6 +1055,9 @@ function createServer(opts = {}) {
1035
1055
  instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
1036
1056
  nodePanelPorts: nodePanelPorts(),
1037
1057
  presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
1058
+ // i parametri del terminale sono configurabili dal server e leggibili
1059
+ // dal client (che ne ha comunque i default iniettabili).
1060
+ terminal: cfg.terminal || null,
1038
1061
  }));
1039
1062
  const apiNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
1040
1063
  const identityMode = cfg.fleetIdentityMode || cfg.fleet?.identity?.mode || 'legacy';
@@ -1672,13 +1695,13 @@ function createServer(opts = {}) {
1672
1695
  const panelFederato = /^\/api\/route\/[^?#]*\/_\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(pathname)
1673
1696
  && /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''));
1674
1697
  if (!panelFederato && !verify(tokenHolder.value, given)) return socket.destroy();
1675
- federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
1698
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret, hubAttaches });
1676
1699
  return;
1677
1700
  }
1678
1701
  if (pathname.startsWith('/federation/route/')) {
1679
1702
  const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
1680
1703
  if (!ingress) return socket.destroy();
1681
- federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
1704
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret, hubAttaches });
1682
1705
  return;
1683
1706
  }
1684
1707
  if (pathname.startsWith('/api/panel/')) {
@@ -1732,7 +1755,7 @@ function createServer(opts = {}) {
1732
1755
  isValidSession: (name) => sessionExistsImpl(name) && wsScope.allowsSession(name),
1733
1756
  runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
1734
1757
  countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
1735
- defaults: { readonlyDefault: cfg.readonlyDefault, tmuxBin: cfg.tmuxBin },
1758
+ defaults: wsBridgeDefaults(cfg),
1736
1759
  ptyGrace,
1737
1760
  onAttach: (sess) => attachedWs.set(ws, sess),
1738
1761
  diagnostics,
@@ -1861,4 +1884,4 @@ function start(opts = {}) {
1861
1884
  return server;
1862
1885
  }
1863
1886
 
1864
- module.exports = { createServer, start };
1887
+ module.exports = { createServer, start, wsBridgeDefaults };
package/lib/ws/bridge.js CHANGED
@@ -117,8 +117,59 @@ function sendJson(ws, value) {
117
117
  try { ws.send(JSON.stringify(value)); } catch (_) {}
118
118
  }
119
119
 
120
+ // Ripresa per sequenza : i frame di output sono numerati e l'ultimo tratto
121
+ // resta in un ring per sessione, cosi' un client che torna puo' chiedere SOLO
122
+ // il mancante invece di ridipingere tutto (o peggio, perdere il tratto).
123
+ const OUTPUT_RING_BYTES = 256 * 1024;
124
+ function createOutputRing({ maxBytes = OUTPUT_RING_BYTES } = {}) {
125
+ const limit = Math.max(1, Math.floor(Number(maxBytes) || OUTPUT_RING_BYTES));
126
+ const frames = [];
127
+ let bytes = 0;
128
+ let nextSeq = 0;
129
+ return {
130
+ push(data) {
131
+ const buf = Buffer.from(data);
132
+ const seq = nextSeq++;
133
+ frames.push({ seq, data: buf });
134
+ bytes += buf.length;
135
+ while (bytes > limit && frames.length > 1) bytes -= frames.shift().data.length;
136
+ return seq;
137
+ },
138
+ // Frame che il client (fermo a `seq`) non ha ancora visto. `null` quando il
139
+ // buco NON e' coperto: inventare un delta parziale corromperebbe lo schermo.
140
+ since(seq) {
141
+ const wanted = Number(seq);
142
+ if (!Number.isInteger(wanted) || wanted < 0) return null;
143
+ if (wanted + 1 >= nextSeq) return [];
144
+ if (!frames.length || frames[0].seq > wanted + 1) return null;
145
+ return frames.filter((f) => f.seq > wanted);
146
+ },
147
+ get nextSeq() { return nextSeq; },
148
+ };
149
+ }
150
+
151
+ // Ogni frame di output porta il suo seq nei primi 4 byte (big endian).
152
+ function frameWithSeq(seq, data) {
153
+ const head = Buffer.alloc(4);
154
+ head.writeUInt32BE((Number(seq) || 0) >>> 0, 0);
155
+ return Buffer.concat([head, Buffer.from(data)]);
156
+ }
157
+
158
+ // Resync del buffer dopo una riconnessione: il client chiede {type:'resync'}
159
+ // e riceve l'intero pane (scrollback cap 2000 righe) con le sequenze di
160
+ // escape (-e) per ridipingere lo schermo senza mai mostrarlo vuoto.
161
+ const RESYNC_MAX_LINES = 2000;
162
+ function defaultCapturePane(session, tmuxBin, captureLines, cb) {
163
+ const lines = Math.max(1, Math.floor(Number(captureLines) || RESYNC_MAX_LINES));
164
+ try {
165
+ require('node:child_process').execFile(tmuxBin || 'tmux',
166
+ ['capture-pane', '-e', '-p', '-S', `-${lines}`, '-t', String(session)],
167
+ { maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => cb(err, stdout));
168
+ } catch (e) { cb(e); }
169
+ }
170
+
120
171
  function bindWs(ws, deps) {
121
- const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {}, ptyGrace = null, diagnostics = null, dropCounter = null } = deps;
172
+ const { openAttach, verifyToken, isValidSession = () => true, runAction = () => false, countClients = () => 0, defaults = {}, onAttach = () => {}, ptyGrace = null, diagnostics = null, dropCounter = null, capturePane = defaultCapturePane } = deps;
122
173
  let record = null;
123
174
  let attached = false;
124
175
  let session = null;
@@ -218,6 +269,7 @@ function bindWs(ws, deps) {
218
269
  onAttach(session, ws);
219
270
  logReattach();
220
271
  sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
272
+ sendJson(ws, { type: 'link', state: 'live' });
221
273
  return;
222
274
  }
223
275
  attached = true;
@@ -254,6 +306,7 @@ function bindWs(ws, deps) {
254
306
  session: msg.session,
255
307
  readonly: defaults.readonlyDefault === true || !!msg.readonly,
256
308
  takeSize,
309
+ ring: createOutputRing({ maxBytes: defaults.outputRingBytes }),
257
310
  ended: false,
258
311
  exitCode: undefined,
259
312
  resumeToken: null,
@@ -264,8 +317,11 @@ function bindWs(ws, deps) {
264
317
  if (ptyGrace) record.resumeToken = ptyGrace.issueToken(record);
265
318
  onAttach(session, ws);
266
319
  record.pty.onData((d) => {
320
+ // Il ring registra SEMPRE: anche mentre nessuno e' attaccato l'output
321
+ // resta disponibile per chi torna con {type:'resume'}.
322
+ const seq = record.ring.push(d);
267
323
  if (!record.ws) return;
268
- try { record.ws.send(Buffer.from(d), { binary: true }); } catch (_) { return; }
324
+ try { record.ws.send(frameWithSeq(seq, d), { binary: true }); } catch (_) { return; }
269
325
  if ((record.ws.bufferedAmount || 0) > MAX_BUFFERED) fail(1011, 'backpressure');
270
326
  });
271
327
  record.pty.onExit((info) => {
@@ -279,6 +335,7 @@ function bindWs(ws, deps) {
279
335
  try { record.ws.close(1000, 'exit'); } catch (_) {}
280
336
  });
281
337
  sendJson(ws, { type: 'attached', reconnectToken: record.resumeToken });
338
+ sendJson(ws, { type: 'link', state: 'live' });
282
339
  return;
283
340
  }
284
341
  // dopo l'attach
@@ -294,6 +351,32 @@ function bindWs(ws, deps) {
294
351
  else if (msg.type === 'input' && !record.readonly) record.pty.write(typeof msg.data === 'string' ? msg.data : '');
295
352
  else if (msg.type === 'key' && !record.readonly) record.pty.write(typeof msg.seq === 'string' ? msg.seq.slice(0, 64) : '');
296
353
  else if (msg.type === 'action') runAction(session, msg.name); // nav window/pane server-side
354
+ // Liveness applicativa: il ping risponde SEMPRE, anche a sessione muta, e
355
+ // senza toccare il PTY. È il segnale che distingue una connessione viva da
356
+ // una mezzo-aperta (il tunnel morto senza FIN non produce nessun close).
357
+ else if (msg.type === 'ping') sendJson(ws, { type: 'pong', t: msg.t });
358
+ // Ripresa: rimanda solo il tratto che il client non ha visto. Se il buco
359
+ // non e' coperto dal ring si chiede il repaint, mai un delta parziale.
360
+ else if (msg.type === 'resume') {
361
+ const missing = record.ring.since(msg.seq);
362
+ if (missing === null) {
363
+ sendJson(ws, { type: 'resync-needed' });
364
+ } else {
365
+ for (const frame of missing) {
366
+ try { ws.send(frameWithSeq(frame.seq, frame.data), { binary: true }); } catch (_) { break; }
367
+ }
368
+ sendJson(ws, { type: 'link', state: 'live' });
369
+ }
370
+ }
371
+ // Resync post-riconnessione: ridipinge il buffer del client dal pane
372
+ // reale. Best-effort — se il capture fallisce lo stream live continua e
373
+ // il client resta sul buffer che aveva (mai uno schermo vuoto forzato).
374
+ else if (msg.type === 'resync') {
375
+ capturePane(session, defaults.tmuxBin || 'tmux', defaults.captureLines, (err, text) => {
376
+ if (err) return;
377
+ sendJson(ws, { type: 'snapshot', data: String(text || '') });
378
+ });
379
+ }
297
380
  }
298
381
 
299
382
  // Alla riconnessione la DURATA della caduta e' il gap close->reopen della
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.9.38",
3
+ "version": "0.9.40",
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": {