@mmmbuto/nexuscrew 0.8.7 → 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 +49 -13
- 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 +3 -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 +57 -9
- 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-D2TrRtWQ.js +0 -90
- package/frontend/dist/assets/index-DrEuy6A6.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() }) {
|
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 = {}) {
|
package/lib/settings/routes.js
CHANGED
|
@@ -53,9 +53,10 @@ const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js')
|
|
|
53
53
|
const { isServiceRunning, readRoles, bootState } = require('../cli/commands.js');
|
|
54
54
|
const { configJsonPath } = require('../config.js');
|
|
55
55
|
const VERSION = require('../../package.json').version;
|
|
56
|
+
const { scrubError } = require('../update/core.js');
|
|
56
57
|
|
|
57
58
|
// Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
|
|
58
|
-
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone']);
|
|
59
|
+
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
|
|
59
60
|
const ROLE_KEYS = new Set(['client', 'node']);
|
|
60
61
|
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
|
|
61
62
|
const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
|
|
@@ -141,6 +142,7 @@ function settingsRoutes(deps = {}) {
|
|
|
141
142
|
// scrive solo il file (come prima), senza reload in-memory.
|
|
142
143
|
const tokenStore = deps.tokenStore || null;
|
|
143
144
|
const closeSessions = deps.closeSessions || null;
|
|
145
|
+
const updater = deps.updater || null;
|
|
144
146
|
const home = cfg.home || os.homedir();
|
|
145
147
|
const configDir = cfg.configDir || path.join(home, '.nexuscrew');
|
|
146
148
|
// configJsonPath() rispetta NEXUSCREW_CONFIG_FILE (stessa risoluzione di
|
|
@@ -191,6 +193,7 @@ function settingsRoutes(deps = {}) {
|
|
|
191
193
|
active: isServiceRunning({ platform, execImpl: seams.execImpl, uid: seams.uid, home }),
|
|
192
194
|
boot: bootState({ platform, execImpl: seams.execImpl, uid: seams.uid, home }).enabled,
|
|
193
195
|
};
|
|
196
|
+
const updateStatus = updater && typeof updater.status === 'function' ? updater.status() : null;
|
|
194
197
|
const out = {
|
|
195
198
|
roles: readRoles(configPath),
|
|
196
199
|
firstRun,
|
|
@@ -198,10 +201,12 @@ function settingsRoutes(deps = {}) {
|
|
|
198
201
|
platform,
|
|
199
202
|
service,
|
|
200
203
|
version: VERSION,
|
|
204
|
+
autoUpdate: updateStatus ? updateStatus.enabled : fileCfg.autoUpdate !== false,
|
|
201
205
|
// Nome dispositivo proposto per i form di pairing (etichetta umana, non
|
|
202
206
|
// lo slug). La UI lo precompila e lascia editing libero.
|
|
203
207
|
deviceName: defaultDeviceName(),
|
|
204
208
|
};
|
|
209
|
+
if (updateStatus) out.update = updateStatus;
|
|
205
210
|
// rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
|
|
206
211
|
// la si prende comunque SOLO dalla vista redatta.
|
|
207
212
|
const st = nodesStore.loadStore(nodesPath);
|
|
@@ -221,10 +226,10 @@ function settingsRoutes(deps = {}) {
|
|
|
221
226
|
return send(res, 400, { error: 'body deve essere un oggetto JSON' });
|
|
222
227
|
}
|
|
223
228
|
for (const k of Object.keys(b)) {
|
|
224
|
-
if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone)` });
|
|
229
|
+
if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate)` });
|
|
225
230
|
}
|
|
226
231
|
if (Object.keys(b).length === 0) {
|
|
227
|
-
return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone)' });
|
|
232
|
+
return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate)' });
|
|
228
233
|
}
|
|
229
234
|
if (b.roles !== undefined) {
|
|
230
235
|
if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
|
|
@@ -241,6 +246,9 @@ function settingsRoutes(deps = {}) {
|
|
|
241
246
|
if (b.wizardDone !== undefined && typeof b.wizardDone !== 'boolean') {
|
|
242
247
|
return send(res, 400, { error: 'wizardDone deve essere boolean' });
|
|
243
248
|
}
|
|
249
|
+
if (b.autoUpdate !== undefined && typeof b.autoUpdate !== 'boolean') {
|
|
250
|
+
return send(res, 400, { error: 'autoUpdate deve essere boolean' });
|
|
251
|
+
}
|
|
244
252
|
|
|
245
253
|
// merge sul config esistente (preserva le chiavi non gestite qui)
|
|
246
254
|
const { cfg: current } = readConfigFile(configPath);
|
|
@@ -251,11 +259,15 @@ function settingsRoutes(deps = {}) {
|
|
|
251
259
|
}
|
|
252
260
|
if (b.port !== undefined) next.port = b.port;
|
|
253
261
|
if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
|
|
262
|
+
if (b.autoUpdate !== undefined) next.autoUpdate = b.autoUpdate;
|
|
254
263
|
atomicWriteConfig(configPath, next);
|
|
264
|
+
if (b.autoUpdate !== undefined && updater && typeof updater.setEnabled === 'function') {
|
|
265
|
+
updater.setEnabled(b.autoUpdate);
|
|
266
|
+
}
|
|
255
267
|
|
|
256
268
|
const out = {
|
|
257
269
|
saved: true,
|
|
258
|
-
config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone },
|
|
270
|
+
config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone, autoUpdate: next.autoUpdate !== false },
|
|
259
271
|
};
|
|
260
272
|
// Il server legge la porta SOLO allo startup: il cambio vale al prossimo
|
|
261
273
|
// restart (contratto dichiarato nella risposta, la UI avvisa).
|
|
@@ -266,6 +278,20 @@ function settingsRoutes(deps = {}) {
|
|
|
266
278
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
267
279
|
});
|
|
268
280
|
|
|
281
|
+
// Auto-update npm: check e apply sono autenticati, READONLY-gated e non
|
|
282
|
+
// accettano mai package/versione dal browser. Il manager usa esclusivamente
|
|
283
|
+
// @mmmbuto/nexuscrew@latest, confronta semver e installa la versione esatta.
|
|
284
|
+
r.post('/update/check', mutGate, async (_req, res) => {
|
|
285
|
+
if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
|
|
286
|
+
try { send(res, 200, await updater.check()); }
|
|
287
|
+
catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
|
|
288
|
+
});
|
|
289
|
+
r.post('/update/apply', mutGate, async (_req, res) => {
|
|
290
|
+
if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
|
|
291
|
+
try { send(res, 202, await updater.apply()); }
|
|
292
|
+
catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
|
|
293
|
+
});
|
|
294
|
+
|
|
269
295
|
// --- POST /token/rotate — atomico + reload live + chiusura WS; token MAI in risposta
|
|
270
296
|
// Contratto §4b(3): "scrittura atomica del token file + chiusura delle sessioni
|
|
271
297
|
// WS/API attive locali + reload credenziali proxy". Audit F7: prima questa route
|
|
@@ -297,25 +323,38 @@ function settingsRoutes(deps = {}) {
|
|
|
297
323
|
try {
|
|
298
324
|
const b = req.body || {};
|
|
299
325
|
const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
|
|
326
|
+
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
327
|
+
const rendezvous = st.rendezvous || null;
|
|
300
328
|
const extra = {};
|
|
301
329
|
if (typeof b.ssh === 'string' && b.ssh.trim()) {
|
|
302
330
|
const ssh = nodesStore.parseSshTarget(b.ssh.trim());
|
|
303
331
|
if (!ssh) return send(res, 400, { error: 'ssh non valido (atteso user@host o alias)' });
|
|
304
332
|
extra.ssh = ssh.value;
|
|
333
|
+
} else if (rendezvous && rendezvous.ssh) {
|
|
334
|
+
// Zero-field creator path: the rendezvous SSH target is reachable by
|
|
335
|
+
// definition. Its publishedPort is the REMOTE NEXUSCREW HTTP port,
|
|
336
|
+
// never the OpenSSH transport port.
|
|
337
|
+
extra.ssh = rendezvous.ssh;
|
|
305
338
|
}
|
|
306
339
|
if (b.sshPort !== undefined && b.sshPort !== null && b.sshPort !== '') {
|
|
307
340
|
if (!nodesStore.isPort(Number(b.sshPort))) return send(res, 400, { error: 'sshPort non valida (1..65535)' });
|
|
308
341
|
extra.sshPort = Number(b.sshPort);
|
|
309
342
|
}
|
|
310
343
|
if (extra.sshPort && !extra.ssh) return send(res, 400, { error: 'sshPort richiede un target SSH' });
|
|
344
|
+
let remotePort = cfg.port;
|
|
345
|
+
if (b.remotePort !== undefined && b.remotePort !== null && b.remotePort !== '') {
|
|
346
|
+
if (!nodesStore.isPort(Number(b.remotePort))) return send(res, 400, { error: 'remotePort non valida (1..65535)' });
|
|
347
|
+
remotePort = Number(b.remotePort);
|
|
348
|
+
} else if (rendezvous && extra.ssh === rendezvous.ssh) {
|
|
349
|
+
remotePort = rendezvous.publishedPort;
|
|
350
|
+
}
|
|
311
351
|
if (typeof b.name === 'string' && b.name.trim()) {
|
|
312
352
|
if (!nodesStore.NODE_NAME_RE.test(b.name.trim())) return send(res, 400, { error: 'name non valido (a-z 0-9 -, max 32)' });
|
|
313
353
|
extra.name = b.name.trim();
|
|
314
354
|
}
|
|
315
355
|
if (extra.ssh && !extra.name) extra.name = nodesStore.toSlug(label);
|
|
316
|
-
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
317
356
|
send(res, 200, peering.createInvite({
|
|
318
|
-
invitesPath, instanceId: st.nodeId, port: cfg.port, label, ...extra,
|
|
357
|
+
invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: cfg.port, label, ...extra,
|
|
319
358
|
}));
|
|
320
359
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
321
360
|
});
|
|
@@ -467,6 +506,7 @@ function settingsRoutes(deps = {}) {
|
|
|
467
506
|
label: localLabel,
|
|
468
507
|
port: cfg.port,
|
|
469
508
|
acceptToken,
|
|
509
|
+
roles: readRoles(configPath),
|
|
470
510
|
}),
|
|
471
511
|
});
|
|
472
512
|
} catch (e) {
|
|
@@ -488,8 +528,9 @@ function settingsRoutes(deps = {}) {
|
|
|
488
528
|
hint: 'rigenera un nuovo link e riprova',
|
|
489
529
|
});
|
|
490
530
|
}
|
|
531
|
+
const joinedRoles = joined.roles === undefined ? null : nodesStore.parseRoles(joined.roles);
|
|
491
532
|
if (!nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
|
|
492
|
-
|| !nodesStore.NODE_ID_RE.test(joined.instanceId)) {
|
|
533
|
+
|| !nodesStore.NODE_ID_RE.test(joined.instanceId) || (joined.roles !== undefined && !joinedRoles)) {
|
|
493
534
|
return failRolledBack(502, 'join', 'join-invalid-response', 'risposta di join non valida dal peer', {
|
|
494
535
|
hint: 'versioni NexusCrew incompatibili? aggiorna entrambi i nodi',
|
|
495
536
|
});
|
|
@@ -506,6 +547,7 @@ function settingsRoutes(deps = {}) {
|
|
|
506
547
|
st = nodesStore.loadOrInitStore(nodesPath);
|
|
507
548
|
st = nodesStore.updateNode(st, b.name, {
|
|
508
549
|
token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
|
|
550
|
+
...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
|
|
509
551
|
});
|
|
510
552
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
511
553
|
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
@@ -818,6 +860,7 @@ function settingsRoutes(deps = {}) {
|
|
|
818
860
|
function publicPeeringRoutes(deps = {}) {
|
|
819
861
|
const cfg = deps.cfg || {};
|
|
820
862
|
const home = cfg.home || os.homedir();
|
|
863
|
+
const configPath = cfg.configPath || configJsonPath();
|
|
821
864
|
const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
|
|
822
865
|
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
823
866
|
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
@@ -831,8 +874,10 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
831
874
|
recent.push(now); attempts.set(key, recent);
|
|
832
875
|
if (recent.length > 10) return res.status(429).json({ error: 'troppi tentativi di pairing' });
|
|
833
876
|
const b = req.body || {};
|
|
877
|
+
const peerRoles = b.roles === undefined ? null : nodesStore.parseRoles(b.roles);
|
|
834
878
|
if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
|
|
835
|
-
|| !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
|
|
879
|
+
|| !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
|
|
880
|
+
|| (b.roles !== undefined && !peerRoles)) {
|
|
836
881
|
return res.status(400).json({ error: 'pairing request non valida' });
|
|
837
882
|
}
|
|
838
883
|
if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
|
|
@@ -848,8 +893,9 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
848
893
|
const credential = peering.createPending({ pendingPath, data: {
|
|
849
894
|
name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
|
|
850
895
|
label: nodesStore.sanitizeLabel(b.label, name),
|
|
896
|
+
...(peerRoles ? { roles: peerRoles, rolesKnown: true } : { rolesKnown: false }),
|
|
851
897
|
} });
|
|
852
|
-
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential });
|
|
898
|
+
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
|
|
853
899
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
854
900
|
});
|
|
855
901
|
r.post('/confirm', (req, res) => {
|
|
@@ -869,6 +915,8 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
869
915
|
direction: 'inbound', transport: 'inbound', autostart: true,
|
|
870
916
|
visibility: 'network', nodeId: pending.instanceId,
|
|
871
917
|
token: pending.acceptToken, acceptToken: b.credential,
|
|
918
|
+
...(pending.roles ? { roles: pending.roles } : {}),
|
|
919
|
+
rolesKnown: pending.rolesKnown === true,
|
|
872
920
|
...(pending.label ? { label: pending.label } : {}),
|
|
873
921
|
});
|
|
874
922
|
nodesStore.atomicWriteStore(nodesPath, st);
|