@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/frontend/dist/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-BZ1dY5k6.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-43DFO1EH.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.49"}
|
package/lib/cells/routes.js
CHANGED
|
@@ -5,6 +5,19 @@ const { isValidSession } = require('../files/store.js');
|
|
|
5
5
|
const { submitTextOk } = require('../tmux/actions.js');
|
|
6
6
|
|
|
7
7
|
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
8
|
+
const CELL_LABEL_MAX = 64;
|
|
9
|
+
|
|
10
|
+
// Una label che esce da questo nodo, o che arriva da un altro, e' testo
|
|
11
|
+
// AUTO-DICHIARATO: la definizione locale e' gia' validata dal parser, ma il
|
|
12
|
+
// payload che si espone e quello che si riceve vanno delimitati comunque.
|
|
13
|
+
// Senza questo un peer puo' far attraversare la directory a una stringa lunga
|
|
14
|
+
// e con a capo, che ogni consumatore poi renderizza.
|
|
15
|
+
function safeCellLabel(value) {
|
|
16
|
+
if (typeof value !== 'string') return '';
|
|
17
|
+
const trimmed = value.trim();
|
|
18
|
+
if (!trimmed || trimmed.length > CELL_LABEL_MAX) return '';
|
|
19
|
+
return /[\x00-\x1f\x7f]/.test(trimmed) ? '' : trimmed;
|
|
20
|
+
}
|
|
8
21
|
const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
9
22
|
const MESSAGE_ID_RE = /^[a-f0-9-]{16,64}$/;
|
|
10
23
|
|
|
@@ -24,6 +37,10 @@ function publicCells(status, instanceId, now = Date.now()) {
|
|
|
24
37
|
id: `${instanceId}:${raw.cell}`,
|
|
25
38
|
instanceId,
|
|
26
39
|
cell: raw.cell,
|
|
40
|
+
// Il nome leggibile viaggia accanto all'id, mai al suo posto: chi riceve
|
|
41
|
+
// questa voce deve poter capire che ruolo occupa la cella senza dover
|
|
42
|
+
// interpretare un identificatore scelto da un altro nodo.
|
|
43
|
+
label: safeCellLabel(raw.label),
|
|
27
44
|
tmuxSession: raw.tmuxSession,
|
|
28
45
|
engine: typeof raw.engine === 'string' ? raw.engine : '',
|
|
29
46
|
model: typeof raw.model === 'string' ? raw.model : '',
|
|
@@ -135,4 +152,4 @@ function cellsRoutes({ fleetP, instanceId, submit, readonly = () => false, now =
|
|
|
135
152
|
return r;
|
|
136
153
|
}
|
|
137
154
|
|
|
138
|
-
module.exports = { cellsRoutes, publicCells, parseVisited, validIdentity };
|
|
155
|
+
module.exports = { cellsRoutes, publicCells, parseVisited, validIdentity, safeCellLabel };
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -507,7 +507,10 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
507
507
|
if (!Array.isArray(cells) || cells.length < 1 || cells.length > MAX_CELLS) {
|
|
508
508
|
throw httpError(400, `cells deve contenere 1..${MAX_CELLS} definizioni`);
|
|
509
509
|
}
|
|
510
|
-
|
|
510
|
+
// `label` e' parte della definizione quanto `prompt`: senza di essa qui, un
|
|
511
|
+
// backup che la contiene verrebbe rifiutato in restore e il round-trip si
|
|
512
|
+
// spezzerebbe proprio sulle celle a cui e' stato dato un nome.
|
|
513
|
+
const allowed = new Set(['id', 'cwd', 'cwdRel', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'commands', 'prompt', 'label']);
|
|
511
514
|
const seen = new Set();
|
|
512
515
|
for (const cell of cells) {
|
|
513
516
|
if (!cell || typeof cell !== 'object' || Array.isArray(cell)) throw httpError(400, 'definizione cell non valida');
|
|
@@ -864,6 +867,9 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
864
867
|
permissionPolicies: { type: 'object', required: false, keyRef: 'engine.id', valueEnum: ['standard', 'unsafe'] },
|
|
865
868
|
commands: { type: 'object', required: false, keyRef: 'engine.id', valueMax: CAPS.MAX_CELL_COMMAND_LEN, managedClient: 'shell' },
|
|
866
869
|
prompt: { type: 'string', required: false, max: CAPS.MAX_PROMPT_LEN },
|
|
870
|
+
// Nome leggibile, distinto dall'id: quest'ultimo resta la chiave di
|
|
871
|
+
// indirizzamento e l'unica origine della sessione tmux.
|
|
872
|
+
label: { type: 'string', required: false, max: CAPS.MAX_LABEL_LEN },
|
|
867
873
|
},
|
|
868
874
|
};
|
|
869
875
|
}
|
package/lib/fleet/definitions.js
CHANGED
|
@@ -378,6 +378,21 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
|
|
|
378
378
|
prompt = c.prompt;
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
// label (opzionale): nome LEGGIBILE della cella, distinto dall'id.
|
|
382
|
+
// L'id resta la chiave stabile con cui si indirizza una cella e con cui si
|
|
383
|
+
// deriva la sessione tmux; la label e' solo cio' che un umano legge. Senza
|
|
384
|
+
// questa distinzione l'id fa anche da nome, e un nodo che battezza la propria
|
|
385
|
+
// cella come il motore la espone cosi' a tutta la rete: chi la riceve non ha
|
|
386
|
+
// modo di sapere che ruolo occupa. Stessa regola gia' usata per la label
|
|
387
|
+
// degli engine e per quella dei nodi: stampabile, non vuota, max 64.
|
|
388
|
+
let label;
|
|
389
|
+
if (c.label !== undefined) {
|
|
390
|
+
if (typeof c.label !== 'string') return null;
|
|
391
|
+
const trimmed = c.label.trim();
|
|
392
|
+
if (!trimmed || trimmed.length > MAX_LABEL_LEN || !isPrintable(trimmed)) return null;
|
|
393
|
+
label = trimmed;
|
|
394
|
+
}
|
|
395
|
+
|
|
381
396
|
// tmuxSession: campo esplicito o derivato da id. UNIVOCO (check in caller).
|
|
382
397
|
// Il nome CANONICO e' tmux-safe (v2 per id puntati): tmux normalizza '.' in
|
|
383
398
|
// '_' nei nomi sessione, per cui `cloud-agy.native` diverrebbe `cloud-agy_native`
|
|
@@ -422,6 +437,7 @@ function parseCell(c, engineIds, engineMap = new Map(), { allowLegacyTmuxNames =
|
|
|
422
437
|
if (permissionPolicies) out.permissionPolicies = permissionPolicies;
|
|
423
438
|
if (commands && Object.keys(commands).length) out.commands = commands;
|
|
424
439
|
if (prompt !== undefined) out.prompt = prompt;
|
|
440
|
+
if (label !== undefined) out.label = label;
|
|
425
441
|
if (legacyTmuxSession) {
|
|
426
442
|
Object.defineProperty(out, 'legacyTmuxSession', {
|
|
427
443
|
value: legacyTmuxSession, enumerable: false, configurable: false,
|
package/lib/fleet/runtime.js
CHANGED
|
@@ -93,7 +93,9 @@ function createBuiltinRuntime(ctx) {
|
|
|
93
93
|
? 'standard'
|
|
94
94
|
: (remembered || engineDefault || '');
|
|
95
95
|
return {
|
|
96
|
-
cell
|
|
96
|
+
// `cell` resta l'id: e' la chiave di indirizzamento. `label` e' il nome
|
|
97
|
+
// leggibile e viaggia accanto, senza mai sostituirlo.
|
|
98
|
+
cell: c.id, label: c.label || '', tmuxSession: c.tmuxSession, engine: c.engine,
|
|
97
99
|
model: c.model || '', models: { ...(c.models || {}) },
|
|
98
100
|
permissionPolicy: effectivePolicy,
|
|
99
101
|
permissionPolicies: { ...(c.permissionPolicies || {}) },
|
package/lib/mcp/cells.js
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
// l'astrazione `ctx.api` (loopback + Bearer del bridge); ACL e identita'
|
|
9
9
|
// owner-qualified sono applicate lato server HTTP.
|
|
10
10
|
const { isValidSession } = require('../files/store.js');
|
|
11
|
+
// Stesso validatore usato in uscita: una label federata non deve poter entrare
|
|
12
|
+
// qui in una forma che noi non produrremmo mai.
|
|
13
|
+
const { safeCellLabel } = require('../cells/routes.js');
|
|
11
14
|
|
|
12
15
|
const NODE_PART_RE = /^[a-z0-9-]{1,32}$/;
|
|
13
16
|
const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
@@ -109,6 +112,14 @@ function normalizeCellPayload(payload, owner, callerSession = null) {
|
|
|
109
112
|
owner: owner.label,
|
|
110
113
|
route,
|
|
111
114
|
cell: raw.cell,
|
|
115
|
+
// `cell` indirizza, `label` si legge. Un nodo che chiama la propria cella
|
|
116
|
+
// come il motore la esporrebbe cosi' a tutta la rete: senza un nome
|
|
117
|
+
// separato chi la riceve non ha modo di sapere che ruolo occupa.
|
|
118
|
+
// La label di una cella REMOTA e' testo auto-dichiarato: si delimita con
|
|
119
|
+
// lo stesso validatore usato in uscita, e si marca come riferita perche'
|
|
120
|
+
// chi legge distingua cio' che sappiamo da cio' che ci e' stato detto.
|
|
121
|
+
label: safeCellLabel(raw.label),
|
|
122
|
+
labelReported: owner.route.length > 0,
|
|
112
123
|
tmuxSession: raw.tmuxSession,
|
|
113
124
|
engine: typeof raw.engine === 'string' ? raw.engine : '',
|
|
114
125
|
model: typeof raw.model === 'string' ? raw.model : '',
|
package/lib/mcp/tools.js
CHANGED
|
@@ -260,7 +260,7 @@ const TOOLS = [
|
|
|
260
260
|
if (f && f.available) {
|
|
261
261
|
fleet = {
|
|
262
262
|
cells: (Array.isArray(f.cells) ? f.cells : []).map((c) => ({
|
|
263
|
-
cell: c.cell, session: c.tmuxSession, engine: c.engine, active: !!c.active,
|
|
263
|
+
cell: c.cell, label: c.label || '', session: c.tmuxSession, engine: c.engine, active: !!c.active,
|
|
264
264
|
})),
|
|
265
265
|
};
|
|
266
266
|
}
|
package/lib/nodes/inventory.js
CHANGED
|
@@ -42,6 +42,11 @@ function routedPeer(entry) {
|
|
|
42
42
|
const route = Array.isArray(entry.route) ? [...entry.route] : [];
|
|
43
43
|
return {
|
|
44
44
|
name: entry.name,
|
|
45
|
+
// Riferita dal nodo che ce l'ha inoltrata, non verificata da noi: si mostra
|
|
46
|
+
// perche' senza di essa un nodo in transito e' solo uno slug, ma resta un
|
|
47
|
+
// dato di seconda mano e non sostituisce l'instanceId.
|
|
48
|
+
label: typeof entry.label === 'string' ? entry.label : '',
|
|
49
|
+
labelReported: true,
|
|
45
50
|
nodeId: entry.instanceId,
|
|
46
51
|
instanceId: entry.instanceId,
|
|
47
52
|
kind: 'transitive',
|
|
@@ -75,4 +75,25 @@ function quarantineSlot(pool, { slot, now = Date.now() } = {}) {
|
|
|
75
75
|
return next;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
|
|
79
|
+
// Esito di uno spegnimento che puo' riguardare PIU' entry insieme: durante la
|
|
80
|
+
// grace di una rotazione lo slot vecchio e quello nuovo coesistono. Una
|
|
81
|
+
// chiusura riuscita su una sola non e' una chiusura: dichiararla tale
|
|
82
|
+
// lascerebbe l'altra viva mentre chi chiama registra il canale come privato.
|
|
83
|
+
// `no pidfile` e `stale (pid dead)` contano come spente: li' non e' rimasto
|
|
84
|
+
// nulla di vivo da attribuire.
|
|
85
|
+
const STOP_ALREADY_GONE = ['no pidfile', 'stale (pid dead)'];
|
|
86
|
+
|
|
87
|
+
function stopWasDemonstrated(result) {
|
|
88
|
+
if (!result) return false;
|
|
89
|
+
return result.stopped === true || STOP_ALREADY_GONE.includes(result.reason);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function summarizeStops(results) {
|
|
93
|
+
const list = Array.isArray(results) ? results : [];
|
|
94
|
+
const stoppedAny = list.some(stopWasDemonstrated);
|
|
95
|
+
const quarantinedAny = list.some((result) => !stopWasDemonstrated(result));
|
|
96
|
+
return { stoppedAny, quarantinedAny, allClosed: stoppedAny && !quarantinedAny };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { LEASE_MS, GRACE_MS, nextReadySlot, prepareRotation, abortPrepared, commitRotation, settleGrace, quarantineSlot, stopWasDemonstrated, summarizeStops };
|
|
@@ -23,8 +23,16 @@ function close(server) {
|
|
|
23
23
|
});
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function createReverseSlotListeners({ app, createServerImpl = http.createServer, diagnostics } = {}) {
|
|
26
|
+
function createReverseSlotListeners({ app, createServerImpl = http.createServer, diagnostics, attachUpgrade } = {}) {
|
|
27
27
|
if (typeof app !== 'function') throw new Error('reverse slot listeners richiede app HTTP');
|
|
28
|
+
// Fail-closed: servire `app` non basta: il routing degli upgrade WS vive
|
|
29
|
+
// sull'istanza `server`, non sull'app Express. Un listener senza handler di
|
|
30
|
+
// upgrade e' HTTP-only e degrada in silenzio (la SPA risponde 200 a un
|
|
31
|
+
// upgrade). Obbligare la dipendenza rende impossibile ricreare il difetto
|
|
32
|
+
// per dimenticanza, invece di affidarsi a un test che enumera gli ingressi.
|
|
33
|
+
if (typeof attachUpgrade !== 'function') {
|
|
34
|
+
throw new Error('reverse slot listeners richiede attachUpgrade');
|
|
35
|
+
}
|
|
28
36
|
const listeners = new Map(); // local target port -> owned server + immutable expected tuple
|
|
29
37
|
|
|
30
38
|
async function open({ nodeName, remotePort, generation, instanceId, secret }) {
|
|
@@ -33,6 +41,7 @@ function createReverseSlotListeners({ app, createServerImpl = http.createServer,
|
|
|
33
41
|
}
|
|
34
42
|
if (typeof secret !== 'string' || !secret) throw new Error('reverse slot listener credential mancante');
|
|
35
43
|
const server = createServerImpl(app);
|
|
44
|
+
attachUpgrade(server);
|
|
36
45
|
let address;
|
|
37
46
|
try { address = await listen(server, { host: '127.0.0.1', port: 0, exclusive: true }); }
|
|
38
47
|
catch (error) { try { server.close(); } catch (_) {} throw error; }
|
|
@@ -96,6 +96,16 @@ async function probeReverseSlot({ port, secret, expected, fetchImpl = fetch, tim
|
|
|
96
96
|
const response = await fetchImpl(`http://127.0.0.1:${port}/reverse-slot-proof`, {
|
|
97
97
|
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(probe), signal: ctrl.signal,
|
|
98
98
|
});
|
|
99
|
+
// Un 409 e' la RISPOSTA di un listener corretto che ha valutato la tupla e
|
|
100
|
+
// l'ha rifiutata: e' un esito, non un'assenza. Collassarlo in
|
|
101
|
+
// "non ottenuta" lo renderebbe indistinguibile da un timeout, e chi
|
|
102
|
+
// classifica gli errori tratterebbe come transitorio un mismatch reale.
|
|
103
|
+
if (response && response.status === 409) {
|
|
104
|
+
const failure = await response.json().catch(() => null);
|
|
105
|
+
const code = failure && typeof failure.code === 'string' && /^[a-z0-9-]{1,64}$/.test(failure.code)
|
|
106
|
+
? failure.code : 'reverse-slot-proof-mismatch';
|
|
107
|
+
return { owned: false, code };
|
|
108
|
+
}
|
|
99
109
|
if (!response || response.status !== 200) return { owned: false, code: 'reverse-slot-proof-unavailable' };
|
|
100
110
|
const body = await response.json().catch(() => null);
|
|
101
111
|
return verifySlotProof({ secret, expected, challenge: probe, response: body });
|
|
@@ -17,13 +17,22 @@ function defaultPath(home = os.homedir()) {
|
|
|
17
17
|
function parseEntry(raw) {
|
|
18
18
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
19
19
|
const keys = Object.keys(raw).sort();
|
|
20
|
-
if (keys.some((k) => !['instanceId', 'lastSeen', 'name', 'route'].includes(k))) return null;
|
|
20
|
+
if (keys.some((k) => !['instanceId', 'lastSeen', 'name', 'route', 'label'].includes(k))) return null;
|
|
21
21
|
if (!NODE_ID_RE.test(raw.instanceId) || !NODE_NAME_RE.test(raw.name)) return null;
|
|
22
22
|
if (!Array.isArray(raw.route) || raw.route.length < 2 || raw.route.length > MAX_HOPS) return null;
|
|
23
23
|
if (raw.route.some((x) => !NODE_NAME_RE.test(x)) || new Set(raw.route).size !== raw.route.length) return null;
|
|
24
24
|
if (raw.name !== raw.route[raw.route.length - 1]) return null;
|
|
25
25
|
if (!Number.isInteger(raw.lastSeen) || raw.lastSeen < 0) return null;
|
|
26
|
-
|
|
26
|
+
// La label persiste solo se rispetta la stessa forma accettata altrove: un
|
|
27
|
+
// file di cache e' un ingresso come un altro e non deve poter reintrodurre
|
|
28
|
+
// testo che non produrremmo mai.
|
|
29
|
+
const label = typeof raw.label === 'string' && raw.label.trim()
|
|
30
|
+
&& raw.label.trim().length <= 64 && !/[\x00-\x1f\x7f]/.test(raw.label.trim())
|
|
31
|
+
? raw.label.trim() : null;
|
|
32
|
+
return {
|
|
33
|
+
instanceId: raw.instanceId, name: raw.name, route: [...raw.route],
|
|
34
|
+
...(label ? { label } : {}), lastSeen: raw.lastSeen,
|
|
35
|
+
};
|
|
27
36
|
}
|
|
28
37
|
|
|
29
38
|
function parseCache(raw) {
|
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
|
@@ -187,9 +187,20 @@ function createServer(opts = {}) {
|
|
|
187
187
|
const key = reverseKey(active.port, expectedGeneration);
|
|
188
188
|
const existing = entries.get(key);
|
|
189
189
|
if (existing) return existing;
|
|
190
|
+
// Il listener prova la PROPRIA identita' a chi compone la sfida, e l'altro
|
|
191
|
+
// capo la costruisce con l'id di QUESTA installazione (probeReverseOwner usa
|
|
192
|
+
// `peer.nodeId`, cioe' noi visti da lui). `node.nodeId` e' invece l'id del
|
|
193
|
+
// nodo REMOTO — lo store lo impone, rifiutando come self-reference una voce
|
|
194
|
+
// il cui nodeId eguagli il proprio. Registrarlo faceva firmare due tuple
|
|
195
|
+
// diverse ai due capi: la prova non tornava mai e Share si spegneva con un
|
|
196
|
+
// errore definitivo, non ritentabile.
|
|
197
|
+
const localInstanceId = (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
198
|
+
// Fail-closed: senza identita' locale il listener nascerebbe con una prova
|
|
199
|
+
// che nessuno puo' validare. Meglio non aprirlo che aprirlo indimostrabile.
|
|
200
|
+
if (!localInstanceId) return null;
|
|
190
201
|
const listener = await reverseSlotListeners.open({
|
|
191
202
|
nodeName: node.name, remotePort: active.port, generation: expectedGeneration,
|
|
192
|
-
instanceId:
|
|
203
|
+
instanceId: localInstanceId, secret: node.acceptToken,
|
|
193
204
|
});
|
|
194
205
|
let launched = nodesTunnel.startReverseForward({
|
|
195
206
|
home: cfg.home || os.homedir(), node, remotePort: active.port, generation: expectedGeneration,
|
|
@@ -227,15 +238,19 @@ function createServer(opts = {}) {
|
|
|
227
238
|
}
|
|
228
239
|
const selected = [...entries.values()].filter((entry) => (remotePort === null || entry.remotePort === remotePort)
|
|
229
240
|
&& (generation === null || entry.generation === generation));
|
|
230
|
-
|
|
241
|
+
// Il valore di ritorno significa "TUTTE le entry selezionate sono spente in
|
|
242
|
+
// modo dimostrabile". Durante la grace di una rotazione ne coesistono due:
|
|
243
|
+
// dichiarare successo perche' UNA e' stata chiusa lascerebbe l'altra viva
|
|
244
|
+
// mentre chi chiama registra il canale come privato.
|
|
245
|
+
const outcomes = [];
|
|
231
246
|
for (const existing of selected) {
|
|
232
247
|
const result = nodesTunnel.stopTunnel({ home: cfg.home || os.homedir(), name: nodesTunnel.reverseTunnelName(name, existing.remotePort, existing.generation) });
|
|
248
|
+
outcomes.push(result);
|
|
233
249
|
// If we cannot prove ownership we neither signal nor close the listener:
|
|
234
250
|
// the channel is quarantined for diagnostics rather than broken by us.
|
|
235
|
-
if (!
|
|
251
|
+
if (!reverseRotation.stopWasDemonstrated(result)) continue;
|
|
236
252
|
entries.delete(reverseKey(existing.remotePort, existing.generation));
|
|
237
253
|
await reverseSlotListeners?.closePort(existing.localPort);
|
|
238
|
-
stopped = true;
|
|
239
254
|
}
|
|
240
255
|
if (!entries.size) rotatableReverse.delete(name);
|
|
241
256
|
if (remotePort === null && generation === null) {
|
|
@@ -243,7 +258,11 @@ function createServer(opts = {}) {
|
|
|
243
258
|
if (watcher) clearInterval(watcher);
|
|
244
259
|
reverseWatchers.delete(name);
|
|
245
260
|
}
|
|
246
|
-
|
|
261
|
+
// La decisione vive in `summarizeStops` (funzione pura, testabile senza
|
|
262
|
+
// processi): successo solo se qualcosa e' stato spento E niente e' rimasto
|
|
263
|
+
// in quarantena. Il caso "nessuna entry" resta false come prima: non si
|
|
264
|
+
// puo' dimostrare nulla.
|
|
265
|
+
return reverseRotation.summarizeStops(outcomes).allClosed;
|
|
247
266
|
}
|
|
248
267
|
async function verifyRotatablePool(node) {
|
|
249
268
|
const pool = node?.reversePool;
|
|
@@ -473,7 +492,12 @@ function createServer(opts = {}) {
|
|
|
473
492
|
}
|
|
474
493
|
|
|
475
494
|
const app = express();
|
|
476
|
-
reverseSlotListeners = createReverseSlotListeners({
|
|
495
|
+
reverseSlotListeners = createReverseSlotListeners({
|
|
496
|
+
app, diagnostics, createServerImpl: cfg.reverseSlotCreateServerImpl,
|
|
497
|
+
// `routeUpgrade` e' hoisted: qui si cattura solo il riferimento, la
|
|
498
|
+
// chiamata avviene quando arriva un upgrade, a server avviato.
|
|
499
|
+
attachUpgrade: (slotServer) => slotServer.on('upgrade', routeUpgrade),
|
|
500
|
+
});
|
|
477
501
|
const distDir = path.join(__dirname, '..', 'frontend', 'dist');
|
|
478
502
|
// no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
|
|
479
503
|
app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
|
|
@@ -795,7 +819,13 @@ function createServer(opts = {}) {
|
|
|
795
819
|
}, opts.wsHeartbeatMs || 30000);
|
|
796
820
|
if (typeof heartbeat.unref === 'function') heartbeat.unref();
|
|
797
821
|
server.on('close', () => clearInterval(heartbeat));
|
|
798
|
-
|
|
822
|
+
// Routing dell'upgrade WS. Dichiarata come funzione (hoisted nello scope di
|
|
823
|
+
// createServer) perche' i listener per-slot nascono PRIMA di questo punto e
|
|
824
|
+
// devono ricevere lo STESSO routing: un listener che serve `app` senza
|
|
825
|
+
// handler di upgrade fa cadere l'upgrade su Express, che non ha una rotta
|
|
826
|
+
// HTTP `/ws` e risponde con la SPA (200) invece di 101 -> terminale nero su
|
|
827
|
+
// ogni peer raggiunto via reverse pool.
|
|
828
|
+
function routeUpgrade(req, socket, head) {
|
|
799
829
|
let pathname;
|
|
800
830
|
try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
|
|
801
831
|
catch (_) { try { socket.destroy(); } catch (_e) {} return; }
|
|
@@ -826,7 +856,8 @@ function createServer(opts = {}) {
|
|
|
826
856
|
return;
|
|
827
857
|
}
|
|
828
858
|
try { socket.destroy(); } catch (_) {}
|
|
829
|
-
}
|
|
859
|
+
}
|
|
860
|
+
server.on('upgrade', routeUpgrade);
|
|
830
861
|
wss.on('connection', (ws, req) => {
|
|
831
862
|
ws.isAlive = true;
|
|
832
863
|
ws.on('pong', () => { ws.isAlive = true; });
|