@mmmbuto/nexuscrew 0.8.20 → 0.8.22

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 };
@@ -186,6 +186,14 @@ function openTunnelLog(home, name) {
186
186
  function classifySshFailure(text, remotePort) {
187
187
  const s = String(text || '');
188
188
  const target = store.isPort(remotePort) ? `127.0.0.1:${remotePort}` : 'la porta NexusCrew richiesta';
189
+ const reverseMatch = s.match(/remote port forwarding failed for listen port\s+(\d+)/i)
190
+ || s.match(/(?:bind|listen)[^\r\n]*127\.0\.0\.1:(\d+)/i);
191
+ const reverseListenPort = reverseMatch ? Number(reverseMatch[1]) : null;
192
+ const reverseTarget = store.isPort(reverseListenPort)
193
+ ? `127.0.0.1:${reverseListenPort}` : 'la porta reverse negoziata';
194
+ const reversePolicy = store.isPort(reverseListenPort)
195
+ ? `che la chiave SSH autorizzi permitlisten="${reverseTarget}"`
196
+ : 'che la chiave SSH autorizzi il reverse listen negoziato';
189
197
  if (/administratively prohibited|request (?:was )?denied|open failed:.*prohibited|port forwarding.*(?:disabled|denied)/i.test(s)) {
190
198
  return {
191
199
  code: 'forward-denied',
@@ -219,7 +227,7 @@ function classifySshFailure(text, remotePort) {
219
227
  return {
220
228
  code: 'reverse-forward-failed',
221
229
  detail: 'il canale inverso non è stato aperto dal nodo hub',
222
- hint: 'la porta può essere già occupata oppure il server può vietare il forwarding; verifica listener e policy SSH sul nodo hub',
230
+ hint: `verifica che ${reverseTarget} non sia occupata e ${reversePolicy}; controlla anche AllowTcpForwarding sul nodo hub`,
223
231
  };
224
232
  }
225
233
  if (/Could not request local forwarding|Address already in use/i.test(s)) {
package/lib/server.js CHANGED
@@ -27,6 +27,8 @@ const { fsRoutes } = require('./fs/routes.js');
27
27
  const nodesStore = require('./nodes/store.js');
28
28
  const nodesTunnel = require('./nodes/tunnel.js');
29
29
  const nodesHealth = require('./nodes/health.js');
30
+ const nodesInventory = require('./nodes/inventory.js');
31
+ const topologyCache = require('./nodes/topology-cache.js');
30
32
  const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
31
33
  const federation = require('./proxy/federation.js');
32
34
  const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
@@ -107,8 +109,8 @@ function createServer(opts = {}) {
107
109
  ...(cfg.updateSeams || {}),
108
110
  });
109
111
  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,
112
+ // selectProvider sceglie UNA volta (startup) builtin|disabled e ritorna
113
+ // {mode,reason,fleet}; routes consumano il .fleet,
112
114
  // quindi fleetP resta una Promise<Fleet> (createServer non diventa async).
113
115
  const fleetP = selectProvider(cfg).then((p) => p.fleet);
114
116
 
@@ -119,7 +121,7 @@ function createServer(opts = {}) {
119
121
  const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
120
122
  // fetch usata dai probe di salute federati (iniettabile nei test); default globale.
121
123
  const healthFetch = cfg.fetchImpl || fetch;
122
- const topologyCachePath = cfg.topologyCachePath || require('./nodes/topology-cache.js').defaultPath(cfg.home || os.homedir());
124
+ const topologyCachePath = cfg.topologyCachePath || topologyCache.defaultPath(cfg.home || os.homedir());
123
125
  const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
124
126
  const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
125
127
  function resolveNode(name) {
@@ -292,6 +294,32 @@ function createServer(opts = {}) {
292
294
  res.json({ nodeId: view.nodeId, nodes });
293
295
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
294
296
  });
297
+ // Canonical management inventory: direct hubs/clients plus routed peers. The
298
+ // latter are deliberately inspect-only because they have no local nodes.json
299
+ // record to mutate. UI and CLI consume the same action matrix.
300
+ api.get('/peers', async (_req, res) => {
301
+ try {
302
+ const st = nodesStore.loadStore(nodesPath);
303
+ if (!st) return res.json({ nodeId: null, peers: [] });
304
+ const direct = nodesStore.redactStore(st).nodes;
305
+ const healths = await nodesHealth.nodesHealth({
306
+ nodes: st.nodes, home: cfg.home || os.homedir(), fetchImpl: healthFetch, now: Date.now(),
307
+ });
308
+ const extras = new Map(direct.map((node, index) => {
309
+ const health = healths[index] || null;
310
+ return [node.name, { health, tunnel: nodesHealth.tunnelFromHealth(health) }];
311
+ }));
312
+ const topology = await federation.collectLocalTopology({
313
+ nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch,
314
+ });
315
+ const peers = nodesInventory.buildInventory({
316
+ direct,
317
+ topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
318
+ extras,
319
+ });
320
+ res.json({ nodeId: st.nodeId, peers });
321
+ } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
322
+ });
295
323
  // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
296
324
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
297
325
  // 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) => {
@@ -530,18 +561,16 @@ function settingsRoutes(deps = {}) {
530
561
  let persistedDesired = wasShared;
531
562
  const fetchImpl = seams.fetchImpl || fetch;
532
563
  const notifyHub = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
533
- const applyLocal = async (shared) => {
534
- st = nodesStore.loadStoreStrict(nodesPath);
535
- st = nodesStore.updateNode(st, name, { shared });
536
- nodesStore.atomicWriteStore(nodesPath, st);
537
- persistedDesired = shared;
538
- node = nodesStore.getNode(st, name);
539
- nodesTunnel.stopTunnel({ home, name });
564
+ const ensureLocal = async ({ restart = false } = {}) => {
565
+ if (restart) nodesTunnel.stopTunnel({ home, name });
540
566
  const started = nodesTunnel.startForward({
541
567
  home, node, localAppPort: runtimePort(),
542
568
  spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
543
569
  sshBin: seams.sshBin, logFd: seams.logFd,
544
570
  });
571
+ // startForward is spec-aware: an exact live argv is idempotent, while a
572
+ // stale pre-upgrade supervisor whose -L/-R no longer matches nodes.json
573
+ // is replaced. This is the recovery path for store=false + stale -R.
545
574
  if (!started.started && started.reason !== 'already running') {
546
575
  throw new Error(started.reason || 'avvio SSH fallito');
547
576
  }
@@ -552,12 +581,28 @@ function settingsRoutes(deps = {}) {
552
581
  });
553
582
  if (!ready || ready.status !== 'healthy') {
554
583
  const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, name, node.remotePort);
555
- throw new Error((diagnosis && diagnosis.detail) || (ready && ready.detail) || 'hub non raggiungibile dopo il riavvio SSH');
584
+ const failure = new Error((diagnosis && diagnosis.detail) || (ready && ready.detail) || 'hub non raggiungibile dopo il riavvio SSH');
585
+ if (diagnosis && typeof diagnosis.hint === 'string') failure.hint = diagnosis.hint;
586
+ throw failure;
556
587
  }
588
+ return started;
589
+ };
590
+ const applyLocal = async (shared) => {
591
+ st = nodesStore.loadStoreStrict(nodesPath);
592
+ st = nodesStore.updateNode(st, name, { shared });
593
+ nodesStore.atomicWriteStore(nodesPath, st);
594
+ persistedDesired = shared;
595
+ node = nodesStore.getNode(st, name);
596
+ await ensureLocal({ restart: true });
557
597
  };
558
598
 
559
599
  try {
560
600
  if (node.shared === body.shared) {
601
+ // Persisted intent is not proof of the detached process mode. A stale
602
+ // supervisor can survive an npm upgrade and keep retrying -R even when
603
+ // the store/UI says private. Re-enter the spec-aware start path before
604
+ // reconciling the hub, without rewriting nodes.json.
605
+ await ensureLocal();
561
606
  await notifyHub(body.shared);
562
607
  return send(res, 200, { name, shared: body.shared, unchanged: true, reconciled: true });
563
608
  }
@@ -578,42 +623,33 @@ function settingsRoutes(deps = {}) {
578
623
  try { await applyLocal(false); } catch (_) { /* best-effort safe rollback */ }
579
624
  }
580
625
  const offPersisted = body.shared === false && persistedDesired === false;
626
+ const redact = (value) => String(value || '').replace(/Bearer\s+\S+/gi, 'Bearer ***');
581
627
  return send(res, 502, {
582
628
  error: body.shared ? 'Share non attivato'
583
629
  : offPersisted ? 'Share disattivato localmente; hub non riconciliato' : 'Share non disattivato',
584
630
  ...(offPersisted ? { shared: false, reconcilePending: true } : {}),
585
- detail: String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***'),
631
+ detail: redact(e && e.message || e),
632
+ ...(e && typeof e.hint === 'string' ? { hint: redact(e.hint) } : {}),
586
633
  });
587
634
  }
588
635
  });
589
636
 
590
637
  r.patch('/nodes/:name/visibility', mutGate, (req, res) => {
591
- try {
592
- const name = String(req.params.name || '');
593
- const visibility = req.body && req.body.visibility;
594
- const selected = (req.body && req.body.selected) || [];
595
- if (!validName(name) || !['network', 'relay-only', 'selected'].includes(visibility)) {
596
- return send(res, 400, { error: 'visibility non valida' });
597
- }
598
- let st = nodesStore.loadStoreStrict(nodesPath);
599
- st = nodesStore.updateNode(st, name, { visibility, selected: visibility === 'selected' ? selected : [] });
600
- nodesStore.atomicWriteStore(nodesPath, st);
601
- send(res, 200, { saved: true, name, visibility });
602
- } 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 : [] });
603
645
  });
604
646
  // Rinomina la label umana di un nodo SENZA toccare il name (route/URL stabili).
605
647
  r.patch('/nodes/:name/label', mutGate, (req, res) => {
606
- try {
607
- const name = String(req.params.name || '');
608
- const label = req.body && req.body.label;
609
- if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
610
- if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
611
- let st = nodesStore.loadStoreStrict(nodesPath);
612
- if (!nodesStore.getNode(st, name)) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
613
- st = nodesStore.updateNode(st, name, { label: nodesStore.sanitizeLabel(label, name) });
614
- nodesStore.atomicWriteStore(nodesPath, st);
615
- send(res, 200, { saved: true, name, label: nodesStore.nodeLabel(nodesStore.getNode(st, name)) });
616
- } 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 });
617
653
  });
618
654
 
619
655
  // Legacy compatibility endpoint: the old node-role/rendezvous flow opened a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.20",
3
+ "version": "0.8.22",
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": {