@mmmbuto/nexuscrew 0.8.7 → 0.8.10
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 +77 -20
- package/frontend/dist/assets/index-CbUkgtAz.js +91 -0
- package/frontend/dist/assets/index-ChGJawuv.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/auth/token.js +1 -1
- package/lib/cli/commands.js +236 -141
- package/lib/cli/doctor.js +17 -14
- package/lib/cli/init.js +2 -2
- package/lib/cli/pidfile.js +3 -2
- package/lib/config.js +6 -0
- package/lib/decks/store.js +5 -2
- package/lib/fleet/builtin.js +69 -5
- package/lib/fleet/routes.js +13 -2
- package/lib/mcp/server.js +161 -0
- package/lib/nodes/commands.js +95 -123
- package/lib/nodes/health.js +33 -14
- package/lib/nodes/peering.js +75 -12
- package/lib/nodes/store.js +30 -20
- package/lib/nodes/tunnel-supervisor.js +29 -9
- package/lib/nodes/tunnel.js +217 -72
- package/lib/proxy/federation.js +81 -9
- package/lib/server.js +47 -32
- package/lib/settings/routes.js +247 -92
- package/lib/update/core.js +157 -0
- package/lib/update/manager.js +245 -0
- package/lib/update/runner.js +144 -0
- package/package.json +2 -2
- package/skills/nexuscrew-agent/SKILL.md +6 -2
- package/frontend/dist/assets/index-D2TrRtWQ.js +0 -90
- package/frontend/dist/assets/index-DrEuy6A6.css +0 -32
package/lib/nodes/commands.js
CHANGED
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
|
-
// lib/nodes/commands.js —
|
|
2
|
+
// lib/nodes/commands.js — implementation helpers for the authenticated PWA.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// Nodes add/list/remove/test/up/down/restart/set-token. These helpers are not
|
|
5
|
+
// advertised as public CLI commands.
|
|
5
6
|
// Invarianti:
|
|
6
7
|
// - token per-nodo MAI loggati/stampati (redazione sempre; set-token li legge
|
|
7
8
|
// da stdin/env, mai da argv -> niente segreti in `ps`).
|
|
8
9
|
// - NEXUSCREW_READONLY blocca le MUTAZIONI DI CONFIG (add/remove/set-token/
|
|
9
|
-
//
|
|
10
|
+
// configuration), while the Settings routes separately gate process lifecycle.
|
|
10
11
|
// - niente shell interpolation: ssh-keygen via execFile (argv), tunnel via spawn argv.
|
|
11
12
|
const fs = require('node:fs');
|
|
13
|
+
const net = require('node:net');
|
|
12
14
|
const os = require('node:os');
|
|
13
15
|
const path = require('node:path');
|
|
14
16
|
const { execFileSync } = require('node:child_process');
|
|
15
17
|
const store = require('./store.js');
|
|
16
18
|
const tunnel = require('./tunnel.js');
|
|
17
|
-
const { resolvePaths,
|
|
19
|
+
const { resolvePaths, DEFAULT_PORT } = require('../cli/url.js');
|
|
18
20
|
|
|
19
21
|
const LOCAL_PORT_BASE = 43001; // porte locali stabili per i forward (design: stabili da nodes.json)
|
|
20
22
|
|
|
@@ -38,6 +40,49 @@ function assignLocalPort(st) {
|
|
|
38
40
|
return p;
|
|
39
41
|
}
|
|
40
42
|
|
|
43
|
+
function bindLocalPort(port, createServerImpl = net.createServer) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const server = createServerImpl();
|
|
46
|
+
const onError = (error) => {
|
|
47
|
+
server.removeListener('listening', onListening);
|
|
48
|
+
reject(error);
|
|
49
|
+
};
|
|
50
|
+
const onListening = () => {
|
|
51
|
+
server.removeListener('error', onError);
|
|
52
|
+
if (typeof server.unref === 'function') server.unref();
|
|
53
|
+
let released = false;
|
|
54
|
+
resolve({
|
|
55
|
+
port,
|
|
56
|
+
release: () => new Promise((done) => {
|
|
57
|
+
if (released) return done();
|
|
58
|
+
released = true;
|
|
59
|
+
try { server.close(() => done()); } catch (_) { done(); }
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
server.once('error', onError);
|
|
64
|
+
server.once('listening', onListening);
|
|
65
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true });
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Selezione OS-aware per il pairing: nodes.json evita collisioni logiche, il
|
|
70
|
+
// bind reale evita porte gia' occupate da processi estranei. La socket resta
|
|
71
|
+
// riservata fino all'avvio del supervisor SSH e viene poi rilasciata una volta.
|
|
72
|
+
async function reserveLocalPort(st, opts = {}) {
|
|
73
|
+
const used = new Set((st.nodes || []).map((n) => n.localPort));
|
|
74
|
+
const createServerImpl = opts.createServerImpl || net.createServer;
|
|
75
|
+
for (let port = opts.start || LOCAL_PORT_BASE; port <= 65535; port += 1) {
|
|
76
|
+
if (used.has(port)) continue;
|
|
77
|
+
try { return await bindLocalPort(port, createServerImpl); }
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error && (error.code === 'EADDRINUSE' || error.code === 'EACCES')) continue;
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
throw new Error('nessuna porta locale disponibile per il tunnel');
|
|
84
|
+
}
|
|
85
|
+
|
|
41
86
|
function defaultKeyPath(home, name) {
|
|
42
87
|
return path.join(home, '.nexuscrew', 'keys', `${name}_ed25519`);
|
|
43
88
|
}
|
|
@@ -59,24 +104,6 @@ function ensureKey(keyPath, name, opts = {}) {
|
|
|
59
104
|
return fs.readFileSync(pubPath, 'utf8').trim();
|
|
60
105
|
}
|
|
61
106
|
|
|
62
|
-
// Scrittura atomica di config.json preservando il resto (per roles).
|
|
63
|
-
function writeConfigRole(configPath, key, value) {
|
|
64
|
-
let cfg = {};
|
|
65
|
-
try { const c = JSON.parse(fs.readFileSync(configPath, 'utf8')); if (c && typeof c === 'object') cfg = c; } catch (_) {}
|
|
66
|
-
const roles = (cfg.roles && typeof cfg.roles === 'object') ? cfg.roles : {};
|
|
67
|
-
cfg.roles = { client: !!roles.client, node: !!roles.node };
|
|
68
|
-
cfg.roles[key] = value;
|
|
69
|
-
const dir = path.dirname(configPath);
|
|
70
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
71
|
-
const tmp = path.join(dir, `.${path.basename(configPath)}.${process.pid}.tmp`);
|
|
72
|
-
try {
|
|
73
|
-
fs.writeFileSync(tmp, `${JSON.stringify(cfg, null, 2)}\n`, { mode: 0o600 });
|
|
74
|
-
fs.chmodSync(tmp, 0o600);
|
|
75
|
-
fs.renameSync(tmp, configPath);
|
|
76
|
-
} catch (e) { try { fs.unlinkSync(tmp); } catch (_) {} throw e; }
|
|
77
|
-
return cfg.roles;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
107
|
// Legge il token remoto da fonte NON-argv: opts.token (test) > env > stdin.
|
|
81
108
|
// MAI da flag CLI: il token comparirebbe in `ps`/history.
|
|
82
109
|
function readSecretToken(opts) {
|
|
@@ -93,7 +120,7 @@ function nodesAdd(opts) {
|
|
|
93
120
|
const { home, nodesPath } = resolveNodePaths(opts);
|
|
94
121
|
const name = opts.name;
|
|
95
122
|
const ssh = opts.ssh;
|
|
96
|
-
if (!name) { log('
|
|
123
|
+
if (!name) { log('nodes add: name mancante (configura il nodo dalla PWA)'); return { code: 1, reason: 'name mancante' }; }
|
|
97
124
|
if (!ssh) { log('nodes add: --ssh user@host obbligatorio'); return { code: 1, reason: 'ssh mancante' }; }
|
|
98
125
|
|
|
99
126
|
const remotePort = opts.remotePort ? Number(opts.remotePort) : DEFAULT_PORT;
|
|
@@ -159,7 +186,6 @@ function nodesList(opts) {
|
|
|
159
186
|
const view = store.redactStore(st); // MAI il token
|
|
160
187
|
const nodes = view.nodes.map((n) => ({ ...n, tunnel: tunnel.readTunnelState(home, n.name) }));
|
|
161
188
|
const out = { nodeId: view.nodeId, nodes };
|
|
162
|
-
if (view.rendezvous) out.rendezvous = view.rendezvous;
|
|
163
189
|
|
|
164
190
|
if (opts.json) { log(JSON.stringify(out, null, 2)); return { code: 0, nodes }; }
|
|
165
191
|
if (nodes.length === 0) { log('nodes: (nessun nodo)'); return { code: 0, nodes }; }
|
|
@@ -175,7 +201,7 @@ function nodesRemove(opts) {
|
|
|
175
201
|
if (isReadonly(opts)) { log('nodes remove: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
176
202
|
const { home, nodesPath } = resolveNodePaths(opts);
|
|
177
203
|
const name = opts.name;
|
|
178
|
-
if (!name) { log('
|
|
204
|
+
if (!name) { log('nodes remove: name mancante'); return { code: 1 }; }
|
|
179
205
|
const st = store.loadStore(nodesPath);
|
|
180
206
|
if (!st) { log('nodes remove: nessun nodes.json valido'); return { code: 1 }; }
|
|
181
207
|
let next;
|
|
@@ -207,15 +233,16 @@ async function nodesTest(opts) {
|
|
|
207
233
|
const log = opts.log || console.log;
|
|
208
234
|
const { home, nodesPath } = resolveNodePaths(opts);
|
|
209
235
|
const name = opts.name;
|
|
210
|
-
if (!name) { log('
|
|
236
|
+
if (!name) { log('nodes test: name mancante'); return { code: 1 }; }
|
|
211
237
|
const st = store.loadStore(nodesPath);
|
|
212
238
|
const node = st ? store.getNode(st, name) : null;
|
|
213
239
|
if (!node) { log(`nodes test: nodo sconosciuto "${name}"`); return { code: 1, result: 'unknown-node' }; }
|
|
214
240
|
|
|
215
241
|
const state = tunnel.readTunnelState(home, name);
|
|
216
242
|
if (state.status !== 'up') {
|
|
217
|
-
|
|
218
|
-
|
|
243
|
+
const diagnostic = tunnel.diagnoseTunnel(home, node, state);
|
|
244
|
+
log(`nodes test [${name}]: ${diagnostic.code.toUpperCase()} — ${diagnostic.detail}${diagnostic.hint ? ` · ${diagnostic.hint}` : ''}`);
|
|
245
|
+
return { code: 1, result: 'tunnel-down', diagnostic };
|
|
219
246
|
}
|
|
220
247
|
|
|
221
248
|
const httpProbe = opts.httpProbe || defaultHttpProbe;
|
|
@@ -226,13 +253,17 @@ async function nodesTest(opts) {
|
|
|
226
253
|
try { health = await httpProbe(`${base}/`, {}); }
|
|
227
254
|
catch (e) { health = { ok: false, error: e && e.message }; }
|
|
228
255
|
if (!health || !health.ok) {
|
|
229
|
-
|
|
230
|
-
|
|
256
|
+
const diagnostic = tunnel.diagnoseTunnel(home, node);
|
|
257
|
+
const realCause = diagnostic.code !== 'transport-ready' ? diagnostic.detail : ((health && health.error) || 'server HTTP non raggiungibile');
|
|
258
|
+
log(`nodes test [${name}]: HEALTH KO — ${realCause}${diagnostic.hint ? ` · ${diagnostic.hint}` : ''}`);
|
|
259
|
+
return { code: 1, result: 'health-ko', diagnostic: diagnostic.code === 'transport-ready'
|
|
260
|
+
? { stage: 'http', code: 'peer-http-unreachable', detail: realCause, hint: 'verifica la porta NexusCrew contenuta nel link' }
|
|
261
|
+
: diagnostic };
|
|
231
262
|
}
|
|
232
263
|
|
|
233
264
|
// token: GET /api/config con Bearer del nodo -> 200 ok, 401 token KO.
|
|
234
265
|
if (!node.token) {
|
|
235
|
-
log(`nodes test [${name}]:
|
|
266
|
+
log(`nodes test [${name}]: ASSOCIAZIONE INCOMPLETA — rimuovi il nodo e ripeti il pairing dalla PWA`);
|
|
236
267
|
return { code: 1, result: 'token-missing' };
|
|
237
268
|
}
|
|
238
269
|
let authed;
|
|
@@ -242,7 +273,7 @@ async function nodesTest(opts) {
|
|
|
242
273
|
log(`nodes test [${name}]: OK — tunnel up, health ok, token valido`);
|
|
243
274
|
return { code: 0, result: 'ok' };
|
|
244
275
|
}
|
|
245
|
-
log(`nodes test [${name}]:
|
|
276
|
+
log(`nodes test [${name}]: CREDENZIALE KO — il pairing non e' piu' valido (status ${(authed && authed.status) || '?'}); ripeti il pairing dalla PWA`);
|
|
246
277
|
return { code: 1, result: 'token-ko' };
|
|
247
278
|
}
|
|
248
279
|
|
|
@@ -256,11 +287,18 @@ async function defaultHttpProbe(url, headers) {
|
|
|
256
287
|
function loadNodeOrFail(opts, log) {
|
|
257
288
|
const { home, nodesPath } = resolveNodePaths(opts);
|
|
258
289
|
const name = opts.name;
|
|
259
|
-
if (!name) { log('
|
|
290
|
+
if (!name) { log('nodes: name mancante'); return null; }
|
|
260
291
|
const st = store.loadStore(nodesPath);
|
|
261
292
|
const node = st ? store.getNode(st, name) : null;
|
|
262
293
|
if (!node) { log(`nodes: nodo sconosciuto "${name}"`); return null; }
|
|
263
|
-
return { home, node };
|
|
294
|
+
return { home, nodesPath, store: st, node };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function persistAutostart(ctx, enabled) {
|
|
298
|
+
const next = store.updateNode(ctx.store, ctx.node.name, { autostart: enabled });
|
|
299
|
+
store.atomicWriteStore(ctx.nodesPath, next);
|
|
300
|
+
ctx.store = next;
|
|
301
|
+
ctx.node = store.getNode(next, ctx.node.name);
|
|
264
302
|
}
|
|
265
303
|
|
|
266
304
|
function nodesUp(opts) {
|
|
@@ -268,29 +306,35 @@ function nodesUp(opts) {
|
|
|
268
306
|
const ctx = loadNodeOrFail(opts, log);
|
|
269
307
|
if (!ctx) return { code: 1 };
|
|
270
308
|
if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
|
|
309
|
+
if (opts.persistAutostart === true && ctx.node.autostart !== true) {
|
|
310
|
+
try { persistAutostart(ctx, true); }
|
|
311
|
+
catch (e) { log(`nodes up [${ctx.node.name}]: impossibile salvare l'avvio automatico — ${e.message}`); return { code: 1, reason: 'autostart write failed' }; }
|
|
312
|
+
}
|
|
271
313
|
const r = tunnel.startForward({ home: ctx.home, node: ctx.node, localAppPort: opts.localAppPort, spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd });
|
|
272
314
|
if (r.started) {
|
|
273
315
|
log(`nodes up [${ctx.node.name}]: tunnel avviato (pid ${r.pid}, local ${ctx.node.localPort})`);
|
|
274
|
-
return { code: 0, started: true, pid: r.pid };
|
|
316
|
+
return { code: 0, started: true, pid: r.pid, diagnostic: tunnel.diagnoseTunnel(ctx.home, ctx.node) };
|
|
275
317
|
}
|
|
276
318
|
if (r.reason === 'already running') {
|
|
277
319
|
log(`nodes up [${ctx.node.name}]: gia' attivo (pid ${r.pid})`);
|
|
278
|
-
return { code: 0, started: false, pid: r.pid };
|
|
320
|
+
return { code: 0, started: false, pid: r.pid, diagnostic: tunnel.diagnoseTunnel(ctx.home, ctx.node) };
|
|
279
321
|
}
|
|
280
322
|
// failure esplicita (ssh mancante / spawn error): surfacciata a CLI e Settings API.
|
|
281
323
|
log(`nodes up [${ctx.node.name}]: avvio tunnel fallito — ${r.reason}`);
|
|
282
|
-
return { code: 1, started: false, reason: r.reason };
|
|
324
|
+
return { code: 1, started: false, reason: r.reason, diagnostic: tunnel.diagnoseTunnel(ctx.home, ctx.node) };
|
|
283
325
|
}
|
|
284
326
|
|
|
285
327
|
function nodesDown(opts) {
|
|
286
328
|
const log = opts.log || console.log;
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
if (
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
329
|
+
const ctx = loadNodeOrFail(opts, log);
|
|
330
|
+
if (!ctx) return { code: 1 };
|
|
331
|
+
if (ctx.node.direction === 'inbound') return { code: 0, stopped: false, inbound: true };
|
|
332
|
+
if (opts.persistAutostart === true && ctx.node.autostart !== false) {
|
|
333
|
+
try { persistAutostart(ctx, false); }
|
|
334
|
+
catch (e) { log(`nodes down [${ctx.node.name}]: impossibile salvare lo stop — ${e.message}`); return { code: 1, reason: 'autostart write failed' }; }
|
|
335
|
+
}
|
|
336
|
+
const r = tunnel.stopTunnel({ home: ctx.home, name: ctx.node.name });
|
|
337
|
+
log(`nodes down [${ctx.node.name}]: ${r.stopped ? `fermato (pid ${r.pid})` : r.reason}`);
|
|
294
338
|
return { code: 0, stopped: r.stopped };
|
|
295
339
|
}
|
|
296
340
|
|
|
@@ -298,16 +342,17 @@ function nodesRestart(opts) {
|
|
|
298
342
|
const log = opts.log || console.log;
|
|
299
343
|
const ctx = loadNodeOrFail(opts, log);
|
|
300
344
|
if (!ctx) return { code: 1 };
|
|
301
|
-
|
|
302
|
-
|
|
345
|
+
if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
|
|
346
|
+
tunnel.stopTunnel({ home: ctx.home, name: ctx.node.name });
|
|
347
|
+
const r = tunnel.startForward({ home: ctx.home, node: ctx.node, localAppPort: opts.localAppPort, spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd });
|
|
303
348
|
if (r.started) {
|
|
304
349
|
log(`nodes restart [${ctx.node.name}]: tunnel riavviato (pid ${r.pid})`);
|
|
305
|
-
return { code: 0, started: true, pid: r.pid };
|
|
350
|
+
return { code: 0, started: true, pid: r.pid, diagnostic: tunnel.diagnoseTunnel(ctx.home, ctx.node) };
|
|
306
351
|
}
|
|
307
352
|
// dopo stop+start, 'already running' non e' atteso: qualunque !started e' un problema
|
|
308
353
|
// esplicito (ssh mancante / spawn error), surfacciato a CLI e Settings API.
|
|
309
354
|
log(`nodes restart [${ctx.node.name}]: riavvio tunnel fallito — ${r.reason || 'sconosciuto'}`);
|
|
310
|
-
return { code: 1, started: false, reason: r.reason || 'spawn failed' };
|
|
355
|
+
return { code: 1, started: false, reason: r.reason || 'spawn failed', diagnostic: tunnel.diagnoseTunnel(ctx.home, ctx.node) };
|
|
311
356
|
}
|
|
312
357
|
|
|
313
358
|
// --- nodes set-token (aggiorna il token remoto; MAI da argv) ----------------
|
|
@@ -316,7 +361,7 @@ function nodesSetToken(opts) {
|
|
|
316
361
|
if (isReadonly(opts)) { log('nodes set-token: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
317
362
|
const { nodesPath } = resolveNodePaths(opts);
|
|
318
363
|
const name = opts.name;
|
|
319
|
-
if (!name) { log('
|
|
364
|
+
if (!name) { log('nodes set-token: name mancante'); return { code: 1 }; }
|
|
320
365
|
const st = store.loadStore(nodesPath);
|
|
321
366
|
if (!st || !store.getNode(st, name)) { log(`nodes set-token: nodo sconosciuto "${name}"`); return { code: 1 }; }
|
|
322
367
|
const token = readSecretToken(opts);
|
|
@@ -329,83 +374,10 @@ function nodesSetToken(opts) {
|
|
|
329
374
|
return { code: 0, name };
|
|
330
375
|
}
|
|
331
376
|
|
|
332
|
-
// --- node on|off (ruolo "nodo raggiungibile", reverse tunnel) ---------------
|
|
333
|
-
function nodeOn(opts) {
|
|
334
|
-
const log = opts.log || console.log;
|
|
335
|
-
if (isReadonly(opts)) { log('node on: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
336
|
-
const { home, configPath, nodesPath } = resolveNodePaths(opts);
|
|
337
|
-
|
|
338
|
-
// Gate permitlisten (§7 advisory a): il ruolo node richiede reverse tunnel con
|
|
339
|
-
// permitlisten sul rendezvous (OpenSSH >=7.8). Se la versione e' nota ed e'
|
|
340
|
-
// troppo vecchia -> rifiuta PRIMA di abilitare. Ignota -> warn, non blocca.
|
|
341
|
-
const v = (opts.sshVersion || tunnel.readSshVersion)(opts.spawnSyncImpl);
|
|
342
|
-
const supp = tunnel.sshSupportsPermitlisten(v);
|
|
343
|
-
if (supp === false) {
|
|
344
|
-
log(`node on: OpenSSH ${v.major}.${v.minor} < 7.8 — permitlisten non supportato dal client; il ruolo node NON e' abilitabile con questa toolchain`);
|
|
345
|
-
return { code: 1, reason: 'permitlisten' };
|
|
346
|
-
}
|
|
347
|
-
if (supp === null) log('node on: versione OpenSSH non determinabile — verifica che il rendezvous supporti permitlisten (>=7.8)');
|
|
348
|
-
|
|
349
|
-
// Configurazione rendezvous opzionale (flags) o gia' presente in nodes.json.
|
|
350
|
-
let st;
|
|
351
|
-
try { st = store.loadOrInitStore(nodesPath); }
|
|
352
|
-
catch (e) { log(`node on: ${e.message}`); return { code: 1 }; }
|
|
353
|
-
|
|
354
|
-
if (opts.rendezvousSsh) {
|
|
355
|
-
const localPort = loadPort(opts); // porta nexus locale da esporre
|
|
356
|
-
const publishedPort = opts.publishedPort ? Number(opts.publishedPort) : localPort;
|
|
357
|
-
const keyPath = opts.key || path.join(home, '.nexuscrew', 'keys', 'rendezvous_ed25519');
|
|
358
|
-
let pub;
|
|
359
|
-
try { pub = ensureKey(keyPath, 'rendezvous', opts); }
|
|
360
|
-
catch (e) { log(`node on: generazione chiave rendezvous fallita (${e.message})`); return { code: 1 }; }
|
|
361
|
-
let next;
|
|
362
|
-
try { next = store.setRendezvous(st, { ssh: opts.rendezvousSsh, publishedPort, localPort, keyPath }); }
|
|
363
|
-
catch (e) { log(`node on: ${e.message}`); return { code: 1 }; }
|
|
364
|
-
store.atomicWriteStore(nodesPath, next);
|
|
365
|
-
st = next;
|
|
366
|
-
log(`node on: rendezvous configurato (${opts.rendezvousSsh}, published ${publishedPort} <- local ${localPort})`);
|
|
367
|
-
log('Incolla nel ~/.ssh/authorized_keys del RENDEZVOUS (lato reverse, chiave dedicata):');
|
|
368
|
-
// permitlisten vincola i -R alla SOLA porta pubblicata (loopback esplicito).
|
|
369
|
-
log(`restrict,port-forwarding,permitlisten="127.0.0.1:${publishedPort}",command="/bin/false" ${pub}`);
|
|
370
|
-
} else if (!st.rendezvous) {
|
|
371
|
-
log('node on: nessun rendezvous configurato — passa --rendezvous user@host [--published-port N] [--key path]');
|
|
372
|
-
return { code: 1, reason: 'no rendezvous' };
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
// Avvia un supervisor detached per il reverse tunnel. Il supervisor ritenta
|
|
376
|
-
// con backoff: al primo setup può partire prima che la pubkey sia stata
|
|
377
|
-
// incollata sul rendezvous e convergerà automaticamente appena autorizzata.
|
|
378
|
-
const tr = tunnel.startReverse({
|
|
379
|
-
home, rendezvous: st.rendezvous,
|
|
380
|
-
spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl,
|
|
381
|
-
sshBin: opts.sshBin, logFd: opts.logFd,
|
|
382
|
-
});
|
|
383
|
-
if (!tr.started && tr.reason !== 'already running') {
|
|
384
|
-
log(`node on: reverse tunnel non avviato — ${tr.reason || 'errore sconosciuto'}; ruolo NON abilitato`);
|
|
385
|
-
return { code: 1, reason: 'reverse tunnel failed' };
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const roles = writeConfigRole(configPath, 'node', true);
|
|
389
|
-
log(`node on: ruolo node ABILITATO (roles: client=${roles.client} node=${roles.node}, reverse pid=${tr.pid})`);
|
|
390
|
-
return { code: 0, roles, tunnel: { started: !!tr.started, pid: tr.pid } };
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function nodeOff(opts) {
|
|
394
|
-
const log = opts.log || console.log;
|
|
395
|
-
if (isReadonly(opts)) { log('node off: READONLY, mutazione bloccata'); return { code: 1, reason: 'readonly' }; }
|
|
396
|
-
const { home, configPath } = resolveNodePaths(opts);
|
|
397
|
-
const roles = writeConfigRole(configPath, 'node', false);
|
|
398
|
-
// Ferma un eventuale reverse tunnel attivo (best-effort, non tocca la config rendezvous).
|
|
399
|
-
try { tunnel.stopTunnel({ home, name: tunnel.REVERSE_NAME }); } catch (_) {}
|
|
400
|
-
log(`node off: ruolo node DISABILITATO (roles: client=${roles.client} node=${roles.node})`);
|
|
401
|
-
return { code: 0, roles };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
377
|
module.exports = {
|
|
405
378
|
nodesAdd, nodesList, nodesRemove, nodesTest,
|
|
406
379
|
nodesUp, nodesDown, nodesRestart, nodesSetToken,
|
|
407
|
-
nodeOn, nodeOff,
|
|
408
380
|
// helper esposti per test/riuso
|
|
409
|
-
assignLocalPort, defaultKeyPath, ensureKey,
|
|
381
|
+
assignLocalPort, bindLocalPort, reserveLocalPort, defaultKeyPath, ensureKey, readSecretToken,
|
|
410
382
|
resolveNodePaths, defaultHttpProbe,
|
|
411
383
|
};
|
package/lib/nodes/health.js
CHANGED
|
@@ -30,22 +30,29 @@ function clearHealthCache() { cache.clear(); }
|
|
|
30
30
|
// onesto). outbound down/401 -> 'down'/'degraded'.
|
|
31
31
|
function tunnelFromHealth(h) {
|
|
32
32
|
if (!h) return { status: 'unknown', managed: false };
|
|
33
|
-
|
|
34
|
-
if (h.
|
|
35
|
-
if (h.transport === 'up') return { status: '
|
|
36
|
-
if (h.transport === '
|
|
33
|
+
const used = h.transportEngine ? { transport: h.transportEngine } : {};
|
|
34
|
+
if (h.status === 'passive') return { status: 'passive', managed: false, ...used };
|
|
35
|
+
if (h.transport === 'up' && h.auth === 'ok') return { status: 'up', managed: h.managed !== false, ...used };
|
|
36
|
+
if (h.transport === 'up' && h.auth === 'failed') return { status: 'degraded', managed: h.managed !== false, ...used };
|
|
37
|
+
if (h.transport === 'up') return { status: 'degraded', managed: h.managed !== false, ...used };
|
|
38
|
+
if (h.transport === 'down') return { status: 'down', managed: h.managed !== false, ...used };
|
|
37
39
|
return { status: 'unknown', managed: false }; // inbound / unknown
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false }) {
|
|
41
43
|
if (!node || typeof node !== 'object') return null;
|
|
42
|
-
const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}`;
|
|
44
|
+
const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}\0${node.shared === true}\0${node.rolesKnown === true}\0${node.roles?.node === true}`;
|
|
43
45
|
const cached = !force && cache.get(cacheKey);
|
|
44
46
|
if (cached && (now - cached.at) < TTL_MS) return cached.health;
|
|
45
47
|
|
|
46
48
|
let health;
|
|
47
49
|
if (node.direction === 'inbound') {
|
|
48
|
-
if (
|
|
50
|
+
if (node.shared !== true) {
|
|
51
|
+
health = {
|
|
52
|
+
transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'passive',
|
|
53
|
+
detail: 'client privato collegato (Share disattivato)', expected: true, managed: false, at: now,
|
|
54
|
+
};
|
|
55
|
+
} else if (!node.token) {
|
|
49
56
|
health = {
|
|
50
57
|
transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'degraded',
|
|
51
58
|
detail: 'peer inbound senza credenziale federation — re-pair', managed: false, at: now,
|
|
@@ -54,27 +61,39 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
|
|
|
54
61
|
const probed = await probeHealth({
|
|
55
62
|
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
|
|
56
63
|
});
|
|
57
|
-
|
|
64
|
+
// The receiving side does not own an inbound client's lifecycle. A
|
|
65
|
+
// client-only (or legacy unknown-role) peer being offline is expected,
|
|
66
|
+
// not a broken server. Live auth/payload failures remain real failures.
|
|
67
|
+
if (probed.transport === 'down' && (node.rolesKnown !== true || node.roles?.node !== true)) {
|
|
68
|
+
health = {
|
|
69
|
+
...probed, status: 'passive', expected: true, managed: false,
|
|
70
|
+
detail: node.rolesKnown === true ? 'client peer offline (expected)' : 'inbound peer offline',
|
|
71
|
+
};
|
|
72
|
+
} else {
|
|
73
|
+
health = { ...probed, managed: false };
|
|
74
|
+
}
|
|
58
75
|
}
|
|
59
76
|
} else {
|
|
60
77
|
const ts = nodesTunnel.readTunnelState(home, node.name);
|
|
61
78
|
if (ts.status !== 'up') {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
79
|
+
const diagnostic = nodesTunnel.diagnoseTunnel(home, node, ts);
|
|
80
|
+
health = {
|
|
81
|
+
transport: 'down', auth: 'unknown', reachability: 'unknown', status: 'down',
|
|
82
|
+
detail: diagnostic.detail, code: diagnostic.code, stage: diagnostic.stage,
|
|
83
|
+
...(diagnostic.hint ? { hint: diagnostic.hint } : {}),
|
|
84
|
+
transportEngine: ts.transport || 'ssh', managed: ts.managed !== false, at: now,
|
|
85
|
+
};
|
|
67
86
|
} else if (!node.token) {
|
|
68
87
|
health = {
|
|
69
88
|
transport: 'up', auth: 'unknown', reachability: 'unknown', status: 'degraded',
|
|
70
89
|
detail: 'tunnel up, credenziali federation assenti (token mancante) — re-pair',
|
|
71
|
-
managed: true, at: now,
|
|
90
|
+
transportEngine: ts.transport || 'ssh', managed: true, at: now,
|
|
72
91
|
};
|
|
73
92
|
} else {
|
|
74
93
|
const probed = await probeHealth({
|
|
75
94
|
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
|
|
76
95
|
});
|
|
77
|
-
health = { ...probed, managed: true };
|
|
96
|
+
health = { ...probed, transportEngine: ts.transport || 'ssh', managed: true };
|
|
78
97
|
}
|
|
79
98
|
}
|
|
80
99
|
cache.set(cacheKey, { health, at: now });
|
package/lib/nodes/peering.js
CHANGED
|
@@ -7,6 +7,8 @@ const store = require('./store.js');
|
|
|
7
7
|
|
|
8
8
|
const INVITE_TTL_MS = 10 * 60 * 1000;
|
|
9
9
|
const REVERSE_PORT_BASE = 44001;
|
|
10
|
+
const CAPABILITY_ID_RE = /^[a-f0-9]{64}$/;
|
|
11
|
+
const CHALLENGE_RE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
10
12
|
|
|
11
13
|
function defaultInvitesPath(home) { return path.join(home, '.nexuscrew', 'invites.json'); }
|
|
12
14
|
function defaultPendingPath(home) { return path.join(home, '.nexuscrew', 'pairing-pending.json'); }
|
|
@@ -89,7 +91,7 @@ function parsePairingUrl(value) {
|
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
function createInvite({
|
|
92
|
-
invitesPath, instanceId, port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes,
|
|
94
|
+
invitesPath, instanceId, port, linkPort = port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes,
|
|
93
95
|
ssh, sshPort, name,
|
|
94
96
|
} = {}) {
|
|
95
97
|
const invite = randomBytes(32).toString('base64url');
|
|
@@ -116,7 +118,8 @@ function createInvite({
|
|
|
116
118
|
if (sshVal && store.isPort(sshPort)) payload.sshPort = sshPort;
|
|
117
119
|
}
|
|
118
120
|
const pair = encodePairing(payload);
|
|
119
|
-
|
|
121
|
+
if (!store.isPort(linkPort)) throw new Error('porta locale del link non valida');
|
|
122
|
+
return { pairingUrl: `http://127.0.0.1:${linkPort}/#pair=${pair}`, expiresAt, version: payload.v };
|
|
120
123
|
}
|
|
121
124
|
|
|
122
125
|
function consumeInvite({ invitesPath, invite, now = Date.now() }) {
|
|
@@ -161,21 +164,81 @@ function consumePending({ pendingPath, credential, now = Date.now() }) {
|
|
|
161
164
|
return found;
|
|
162
165
|
}
|
|
163
166
|
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
167
|
+
// Prova pubblica ma capability-bound usata durante il pairing. Il client NON
|
|
168
|
+
// invia mai l'invito/credential: invia SHA256(SHA256(capability)) + challenge;
|
|
169
|
+
// il peer cerca il record attivo e firma il challenge con SHA256(capability).
|
|
170
|
+
// Un processo HTTP estraneo sulla stessa porta non puo' quindi diventare un
|
|
171
|
+
// falso positivo e non riceve materiale sufficiente per consumare l'invito.
|
|
172
|
+
function capabilityIdentity({ invitesPath, pendingPath, capabilityId, challenge, now = Date.now() } = {}) {
|
|
173
|
+
if (!CAPABILITY_ID_RE.test(String(capabilityId || '')) || !CHALLENGE_RE.test(String(challenge || ''))) return null;
|
|
174
|
+
const rows = [
|
|
175
|
+
...readInvites(invitesPath, now),
|
|
176
|
+
...readInvites(pendingPath, now),
|
|
177
|
+
];
|
|
178
|
+
const row = rows.find((x) => {
|
|
179
|
+
if (!x || !CAPABILITY_ID_RE.test(String(x.hash || ''))) return false;
|
|
180
|
+
const id = crypto.createHash('sha256').update(Buffer.from(x.hash, 'hex')).digest('hex');
|
|
181
|
+
return safeEqual(id, capabilityId);
|
|
182
|
+
});
|
|
183
|
+
if (!row) return null;
|
|
184
|
+
return crypto.createHmac('sha256', Buffer.from(row.hash, 'hex')).update(challenge).digest('base64url');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function capabilityProbeMaterial(capability, randomBytes = crypto.randomBytes) {
|
|
188
|
+
if (!store.validToken(capability)) return null;
|
|
189
|
+
const key = crypto.createHash('sha256').update(capability).digest();
|
|
190
|
+
return {
|
|
191
|
+
key,
|
|
192
|
+
capabilityId: crypto.createHash('sha256').update(key).digest('hex'),
|
|
193
|
+
challenge: randomBytes(24).toString('base64url'),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Probe di trasporto del tunnel -L PRIMA di consumare l'invito e dopo il
|
|
198
|
+
// restart negoziato. Oltre alla reachability verifica prova capability e
|
|
199
|
+
// instanceId atteso. Bounded, con sleep/random iniettabili per test.
|
|
200
|
+
async function probeTransportReady({
|
|
201
|
+
port, capability, expectedInstanceId, fetchImpl = fetch, attempts = 6,
|
|
202
|
+
timeoutMs = 1500, sleep, randomBytes,
|
|
203
|
+
} = {}) {
|
|
170
204
|
const wait = typeof sleep === 'function' ? sleep : (ms) => new Promise((r) => setTimeout(r, ms));
|
|
205
|
+
const material = capabilityProbeMaterial(capability, randomBytes);
|
|
206
|
+
if (!store.isPort(port) || !material || !store.NODE_ID_RE.test(String(expectedInstanceId || ''))) {
|
|
207
|
+
return { ready: false, attempts: 0, code: 'identity-probe-invalid', lastError: 'parametri identity probe non validi' };
|
|
208
|
+
}
|
|
171
209
|
let lastError = '';
|
|
210
|
+
let code = 'transport-not-ready';
|
|
172
211
|
for (let i = 0; i < attempts; i += 1) {
|
|
173
212
|
let timer;
|
|
174
213
|
try {
|
|
175
214
|
const ctrl = new AbortController();
|
|
176
215
|
timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
177
|
-
await fetchImpl(`http://127.0.0.1:${port}/
|
|
178
|
-
|
|
216
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/pair/identity`, {
|
|
217
|
+
method: 'POST',
|
|
218
|
+
headers: { 'content-type': 'application/json' },
|
|
219
|
+
body: JSON.stringify({ capabilityId: material.capabilityId, challenge: material.challenge }),
|
|
220
|
+
signal: ctrl.signal,
|
|
221
|
+
});
|
|
222
|
+
if (!response || response.status !== 200) {
|
|
223
|
+
code = 'identity-proof-rejected';
|
|
224
|
+
throw new Error(`identity probe HTTP ${(response && response.status) || '?'}`);
|
|
225
|
+
}
|
|
226
|
+
const body = await response.json().catch(() => null);
|
|
227
|
+
if (!body || body.ok !== true || !store.NODE_ID_RE.test(String(body.instanceId || ''))
|
|
228
|
+
|| typeof body.proof !== 'string') {
|
|
229
|
+
code = 'identity-proof-invalid';
|
|
230
|
+
throw new Error('identity probe payload non valido');
|
|
231
|
+
}
|
|
232
|
+
if (body.instanceId !== expectedInstanceId) {
|
|
233
|
+
code = 'peer-identity-mismatch';
|
|
234
|
+
throw new Error('instanceId del peer non coincide con il link');
|
|
235
|
+
}
|
|
236
|
+
const expected = crypto.createHmac('sha256', material.key).update(material.challenge).digest('base64url');
|
|
237
|
+
if (!safeEqual(expected, body.proof)) {
|
|
238
|
+
code = 'identity-proof-invalid';
|
|
239
|
+
throw new Error('prova crittografica del peer non valida');
|
|
240
|
+
}
|
|
241
|
+
return { ready: true, attempts: i + 1, instanceId: body.instanceId };
|
|
179
242
|
} catch (e) {
|
|
180
243
|
lastError = String((e && e.message) || e);
|
|
181
244
|
} finally {
|
|
@@ -183,12 +246,12 @@ async function probeTransportReady({ port, fetchImpl = fetch, attempts = 6, time
|
|
|
183
246
|
}
|
|
184
247
|
if (i < attempts - 1) await wait(250 * (i + 1));
|
|
185
248
|
}
|
|
186
|
-
return { ready: false, attempts, lastError };
|
|
249
|
+
return { ready: false, attempts, code, lastError };
|
|
187
250
|
}
|
|
188
251
|
|
|
189
252
|
module.exports = {
|
|
190
253
|
INVITE_TTL_MS, REVERSE_PORT_BASE, defaultInvitesPath, defaultPendingPath, safeEqual,
|
|
191
254
|
readInvites, writeInvites, encodePairing, decodePairing, parsePairingUrl,
|
|
192
255
|
createInvite, consumeInvite, allocateReversePort, createPending, consumePending,
|
|
193
|
-
probeTransportReady,
|
|
256
|
+
capabilityIdentity, capabilityProbeMaterial, probeTransportReady,
|
|
194
257
|
};
|