@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.
- package/README.md +33 -21
- package/frontend/dist/assets/{index-DQbVJLji.css → index-77r8nzQf.css} +1 -1
- package/frontend/dist/assets/index-ClJP2j6k.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 +233 -8
- package/lib/cli/fleet-service.js +7 -41
- package/lib/cli/init.js +0 -2
- package/lib/config.js +0 -2
- package/lib/fleet/builtin.js +4 -5
- 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 +7 -14
- package/lib/fleet/runtime.js +39 -20
- package/lib/nodes/commands.js +160 -19
- package/lib/nodes/inventory.js +81 -0
- package/lib/nodes/tunnel.js +9 -1
- package/lib/server.js +31 -3
- package/lib/settings/routes.js +68 -32
- package/package.json +1 -1
- package/frontend/dist/assets/index-CQnOyaXz.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/nodes/tunnel.js
CHANGED
|
@@ -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:
|
|
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)
|
|
111
|
-
//
|
|
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 ||
|
|
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.
|
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) => {
|
|
@@ -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
|
|
534
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
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
|