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