@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.
@@ -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 view = store.redactStore(st); // MAI il token
191
- const nodes = view.nodes.map((n) => ({ ...n, tunnel: tunnel.readTunnelState(home, n.name) }));
192
- const out = { nodeId: view.nodeId, nodes };
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) { log(JSON.stringify(out, null, 2)); return { code: 0, nodes }; }
195
- if (nodes.length === 0) { log('nodes: (nessun nodo)'); return { code: 0, nodes }; }
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
- log(`${n.name}\t${n.ssh}${n.sshPort ? `:${n.sshPort}` : ''}\tlocal:${n.localPort} -> nexus:${n.remotePort}\ttunnel:${n.tunnel.status}\ttoken:${n.hasToken ? 'set' : 'unset'}`);
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
- return { code: 0, nodes };
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 name = opts.name;
208
- if (!name) { log('nodes remove: name mancante'); return { code: 1 }; }
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 name = opts.name;
240
- if (!name) { log('nodes test: name mancante'); return { code: 1 }; }
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 ? store.getNode(st, name) : null;
243
- if (!node) { log(`nodes test: nodo sconosciuto "${name}"`); return { code: 1, result: 'unknown-node' }; }
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 name = opts.name;
323
- if (!name) { log('nodes: name mancante'); return null; }
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 ? store.getNode(st, name) : null;
326
- if (!node) { log(`nodes: nodo sconosciuto "${name}"`); return null; }
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
- // selectProvider sceglie UNA volta (startup) il provider external|builtin|disabled
111
- // (design §4b/§9b/§9g) e ritorna {mode,reason,fleet}; routes consumano il .fleet,
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 || require('./nodes/topology-cache.js').defaultPath(cfg.home || os.homedir());
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.
@@ -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
- try {
608
- const name = String(req.params.name || '');
609
- const visibility = req.body && req.body.visibility;
610
- const selected = (req.body && req.body.selected) || [];
611
- if (!validName(name) || !['network', 'relay-only', 'selected'].includes(visibility)) {
612
- return send(res, 400, { error: 'visibility non valida' });
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
- try {
623
- const name = String(req.params.name || '');
624
- const label = req.body && req.body.label;
625
- if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
626
- if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
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
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.21",
3
+ "version": "0.8.23",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {