@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.
- package/CHANGELOG.md +17 -0
- package/frontend/dist/assets/index-BJBurY16.js +169 -0
- package/frontend/dist/assets/index-Csz8VaFz.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/config.js +38 -4
- package/lib/proxy/federation.js +326 -4
- package/lib/server.js +27 -4
- package/lib/ws/bridge.js +85 -2
- package/package.json +1 -1
- package/frontend/dist/assets/index-DDuZWnkm.js +0 -167
- package/frontend/dist/assets/index-XusWm2UG.css +0 -32
package/lib/proxy/federation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
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
|