@mmmbuto/nexuscrew 0.9.39 → 0.9.41
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 +18 -1
- package/bin/nc-activity-hook.js +55 -0
- package/frontend/dist/assets/{index-Csz8VaFz.css → index-Ca1Rl40n.css} +1 -1
- package/frontend/dist/assets/index-DGa2FEN2.js +169 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/config.js +38 -4
- package/lib/files/activity.js +294 -0
- package/lib/fleet/cell-exec.js +66 -3
- package/lib/fleet/managed.js +225 -30
- package/lib/fleet/mcp-cache-readiness.js +154 -0
- package/lib/fleet/prompt-delivery.js +65 -12
- package/lib/fleet/runtime.js +95 -9
- package/lib/notify/ask-relay.js +104 -19
- package/lib/proxy/federation.js +262 -5
- package/lib/server.js +40 -4
- package/lib/ws/bridge.js +64 -4
- package/package.json +4 -3
- package/frontend/dist/assets/index-Dhmlcepq.js +0 -167
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,6 +1538,43 @@ 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 });
|
|
1531
1579
|
// Osservatore NON consumante della testa di risposta dell'upstream: serve
|
|
1532
1580
|
// solo a sapere se il 101 e' transitato (client in stato WebSocket) o se
|
|
@@ -1581,14 +1629,48 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1581
1629
|
const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
|
|
1582
1630
|
socket.once('close', remove); up.once('close', remove);
|
|
1583
1631
|
}
|
|
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
|
+
}
|
|
1584
1661
|
socket.pipe(up);
|
|
1585
1662
|
// end:false: la chiusura verso il browser la governiamo noi, o la pipe
|
|
1586
1663
|
// chiuderebbe il socket PRIMA che il frame di close 4402 possa partire.
|
|
1587
1664
|
up.pipe(socket, { end: false });
|
|
1588
1665
|
});
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
+
}
|
|
1592
1674
|
socket.on('error', () => up.destroy());
|
|
1593
1675
|
}
|
|
1594
1676
|
|
|
@@ -1610,6 +1692,181 @@ function rejectAsNoSuchSession(socket, req) {
|
|
|
1610
1692
|
} catch (_) { try { socket.destroy(); } catch (_) { /* idem */ } }
|
|
1611
1693
|
}
|
|
1612
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
|
+
|
|
1613
1870
|
function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
|
|
1614
1871
|
|
|
1615
1872
|
// Frame di close costruito a mano: sul ramo "hop morto DOPO il 101" non c'e'
|
|
@@ -1648,7 +1905,7 @@ module.exports = {
|
|
|
1648
1905
|
isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, routeHandler,
|
|
1649
1906
|
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
|
|
1650
1907
|
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
1651
|
-
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
1908
|
+
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade, createHubAttachStore,
|
|
1652
1909
|
probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
|
|
1653
1910
|
hubPoolRequest, verifyHubPoolSlot, reserveHubPoolSlot, commitHubPoolSlot, settleHubPoolSlot, abortHubPoolSlot, getHubPoolStatus,
|
|
1654
1911
|
};
|
package/lib/server.js
CHANGED
|
@@ -20,6 +20,7 @@ const { requireToken, bearerFrom } = require('./auth/middleware.js');
|
|
|
20
20
|
const { filesRoutes } = require('./files/routes.js');
|
|
21
21
|
const { createOutboxWatcher } = require('./files/watcher.js');
|
|
22
22
|
const { leggiTelemetria } = require('./files/telemetry.js');
|
|
23
|
+
const { leggiAttivita } = require('./files/activity.js');
|
|
23
24
|
const VERSION = require('../package.json').version;
|
|
24
25
|
const { transcribe } = require('./voice/transcribe.js');
|
|
25
26
|
const { selectProvider } = require('./fleet/provider.js');
|
|
@@ -110,8 +111,28 @@ function uiBuildVersion(distDir) {
|
|
|
110
111
|
} catch (_) { return null; }
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
// I defaults del bridge ws vengono dalla config del server. E' una funzione a
|
|
115
|
+
// se' perche' il cablaggio sia verificabile senza montare una sessione tmux.
|
|
116
|
+
function wsBridgeDefaults(cfg = {}) {
|
|
117
|
+
const terminal = cfg.terminal && typeof cfg.terminal === 'object' ? cfg.terminal : {};
|
|
118
|
+
return {
|
|
119
|
+
readonlyDefault: cfg.readonlyDefault,
|
|
120
|
+
tmuxBin: cfg.tmuxBin,
|
|
121
|
+
outputRingBytes: terminal.outputRingBytes || undefined,
|
|
122
|
+
captureLines: terminal.captureLines || undefined,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
113
126
|
function createServer(opts = {}) {
|
|
114
127
|
const cfg = loadConfig(opts);
|
|
128
|
+
// UNA istanza per server: trattiene le attach federate quando il client cade,
|
|
129
|
+
// cosi' chi torna con la stessa identita' riprende dal buffer. I parametri
|
|
130
|
+
// vengono dalla config del server, non da valori cablati.
|
|
131
|
+
const hubAttaches = federation.createHubAttachStore({
|
|
132
|
+
graceMs: cfg.terminal && cfg.terminal.hubGraceMs,
|
|
133
|
+
ringBytes: cfg.terminal && cfg.terminal.outputRingBytes,
|
|
134
|
+
maxAttaches: 8,
|
|
135
|
+
});
|
|
115
136
|
assertLoopback(cfg.bind);
|
|
116
137
|
// Token holder LIVE (da revisione di sicurezza): requireToken/verify leggono tokenStore.get()
|
|
117
138
|
// ad ogni richiesta, cosi' una rotazione via Settings API invalida il VECCHIO token
|
|
@@ -973,6 +994,12 @@ function createServer(opts = {}) {
|
|
|
973
994
|
// Claude via statusline). Null per tutte le altre — assenza
|
|
974
995
|
// legittima, non un trattino: la riga resta com'era.
|
|
975
996
|
telemetry: leggiTelemetria(cfg.filesRoot, s.name),
|
|
997
|
+
// Stato del turno pubblicato dagli hook della cella: lavora /
|
|
998
|
+
// ferma / attesa. `null` NON e' «ferma»: e' «non verificato» —
|
|
999
|
+
// nessun evento entro la finestra, oppure una cella che non ha questo
|
|
1000
|
+
// canale (codex-vl, pi, shell). La UI non deve dedurre «ferma» da un
|
|
1001
|
+
// silenzio: e' esattamente l'errore che il titolo del pane fa oggi.
|
|
1002
|
+
attivita: leggiAttivita(cfg.filesRoot, s.name),
|
|
976
1003
|
};
|
|
977
1004
|
}));
|
|
978
1005
|
res.json({ sessions: enriched });
|
|
@@ -1035,6 +1062,9 @@ function createServer(opts = {}) {
|
|
|
1035
1062
|
instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
1036
1063
|
nodePanelPorts: nodePanelPorts(),
|
|
1037
1064
|
presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
|
|
1065
|
+
// i parametri del terminale sono configurabili dal server e leggibili
|
|
1066
|
+
// dal client (che ne ha comunque i default iniettabili).
|
|
1067
|
+
terminal: cfg.terminal || null,
|
|
1038
1068
|
}));
|
|
1039
1069
|
const apiNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
1040
1070
|
const identityMode = cfg.fleetIdentityMode || cfg.fleet?.identity?.mode || 'legacy';
|
|
@@ -1249,6 +1279,12 @@ function createServer(opts = {}) {
|
|
|
1249
1279
|
});
|
|
1250
1280
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1251
1281
|
});
|
|
1282
|
+
// Riconciliazione dopo refresh: gli esiti incerti noti al relay (solo
|
|
1283
|
+
// contatori/ID, mai testo) per ricostruire lo stato «verifica» senza reinvii.
|
|
1284
|
+
api.get('/asks-relay/state', (_req, res) => {
|
|
1285
|
+
try { res.json({ attempts: askRelay.state() }); }
|
|
1286
|
+
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1287
|
+
});
|
|
1252
1288
|
// The client re-emits imported events into the local hub (deliverOnly: the
|
|
1253
1289
|
// notifier is bypassed on purpose). The alert relay hangs off that same call,
|
|
1254
1290
|
// so ONLY an imported notify can ring this device, and never through the
|
|
@@ -1672,13 +1708,13 @@ function createServer(opts = {}) {
|
|
|
1672
1708
|
const panelFederato = /^\/api\/route\/[^?#]*\/_\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(pathname)
|
|
1673
1709
|
&& /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''));
|
|
1674
1710
|
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 });
|
|
1711
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret, hubAttaches });
|
|
1676
1712
|
return;
|
|
1677
1713
|
}
|
|
1678
1714
|
if (pathname.startsWith('/federation/route/')) {
|
|
1679
1715
|
const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
|
|
1680
1716
|
if (!ingress) return socket.destroy();
|
|
1681
|
-
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
|
|
1717
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret, hubAttaches });
|
|
1682
1718
|
return;
|
|
1683
1719
|
}
|
|
1684
1720
|
if (pathname.startsWith('/api/panel/')) {
|
|
@@ -1732,7 +1768,7 @@ function createServer(opts = {}) {
|
|
|
1732
1768
|
isValidSession: (name) => sessionExistsImpl(name) && wsScope.allowsSession(name),
|
|
1733
1769
|
runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
|
|
1734
1770
|
countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
|
|
1735
|
-
defaults:
|
|
1771
|
+
defaults: wsBridgeDefaults(cfg),
|
|
1736
1772
|
ptyGrace,
|
|
1737
1773
|
onAttach: (sess) => attachedWs.set(ws, sess),
|
|
1738
1774
|
diagnostics,
|
|
@@ -1861,4 +1897,4 @@ function start(opts = {}) {
|
|
|
1861
1897
|
return server;
|
|
1862
1898
|
}
|
|
1863
1899
|
|
|
1864
|
-
module.exports = { createServer, start };
|
|
1900
|
+
module.exports = { createServer, start, wsBridgeDefaults };
|
package/lib/ws/bridge.js
CHANGED
|
@@ -117,14 +117,53 @@ 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
|
+
|
|
120
158
|
// Resync del buffer dopo una riconnessione: il client chiede {type:'resync'}
|
|
121
159
|
// e riceve l'intero pane (scrollback cap 2000 righe) con le sequenze di
|
|
122
160
|
// escape (-e) per ridipingere lo schermo senza mai mostrarlo vuoto.
|
|
123
161
|
const RESYNC_MAX_LINES = 2000;
|
|
124
|
-
function defaultCapturePane(session, tmuxBin, cb) {
|
|
162
|
+
function defaultCapturePane(session, tmuxBin, captureLines, cb) {
|
|
163
|
+
const lines = Math.max(1, Math.floor(Number(captureLines) || RESYNC_MAX_LINES));
|
|
125
164
|
try {
|
|
126
165
|
require('node:child_process').execFile(tmuxBin || 'tmux',
|
|
127
|
-
['capture-pane', '-e', '-p', '-S', `-${
|
|
166
|
+
['capture-pane', '-e', '-p', '-S', `-${lines}`, '-t', String(session)],
|
|
128
167
|
{ maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => cb(err, stdout));
|
|
129
168
|
} catch (e) { cb(e); }
|
|
130
169
|
}
|
|
@@ -267,6 +306,7 @@ function bindWs(ws, deps) {
|
|
|
267
306
|
session: msg.session,
|
|
268
307
|
readonly: defaults.readonlyDefault === true || !!msg.readonly,
|
|
269
308
|
takeSize,
|
|
309
|
+
ring: createOutputRing({ maxBytes: defaults.outputRingBytes }),
|
|
270
310
|
ended: false,
|
|
271
311
|
exitCode: undefined,
|
|
272
312
|
resumeToken: null,
|
|
@@ -277,8 +317,11 @@ function bindWs(ws, deps) {
|
|
|
277
317
|
if (ptyGrace) record.resumeToken = ptyGrace.issueToken(record);
|
|
278
318
|
onAttach(session, ws);
|
|
279
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);
|
|
280
323
|
if (!record.ws) return;
|
|
281
|
-
try { record.ws.send(
|
|
324
|
+
try { record.ws.send(frameWithSeq(seq, d), { binary: true }); } catch (_) { return; }
|
|
282
325
|
if ((record.ws.bufferedAmount || 0) > MAX_BUFFERED) fail(1011, 'backpressure');
|
|
283
326
|
});
|
|
284
327
|
record.pty.onExit((info) => {
|
|
@@ -308,11 +351,28 @@ function bindWs(ws, deps) {
|
|
|
308
351
|
else if (msg.type === 'input' && !record.readonly) record.pty.write(typeof msg.data === 'string' ? msg.data : '');
|
|
309
352
|
else if (msg.type === 'key' && !record.readonly) record.pty.write(typeof msg.seq === 'string' ? msg.seq.slice(0, 64) : '');
|
|
310
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
|
+
}
|
|
311
371
|
// Resync post-riconnessione: ridipinge il buffer del client dal pane
|
|
312
372
|
// reale. Best-effort — se il capture fallisce lo stream live continua e
|
|
313
373
|
// il client resta sul buffer che aveva (mai uno schermo vuoto forzato).
|
|
314
374
|
else if (msg.type === 'resync') {
|
|
315
|
-
capturePane(session, defaults.tmuxBin || 'tmux', (err, text) => {
|
|
375
|
+
capturePane(session, defaults.tmuxBin || 'tmux', defaults.captureLines, (err, text) => {
|
|
316
376
|
if (err) return;
|
|
317
377
|
sendJson(ws, { type: 'snapshot', data: String(text || '') });
|
|
318
378
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmmbuto/nexuscrew",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.41",
|
|
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": {
|
|
@@ -45,15 +45,16 @@
|
|
|
45
45
|
"express": "^4.21.0",
|
|
46
46
|
"multer": "^2.2.0",
|
|
47
47
|
"qrcode-terminal": "^0.12.0",
|
|
48
|
+
"smol-toml": "1.9.0",
|
|
48
49
|
"web-push": "^3.6.7",
|
|
49
50
|
"ws": "^8.18.0"
|
|
50
51
|
},
|
|
51
52
|
"optionalDependencies": {
|
|
52
|
-
"@mmmbuto/node-pty-android-arm64": "^1.1.2",
|
|
53
53
|
"@lydell/node-pty-darwin-arm64": "^1.2.0-beta.14",
|
|
54
54
|
"@lydell/node-pty-darwin-x64": "^1.2.0-beta.14",
|
|
55
55
|
"@lydell/node-pty-linux-arm64": "^1.2.0-beta.12",
|
|
56
|
-
"@lydell/node-pty-linux-x64": "^1.2.0-beta.12"
|
|
56
|
+
"@lydell/node-pty-linux-x64": "^1.2.0-beta.12",
|
|
57
|
+
"@mmmbuto/node-pty-android-arm64": "^1.1.2"
|
|
57
58
|
},
|
|
58
59
|
"engines": {
|
|
59
60
|
"node": ">=18"
|