@mmmbuto/nexuscrew 0.9.33 → 0.9.35
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 +14 -0
- package/frontend/dist/assets/{index-DqCfZFGi.css → index-XusWm2UG.css} +1 -1
- package/frontend/dist/assets/index-cShz4eZG.js +167 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/fleet/builtin.js +60 -1
- package/lib/fleet/causes.js +3 -0
- package/lib/fleet/cell-exec.js +12 -12
- package/lib/fleet/cell-lease-server.js +4 -4
- package/lib/fleet/definitions.js +109 -6
- package/lib/fleet/managed.js +257 -3
- package/lib/fleet/runtime.js +7 -1
- package/lib/mcp/cells.js +21 -1
- package/lib/mcp/tools.js +4 -2
- package/lib/proxy/federation.js +113 -20
- package/package.json +1 -1
- package/frontend/dist/assets/index-Uco-EbNb.js +0 -167
package/lib/mcp/cells.js
CHANGED
|
@@ -75,6 +75,10 @@ function topologyOwners(payload) {
|
|
|
75
75
|
route: [...node.route],
|
|
76
76
|
label: typeof node.label === 'string' && node.label ? node.label : (node.name || node.route.join(' › ')),
|
|
77
77
|
stale: node.stale === true,
|
|
78
|
+
// Serve alla directory per dire QUANDO un nodo stale e' stato visto
|
|
79
|
+
// l'ultima volta: senza, la voce in unavailable parlerebbe dello stato
|
|
80
|
+
// senza dire da quanto.
|
|
81
|
+
lastSeen: Number.isFinite(node.lastSeen) ? node.lastSeen : null,
|
|
78
82
|
});
|
|
79
83
|
}
|
|
80
84
|
return out;
|
|
@@ -162,10 +166,26 @@ async function readCellDirectory(ctx, callerSession = null) {
|
|
|
162
166
|
]);
|
|
163
167
|
const localId = String(config && config.instanceId || '');
|
|
164
168
|
if (!NODE_ID_RE.test(localId)) throw new Error('instanceId locale non disponibile');
|
|
169
|
+
const candidati = topologyOwners(topology).filter((owner) => owner.instanceId !== localId);
|
|
165
170
|
const owners = [{ instanceId: localId, route: [], label: 'Local', stale: false },
|
|
166
|
-
...
|
|
171
|
+
...candidati.filter((owner) => !owner.stale)];
|
|
167
172
|
const cells = [];
|
|
168
173
|
const unavailable = [];
|
|
174
|
+
// Un owner STALE resta nel gate (non si interroga un nodo che la topologia
|
|
175
|
+
// dichiara stantio) ma non SPARISCE dalla directory: chi legge `nc_cells`
|
|
176
|
+
// deve distinguere «questo nodo non risponde da X» da «questo nodo non
|
|
177
|
+
// esiste». Stesso shape di unavailableOwner, failure 'stale' + lastSeen.
|
|
178
|
+
for (const owner of candidati) {
|
|
179
|
+
if (!owner.stale) continue;
|
|
180
|
+
unavailable.push({
|
|
181
|
+
instanceId: owner.instanceId,
|
|
182
|
+
owner: owner.label,
|
|
183
|
+
route: owner.route.length ? owner.route.join('/') : 'local',
|
|
184
|
+
...(owner.route.length === 0 ? { local: true } : {}),
|
|
185
|
+
failure: 'stale',
|
|
186
|
+
lastSeen: Number.isFinite(owner.lastSeen) ? owner.lastSeen : null,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
169
189
|
await Promise.all(owners.map(async (owner) => {
|
|
170
190
|
const apiPath = owner.route.length ? routePath(owner.route, 'cells') : '/api/cells';
|
|
171
191
|
if (!apiPath) return;
|
package/lib/mcp/tools.js
CHANGED
|
@@ -252,7 +252,7 @@ async function readVlDirectory(ctx) {
|
|
|
252
252
|
const localId = String(config && config.instanceId || '');
|
|
253
253
|
if (!NODE_ID_RE.test(localId)) throw new Error('instanceId locale non disponibile');
|
|
254
254
|
const owners = [{ instanceId: localId, route: [], label: 'Local', stale: false },
|
|
255
|
-
...topologyOwners(topology).filter((owner) =>
|
|
255
|
+
...topologyOwners(topology).filter((owner) => owner.instanceId !== localId)];
|
|
256
256
|
const nodes = [];
|
|
257
257
|
const availableOwners = [];
|
|
258
258
|
const unavailable = [];
|
|
@@ -455,7 +455,9 @@ const TOOLS = [
|
|
|
455
455
|
if (!NODE_ID_RE.test(localNodeId)) throw new Error('instanceId locale non disponibile');
|
|
456
456
|
if (!localDecks || !Array.isArray(localDecks.decks)) throw new Error('risposta deck non valida');
|
|
457
457
|
|
|
458
|
-
|
|
458
|
+
// Gli owner stale restano interrogabili con la loro route (best-effort):
|
|
459
|
+
// un ramo lento degrada a stale, non sparisce dalla directory dei deck.
|
|
460
|
+
const remotes = topologyOwners(topology).filter((owner) => owner.instanceId !== localNodeId);
|
|
459
461
|
const viewerById = new Map([[localNodeId, []], ...remotes.map((owner) => [owner.instanceId, owner.route])]);
|
|
460
462
|
const sources = [{
|
|
461
463
|
owner: { instanceId: localNodeId, route: [], label: 'Local' },
|
package/lib/proxy/federation.js
CHANGED
|
@@ -19,6 +19,24 @@ const { COOKIE_NAME: PANEL_COOKIE_NAME } = require('./panel-auth.js');
|
|
|
19
19
|
const MAX_HOPS = 4;
|
|
20
20
|
const ROUTE_DELIMITER = '_';
|
|
21
21
|
const TOPOLOGY_PEER_TIMEOUT_MS = 1500;
|
|
22
|
+
// Budget per hop della topologia federata: quando la richiesta arriva da un
|
|
23
|
+
// peer, il fan-out verso i propri figli si tiene questo margine sotto il
|
|
24
|
+
// budget del chiamante, cosi' la risposta rientra sempre in chi chiama.
|
|
25
|
+
const HOP_MARGIN_MS = 500;
|
|
26
|
+
const CHILD_BUDGET_FLOOR_MS = 300;
|
|
27
|
+
// Ultimo snapshot riuscito per figlio diretto (cache in memoria, chiave
|
|
28
|
+
// store+figlio): serve i rami lenti come stale invece di farli sparire dal
|
|
29
|
+
// ramo del chiamante.
|
|
30
|
+
const CHILD_SNAPSHOT_MAX = 64;
|
|
31
|
+
const childSnapshotsByNodeId = new Map();
|
|
32
|
+
|
|
33
|
+
function recordChildSnapshot(nodesPath, nodeId, nodes) {
|
|
34
|
+
childSnapshotsByNodeId.set(`${nodesPath}::${nodeId}`, { ts: Date.now(), nodes });
|
|
35
|
+
if (childSnapshotsByNodeId.size <= CHILD_SNAPSHOT_MAX) return;
|
|
36
|
+
let oldest = null;
|
|
37
|
+
for (const [id, snap] of childSnapshotsByNodeId) if (!oldest || snap.ts < oldest[1].ts) oldest = [id, snap];
|
|
38
|
+
if (oldest) childSnapshotsByNodeId.delete(oldest[0]);
|
|
39
|
+
}
|
|
22
40
|
// Finestra di attesa del reverse channel su Share ON: limitata per non
|
|
23
41
|
// diventare un retry storm, ma sufficiente al bind subito dopo un pairing.
|
|
24
42
|
const SHARE_HEALTH_ATTEMPTS = 10;
|
|
@@ -959,7 +977,7 @@ async function fetchPeerTopology({ node, ttl, seen, fetchImpl, timeoutMs }) {
|
|
|
959
977
|
}, budget);
|
|
960
978
|
});
|
|
961
979
|
const request = (async () => {
|
|
962
|
-
const u = `http://127.0.0.1:${node.localPort}/federation/topology?ttl=${ttl - 1}&visited=${encodeURIComponent([...seen].join(','))}`;
|
|
980
|
+
const u = `http://127.0.0.1:${node.localPort}/federation/topology?ttl=${ttl - 1}&visited=${encodeURIComponent([...seen].join(','))}&budget=${budget}`;
|
|
963
981
|
const response = await fetchImpl(u, {
|
|
964
982
|
headers: { authorization: `Bearer ${node.token}` }, signal: ctrl.signal,
|
|
965
983
|
});
|
|
@@ -969,14 +987,49 @@ async function fetchPeerTopology({ node, ttl, seen, fetchImpl, timeoutMs }) {
|
|
|
969
987
|
finally { clearTimeout(timer); }
|
|
970
988
|
}
|
|
971
989
|
|
|
990
|
+
// Validazione dei nodi figli riportati da un peer (stesso gate del loop
|
|
991
|
+
// storico): identita' strict, route valida e aciclica verso il padre, nome
|
|
992
|
+
// coerente con l'ultimo hop. Le entry valide sono relative al figlio.
|
|
993
|
+
// Lo stato stale/lastSeen che il salto precedente aveva gia' calcolato
|
|
994
|
+
// VIAGGIA con il figlio (ben formato: stale===true, lastSeen intero): senza
|
|
995
|
+
// questa propagazione l'ultimo hop ricompatterebbe tutto come fresco e il
|
|
996
|
+
// badge veritiero non arriverebbe mai a chi guarda.
|
|
997
|
+
function validatedChildNodes(n, seen, ttl, body) {
|
|
998
|
+
if (!body || body.instanceId !== n.nodeId || !Array.isArray(body.nodes)) return null;
|
|
999
|
+
const out = [];
|
|
1000
|
+
for (const child of body.nodes) {
|
|
1001
|
+
if (!child || !store.NODE_ID_RE.test(child.instanceId) || child.instanceId === n.nodeId || seen.has(child.instanceId)
|
|
1002
|
+
|| !store.NODE_NAME_RE.test(child.name)
|
|
1003
|
+
|| !Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
|
|
1004
|
+
|| child.route.some((x) => !store.NODE_NAME_RE.test(x))
|
|
1005
|
+
|| new Set(child.route).size !== child.route.length
|
|
1006
|
+
|| child.name !== child.route[child.route.length - 1]
|
|
1007
|
+
|| child.route.includes(n.name)) continue;
|
|
1008
|
+
out.push({
|
|
1009
|
+
instanceId: child.instanceId, name: child.name, route: [...child.route],
|
|
1010
|
+
label: store.validLabel(child.label) ? child.label.trim() : '',
|
|
1011
|
+
...(child.stale === true
|
|
1012
|
+
? { stale: true, ...(Number.isSafeInteger(child.lastSeen) ? { lastSeen: child.lastSeen } : {}) }
|
|
1013
|
+
: {}),
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
return out;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
972
1019
|
async function collectTopologyDetailed({
|
|
973
1020
|
nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch,
|
|
974
|
-
timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS,
|
|
1021
|
+
timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS, childSnapshots = true,
|
|
975
1022
|
}) {
|
|
976
1023
|
const st = store.loadStore(nodesPath);
|
|
977
1024
|
if (!st) return { instanceId: null, nodes: [], authoritative: [] };
|
|
978
1025
|
const seen = new Set(visited.filter((x) => store.NODE_ID_RE.test(x)));
|
|
979
1026
|
seen.add(st.nodeId);
|
|
1027
|
+
const budget = Number.isFinite(timeoutMs)
|
|
1028
|
+
? Math.max(1, Math.min(30000, Math.floor(timeoutMs))) : TOPOLOGY_PEER_TIMEOUT_MS;
|
|
1029
|
+
// Richiesta da un peer: il fan-out verso i figli usa un budget inferiore a
|
|
1030
|
+
// quello del chiamante, cosi' questa risposta rientra sempre nel suo budget.
|
|
1031
|
+
const childBudget = ingress
|
|
1032
|
+
? Math.max(CHILD_BUDGET_FLOOR_MS, budget - HOP_MARGIN_MS) : budget;
|
|
980
1033
|
const out = [];
|
|
981
1034
|
const authoritative = [];
|
|
982
1035
|
const probes = [];
|
|
@@ -995,22 +1048,17 @@ async function collectTopologyDetailed({
|
|
|
995
1048
|
label: store.validLabel(n.label) ? n.label.trim() : '',
|
|
996
1049
|
});
|
|
997
1050
|
if (ttl <= 1 || !n.token) continue;
|
|
998
|
-
probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs }) });
|
|
1051
|
+
probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs: childBudget }) });
|
|
999
1052
|
}
|
|
1000
1053
|
const results = await Promise.all(probes.map(async ({ n, pending }) => {
|
|
1001
1054
|
try { return { n, ...(await pending) }; } catch (_) { return { n, response: null, body: null }; }
|
|
1002
1055
|
}));
|
|
1003
1056
|
for (const { n, response, body } of results) {
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|| !Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
|
|
1010
|
-
|| child.route.some((x) => !store.NODE_NAME_RE.test(x))
|
|
1011
|
-
|| new Set(child.route).size !== child.route.length
|
|
1012
|
-
|| child.name !== child.route[child.route.length - 1]
|
|
1013
|
-
|| child.route.includes(n.name)) continue;
|
|
1057
|
+
const children = response && response.ok ? validatedChildNodes(n, seen, ttl, body) : null;
|
|
1058
|
+
if (children) {
|
|
1059
|
+
authoritative.push(n.name);
|
|
1060
|
+
recordChildSnapshot(nodesPath, n.nodeId, children);
|
|
1061
|
+
for (const child of children) {
|
|
1014
1062
|
// La label di un nodo in transito e' testo AUTO-DICHIARATO da un altro
|
|
1015
1063
|
// nodo: si accetta solo se rispetta la stessa forma di una label locale,
|
|
1016
1064
|
// altrimenti si resta senza nome invece di propagare qualcosa di
|
|
@@ -1018,8 +1066,37 @@ async function collectTopologyDetailed({
|
|
|
1018
1066
|
out.push({
|
|
1019
1067
|
instanceId: child.instanceId, name: child.name,
|
|
1020
1068
|
route: [n.name, ...child.route], direct: false,
|
|
1021
|
-
label:
|
|
1069
|
+
label: child.label,
|
|
1070
|
+
...(child.stale === true
|
|
1071
|
+
? { stale: true, ...(Number.isSafeInteger(child.lastSeen) ? { lastSeen: child.lastSeen } : {}) }
|
|
1072
|
+
: {}),
|
|
1022
1073
|
});
|
|
1074
|
+
}
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
// Figlio che non ha risposto in tempo (failure di trasporto: response null):
|
|
1078
|
+
// si serve l'ultimo snapshot riuscito marcando le entry stale con lastSeen,
|
|
1079
|
+
// invece di far sparire l'intero ramo dalla topologia di chi chiama. Una
|
|
1080
|
+
// risposta ARRIVATA ma non valida (binding instanceId sbagliato, errore
|
|
1081
|
+
// HTTP) non resuscita nulla: il ramo resta assente, come prima. (Raccolta
|
|
1082
|
+
// locale esclusa: la sua cache su disco ha gia' questa semantica con il
|
|
1083
|
+
// proprio clock.)
|
|
1084
|
+
const snapshot = childSnapshots && !response
|
|
1085
|
+
? childSnapshotsByNodeId.get(`${nodesPath}::${n.nodeId}`) : null;
|
|
1086
|
+
if (!snapshot) continue;
|
|
1087
|
+
const lastSeen = Math.floor(snapshot.ts / 1000);
|
|
1088
|
+
for (const child of snapshot.nodes) {
|
|
1089
|
+
if (child.instanceId === n.nodeId || seen.has(child.instanceId)) continue;
|
|
1090
|
+
if (!Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
|
|
1091
|
+
|| child.route.includes(n.name)) continue;
|
|
1092
|
+
out.push({
|
|
1093
|
+
instanceId: child.instanceId, name: child.name,
|
|
1094
|
+
route: [n.name, ...child.route], direct: false,
|
|
1095
|
+
label: child.label, stale: true,
|
|
1096
|
+
// il lastSeen piu' veritiero e' quello che il figlio stesso aveva
|
|
1097
|
+
// riportato (se era gia' stale); altrimenti il momento dello snapshot
|
|
1098
|
+
lastSeen: Number.isSafeInteger(child.lastSeen) ? child.lastSeen : lastSeen,
|
|
1099
|
+
});
|
|
1023
1100
|
}
|
|
1024
1101
|
}
|
|
1025
1102
|
const ids = new Set(); const routes = new Set(); const unique = [];
|
|
@@ -1037,12 +1114,14 @@ async function collectTopology(opts) {
|
|
|
1037
1114
|
}
|
|
1038
1115
|
|
|
1039
1116
|
// Local roster: live topology plus a credential-free cache of previously seen
|
|
1040
|
-
// transitive nodes.
|
|
1117
|
+
// transitive nodes. Slow children fall back to the last good in-memory
|
|
1118
|
+
// snapshot (stale:true); the peer endpoint returns them too, so a branch that
|
|
1119
|
+
// misses one poll degrades to stale instead of disappearing.
|
|
1041
1120
|
async function collectLocalTopology({
|
|
1042
1121
|
nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000),
|
|
1043
1122
|
timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS,
|
|
1044
1123
|
}) {
|
|
1045
|
-
const live = await collectTopologyDetailed({ nodesPath, fetchImpl, timeoutMs });
|
|
1124
|
+
const live = await collectTopologyDetailed({ nodesPath, fetchImpl, timeoutMs, childSnapshots: false });
|
|
1046
1125
|
const st = store.loadStore(nodesPath);
|
|
1047
1126
|
const directNames = new Set(((st && st.nodes) || [])
|
|
1048
1127
|
.filter((n) => n.direction !== 'inbound' || n.shared === true)
|
|
@@ -1063,11 +1142,14 @@ async function collectLocalTopology({
|
|
|
1063
1142
|
for (const n of live.nodes) {
|
|
1064
1143
|
// La cache conserva anche la label: senza, un nodo che torna stale perde il
|
|
1065
1144
|
// nome e ricompare come slug, cioe' esattamente il difetto che si voleva
|
|
1066
|
-
// togliere. Resta un dato riferito, non una prova.
|
|
1145
|
+
// togliere. Resta un dato riferito, non una prova. Per i nodi arrivati
|
|
1146
|
+
// gia' stale dal salto precedente, il lastSeen ORIGINALE e' il dato vero:
|
|
1147
|
+
// "ora" mentirebbe su quando sono stati visti l'ultima volta.
|
|
1067
1148
|
if (n.route.length > 1) {
|
|
1068
1149
|
next.set(n.instanceId, {
|
|
1069
1150
|
instanceId: n.instanceId, name: n.name, route: [...n.route],
|
|
1070
|
-
...(n.label ? { label: n.label } : {}),
|
|
1151
|
+
...(n.label ? { label: n.label } : {}),
|
|
1152
|
+
lastSeen: n.stale === true && Number.isSafeInteger(n.lastSeen) ? n.lastSeen : now,
|
|
1071
1153
|
});
|
|
1072
1154
|
}
|
|
1073
1155
|
}
|
|
@@ -1077,7 +1159,13 @@ async function collectLocalTopology({
|
|
|
1077
1159
|
try { topologyCache.atomicWriteCache(cacheFile, serialized); } catch (_) {}
|
|
1078
1160
|
}
|
|
1079
1161
|
|
|
1080
|
-
|
|
1162
|
+
// Solo i nodi visti DIRETTAMENTE vivi prendono stale:false + lastSeen=now:
|
|
1163
|
+
// un nodo che il peer aveva gia' marcato stale resta stale col suo lastSeen.
|
|
1164
|
+
const nodes = live.nodes.map((n) => ({
|
|
1165
|
+
...n,
|
|
1166
|
+
stale: n.stale === true,
|
|
1167
|
+
lastSeen: n.stale === true && Number.isSafeInteger(n.lastSeen) ? n.lastSeen : now,
|
|
1168
|
+
}));
|
|
1081
1169
|
for (const n of cached) {
|
|
1082
1170
|
if (!liveIds.has(n.instanceId) && !liveRoutes.has(n.route.join('/'))) nodes.push({ ...n, direct: false, stale: true });
|
|
1083
1171
|
}
|
|
@@ -1302,7 +1390,12 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
1302
1390
|
r.get('/topology', async (req, res) => {
|
|
1303
1391
|
const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
|
|
1304
1392
|
const visited = String(req.query.visited || '').split(',');
|
|
1305
|
-
|
|
1393
|
+
// Il budget residuo del chiamante viaggia nella richiesta: il fan-out
|
|
1394
|
+
// interno se ne tiene un margine (budget per hop) per rispondere in tempo.
|
|
1395
|
+
const rawBudget = Number(req.query.budget);
|
|
1396
|
+
const timeoutMs = Number.isFinite(rawBudget)
|
|
1397
|
+
? Math.max(CHILD_BUDGET_FLOOR_MS, Math.min(30000, Math.floor(rawBudget))) : TOPOLOGY_PEER_TIMEOUT_MS;
|
|
1398
|
+
res.json(await collectTopology({ nodesPath, ingress: req.peer, ttl, visited, fetchImpl, timeoutMs }));
|
|
1306
1399
|
});
|
|
1307
1400
|
// A connected client publishes itself through the SAME SSH connection by
|
|
1308
1401
|
// toggling its optional -R channel. The hub records that intent only after a
|