@mmmbuto/nexuscrew 0.8.21 → 0.8.23
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 +27 -22
- package/frontend/dist/assets/{index-DQbVJLji.css → index-77r8nzQf.css} +1 -1
- package/frontend/dist/assets/index-C0QL3gEp.js +91 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +2 -2
- package/lib/cli/commands.js +257 -10
- package/lib/cli/doctor.js +65 -11
- package/lib/cli/fleet-service.js +10 -41
- package/lib/cli/init.js +30 -8
- package/lib/config.js +7 -2
- package/lib/fleet/builtin.js +17 -6
- package/lib/fleet/cell-exec.js +165 -11
- package/lib/fleet/definitions.js +1 -1
- package/lib/fleet/provider.js +10 -90
- package/lib/fleet/routes.js +8 -14
- package/lib/fleet/runtime.js +44 -22
- package/lib/nodes/commands.js +160 -19
- package/lib/nodes/inventory.js +81 -0
- package/lib/server.js +40 -6
- package/lib/settings/routes.js +43 -23
- package/lib/tmux/lifecycle.js +4 -2
- package/lib/tmux/shared-server.js +60 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-DaFQbMHq.js +0 -93
- package/lib/fleet/exec.js +0 -32
- package/lib/fleet/index.js +0 -180
package/lib/nodes/commands.js
CHANGED
|
@@ -16,6 +16,8 @@ const path = require('node:path');
|
|
|
16
16
|
const { execFileSync } = require('node:child_process');
|
|
17
17
|
const store = require('./store.js');
|
|
18
18
|
const tunnel = require('./tunnel.js');
|
|
19
|
+
const topologyCache = require('./topology-cache.js');
|
|
20
|
+
const inventory = require('./inventory.js');
|
|
19
21
|
const federation = require('../proxy/federation.js');
|
|
20
22
|
const { resolvePaths, DEFAULT_PORT } = require('../cli/url.js');
|
|
21
23
|
|
|
@@ -31,6 +33,32 @@ function resolveNodePaths(opts) {
|
|
|
31
33
|
return { home, configDir, configPath, nodesPath };
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
function resolveStoredNode(st, ref) {
|
|
37
|
+
const value = String(ref || '').trim();
|
|
38
|
+
if (!value) return null;
|
|
39
|
+
return st.nodes.find((node) => node.name === value || node.nodeId === value) || null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function loadPeerInventory(opts = {}) {
|
|
43
|
+
const { home, nodesPath } = resolveNodePaths(opts);
|
|
44
|
+
const st = store.loadStore(nodesPath);
|
|
45
|
+
if (!st) return { nodeId: null, nodes: [], peers: [] };
|
|
46
|
+
const direct = store.redactStore(st).nodes;
|
|
47
|
+
const extras = new Map(direct.map((node) => [node.name, {
|
|
48
|
+
tunnel: node.direction === 'inbound'
|
|
49
|
+
? { status: node.shared === true ? 'shared-peer' : 'private-peer', managed: false }
|
|
50
|
+
: tunnel.readTunnelState(home, node.name),
|
|
51
|
+
}]));
|
|
52
|
+
const cachePath = opts.topologyCachePath || topologyCache.defaultPath(home);
|
|
53
|
+
const cached = topologyCache.loadCache(cachePath);
|
|
54
|
+
const peers = inventory.buildInventory({
|
|
55
|
+
direct,
|
|
56
|
+
topology: cached && Array.isArray(cached.nodes) ? cached.nodes : [],
|
|
57
|
+
extras,
|
|
58
|
+
});
|
|
59
|
+
return { nodeId: st.nodeId, nodes: peers.filter((peer) => peer.kind === 'direct'), peers };
|
|
60
|
+
}
|
|
61
|
+
|
|
34
62
|
// Prima localPort libera >= base, evitando le porte gia' assegnate (porta STABILE:
|
|
35
63
|
// una volta scelta resta in nodes.json, non si ricicla).
|
|
36
64
|
function assignLocalPort(st) {
|
|
@@ -187,16 +215,122 @@ function nodesList(opts) {
|
|
|
187
215
|
return { code: 0, nodes: [] };
|
|
188
216
|
}
|
|
189
217
|
|
|
190
|
-
const
|
|
191
|
-
const
|
|
192
|
-
const
|
|
218
|
+
const out = loadPeerInventory(opts); // MAI il token
|
|
219
|
+
const { nodes, peers } = out;
|
|
220
|
+
const visiblePeers = opts.direct === true ? nodes : peers;
|
|
193
221
|
|
|
194
|
-
if (opts.json) {
|
|
195
|
-
|
|
222
|
+
if (opts.json) {
|
|
223
|
+
log(JSON.stringify({ ...out, peers: visiblePeers }, null, 2));
|
|
224
|
+
return { code: 0, nodes, peers: visiblePeers };
|
|
225
|
+
}
|
|
226
|
+
if (visiblePeers.length === 0) { log('nodes: (nessun nodo)'); return { code: 0, nodes, peers: visiblePeers }; }
|
|
196
227
|
for (const n of nodes) {
|
|
197
|
-
|
|
228
|
+
const endpoint = n.direction === 'inbound' ? 'client collegato al hub'
|
|
229
|
+
: `${n.ssh}${n.sshPort ? `:${n.sshPort}` : ''} · local:${n.localPort} -> nexus:${n.remotePort}`;
|
|
230
|
+
log(`${n.name}\t${n.nodeId || '-'}\t${n.relation}\t${endpoint}\tstatus:${n.tunnel.status}`);
|
|
231
|
+
}
|
|
232
|
+
for (const n of visiblePeers.filter((peer) => peer.kind === 'transitive')) {
|
|
233
|
+
log(`${n.name}\t${n.nodeId}\trouted\tvia:${n.route.join(' -> ')}\tazioni:inspect`);
|
|
234
|
+
}
|
|
235
|
+
return { code: 0, nodes, peers: visiblePeers };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function nodesInspect(opts) {
|
|
239
|
+
const log = opts.log || console.log;
|
|
240
|
+
const out = loadPeerInventory(opts);
|
|
241
|
+
const found = inventory.resolvePeer(out.peers, opts.ref || opts.name);
|
|
242
|
+
if (!found.peer) {
|
|
243
|
+
log(`nodes inspect: ${found.error}`);
|
|
244
|
+
return { code: 1, reason: 'unknown-node' };
|
|
198
245
|
}
|
|
199
|
-
|
|
246
|
+
if (opts.json) log(JSON.stringify(found.peer, null, 2));
|
|
247
|
+
else {
|
|
248
|
+
const peer = found.peer;
|
|
249
|
+
log(`${peer.label || peer.name} (${peer.name})`);
|
|
250
|
+
log(`nodeId: ${peer.nodeId || '-'}`);
|
|
251
|
+
log(`tipo: ${peer.kind} · ${peer.relation}`);
|
|
252
|
+
log(`route: ${peer.route.join(' -> ')}`);
|
|
253
|
+
if (peer.tunnel) log(`status: ${peer.tunnel.status}`);
|
|
254
|
+
log(`azioni: ${Object.keys(peer.actions || {}).filter((key) => peer.actions[key]).join(', ')}`);
|
|
255
|
+
}
|
|
256
|
+
return { code: 0, peer: found.peer };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function nodesEdit(opts) {
|
|
260
|
+
const log = opts.log || console.log;
|
|
261
|
+
if (isReadonly(opts)) { log('nodes edit: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
262
|
+
const { home, nodesPath } = resolveNodePaths(opts);
|
|
263
|
+
let st;
|
|
264
|
+
try { st = store.loadStoreStrict(nodesPath); }
|
|
265
|
+
catch (e) { log(`nodes edit: ${e.message}`); return { code: 1, reason: 'store invalido', status: e.status || 500, errorCode: e.code || null }; }
|
|
266
|
+
const node = resolveStoredNode(st, opts.ref || opts.name);
|
|
267
|
+
if (!node) { log(`nodes edit: nodo sconosciuto "${opts.ref || opts.name || ''}"`); return { code: 1, reason: 'unknown-node' }; }
|
|
268
|
+
const supplied = opts.patch && typeof opts.patch === 'object' ? opts.patch : {};
|
|
269
|
+
const allowed = node.direction === 'inbound'
|
|
270
|
+
? new Set(['label', 'visibility', 'selected'])
|
|
271
|
+
: new Set(['label', 'ssh', 'sshPort', 'autostart']);
|
|
272
|
+
const keys = Object.keys(supplied);
|
|
273
|
+
const invalid = keys.find((key) => !allowed.has(key));
|
|
274
|
+
if (invalid) {
|
|
275
|
+
log(`nodes edit: campo "${invalid}" non modificabile per un peer ${node.direction}`);
|
|
276
|
+
return { code: 1, reason: 'field-not-editable' };
|
|
277
|
+
}
|
|
278
|
+
if (keys.length === 0) { log('nodes edit: nessuna modifica richiesta'); return { code: 1, reason: 'empty-patch' }; }
|
|
279
|
+
const patch = { ...supplied };
|
|
280
|
+
if (Object.hasOwn(patch, 'label')) {
|
|
281
|
+
if (!store.validLabel(patch.label)) { log('nodes edit: label non valida (max 64 char, niente a capo)'); return { code: 1, reason: 'invalid-label' }; }
|
|
282
|
+
patch.label = patch.label.trim();
|
|
283
|
+
}
|
|
284
|
+
if (Object.hasOwn(patch, 'ssh')) {
|
|
285
|
+
const parsed = store.parseSshTarget(patch.ssh);
|
|
286
|
+
if (!parsed) { log('nodes edit: ssh non valido (atteso user@host o Host alias)'); return { code: 1, reason: 'invalid-ssh' }; }
|
|
287
|
+
patch.ssh = parsed.value;
|
|
288
|
+
}
|
|
289
|
+
if (Object.hasOwn(patch, 'sshPort')) {
|
|
290
|
+
patch.sshPort = Number(patch.sshPort);
|
|
291
|
+
if (!store.isPort(patch.sshPort)) { log('nodes edit: sshPort non valida (1..65535)'); return { code: 1, reason: 'invalid-ssh-port' }; }
|
|
292
|
+
}
|
|
293
|
+
if (Object.hasOwn(patch, 'autostart') && typeof patch.autostart !== 'boolean') {
|
|
294
|
+
log('nodes edit: autostart deve essere booleano'); return { code: 1, reason: 'invalid-autostart' };
|
|
295
|
+
}
|
|
296
|
+
if (Object.hasOwn(patch, 'visibility') && !['network', 'relay-only', 'selected'].includes(patch.visibility)) {
|
|
297
|
+
log('nodes edit: visibility non valida'); return { code: 1, reason: 'invalid-visibility' };
|
|
298
|
+
}
|
|
299
|
+
if (patch.visibility !== 'selected') delete patch.selected;
|
|
300
|
+
|
|
301
|
+
let next;
|
|
302
|
+
try { next = store.updateNode(st, node.name, patch); }
|
|
303
|
+
catch (e) { log(`nodes edit: ${e.message}`); return { code: 1, reason: 'invalid-patch' }; }
|
|
304
|
+
|
|
305
|
+
const transportChanged = node.direction === 'outbound'
|
|
306
|
+
&& ['ssh', 'sshPort'].some((key) => Object.hasOwn(patch, key));
|
|
307
|
+
const wasUp = transportChanged && tunnel.readTunnelState(home, node.name).status === 'up';
|
|
308
|
+
try {
|
|
309
|
+
store.atomicWriteStore(nodesPath, next);
|
|
310
|
+
if (wasUp) {
|
|
311
|
+
const stopImpl = opts.stopTunnel || tunnel.stopTunnel;
|
|
312
|
+
const startImpl = opts.startForward || tunnel.startForward;
|
|
313
|
+
stopImpl({ home, name: node.name });
|
|
314
|
+
const started = startImpl({
|
|
315
|
+
home, node: store.getNode(next, node.name), localAppPort: opts.localAppPort,
|
|
316
|
+
spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd,
|
|
317
|
+
});
|
|
318
|
+
if (!started.started && started.reason !== 'already running') throw new Error(started.reason || 'riavvio tunnel fallito');
|
|
319
|
+
}
|
|
320
|
+
} catch (e) {
|
|
321
|
+
try {
|
|
322
|
+
store.atomicWriteStore(nodesPath, st);
|
|
323
|
+
if (wasUp) (opts.startForward || tunnel.startForward)({
|
|
324
|
+
home, node, localAppPort: opts.localAppPort,
|
|
325
|
+
spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd,
|
|
326
|
+
});
|
|
327
|
+
} catch (_) { /* rollback best-effort */ }
|
|
328
|
+
log(`nodes edit: modifica annullata — ${e.message}`);
|
|
329
|
+
return { code: 1, reason: 'write-or-restart-failed' };
|
|
330
|
+
}
|
|
331
|
+
const edited = store.redactNode(store.getNode(next, node.name));
|
|
332
|
+
log(`nodes edit: nodo "${node.name}" aggiornato`);
|
|
333
|
+
return { code: 0, name: node.name, node: edited, restarted: wasUp };
|
|
200
334
|
}
|
|
201
335
|
|
|
202
336
|
// --- nodes remove ----------------------------------------------------------
|
|
@@ -204,10 +338,13 @@ function nodesRemove(opts) {
|
|
|
204
338
|
const log = opts.log || console.log;
|
|
205
339
|
if (isReadonly(opts)) { log('nodes remove: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
206
340
|
const { home, nodesPath } = resolveNodePaths(opts);
|
|
207
|
-
const
|
|
208
|
-
if (!
|
|
341
|
+
const ref = opts.ref || opts.name;
|
|
342
|
+
if (!ref) { log('nodes remove: riferimento nodo mancante'); return { code: 1 }; }
|
|
209
343
|
const st = store.loadStore(nodesPath);
|
|
210
344
|
if (!st) { log('nodes remove: nessun nodes.json valido'); return { code: 1 }; }
|
|
345
|
+
const node = resolveStoredNode(st, ref);
|
|
346
|
+
if (!node) { log(`nodes remove: nodo sconosciuto "${ref}"`); return { code: 1, reason: 'unknown-node' }; }
|
|
347
|
+
const name = node.name;
|
|
211
348
|
let next;
|
|
212
349
|
try { next = store.removeNode(st, name); }
|
|
213
350
|
catch (e) { log(`nodes remove: ${e.message}`); return { code: 1 }; }
|
|
@@ -236,11 +373,12 @@ function nodesRemove(opts) {
|
|
|
236
373
|
async function nodesTest(opts) {
|
|
237
374
|
const log = opts.log || console.log;
|
|
238
375
|
const { home, nodesPath } = resolveNodePaths(opts);
|
|
239
|
-
const
|
|
240
|
-
if (!
|
|
376
|
+
const ref = opts.ref || opts.name;
|
|
377
|
+
if (!ref) { log('nodes test: riferimento nodo mancante'); return { code: 1 }; }
|
|
241
378
|
const st = store.loadStore(nodesPath);
|
|
242
|
-
const node = st ?
|
|
243
|
-
if (!node) { log(`nodes test: nodo sconosciuto "${
|
|
379
|
+
const node = st ? resolveStoredNode(st, ref) : null;
|
|
380
|
+
if (!node) { log(`nodes test: nodo sconosciuto "${ref}"`); return { code: 1, result: 'unknown-node' }; }
|
|
381
|
+
const name = node.name;
|
|
244
382
|
|
|
245
383
|
// An inbound peer is reached through the reverse listener owned by sshd on
|
|
246
384
|
// this hub; there is intentionally no local tunnel pidfile to inspect.
|
|
@@ -319,11 +457,11 @@ async function defaultHttpProbe(url, headers) {
|
|
|
319
457
|
// --- nodes up/down/restart (lifecycle tunnel, NON tocca la config) ----------
|
|
320
458
|
function loadNodeOrFail(opts, log) {
|
|
321
459
|
const { home, nodesPath } = resolveNodePaths(opts);
|
|
322
|
-
const
|
|
323
|
-
if (!
|
|
460
|
+
const ref = opts.ref || opts.name;
|
|
461
|
+
if (!ref) { log('nodes: riferimento nodo mancante'); return null; }
|
|
324
462
|
const st = store.loadStore(nodesPath);
|
|
325
|
-
const node = st ?
|
|
326
|
-
if (!node) { log(`nodes: nodo sconosciuto "${
|
|
463
|
+
const node = st ? resolveStoredNode(st, ref) : null;
|
|
464
|
+
if (!node) { log(`nodes: nodo sconosciuto "${ref}"`); return null; }
|
|
327
465
|
return { home, nodesPath, store: st, node };
|
|
328
466
|
}
|
|
329
467
|
|
|
@@ -336,6 +474,7 @@ function persistAutostart(ctx, enabled) {
|
|
|
336
474
|
|
|
337
475
|
function nodesUp(opts) {
|
|
338
476
|
const log = opts.log || console.log;
|
|
477
|
+
if (isReadonly(opts)) { log('nodes up: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
339
478
|
const ctx = loadNodeOrFail(opts, log);
|
|
340
479
|
if (!ctx) return { code: 1 };
|
|
341
480
|
if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
|
|
@@ -359,6 +498,7 @@ function nodesUp(opts) {
|
|
|
359
498
|
|
|
360
499
|
function nodesDown(opts) {
|
|
361
500
|
const log = opts.log || console.log;
|
|
501
|
+
if (isReadonly(opts)) { log('nodes down: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
362
502
|
const ctx = loadNodeOrFail(opts, log);
|
|
363
503
|
if (!ctx) return { code: 1 };
|
|
364
504
|
if (ctx.node.direction === 'inbound') return { code: 0, stopped: false, inbound: true };
|
|
@@ -373,6 +513,7 @@ function nodesDown(opts) {
|
|
|
373
513
|
|
|
374
514
|
function nodesRestart(opts) {
|
|
375
515
|
const log = opts.log || console.log;
|
|
516
|
+
if (isReadonly(opts)) { log('nodes restart: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
376
517
|
const ctx = loadNodeOrFail(opts, log);
|
|
377
518
|
if (!ctx) return { code: 1 };
|
|
378
519
|
if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
|
|
@@ -408,9 +549,9 @@ function nodesSetToken(opts) {
|
|
|
408
549
|
}
|
|
409
550
|
|
|
410
551
|
module.exports = {
|
|
411
|
-
nodesAdd, nodesList, nodesRemove, nodesTest,
|
|
552
|
+
nodesAdd, nodesList, nodesInspect, nodesEdit, nodesRemove, nodesTest,
|
|
412
553
|
nodesUp, nodesDown, nodesRestart, nodesSetToken,
|
|
413
554
|
// helper esposti per test/riuso
|
|
414
555
|
assignLocalPort, bindLocalPort, reserveLocalPort, defaultKeyPath, ensureKey, readSecretToken,
|
|
415
|
-
resolveNodePaths, defaultHttpProbe,
|
|
556
|
+
resolveNodePaths, resolveStoredNode, loadPeerInventory, defaultHttpProbe,
|
|
416
557
|
};
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Canonical peer inventory shared by HTTP, CLI and the PWA. A direct peer is
|
|
4
|
+
// backed by nodes.json and can therefore expose management actions. A routed
|
|
5
|
+
// peer comes from topology-cache.json: it is visible and inspectable, but it
|
|
6
|
+
// must never pretend to support mutations on this installation.
|
|
7
|
+
|
|
8
|
+
function actionsFor(node, kind = 'direct') {
|
|
9
|
+
if (kind !== 'direct') return { inspect: true };
|
|
10
|
+
const outbound = node.direction !== 'inbound';
|
|
11
|
+
return {
|
|
12
|
+
inspect: true,
|
|
13
|
+
edit: true,
|
|
14
|
+
remove: true,
|
|
15
|
+
test: true,
|
|
16
|
+
...(outbound ? {
|
|
17
|
+
connect: true,
|
|
18
|
+
disconnect: true,
|
|
19
|
+
restart: true,
|
|
20
|
+
share: true,
|
|
21
|
+
} : {
|
|
22
|
+
visibility: true,
|
|
23
|
+
}),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function directPeer(node, extra = {}) {
|
|
28
|
+
const direction = node.direction || 'outbound';
|
|
29
|
+
return {
|
|
30
|
+
...node,
|
|
31
|
+
...extra,
|
|
32
|
+
kind: 'direct',
|
|
33
|
+
relation: direction === 'inbound' ? 'client' : 'hub',
|
|
34
|
+
manageable: true,
|
|
35
|
+
route: [node.name],
|
|
36
|
+
hops: 1,
|
|
37
|
+
actions: actionsFor(node, 'direct'),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function routedPeer(entry) {
|
|
42
|
+
const route = Array.isArray(entry.route) ? [...entry.route] : [];
|
|
43
|
+
return {
|
|
44
|
+
name: entry.name,
|
|
45
|
+
nodeId: entry.instanceId,
|
|
46
|
+
instanceId: entry.instanceId,
|
|
47
|
+
kind: 'transitive',
|
|
48
|
+
relation: 'routed',
|
|
49
|
+
manageable: false,
|
|
50
|
+
route,
|
|
51
|
+
hops: route.length,
|
|
52
|
+
lastSeen: entry.lastSeen,
|
|
53
|
+
stale: entry.stale === true,
|
|
54
|
+
actions: actionsFor(entry, 'transitive'),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function buildInventory({ direct = [], topology = [], extras = new Map() } = {}) {
|
|
59
|
+
const directIds = new Set(direct.map((node) => node && node.nodeId).filter(Boolean));
|
|
60
|
+
const peers = direct.map((node) => directPeer(node, extras.get(node.name) || {}));
|
|
61
|
+
for (const entry of topology) {
|
|
62
|
+
if (!entry || directIds.has(entry.instanceId)) continue;
|
|
63
|
+
peers.push(routedPeer(entry));
|
|
64
|
+
}
|
|
65
|
+
return peers;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function resolvePeer(peers, ref) {
|
|
69
|
+
const value = String(ref || '').trim();
|
|
70
|
+
if (!value) return { error: 'riferimento nodo mancante' };
|
|
71
|
+
const byId = peers.filter((peer) => peer.nodeId === value || peer.instanceId === value);
|
|
72
|
+
if (byId.length === 1) return { peer: byId[0] };
|
|
73
|
+
const byName = peers.filter((peer) => peer.name === value);
|
|
74
|
+
if (byName.length === 1) return { peer: byName[0] };
|
|
75
|
+
if (byId.length > 1 || byName.length > 1) {
|
|
76
|
+
return { error: `riferimento ambiguo "${value}": usa il nodeId` };
|
|
77
|
+
}
|
|
78
|
+
return { error: `nodo sconosciuto "${value}"` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { actionsFor, directPeer, routedPeer, buildInventory, resolvePeer };
|
package/lib/server.js
CHANGED
|
@@ -12,6 +12,7 @@ const { listSessions, attachedClients, setSessionVisibility } = require('./tmux/
|
|
|
12
12
|
const { runAction, pasteToSession, submitToSession } = require('./tmux/actions.js');
|
|
13
13
|
const { createSession, killSession, isProtectedSession } = require('./tmux/lifecycle.js');
|
|
14
14
|
const { createPreviewSampler } = require('./tmux/preview.js');
|
|
15
|
+
const { requireSharedTmuxProtection } = require('./tmux/shared-server.js');
|
|
15
16
|
const { openAttach } = require('./pty/attach.js');
|
|
16
17
|
const { bindWs } = require('./ws/bridge.js');
|
|
17
18
|
const { loadOrCreateToken, verify } = require('./auth/token.js');
|
|
@@ -27,6 +28,8 @@ const { fsRoutes } = require('./fs/routes.js');
|
|
|
27
28
|
const nodesStore = require('./nodes/store.js');
|
|
28
29
|
const nodesTunnel = require('./nodes/tunnel.js');
|
|
29
30
|
const nodesHealth = require('./nodes/health.js');
|
|
31
|
+
const nodesInventory = require('./nodes/inventory.js');
|
|
32
|
+
const topologyCache = require('./nodes/topology-cache.js');
|
|
30
33
|
const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
|
|
31
34
|
const federation = require('./proxy/federation.js');
|
|
32
35
|
const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
|
|
@@ -107,10 +110,14 @@ function createServer(opts = {}) {
|
|
|
107
110
|
...(cfg.updateSeams || {}),
|
|
108
111
|
});
|
|
109
112
|
const attachedWs = new Map(); // ws -> session (per il push dei frame files)
|
|
110
|
-
|
|
111
|
-
|
|
113
|
+
const ensureTmuxProtection = () => requireSharedTmuxProtection(cfg.tmuxBin, {
|
|
114
|
+
enabled: cfg.protectSharedTmuxServer !== false,
|
|
115
|
+
home: cfg.home || os.homedir(),
|
|
116
|
+
});
|
|
117
|
+
// selectProvider sceglie UNA volta (startup) builtin|disabled e ritorna
|
|
118
|
+
// {mode,reason,fleet}; routes consumano il .fleet,
|
|
112
119
|
// quindi fleetP resta una Promise<Fleet> (createServer non diventa async).
|
|
113
|
-
const fleetP = selectProvider(cfg).then((p) => p.fleet);
|
|
120
|
+
const fleetP = selectProvider({ ...cfg, ensureTmuxProtection }).then((p) => p.fleet);
|
|
114
121
|
|
|
115
122
|
// Multi-node (B1): nodes.json e' la fonte dati (B0). Il proxy risolve <name>
|
|
116
123
|
// -> {localPort, token} leggendo lo store ad ogni richiesta (fresh: rotazione
|
|
@@ -119,7 +126,7 @@ function createServer(opts = {}) {
|
|
|
119
126
|
const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
|
|
120
127
|
// fetch usata dai probe di salute federati (iniettabile nei test); default globale.
|
|
121
128
|
const healthFetch = cfg.fetchImpl || fetch;
|
|
122
|
-
const topologyCachePath = cfg.topologyCachePath ||
|
|
129
|
+
const topologyCachePath = cfg.topologyCachePath || topologyCache.defaultPath(cfg.home || os.homedir());
|
|
123
130
|
const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
|
|
124
131
|
const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
125
132
|
function resolveNode(name) {
|
|
@@ -209,7 +216,7 @@ function createServer(opts = {}) {
|
|
|
209
216
|
try {
|
|
210
217
|
const { name, cwd, preset } = req.body || {};
|
|
211
218
|
await createSession(cfg.tmuxBin, { name, cwd, preset },
|
|
212
|
-
{ home: os.homedir(), presets: cfg.sessionPresets });
|
|
219
|
+
{ home: os.homedir(), presets: cfg.sessionPresets, ensureProtection: ensureTmuxProtection });
|
|
213
220
|
res.status(201).json({ created: true, name });
|
|
214
221
|
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
215
222
|
});
|
|
@@ -221,7 +228,7 @@ function createServer(opts = {}) {
|
|
|
221
228
|
if (isProtectedSession(name, fleet.isCellSession)) {
|
|
222
229
|
return res.status(409).json({ error: 'sessione di cella: usa fleet down' });
|
|
223
230
|
}
|
|
224
|
-
const killed = await killSession(cfg.tmuxBin, name);
|
|
231
|
+
const killed = await killSession(cfg.tmuxBin, name, { ensureProtection: ensureTmuxProtection });
|
|
225
232
|
if (!killed) return res.status(404).json({ error: 'sessione inesistente' });
|
|
226
233
|
res.json({ killed: true });
|
|
227
234
|
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
@@ -240,6 +247,7 @@ function createServer(opts = {}) {
|
|
|
240
247
|
api.get('/config', (_req, res) => res.json({
|
|
241
248
|
readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
|
|
242
249
|
bind: cfg.bind, port: cfg.port,
|
|
250
|
+
protectSharedTmuxServer: cfg.protectSharedTmuxServer !== false,
|
|
243
251
|
instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
244
252
|
presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
|
|
245
253
|
}));
|
|
@@ -292,6 +300,32 @@ function createServer(opts = {}) {
|
|
|
292
300
|
res.json({ nodeId: view.nodeId, nodes });
|
|
293
301
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
294
302
|
});
|
|
303
|
+
// Canonical management inventory: direct hubs/clients plus routed peers. The
|
|
304
|
+
// latter are deliberately inspect-only because they have no local nodes.json
|
|
305
|
+
// record to mutate. UI and CLI consume the same action matrix.
|
|
306
|
+
api.get('/peers', async (_req, res) => {
|
|
307
|
+
try {
|
|
308
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
309
|
+
if (!st) return res.json({ nodeId: null, peers: [] });
|
|
310
|
+
const direct = nodesStore.redactStore(st).nodes;
|
|
311
|
+
const healths = await nodesHealth.nodesHealth({
|
|
312
|
+
nodes: st.nodes, home: cfg.home || os.homedir(), fetchImpl: healthFetch, now: Date.now(),
|
|
313
|
+
});
|
|
314
|
+
const extras = new Map(direct.map((node, index) => {
|
|
315
|
+
const health = healths[index] || null;
|
|
316
|
+
return [node.name, { health, tunnel: nodesHealth.tunnelFromHealth(health) }];
|
|
317
|
+
}));
|
|
318
|
+
const topology = await federation.collectLocalTopology({
|
|
319
|
+
nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch,
|
|
320
|
+
});
|
|
321
|
+
const peers = nodesInventory.buildInventory({
|
|
322
|
+
direct,
|
|
323
|
+
topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
|
|
324
|
+
extras,
|
|
325
|
+
});
|
|
326
|
+
res.json({ nodeId: st.nodeId, peers });
|
|
327
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
328
|
+
});
|
|
295
329
|
// Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
|
|
296
330
|
// wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
|
|
297
331
|
// READONLY route-level e la redazione token vivono dentro settingsRoutes.
|
package/lib/settings/routes.js
CHANGED
|
@@ -65,6 +65,7 @@ const { publicPeeringRoutes } = require('./public-peering-routes.js');
|
|
|
65
65
|
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
|
|
66
66
|
const ROLE_KEYS = new Set(['client', 'node']);
|
|
67
67
|
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
|
|
68
|
+
const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected']);
|
|
68
69
|
|
|
69
70
|
// Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
|
|
70
71
|
// NON usa ciecamente hostname: se l'hostname e' vuoto o 'localhost' (tipico di
|
|
@@ -186,6 +187,21 @@ function settingsRoutes(deps = {}) {
|
|
|
186
187
|
});
|
|
187
188
|
|
|
188
189
|
const validName = (name) => typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name);
|
|
190
|
+
const applyNodeEdit = (res, name, patch) => {
|
|
191
|
+
const cap = capture();
|
|
192
|
+
const out = nodesCmds.nodesEdit({ ...cliOpts(cap), ref: name, patch });
|
|
193
|
+
if (out.code === 0) return send(res, 200, {
|
|
194
|
+
saved: true, name: out.name, node: out.node, restarted: out.restarted === true,
|
|
195
|
+
});
|
|
196
|
+
const message = lastLine(cap, 'nodes edit fallito');
|
|
197
|
+
if (out.reason === 'readonly') return send(res, 403, { error: message });
|
|
198
|
+
if (out.reason === 'unknown-node') return send(res, 404, { error: message });
|
|
199
|
+
if (out.reason === 'store invalido') {
|
|
200
|
+
return send(res, out.status || 503, { error: message, ...(out.errorCode ? { code: out.errorCode } : {}) });
|
|
201
|
+
}
|
|
202
|
+
const status = out.reason === 'write-or-restart-failed' ? 502 : 400;
|
|
203
|
+
return send(res, status, { error: message });
|
|
204
|
+
};
|
|
189
205
|
|
|
190
206
|
// --- GET / — vista read-only per wizard/settings UI -----------------------
|
|
191
207
|
r.get('/', (_req, res) => {
|
|
@@ -459,6 +475,21 @@ function settingsRoutes(deps = {}) {
|
|
|
459
475
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
460
476
|
});
|
|
461
477
|
|
|
478
|
+
// General direct-peer edit endpoint used by both the PWA and headless CLI.
|
|
479
|
+
// Stable identity fields (name/nodeId/ports/capabilities) are not writable;
|
|
480
|
+
// nodesEdit applies the direction-aware allowlist and tunnel rollback.
|
|
481
|
+
r.patch('/nodes/:name', mutGate, (req, res) => {
|
|
482
|
+
const name = String(req.params.name || '');
|
|
483
|
+
const body = req.body;
|
|
484
|
+
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
485
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
486
|
+
return send(res, 400, { error: 'body deve essere un oggetto JSON' });
|
|
487
|
+
}
|
|
488
|
+
const invalid = Object.keys(body).find((key) => !EDIT_KEYS.has(key));
|
|
489
|
+
if (invalid) return send(res, 400, { error: `campo non modificabile: "${invalid}"` });
|
|
490
|
+
return applyNodeEdit(res, name, body);
|
|
491
|
+
});
|
|
492
|
+
|
|
462
493
|
// --- POST /nodes/:name/test — non-mutante (POST per coerenza azione) -------
|
|
463
494
|
// NON gated READONLY: e' una probe diagnostica, coerente col `nodes test` CLI.
|
|
464
495
|
r.post('/nodes/:name/test', async (req, res) => {
|
|
@@ -604,32 +635,21 @@ function settingsRoutes(deps = {}) {
|
|
|
604
635
|
});
|
|
605
636
|
|
|
606
637
|
r.patch('/nodes/:name/visibility', mutGate, (req, res) => {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
615
|
-
st = nodesStore.updateNode(st, name, { visibility, selected: visibility === 'selected' ? selected : [] });
|
|
616
|
-
nodesStore.atomicWriteStore(nodesPath, st);
|
|
617
|
-
send(res, 200, { saved: true, name, visibility });
|
|
618
|
-
} catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
|
|
638
|
+
const name = String(req.params.name || '');
|
|
639
|
+
const visibility = req.body && req.body.visibility;
|
|
640
|
+
const selected = (req.body && req.body.selected) || [];
|
|
641
|
+
if (!validName(name) || !['network', 'relay-only', 'selected'].includes(visibility)) {
|
|
642
|
+
return send(res, 400, { error: 'visibility non valida' });
|
|
643
|
+
}
|
|
644
|
+
return applyNodeEdit(res, name, { visibility, selected: visibility === 'selected' ? selected : [] });
|
|
619
645
|
});
|
|
620
646
|
// Rinomina la label umana di un nodo SENZA toccare il name (route/URL stabili).
|
|
621
647
|
r.patch('/nodes/:name/label', mutGate, (req, res) => {
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
let st = nodesStore.loadStoreStrict(nodesPath);
|
|
628
|
-
if (!nodesStore.getNode(st, name)) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
|
|
629
|
-
st = nodesStore.updateNode(st, name, { label: nodesStore.sanitizeLabel(label, name) });
|
|
630
|
-
nodesStore.atomicWriteStore(nodesPath, st);
|
|
631
|
-
send(res, 200, { saved: true, name, label: nodesStore.nodeLabel(nodesStore.getNode(st, name)) });
|
|
632
|
-
} catch (e) { send(res, e.status || 400, { error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
|
|
648
|
+
const name = String(req.params.name || '');
|
|
649
|
+
const label = req.body && req.body.label;
|
|
650
|
+
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
651
|
+
if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
|
|
652
|
+
return applyNodeEdit(res, name, { label });
|
|
633
653
|
});
|
|
634
654
|
|
|
635
655
|
// Legacy compatibility endpoint: the old node-role/rendezvous flow opened a
|
package/lib/tmux/lifecycle.js
CHANGED
|
@@ -51,7 +51,8 @@ function buildCreateArgs(name, realCwd, preset, extraPresets) {
|
|
|
51
51
|
|
|
52
52
|
function httpError(status, msg) { const e = new Error(msg); e.status = status; return e; }
|
|
53
53
|
|
|
54
|
-
function createSession(tmuxBin, { name, cwd, preset }, { home, presets } = {}) {
|
|
54
|
+
async function createSession(tmuxBin, { name, cwd, preset }, { home, presets, ensureProtection } = {}) {
|
|
55
|
+
if (typeof ensureProtection === 'function') await ensureProtection();
|
|
55
56
|
return new Promise((resolve, reject) => {
|
|
56
57
|
if (!validSessionName(name)) return reject(httpError(400, 'nome sessione non valido'));
|
|
57
58
|
// Il namespace cloud-* e' delle celle fleet (audit finale #1): una generica
|
|
@@ -71,7 +72,8 @@ function createSession(tmuxBin, { name, cwd, preset }, { home, presets } = {}) {
|
|
|
71
72
|
});
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
function killSession(tmuxBin, name) {
|
|
75
|
+
async function killSession(tmuxBin, name, { ensureProtection } = {}) {
|
|
76
|
+
if (typeof ensureProtection === 'function') await ensureProtection();
|
|
75
77
|
return new Promise((resolve, reject) => {
|
|
76
78
|
execFile(tmuxBin, ['kill-session', '-t', `=${name}`], (err, _o, stderr) => {
|
|
77
79
|
if (err) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const { execFile } = require('node:child_process');
|
|
5
|
+
const { minimalRuntimeEnv } = require('../runtime/env.js');
|
|
6
|
+
|
|
7
|
+
// Operational guard for NexusCrew's shared tmux server. tmux command aliases
|
|
8
|
+
// are not a security boundary against another process running as the same UID,
|
|
9
|
+
// but they neutralize accidental `tmux kill-server` calls while exit-empty=off
|
|
10
|
+
// keeps the server alive when the last managed session is intentionally stopped.
|
|
11
|
+
const KILL_SERVER_ALIAS_INDEX = 'command-alias[100]';
|
|
12
|
+
const KILL_SERVER_ALIAS = 'kill-server=display-message "DENIED: kill-server disabled by NexusCrew"';
|
|
13
|
+
|
|
14
|
+
function protectionArgs() {
|
|
15
|
+
return [
|
|
16
|
+
'start-server',
|
|
17
|
+
';', 'set-option', '-s', 'exit-empty', 'off',
|
|
18
|
+
';', 'set-option', '-s', KILL_SERVER_ALIAS_INDEX, KILL_SERVER_ALIAS,
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function protectSharedTmuxServer(tmuxBin = 'tmux', opts = {}) {
|
|
23
|
+
if (opts.enabled === false) return Promise.resolve({ ok: true, protected: false, reason: 'disabled' });
|
|
24
|
+
const execFileImpl = opts.execFileImpl || execFile;
|
|
25
|
+
const env = opts.env || minimalRuntimeEnv(process.env, { home: opts.home || os.homedir() });
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
try {
|
|
28
|
+
execFileImpl(tmuxBin, protectionArgs(), { env, timeout: opts.timeoutMs || 5000 }, (err, _stdout, stderr) => {
|
|
29
|
+
if (err) {
|
|
30
|
+
return resolve({
|
|
31
|
+
ok: false,
|
|
32
|
+
protected: false,
|
|
33
|
+
reason: String(stderr || err.message || 'tmux protection failed').trim(),
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
resolve({ ok: true, protected: true, reason: 'kill-server guarded; exit-empty off' });
|
|
37
|
+
});
|
|
38
|
+
} catch (error) {
|
|
39
|
+
resolve({ ok: false, protected: false, reason: String(error && error.message || error) });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function requireSharedTmuxProtection(tmuxBin, opts = {}) {
|
|
45
|
+
const result = await protectSharedTmuxServer(tmuxBin, opts);
|
|
46
|
+
if (!result.ok) {
|
|
47
|
+
const error = new Error(`tmux shared-server protection failed: ${result.reason || 'unknown error'}`);
|
|
48
|
+
error.status = 500;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
KILL_SERVER_ALIAS_INDEX,
|
|
56
|
+
KILL_SERVER_ALIAS,
|
|
57
|
+
protectionArgs,
|
|
58
|
+
protectSharedTmuxServer,
|
|
59
|
+
requireSharedTmuxProtection,
|
|
60
|
+
};
|