@mmmbuto/nexuscrew 0.8.6 → 0.8.9
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/README.md +68 -20
- package/frontend/dist/assets/index-9Drd8g0k.css +32 -0
- package/frontend/dist/assets/index-DTmT7yhV.js +91 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/config.js +6 -0
- package/lib/fleet/builtin.js +57 -4
- package/lib/fleet/routes.js +13 -2
- package/lib/nodes/health.js +13 -2
- package/lib/nodes/peering.js +29 -2
- package/lib/nodes/store.js +9 -2
- package/lib/proxy/federation.js +14 -4
- package/lib/server.js +13 -3
- package/lib/settings/routes.js +248 -67
- package/lib/update/core.js +157 -0
- package/lib/update/manager.js +245 -0
- package/lib/update/runner.js +144 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-CEFh0hI4.js +0 -90
- package/frontend/dist/assets/index-kX5IUxt4.css +0 -32
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-DTmT7yhV.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-9Drd8g0k.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.9"}
|
package/lib/config.js
CHANGED
|
@@ -30,6 +30,9 @@ function baseDefaults() {
|
|
|
30
30
|
fleetEnabled: true,
|
|
31
31
|
fleetBin: path.join(os.homedir(), '.local', 'bin', 'fleet'),
|
|
32
32
|
providerSecretsPath: path.join(os.homedir(), '.nexuscrew', 'providers.env'),
|
|
33
|
+
// Installazioni npm globali controllano periodicamente il dist-tag latest.
|
|
34
|
+
// Il manager aggiorna solo verso una semver superiore: mai downgrade.
|
|
35
|
+
autoUpdate: true,
|
|
33
36
|
sessionPresets: {},
|
|
34
37
|
};
|
|
35
38
|
}
|
|
@@ -63,6 +66,9 @@ function envOverrides() {
|
|
|
63
66
|
if (process.env.NEXUSCREW_FLEET) e.fleetEnabled = process.env.NEXUSCREW_FLEET !== '0';
|
|
64
67
|
if (process.env.NEXUSCREW_FLEET_BIN) e.fleetBin = process.env.NEXUSCREW_FLEET_BIN;
|
|
65
68
|
if (process.env.NEXUSCREW_PROVIDER_SECRETS) e.providerSecretsPath = process.env.NEXUSCREW_PROVIDER_SECRETS;
|
|
69
|
+
if (process.env.NEXUSCREW_AUTO_UPDATE !== undefined) {
|
|
70
|
+
e.autoUpdate = !['', '0', 'false', 'no', 'off'].includes(String(process.env.NEXUSCREW_AUTO_UPDATE).toLowerCase());
|
|
71
|
+
}
|
|
66
72
|
return e;
|
|
67
73
|
}
|
|
68
74
|
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -26,7 +26,7 @@ const path = require('node:path');
|
|
|
26
26
|
const { execFile } = require('node:child_process');
|
|
27
27
|
const {
|
|
28
28
|
parseDefinitions, validateCommandTrust, resolveCwd,
|
|
29
|
-
loadDefinitions, atomicWrite, CAPS, validTmuxName,
|
|
29
|
+
loadDefinitions, atomicWrite, CAPS, MAX_CELLS, validTmuxName,
|
|
30
30
|
} = require('./definitions.js');
|
|
31
31
|
const {
|
|
32
32
|
publicCatalog, describeManaged, resolveManagedEngine, discoverOllamaModels, discoverPiModels,
|
|
@@ -56,7 +56,7 @@ function minimalEnv() {
|
|
|
56
56
|
return env;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
|
|
59
|
+
function httpError(status, msg, data = null) { const e = new Error(msg); e.status = status; if (data) e.data = data; return e; }
|
|
60
60
|
|
|
61
61
|
// Marcatore di redazione (design §9h): stderr/stdout dei comandi tmux falliti
|
|
62
62
|
// NON devono mai ecoare i segreti delle definizioni.
|
|
@@ -541,6 +541,59 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
541
541
|
const sessions = await refreshSessions();
|
|
542
542
|
return { ok: true, active: sessions.has(findCell(defs, id).tmuxSession) };
|
|
543
543
|
}
|
|
544
|
+
|
|
545
|
+
// Ripristino selettivo atomico da backup PWA. Il body contiene SOLO campi
|
|
546
|
+
// cella allowlisted (mai engine.env/provider secrets). Tutte le celle vengono
|
|
547
|
+
// validate insieme da atomicWrite: un errore lascia il file precedente intatto.
|
|
548
|
+
async function restoreCells(cells) {
|
|
549
|
+
if (readonly()) throw httpError(403, 'READONLY: restore-cells bloccato');
|
|
550
|
+
if (!Array.isArray(cells) || cells.length < 1 || cells.length > MAX_CELLS) {
|
|
551
|
+
throw httpError(400, `cells deve contenere 1..${MAX_CELLS} definizioni`);
|
|
552
|
+
}
|
|
553
|
+
const allowed = new Set(['id', 'cwd', 'engine', 'boot', 'model', 'models', 'permissionPolicies', 'prompt']);
|
|
554
|
+
const seen = new Set();
|
|
555
|
+
for (const cell of cells) {
|
|
556
|
+
if (!cell || typeof cell !== 'object' || Array.isArray(cell)) throw httpError(400, 'definizione cell non valida');
|
|
557
|
+
for (const key of Object.keys(cell)) if (!allowed.has(key)) throw httpError(400, `campo backup non ammesso: ${key}`);
|
|
558
|
+
if (typeof cell.id !== 'string' || seen.has(cell.id)) throw httpError(400, `id cell duplicato o mancante: ${cell.id || '?'}`);
|
|
559
|
+
seen.add(cell.id);
|
|
560
|
+
}
|
|
561
|
+
const defs = reloadDefs();
|
|
562
|
+
const availableEngines = new Set(defs.engines.map((engine) => engine.id));
|
|
563
|
+
const referencedEngines = new Set();
|
|
564
|
+
for (const cell of cells) {
|
|
565
|
+
referencedEngines.add(cell.engine);
|
|
566
|
+
for (const id of Object.keys(cell.models || {})) referencedEngines.add(id);
|
|
567
|
+
for (const id of Object.keys(cell.permissionPolicies || {})) referencedEngines.add(id);
|
|
568
|
+
}
|
|
569
|
+
const missingEngines = [...referencedEngines].filter((id) => !availableEngines.has(id)).sort();
|
|
570
|
+
if (missingEngines.length) {
|
|
571
|
+
throw httpError(400, `engine mancanti sul target: ${missingEngines.join(', ')}`, {
|
|
572
|
+
code: 'missing-engines', missingEngines,
|
|
573
|
+
hint: 'definisci prima gli engine mancanti oppure mappa le celle su engine disponibili',
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
const replaced = cells.filter((cell) => !!findCell(defs, cell.id)).map((cell) => cell.id);
|
|
577
|
+
const created = cells.filter((cell) => !findCell(defs, cell.id)).map((cell) => cell.id);
|
|
578
|
+
await mutate(defs, (draft) => {
|
|
579
|
+
for (const cell of cells) {
|
|
580
|
+
const index = draft.cells.findIndex((current) => current.id === cell.id);
|
|
581
|
+
const next = { ...cell };
|
|
582
|
+
if (index >= 0) {
|
|
583
|
+
// tmuxSession e' identita' runtime immutabile: il backup non la porta.
|
|
584
|
+
next.tmuxSession = draft.cells[index].tmuxSession;
|
|
585
|
+
draft.cells[index] = next;
|
|
586
|
+
} else draft.cells.push(next);
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
const sessions = await refreshSessions();
|
|
590
|
+
const saved = reloadDefs();
|
|
591
|
+
return {
|
|
592
|
+
ok: true, count: cells.length, created, replaced,
|
|
593
|
+
needsRestart: cells.map((cell) => findCell(saved, cell.id))
|
|
594
|
+
.filter((cell) => cell && sessions.has(cell.tmuxSession)).map((cell) => cell.id),
|
|
595
|
+
};
|
|
596
|
+
}
|
|
544
597
|
async function removeCell(id, opts = {}) {
|
|
545
598
|
if (readonly()) throw httpError(403, 'READONLY: remove-cell bloccato');
|
|
546
599
|
let defs = reloadDefs();
|
|
@@ -704,7 +757,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
704
757
|
}
|
|
705
758
|
|
|
706
759
|
function capabilities() {
|
|
707
|
-
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'schema', 'definitions'];
|
|
760
|
+
return ['status', 'up', 'down', 'restart', 'engine', 'boot', 'define', 'edit', 'remove', 'import', 'restore', 'schema', 'definitions'];
|
|
708
761
|
}
|
|
709
762
|
|
|
710
763
|
return {
|
|
@@ -712,7 +765,7 @@ async function createBuiltinFleet(cfg = {}) {
|
|
|
712
765
|
provider: 'builtin',
|
|
713
766
|
status, up, down, restart, engine: setEngine, boot: setBoot, isCellSession,
|
|
714
767
|
defineEngine, editEngine, removeEngine,
|
|
715
|
-
defineCell, editCell, removeCell, importCell,
|
|
768
|
+
defineCell, editCell, removeCell, importCell, restoreCells,
|
|
716
769
|
schema, definitions, capabilities,
|
|
717
770
|
};
|
|
718
771
|
}
|
package/lib/fleet/routes.js
CHANGED
|
@@ -25,7 +25,9 @@ function requireCap(fleet, cap) {
|
|
|
25
25
|
// async): ogni handler attende la resolve; unavailable → 404 sui comandi.
|
|
26
26
|
function fleetRoutes(fleetP, cfg = {}) {
|
|
27
27
|
const r = express.Router();
|
|
28
|
-
|
|
28
|
+
const smallJson = express.json({ limit: '4kb' });
|
|
29
|
+
const restoreJson = express.json({ limit: '256kb' });
|
|
30
|
+
r.use((req, res, next) => (req.path === '/restore-cells' ? restoreJson : smallJson)(req, res, next));
|
|
29
31
|
|
|
30
32
|
const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
31
33
|
|
|
@@ -38,7 +40,7 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
38
40
|
if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
|
|
39
41
|
res.json(await fn(fleet, req.body || {}));
|
|
40
42
|
} catch (e) {
|
|
41
|
-
res.status(e.status || 500).json({ error: String(e.message || e) });
|
|
43
|
+
res.status(e.status || 500).json({ error: String(e.message || e), ...(e.data || {}) });
|
|
42
44
|
}
|
|
43
45
|
};
|
|
44
46
|
|
|
@@ -110,10 +112,19 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
110
112
|
r.post('/define-cell', guard((f, b) => { requireCap(f, 'define'); return f.defineCell(b.def); }, { mutate: true }));
|
|
111
113
|
r.post('/edit-cell', guard((f, b) => { requireCap(f, 'edit'); return f.editCell(b.id, b.patch); }, { mutate: true }));
|
|
112
114
|
r.post('/remove-cell', guard((f, b) => { requireCap(f, 'remove'); return f.removeCell(b.id, { stop: b.stop === true }); }, { mutate: true }));
|
|
115
|
+
r.post('/restore-cells', guard((f, b) => { requireCap(f, 'restore'); return f.restoreCells(b.cells); }, { mutate: true }));
|
|
113
116
|
// Riconciliazione sessione tmux esistente (cella Fleet legacy orfana) in cella
|
|
114
117
|
// gestita fleet.json. Capability 'define' (solo builtin): external legacy -> 501.
|
|
115
118
|
r.post('/import-cell', guard(async (f, b) => { requireCap(f, 'import'); return f.importCell(b || {}); }, { mutate: true }));
|
|
116
119
|
|
|
120
|
+
r.use((err, _req, res, _next) => {
|
|
121
|
+
if (err && (err.type === 'entity.too.large' || err.status === 413)) {
|
|
122
|
+
return res.status(413).json({ error: 'body troppo grande', code: 'body-too-large' });
|
|
123
|
+
}
|
|
124
|
+
if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido', code: 'invalid-json' });
|
|
125
|
+
return res.status(err.status || 400).json({ error: String(err.message || err) });
|
|
126
|
+
});
|
|
127
|
+
|
|
117
128
|
return r;
|
|
118
129
|
}
|
|
119
130
|
|
package/lib/nodes/health.js
CHANGED
|
@@ -30,6 +30,7 @@ function clearHealthCache() { cache.clear(); }
|
|
|
30
30
|
// onesto). outbound down/401 -> 'down'/'degraded'.
|
|
31
31
|
function tunnelFromHealth(h) {
|
|
32
32
|
if (!h) return { status: 'unknown', managed: false };
|
|
33
|
+
if (h.status === 'passive') return { status: 'passive', managed: false };
|
|
33
34
|
if (h.transport === 'up' && h.auth === 'ok') return { status: 'up', managed: h.managed !== false };
|
|
34
35
|
if (h.transport === 'up' && h.auth === 'failed') return { status: 'degraded', managed: h.managed !== false };
|
|
35
36
|
if (h.transport === 'up') return { status: 'degraded', managed: h.managed !== false };
|
|
@@ -39,7 +40,7 @@ function tunnelFromHealth(h) {
|
|
|
39
40
|
|
|
40
41
|
async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false }) {
|
|
41
42
|
if (!node || typeof node !== 'object') return null;
|
|
42
|
-
const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}`;
|
|
43
|
+
const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}\0${node.rolesKnown === true}\0${node.roles?.node === true}`;
|
|
43
44
|
const cached = !force && cache.get(cacheKey);
|
|
44
45
|
if (cached && (now - cached.at) < TTL_MS) return cached.health;
|
|
45
46
|
|
|
@@ -54,7 +55,17 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
|
|
|
54
55
|
const probed = await probeHealth({
|
|
55
56
|
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
|
|
56
57
|
});
|
|
57
|
-
|
|
58
|
+
// The receiving side does not own an inbound client's lifecycle. A
|
|
59
|
+
// client-only (or legacy unknown-role) peer being offline is expected,
|
|
60
|
+
// not a broken server. Live auth/payload failures remain real failures.
|
|
61
|
+
if (probed.transport === 'down' && (node.rolesKnown !== true || node.roles?.node !== true)) {
|
|
62
|
+
health = {
|
|
63
|
+
...probed, status: 'passive', expected: true, managed: false,
|
|
64
|
+
detail: node.rolesKnown === true ? 'client peer offline (expected)' : 'inbound peer offline',
|
|
65
|
+
};
|
|
66
|
+
} else {
|
|
67
|
+
health = { ...probed, managed: false };
|
|
68
|
+
}
|
|
58
69
|
}
|
|
59
70
|
} else {
|
|
60
71
|
const ts = nodesTunnel.readTunnelState(home, node.name);
|
package/lib/nodes/peering.js
CHANGED
|
@@ -89,7 +89,7 @@ function parsePairingUrl(value) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function createInvite({
|
|
92
|
-
invitesPath, instanceId, port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes,
|
|
92
|
+
invitesPath, instanceId, port, linkPort = port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes,
|
|
93
93
|
ssh, sshPort, name,
|
|
94
94
|
} = {}) {
|
|
95
95
|
const invite = randomBytes(32).toString('base64url');
|
|
@@ -116,7 +116,8 @@ function createInvite({
|
|
|
116
116
|
if (sshVal && store.isPort(sshPort)) payload.sshPort = sshPort;
|
|
117
117
|
}
|
|
118
118
|
const pair = encodePairing(payload);
|
|
119
|
-
|
|
119
|
+
if (!store.isPort(linkPort)) throw new Error('porta locale del link non valida');
|
|
120
|
+
return { pairingUrl: `http://127.0.0.1:${linkPort}/#pair=${pair}`, expiresAt, version: payload.v };
|
|
120
121
|
}
|
|
121
122
|
|
|
122
123
|
function consumeInvite({ invitesPath, invite, now = Date.now() }) {
|
|
@@ -161,8 +162,34 @@ function consumePending({ pendingPath, credential, now = Date.now() }) {
|
|
|
161
162
|
return found;
|
|
162
163
|
}
|
|
163
164
|
|
|
165
|
+
// Probe di trasporto del tunnel -L provvisorio PRIMA di consumare l'invite
|
|
166
|
+
// one-time: qualunque risposta HTTP dal peer attraverso la forward (anche un
|
|
167
|
+
// 401 su /federation/health senza credenziali) dimostra che ssh+forward sono
|
|
168
|
+
// vivi; un errore di rete no. Sostituisce lo sleep fisso 900ms: bounded, con
|
|
169
|
+
// sleep iniettabile (deterministico nei test) e timeout per tentativo.
|
|
170
|
+
async function probeTransportReady({ port, fetchImpl = fetch, attempts = 6, timeoutMs = 1500, sleep } = {}) {
|
|
171
|
+
const wait = typeof sleep === 'function' ? sleep : (ms) => new Promise((r) => setTimeout(r, ms));
|
|
172
|
+
let lastError = '';
|
|
173
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
174
|
+
let timer;
|
|
175
|
+
try {
|
|
176
|
+
const ctrl = new AbortController();
|
|
177
|
+
timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
178
|
+
await fetchImpl(`http://127.0.0.1:${port}/federation/health`, { signal: ctrl.signal });
|
|
179
|
+
return { ready: true, attempts: i + 1 };
|
|
180
|
+
} catch (e) {
|
|
181
|
+
lastError = String((e && e.message) || e);
|
|
182
|
+
} finally {
|
|
183
|
+
if (timer) clearTimeout(timer);
|
|
184
|
+
}
|
|
185
|
+
if (i < attempts - 1) await wait(250 * (i + 1));
|
|
186
|
+
}
|
|
187
|
+
return { ready: false, attempts, lastError };
|
|
188
|
+
}
|
|
189
|
+
|
|
164
190
|
module.exports = {
|
|
165
191
|
INVITE_TTL_MS, REVERSE_PORT_BASE, defaultInvitesPath, defaultPendingPath, safeEqual,
|
|
166
192
|
readInvites, writeInvites, encodePairing, decodePairing, parsePairingUrl,
|
|
167
193
|
createInvite, consumeInvite, allocateReversePort, createPending, consumePending,
|
|
194
|
+
probeTransportReady,
|
|
168
195
|
};
|
package/lib/nodes/store.js
CHANGED
|
@@ -103,7 +103,7 @@ const LABEL_MAX = 64;
|
|
|
103
103
|
// tollerato oltre a quelli noti (schema chiuso: garbage -> null, non guess).
|
|
104
104
|
const NODE_KEYS = new Set([
|
|
105
105
|
'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
|
|
106
|
-
'roles', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
|
|
106
|
+
'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
|
|
107
107
|
'direction', 'reversePort', 'label',
|
|
108
108
|
]);
|
|
109
109
|
function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
@@ -123,6 +123,10 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
123
123
|
if (schemaVersion === LEGACY_SCHEMA_VERSION && !identityFile) return null;
|
|
124
124
|
const roles = parseRoles(n.roles);
|
|
125
125
|
if (!roles) return null;
|
|
126
|
+
if (n.rolesKnown !== undefined && typeof n.rolesKnown !== 'boolean') return null;
|
|
127
|
+
// Before 0.8.9 `roles` was filled with a local default and did not describe
|
|
128
|
+
// the remote peer. Only the explicit marker makes role-based health safe.
|
|
129
|
+
const rolesKnown = n.rolesKnown === true;
|
|
126
130
|
// label (display) opzionale ma, se presente, strict: stringa 1..LABEL_MAX, no
|
|
127
131
|
// control char (newline/tab inclusi: una label su piu' righe non ha senso in
|
|
128
132
|
// UI), no solo-spazi. Garbage -> null (schema chiuso), mai guess/truncate.
|
|
@@ -140,6 +144,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
140
144
|
remotePort: n.remotePort,
|
|
141
145
|
localPort: n.localPort,
|
|
142
146
|
roles,
|
|
147
|
+
rolesKnown,
|
|
143
148
|
direction,
|
|
144
149
|
transport: n.transport || (direction === 'inbound' ? 'inbound' : (schemaVersion === LEGACY_SCHEMA_VERSION ? 'ssh' : 'auto')),
|
|
145
150
|
autostart: n.autostart === undefined ? schemaVersion !== LEGACY_SCHEMA_VERSION : n.autostart,
|
|
@@ -379,6 +384,8 @@ function redactNode(n) {
|
|
|
379
384
|
remotePort: n.remotePort,
|
|
380
385
|
localPort: n.localPort,
|
|
381
386
|
direction: n.direction || 'outbound',
|
|
387
|
+
roles: { ...n.roles },
|
|
388
|
+
rolesKnown: n.rolesKnown === true,
|
|
382
389
|
transport: n.transport || 'ssh',
|
|
383
390
|
autostart: !!n.autostart,
|
|
384
391
|
visibility: n.visibility || 'network',
|
|
@@ -486,7 +493,7 @@ function suggestNodeName(input, existing = []) {
|
|
|
486
493
|
|
|
487
494
|
module.exports = {
|
|
488
495
|
// parse/validate
|
|
489
|
-
parseStore, parseNode, parseRendezvous, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
|
|
496
|
+
parseStore, parseNode, parseRendezvous, parseRoles, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
|
|
490
497
|
// I/O
|
|
491
498
|
defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
|
|
492
499
|
// mutazioni
|
package/lib/proxy/federation.js
CHANGED
|
@@ -55,7 +55,7 @@ function knownResource(resource) {
|
|
|
55
55
|
|| resource === '/files/download'
|
|
56
56
|
|| resource === '/files/upload'
|
|
57
57
|
|| resource === '/ws'
|
|
58
|
-
|| /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell)$/.test(resource);
|
|
58
|
+
|| /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource);
|
|
59
59
|
}
|
|
60
60
|
|
|
61
61
|
function allowedResource(resource, method = 'GET') {
|
|
@@ -68,7 +68,7 @@ function allowedResource(resource, method = 'GET') {
|
|
|
68
68
|
if (resource === '/files/upload') return method === 'POST';
|
|
69
69
|
if (resource === '/ws') return method === 'GET';
|
|
70
70
|
if (/^\/fleet\/(status|schema|definitions)$/.test(resource)) return method === 'GET';
|
|
71
|
-
if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell)$/.test(resource)) return method === 'POST';
|
|
71
|
+
if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource)) return method === 'POST';
|
|
72
72
|
return false;
|
|
73
73
|
}
|
|
74
74
|
|
|
@@ -155,6 +155,14 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
|
|
|
155
155
|
} else if (expectedInstanceId && body.instanceId !== expectedInstanceId) {
|
|
156
156
|
out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'peer instanceId inatteso — tunnel/porta punta al nodo sbagliato';
|
|
157
157
|
} else {
|
|
158
|
+
if (body.roles !== undefined) {
|
|
159
|
+
const roles = store.parseRoles(body.roles);
|
|
160
|
+
if (!roles) {
|
|
161
|
+
out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'health roles non validi';
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
out.roles = roles; out.rolesKnown = true;
|
|
165
|
+
}
|
|
158
166
|
out.reachability = 'ok'; out.status = 'healthy'; out.detail = 'ok';
|
|
159
167
|
}
|
|
160
168
|
} else if (r.status === 401) {
|
|
@@ -256,7 +264,7 @@ async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, n
|
|
|
256
264
|
return { instanceId: live.instanceId, nodes };
|
|
257
265
|
}
|
|
258
266
|
|
|
259
|
-
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly, version = null }) {
|
|
267
|
+
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly, version = null, roles = null }) {
|
|
260
268
|
const r = express.Router();
|
|
261
269
|
r.use((req, res, next) => {
|
|
262
270
|
const peer = peerFromToken(nodesPath, bearerFrom(req));
|
|
@@ -269,7 +277,9 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
269
277
|
// reachability (payload). Nessun segreto in risposta.
|
|
270
278
|
r.get('/health', (_req, res) => {
|
|
271
279
|
const st = store.loadStore(nodesPath);
|
|
272
|
-
|
|
280
|
+
const advertisedRoles = typeof roles === 'function' ? roles() : null;
|
|
281
|
+
res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly(),
|
|
282
|
+
...(advertisedRoles ? { roles: advertisedRoles } : {}) });
|
|
273
283
|
});
|
|
274
284
|
r.get('/topology', async (req, res) => {
|
|
275
285
|
const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
|
package/lib/server.js
CHANGED
|
@@ -36,6 +36,7 @@ const { createPushService } = require('./notify/push.js');
|
|
|
36
36
|
const { createAsksStore } = require('./notify/asks.js');
|
|
37
37
|
const { createNotifier } = require('./notify/notifier.js');
|
|
38
38
|
const { notifyRoutes } = require('./notify/routes.js');
|
|
39
|
+
const { createNpmUpdater } = require('./update/manager.js');
|
|
39
40
|
|
|
40
41
|
function sessionExists(tmuxBin, name) {
|
|
41
42
|
if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
|
|
@@ -97,6 +98,13 @@ function createServer(opts = {}) {
|
|
|
97
98
|
});
|
|
98
99
|
const asksStore = createAsksStore({ dir: notifyDir });
|
|
99
100
|
const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
|
|
101
|
+
const updater = opts.updateManager || createNpmUpdater({
|
|
102
|
+
currentVersion: VERSION,
|
|
103
|
+
home: cfg.home || os.homedir(),
|
|
104
|
+
enabled: cfg.autoUpdate !== false,
|
|
105
|
+
readonly: bridgeReadonly(),
|
|
106
|
+
...(cfg.updateSeams || {}),
|
|
107
|
+
});
|
|
100
108
|
const attachedWs = new Map(); // ws -> session (per il push dei frame files)
|
|
101
109
|
// selectProvider sceglie UNA volta (startup) il provider external|builtin|disabled
|
|
102
110
|
// (design §4b/§9b/§9g) e ritorna {mode,reason,fleet}; routes consumano il .fleet,
|
|
@@ -251,7 +259,7 @@ function createServer(opts = {}) {
|
|
|
251
259
|
// Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
|
|
252
260
|
// wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
|
|
253
261
|
// READONLY route-level e la redazione token vivono dentro settingsRoutes.
|
|
254
|
-
api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
|
|
262
|
+
api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions, updater }));
|
|
255
263
|
api.get('/topology', async (_req, res) => {
|
|
256
264
|
try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
|
|
257
265
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
@@ -283,6 +291,7 @@ function createServer(opts = {}) {
|
|
|
283
291
|
app.use('/api', api);
|
|
284
292
|
app.use('/federation', federation.peerRouter({
|
|
285
293
|
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
|
|
294
|
+
roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
|
|
286
295
|
}));
|
|
287
296
|
|
|
288
297
|
// Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
|
|
@@ -314,7 +323,8 @@ function createServer(opts = {}) {
|
|
|
314
323
|
// Close the watcher when the HTTP server closes. Registered HERE (inside
|
|
315
324
|
// createServer) — not in start() — so every createServer consumer is covered,
|
|
316
325
|
// not only the start() path. watcher.close() is idempotent.
|
|
317
|
-
server.on('
|
|
326
|
+
server.on('listening', () => updater.start());
|
|
327
|
+
server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); });
|
|
318
328
|
// noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
|
|
319
329
|
// (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
|
|
320
330
|
// dell'HTTP (auth locale -> name strict -> inject token) prima del piping.
|
|
@@ -394,7 +404,7 @@ function createServer(opts = {}) {
|
|
|
394
404
|
}
|
|
395
405
|
});
|
|
396
406
|
|
|
397
|
-
return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP };
|
|
407
|
+
return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater };
|
|
398
408
|
}
|
|
399
409
|
|
|
400
410
|
function start(opts = {}) {
|