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