@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/settings/routes.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Contratto duro:
|
|
5
5
|
// - read-only: GET /api/settings (roles, firstRun, port, platform, service,
|
|
6
|
-
//
|
|
6
|
+
// version).
|
|
7
7
|
// - mutanti (LISTA CHIUSA §4b(6)), tutti dietro requireToken (montati sotto il
|
|
8
8
|
// router /api gia' autenticato) + gate READONLY route-level (pattern
|
|
9
9
|
// lib/fleet/routes.js, 403 esplicito):
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// POST /token/rotate riusa rotateToken(); token MAI in risposta (§4b(3))
|
|
12
12
|
// POST /nodes nodes add (riusa lib/nodes/commands.js)
|
|
13
13
|
// DELETE /nodes/:name nodes remove
|
|
14
|
-
//
|
|
14
|
+
// PATCH /nodes/:name/share publish/revoke the local node on its hub
|
|
15
|
+
// POST /node-role retired compatibility endpoint (410)
|
|
15
16
|
// POST /service/regenerate rigenera unit service (NO restart automatico)
|
|
16
17
|
// - NON gate READONLY (lifecycle di PROCESSO, non mutazione di config — decisione
|
|
17
18
|
// B0 documentata in lib/nodes/commands.js: READONLY blocca le mutazioni di
|
|
@@ -27,9 +28,9 @@
|
|
|
27
28
|
// 403 readonly, 404 nodo, 409 conflitto, 500 con messaggio). Niente stack trace.
|
|
28
29
|
// - validazione input strict fail-closed: schema chiuso per ogni body, garbage
|
|
29
30
|
// -> 400 con causa, mai guess.
|
|
30
|
-
// - NIENTE reimplementazione della logica B0: i mutanti nodes
|
|
31
|
-
//
|
|
32
|
-
//
|
|
31
|
+
// - NIENTE reimplementazione della logica B0: i mutanti nodes incapsulano le
|
|
32
|
+
// funzioni CLI di lib/nodes/commands.js (log catturato per estrarre l'esito);
|
|
33
|
+
// config.json scritto col pattern atomico
|
|
33
34
|
// tmp+rename di lib/nodes/store.js.
|
|
34
35
|
//
|
|
35
36
|
// Nota lifecycle: i tunnel avviati da /nodes/:name/up sono spawn DETACHED+unref
|
|
@@ -53,12 +54,12 @@ const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js')
|
|
|
53
54
|
const { isServiceRunning, readRoles, bootState } = require('../cli/commands.js');
|
|
54
55
|
const { configJsonPath } = require('../config.js');
|
|
55
56
|
const VERSION = require('../../package.json').version;
|
|
57
|
+
const { scrubError } = require('../update/core.js');
|
|
56
58
|
|
|
57
59
|
// Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
|
|
58
|
-
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone']);
|
|
60
|
+
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
|
|
59
61
|
const ROLE_KEYS = new Set(['client', 'node']);
|
|
60
62
|
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
|
|
61
|
-
const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
|
|
62
63
|
|
|
63
64
|
// Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
|
|
64
65
|
// NON usa ciecamente hostname: se l'hostname e' vuoto o 'localhost' (tipico di
|
|
@@ -141,6 +142,7 @@ function settingsRoutes(deps = {}) {
|
|
|
141
142
|
// scrive solo il file (come prima), senza reload in-memory.
|
|
142
143
|
const tokenStore = deps.tokenStore || null;
|
|
143
144
|
const closeSessions = deps.closeSessions || null;
|
|
145
|
+
const updater = deps.updater || null;
|
|
144
146
|
const home = cfg.home || os.homedir();
|
|
145
147
|
const configDir = cfg.configDir || path.join(home, '.nexuscrew');
|
|
146
148
|
// configJsonPath() rispetta NEXUSCREW_CONFIG_FILE (stessa risoluzione di
|
|
@@ -153,6 +155,7 @@ function settingsRoutes(deps = {}) {
|
|
|
153
155
|
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
154
156
|
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
155
157
|
const platform = seams.platform || detectPlatform();
|
|
158
|
+
const runtimePort = typeof deps.runtimePort === 'function' ? deps.runtimePort : () => cfg.port;
|
|
156
159
|
|
|
157
160
|
const r = express.Router();
|
|
158
161
|
r.use(express.json({ limit: '8kb' }));
|
|
@@ -174,6 +177,7 @@ function settingsRoutes(deps = {}) {
|
|
|
174
177
|
spawnImpl: seams.spawnImpl, sshBin: seams.sshBin, logFd: seams.logFd,
|
|
175
178
|
httpProbe: seams.httpProbe, sshVersion: seams.sshVersion,
|
|
176
179
|
spawnSyncImpl: seams.spawnSyncImpl,
|
|
180
|
+
localAppPort: runtimePort(),
|
|
177
181
|
});
|
|
178
182
|
|
|
179
183
|
const validName = (name) => typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name);
|
|
@@ -191,6 +195,7 @@ function settingsRoutes(deps = {}) {
|
|
|
191
195
|
active: isServiceRunning({ platform, execImpl: seams.execImpl, uid: seams.uid, home }),
|
|
192
196
|
boot: bootState({ platform, execImpl: seams.execImpl, uid: seams.uid, home }).enabled,
|
|
193
197
|
};
|
|
198
|
+
const updateStatus = updater && typeof updater.status === 'function' ? updater.status() : null;
|
|
194
199
|
const out = {
|
|
195
200
|
roles: readRoles(configPath),
|
|
196
201
|
firstRun,
|
|
@@ -198,17 +203,12 @@ function settingsRoutes(deps = {}) {
|
|
|
198
203
|
platform,
|
|
199
204
|
service,
|
|
200
205
|
version: VERSION,
|
|
206
|
+
autoUpdate: updateStatus ? updateStatus.enabled : fileCfg.autoUpdate !== false,
|
|
201
207
|
// Nome dispositivo proposto per i form di pairing (etichetta umana, non
|
|
202
208
|
// lo slug). La UI lo precompila e lascia editing libero.
|
|
203
209
|
deviceName: defaultDeviceName(),
|
|
204
210
|
};
|
|
205
|
-
|
|
206
|
-
// la si prende comunque SOLO dalla vista redatta.
|
|
207
|
-
const st = nodesStore.loadStore(nodesPath);
|
|
208
|
-
if (st) {
|
|
209
|
-
const view = nodesStore.redactStore(st);
|
|
210
|
-
if (view.rendezvous) out.rendezvous = view.rendezvous;
|
|
211
|
-
}
|
|
211
|
+
if (updateStatus) out.update = updateStatus;
|
|
212
212
|
send(res, 200, out);
|
|
213
213
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
214
214
|
});
|
|
@@ -221,10 +221,10 @@ function settingsRoutes(deps = {}) {
|
|
|
221
221
|
return send(res, 400, { error: 'body deve essere un oggetto JSON' });
|
|
222
222
|
}
|
|
223
223
|
for (const k of Object.keys(b)) {
|
|
224
|
-
if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone)` });
|
|
224
|
+
if (!CONFIG_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (whitelist: roles, port, wizardDone, autoUpdate)` });
|
|
225
225
|
}
|
|
226
226
|
if (Object.keys(b).length === 0) {
|
|
227
|
-
return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone)' });
|
|
227
|
+
return send(res, 400, { error: 'nessuna chiave da scrivere (whitelist: roles, port, wizardDone, autoUpdate)' });
|
|
228
228
|
}
|
|
229
229
|
if (b.roles !== undefined) {
|
|
230
230
|
if (!b.roles || typeof b.roles !== 'object' || Array.isArray(b.roles)) {
|
|
@@ -238,9 +238,22 @@ function settingsRoutes(deps = {}) {
|
|
|
238
238
|
if (b.port !== undefined && !nodesStore.isPort(b.port)) {
|
|
239
239
|
return send(res, 400, { error: 'port deve essere un intero 1..65535' });
|
|
240
240
|
}
|
|
241
|
+
if (b.port !== undefined && b.port !== runtimePort()) {
|
|
242
|
+
const peers = nodesStore.loadStore(nodesPath);
|
|
243
|
+
if (nodesStore.hasPairedPeers(peers)) {
|
|
244
|
+
return send(res, 409, {
|
|
245
|
+
error: 'porta non modificata: esistono nodi già collegati',
|
|
246
|
+
code: 'paired-port-change-refused',
|
|
247
|
+
hint: 'libera la porta corrente oppure rimuovi e ricollega intenzionalmente i peer',
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
241
251
|
if (b.wizardDone !== undefined && typeof b.wizardDone !== 'boolean') {
|
|
242
252
|
return send(res, 400, { error: 'wizardDone deve essere boolean' });
|
|
243
253
|
}
|
|
254
|
+
if (b.autoUpdate !== undefined && typeof b.autoUpdate !== 'boolean') {
|
|
255
|
+
return send(res, 400, { error: 'autoUpdate deve essere boolean' });
|
|
256
|
+
}
|
|
244
257
|
|
|
245
258
|
// merge sul config esistente (preserva le chiavi non gestite qui)
|
|
246
259
|
const { cfg: current } = readConfigFile(configPath);
|
|
@@ -251,11 +264,15 @@ function settingsRoutes(deps = {}) {
|
|
|
251
264
|
}
|
|
252
265
|
if (b.port !== undefined) next.port = b.port;
|
|
253
266
|
if (b.wizardDone !== undefined) next.wizardDone = b.wizardDone;
|
|
267
|
+
if (b.autoUpdate !== undefined) next.autoUpdate = b.autoUpdate;
|
|
254
268
|
atomicWriteConfig(configPath, next);
|
|
269
|
+
if (b.autoUpdate !== undefined && updater && typeof updater.setEnabled === 'function') {
|
|
270
|
+
updater.setEnabled(b.autoUpdate);
|
|
271
|
+
}
|
|
255
272
|
|
|
256
273
|
const out = {
|
|
257
274
|
saved: true,
|
|
258
|
-
config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone },
|
|
275
|
+
config: { roles: next.roles, port: next.port, wizardDone: next.wizardDone, autoUpdate: next.autoUpdate !== false },
|
|
259
276
|
};
|
|
260
277
|
// Il server legge la porta SOLO allo startup: il cambio vale al prossimo
|
|
261
278
|
// restart (contratto dichiarato nella risposta, la UI avvisa).
|
|
@@ -266,6 +283,20 @@ function settingsRoutes(deps = {}) {
|
|
|
266
283
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
267
284
|
});
|
|
268
285
|
|
|
286
|
+
// Auto-update npm: check e apply sono autenticati, READONLY-gated e non
|
|
287
|
+
// accettano mai package/versione dal browser. Il manager usa esclusivamente
|
|
288
|
+
// @mmmbuto/nexuscrew@latest, confronta semver e installa la versione esatta.
|
|
289
|
+
r.post('/update/check', mutGate, async (_req, res) => {
|
|
290
|
+
if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
|
|
291
|
+
try { send(res, 200, await updater.check()); }
|
|
292
|
+
catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
|
|
293
|
+
});
|
|
294
|
+
r.post('/update/apply', mutGate, async (_req, res) => {
|
|
295
|
+
if (!updater) return send(res, 501, { error: 'auto-update non disponibile' });
|
|
296
|
+
try { send(res, 202, await updater.apply()); }
|
|
297
|
+
catch (e) { send(res, e.status || 500, { error: scrubError(e), ...(e.code ? { code: e.code } : {}) }); }
|
|
298
|
+
});
|
|
299
|
+
|
|
269
300
|
// --- POST /token/rotate — atomico + reload live + chiusura WS; token MAI in risposta
|
|
270
301
|
// Contratto §4b(3): "scrittura atomica del token file + chiusura delle sessioni
|
|
271
302
|
// WS/API attive locali + reload credenziali proxy". Audit F7: prima questa route
|
|
@@ -281,7 +312,7 @@ function settingsRoutes(deps = {}) {
|
|
|
281
312
|
if (typeof closeSessions === 'function') closeSessions();
|
|
282
313
|
send(res, 200, {
|
|
283
314
|
rotated: true,
|
|
284
|
-
note: 'token ruotato: sessioni attive chiuse, vecchio token invalidato (401) — recupera il nuovo con `nexuscrew
|
|
315
|
+
note: 'token ruotato: sessioni attive chiuse, vecchio token invalidato (401) — recupera il nuovo con `nexuscrew show token`',
|
|
285
316
|
});
|
|
286
317
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
287
318
|
});
|
|
@@ -297,6 +328,7 @@ function settingsRoutes(deps = {}) {
|
|
|
297
328
|
try {
|
|
298
329
|
const b = req.body || {};
|
|
299
330
|
const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
|
|
331
|
+
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
300
332
|
const extra = {};
|
|
301
333
|
if (typeof b.ssh === 'string' && b.ssh.trim()) {
|
|
302
334
|
const ssh = nodesStore.parseSshTarget(b.ssh.trim());
|
|
@@ -308,14 +340,19 @@ function settingsRoutes(deps = {}) {
|
|
|
308
340
|
extra.sshPort = Number(b.sshPort);
|
|
309
341
|
}
|
|
310
342
|
if (extra.sshPort && !extra.ssh) return send(res, 400, { error: 'sshPort richiede un target SSH' });
|
|
343
|
+
if (!extra.ssh) return send(res, 400, { error: 'indica l’Host SSH pubblico/alias con cui gli altri dispositivi raggiungono questo hub' });
|
|
344
|
+
let remotePort = runtimePort();
|
|
345
|
+
if (b.remotePort !== undefined && b.remotePort !== null && b.remotePort !== '') {
|
|
346
|
+
if (!nodesStore.isPort(Number(b.remotePort))) return send(res, 400, { error: 'remotePort non valida (1..65535)' });
|
|
347
|
+
remotePort = Number(b.remotePort);
|
|
348
|
+
}
|
|
311
349
|
if (typeof b.name === 'string' && b.name.trim()) {
|
|
312
350
|
if (!nodesStore.NODE_NAME_RE.test(b.name.trim())) return send(res, 400, { error: 'name non valido (a-z 0-9 -, max 32)' });
|
|
313
351
|
extra.name = b.name.trim();
|
|
314
352
|
}
|
|
315
353
|
if (extra.ssh && !extra.name) extra.name = nodesStore.toSlug(label);
|
|
316
|
-
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
317
354
|
send(res, 200, peering.createInvite({
|
|
318
|
-
invitesPath, instanceId: st.nodeId, port:
|
|
355
|
+
invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: runtimePort(), label, ...extra,
|
|
319
356
|
}));
|
|
320
357
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
321
358
|
});
|
|
@@ -352,6 +389,8 @@ function settingsRoutes(deps = {}) {
|
|
|
352
389
|
const localLabel = nodesStore.sanitizeLabel(b.localLabel, defaultDeviceName());
|
|
353
390
|
const fetchImpl = seams.fetchImpl || fetch;
|
|
354
391
|
const sleep = typeof seams.pairDelay === 'function' ? seams.pairDelay : undefined;
|
|
392
|
+
const transportProbe = typeof seams.probeTransportReady === 'function'
|
|
393
|
+
? seams.probeTransportReady : peering.probeTransportReady;
|
|
355
394
|
const requestTimeoutMs = Number.isInteger(seams.pairRequestTimeoutMs) && seams.pairRequestTimeoutMs > 0
|
|
356
395
|
? seams.pairRequestTimeoutMs : 6000;
|
|
357
396
|
// Every protocol request must terminate. Readiness and federation health
|
|
@@ -365,6 +404,7 @@ function settingsRoutes(deps = {}) {
|
|
|
365
404
|
};
|
|
366
405
|
|
|
367
406
|
let provisionalPort = null;
|
|
407
|
+
let portReservation = null;
|
|
368
408
|
let rollbackCredential = null;
|
|
369
409
|
let created = false;
|
|
370
410
|
let rolledBack = false;
|
|
@@ -372,6 +412,10 @@ function settingsRoutes(deps = {}) {
|
|
|
372
412
|
// credenziale provvisoria sul peer (se emessa) e rimuove nodo+tunnel locali.
|
|
373
413
|
const rollback = async () => {
|
|
374
414
|
if (rolledBack) return; rolledBack = true;
|
|
415
|
+
if (portReservation) {
|
|
416
|
+
try { await portReservation.release(); } catch (_) { /* best-effort */ }
|
|
417
|
+
portReservation = null;
|
|
418
|
+
}
|
|
375
419
|
if (rollbackCredential && provisionalPort) {
|
|
376
420
|
try {
|
|
377
421
|
await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
|
|
@@ -413,7 +457,18 @@ function settingsRoutes(deps = {}) {
|
|
|
413
457
|
hint: 'usa il nodo esistente oppure rimuovilo prima di rifare il pairing',
|
|
414
458
|
});
|
|
415
459
|
}
|
|
416
|
-
|
|
460
|
+
let localPort;
|
|
461
|
+
try {
|
|
462
|
+
portReservation = await nodesCmds.reserveLocalPort(st, {
|
|
463
|
+
createServerImpl: seams.createPortServer,
|
|
464
|
+
});
|
|
465
|
+
localPort = portReservation.port;
|
|
466
|
+
} catch (e) {
|
|
467
|
+
return fail(502, 'ssh-start', 'local-port-unavailable',
|
|
468
|
+
`nessuna porta locale disponibile per il tunnel: ${String((e && e.message) || e)}`, {
|
|
469
|
+
retryable: true, hint: 'chiudi il processo che occupa le porte locali e riprova',
|
|
470
|
+
});
|
|
471
|
+
}
|
|
417
472
|
provisionalPort = localPort;
|
|
418
473
|
const acceptToken = crypto.randomBytes(32).toString('base64url');
|
|
419
474
|
try {
|
|
@@ -426,15 +481,20 @@ function settingsRoutes(deps = {}) {
|
|
|
426
481
|
} catch (e) {
|
|
427
482
|
const msg = String((e && e.message) || e);
|
|
428
483
|
const isDup = msg.includes('duplicato') || msg.includes('self-reference');
|
|
429
|
-
return
|
|
484
|
+
return failRolledBack(isDup ? 409 : 400, isDup ? 'conflict' : 'validation', 'node-rejected', msg, { retryable: !isDup });
|
|
430
485
|
}
|
|
431
486
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
432
487
|
created = true;
|
|
433
488
|
const node = nodesStore.getNode(st, b.name);
|
|
434
489
|
|
|
435
490
|
// --- ssh-start: supervisor del tunnel -L provvisorio --------------------
|
|
491
|
+
// Il bind OS-aware ha protetto la scelta fino a questo punto. SSH deve
|
|
492
|
+
// prendere la stessa porta, quindi rilasciamo immediatamente prima dello
|
|
493
|
+
// spawn (eventuali race residue emergono come local-forward-bind).
|
|
494
|
+
await portReservation.release();
|
|
495
|
+
portReservation = null;
|
|
436
496
|
const started = nodesTunnel.startForward({
|
|
437
|
-
home, node, localAppPort:
|
|
497
|
+
home, node, localAppPort: runtimePort(),
|
|
438
498
|
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
439
499
|
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
440
500
|
});
|
|
@@ -446,13 +506,19 @@ function settingsRoutes(deps = {}) {
|
|
|
446
506
|
}
|
|
447
507
|
|
|
448
508
|
// --- ssh-ready: readiness bounded PRIMA di consumare l'invite -----------
|
|
449
|
-
const ready = await
|
|
509
|
+
const ready = await transportProbe({
|
|
510
|
+
port: localPort, capability: pair.invite, expectedInstanceId: pair.instanceId,
|
|
511
|
+
fetchImpl, sleep,
|
|
512
|
+
});
|
|
450
513
|
if (!ready.ready) {
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
514
|
+
const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, b.name, pair.port);
|
|
515
|
+
return failRolledBack(502, 'ssh-ready', (diagnosis && diagnosis.code) || ready.code || 'transport-not-ready',
|
|
516
|
+
(diagnosis && diagnosis.detail)
|
|
517
|
+
|| `il peer non risponde attraverso il tunnel SSH (${ready.attempts} tentativi${ready.lastError ? `: ${ready.lastError}` : ''})`, {
|
|
518
|
+
retryable: true,
|
|
519
|
+
hint: (diagnosis && diagnosis.hint)
|
|
520
|
+
|| 'controlla target SSH, porta e chiavi; il link NON e\' stato consumato, puoi riprovare',
|
|
521
|
+
});
|
|
456
522
|
}
|
|
457
523
|
|
|
458
524
|
// --- join: consuma l'invite one-time (UNA volta, mai replay) ------------
|
|
@@ -465,8 +531,12 @@ function settingsRoutes(deps = {}) {
|
|
|
465
531
|
instanceId: st.nodeId,
|
|
466
532
|
name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
|
|
467
533
|
label: localLabel,
|
|
468
|
-
port:
|
|
534
|
+
port: runtimePort(),
|
|
469
535
|
acceptToken,
|
|
536
|
+
// Pairing establishes a private client-to-hub connection. Sharing
|
|
537
|
+
// this device back through the hub is a separate explicit action.
|
|
538
|
+
shared: false,
|
|
539
|
+
roles: readRoles(configPath),
|
|
470
540
|
}),
|
|
471
541
|
});
|
|
472
542
|
} catch (e) {
|
|
@@ -488,8 +558,9 @@ function settingsRoutes(deps = {}) {
|
|
|
488
558
|
hint: 'rigenera un nuovo link e riprova',
|
|
489
559
|
});
|
|
490
560
|
}
|
|
561
|
+
const joinedRoles = joined.roles === undefined ? null : nodesStore.parseRoles(joined.roles);
|
|
491
562
|
if (!nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
|
|
492
|
-
|| !nodesStore.NODE_ID_RE.test(joined.instanceId)) {
|
|
563
|
+
|| !nodesStore.NODE_ID_RE.test(joined.instanceId) || (joined.roles !== undefined && !joinedRoles)) {
|
|
493
564
|
return failRolledBack(502, 'join', 'join-invalid-response', 'risposta di join non valida dal peer', {
|
|
494
565
|
hint: 'versioni NexusCrew incompatibili? aggiorna entrambi i nodi',
|
|
495
566
|
});
|
|
@@ -502,25 +573,36 @@ function settingsRoutes(deps = {}) {
|
|
|
502
573
|
});
|
|
503
574
|
}
|
|
504
575
|
|
|
505
|
-
// --- tunnel-final:
|
|
576
|
+
// --- tunnel-final: connessione privata, solo -L -------------------------
|
|
577
|
+
// reversePort resta negoziata per un futuro Share opt-in, ma il builder
|
|
578
|
+
// non emette -R finche' shared non diventa true.
|
|
506
579
|
st = nodesStore.loadOrInitStore(nodesPath);
|
|
507
580
|
st = nodesStore.updateNode(st, b.name, {
|
|
508
581
|
token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
|
|
582
|
+
shared: false,
|
|
583
|
+
...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
|
|
509
584
|
});
|
|
510
585
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
511
586
|
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
512
587
|
const finalStart = nodesTunnel.startForward({
|
|
513
|
-
home, node: nodesStore.getNode(st, b.name), localAppPort:
|
|
588
|
+
home, node: nodesStore.getNode(st, b.name), localAppPort: runtimePort(),
|
|
514
589
|
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
515
590
|
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
516
591
|
});
|
|
517
592
|
if (!finalStart.started && finalStart.reason !== 'already running') {
|
|
518
593
|
return failRolledBack(502, 'tunnel-final', 'tunnel-restart-failed', finalStart.reason || 'riavvio del tunnel negoziato fallito', {});
|
|
519
594
|
}
|
|
520
|
-
const readyFinal = await
|
|
595
|
+
const readyFinal = await transportProbe({
|
|
596
|
+
port: localPort, capability: joined.credential, expectedInstanceId: joined.instanceId,
|
|
597
|
+
fetchImpl, sleep,
|
|
598
|
+
});
|
|
521
599
|
if (!readyFinal.ready) {
|
|
522
|
-
|
|
523
|
-
|
|
600
|
+
const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, b.name, pair.port);
|
|
601
|
+
return failRolledBack(502, 'tunnel-final', (diagnosis && diagnosis.code) || readyFinal.code || 'transport-not-ready',
|
|
602
|
+
(diagnosis && diagnosis.detail)
|
|
603
|
+
|| `il tunnel negoziato non risponde o non corrisponde al peer atteso (${readyFinal.attempts} tentativi)`, {
|
|
604
|
+
hint: (diagnosis && diagnosis.hint) || 'verifica il target SSH e riprova con un nuovo link',
|
|
605
|
+
});
|
|
524
606
|
}
|
|
525
607
|
|
|
526
608
|
// --- confirm: idempotente lato peer -> bounded retry ---------------------
|
|
@@ -654,7 +736,10 @@ function settingsRoutes(deps = {}) {
|
|
|
654
736
|
const out = await nodesCmds.nodesTest({ ...cliOpts(cap), name });
|
|
655
737
|
if (out.result === 'unknown-node') return send(res, 404, { error: `nodo sconosciuto "${name}"` });
|
|
656
738
|
// result distingue: ok | tunnel-down | health-ko | token-missing | token-ko
|
|
657
|
-
send(res, 200, {
|
|
739
|
+
send(res, 200, {
|
|
740
|
+
ok: out.result === 'ok', result: out.result, detail: lastLine(cap, ''),
|
|
741
|
+
...(out.diagnostic ? { diagnostic: out.diagnostic } : {}),
|
|
742
|
+
});
|
|
658
743
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
659
744
|
});
|
|
660
745
|
|
|
@@ -673,21 +758,103 @@ function settingsRoutes(deps = {}) {
|
|
|
673
758
|
const fn = action === 'up' ? nodesCmds.nodesUp
|
|
674
759
|
: action === 'down' ? nodesCmds.nodesDown
|
|
675
760
|
: nodesCmds.nodesRestart;
|
|
676
|
-
const out = fn({ ...cliOpts(cap), name });
|
|
761
|
+
const out = fn({ ...cliOpts(cap), name, persistAutostart: action === 'up' || action === 'down' });
|
|
677
762
|
if (out.code !== 0) {
|
|
678
763
|
const msg = lastLine(cap, `nodes ${action} fallito`);
|
|
679
764
|
const status = /sconosciuto/.test(msg) ? 404 : 500;
|
|
680
|
-
return send(res, status, { error: msg });
|
|
765
|
+
return send(res, status, { error: msg, ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}) });
|
|
681
766
|
}
|
|
682
|
-
if (action === 'up') return send(res, 200, { name, started: out.started, pid: out.pid });
|
|
767
|
+
if (action === 'up') return send(res, 200, { name, started: out.started, pid: out.pid, diagnostic: out.diagnostic });
|
|
683
768
|
if (action === 'down') return send(res, 200, { name, stopped: out.stopped });
|
|
684
|
-
return send(res, 200, { name, restarted: true, pid: out.pid });
|
|
769
|
+
return send(res, 200, { name, restarted: true, pid: out.pid, diagnostic: out.diagnostic });
|
|
685
770
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
686
771
|
};
|
|
687
772
|
}
|
|
688
773
|
r.post('/nodes/:name/up', mutGate, lifecycleHandler('up'));
|
|
689
774
|
r.post('/nodes/:name/down', mutGate, lifecycleHandler('down'));
|
|
690
775
|
r.post('/nodes/:name/restart', mutGate, lifecycleHandler('restart'));
|
|
776
|
+
|
|
777
|
+
// Share is the only publication control exposed to the user. The normal
|
|
778
|
+
// paired connection is -L only; enabling Share restarts the same supervised
|
|
779
|
+
// SSH session with its negotiated -R and asks the hub to advertise it only
|
|
780
|
+
// after the reverse channel passes an authenticated health probe.
|
|
781
|
+
r.patch('/nodes/:name/share', mutGate, async (req, res) => {
|
|
782
|
+
const name = String(req.params.name || '');
|
|
783
|
+
const body = req.body || {};
|
|
784
|
+
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
785
|
+
if (Object.keys(body).some((k) => k !== 'shared') || typeof body.shared !== 'boolean') {
|
|
786
|
+
return send(res, 400, { error: 'body non valido: atteso {shared:boolean}' });
|
|
787
|
+
}
|
|
788
|
+
let st = nodesStore.loadStore(nodesPath);
|
|
789
|
+
let node = st && nodesStore.getNode(st, name);
|
|
790
|
+
if (!node) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
|
|
791
|
+
if (node.direction !== 'outbound') return send(res, 409, { error: 'Share si attiva sul dispositivo che possiede la connessione SSH' });
|
|
792
|
+
if (!node.token || !node.nodeId) return send(res, 409, { error: 'nodo non associato: ripeti il pairing' });
|
|
793
|
+
if (body.shared && !nodesStore.isPort(node.reversePort)) {
|
|
794
|
+
return send(res, 409, { error: 'canale share non negoziato: ripeti il pairing' });
|
|
795
|
+
}
|
|
796
|
+
if (node.shared === body.shared) return send(res, 200, { name, shared: body.shared, unchanged: true });
|
|
797
|
+
|
|
798
|
+
const fetchImpl = seams.fetchImpl || fetch;
|
|
799
|
+
const notifyHub = async (shared) => {
|
|
800
|
+
const ctrl = new AbortController();
|
|
801
|
+
const timer = setTimeout(() => ctrl.abort(), 5000);
|
|
802
|
+
try {
|
|
803
|
+
const response = await fetchImpl(`http://127.0.0.1:${node.localPort}/federation/share`, {
|
|
804
|
+
method: 'POST', signal: ctrl.signal,
|
|
805
|
+
headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
|
|
806
|
+
body: JSON.stringify({ shared }),
|
|
807
|
+
});
|
|
808
|
+
if (!response.ok) throw new Error(`hub HTTP ${response.status}`);
|
|
809
|
+
} finally { clearTimeout(timer); }
|
|
810
|
+
};
|
|
811
|
+
const applyLocal = async (shared) => {
|
|
812
|
+
st = nodesStore.loadOrInitStore(nodesPath);
|
|
813
|
+
st = nodesStore.updateNode(st, name, { shared });
|
|
814
|
+
nodesStore.atomicWriteStore(nodesPath, st);
|
|
815
|
+
node = nodesStore.getNode(st, name);
|
|
816
|
+
nodesTunnel.stopTunnel({ home, name });
|
|
817
|
+
const started = nodesTunnel.startForward({
|
|
818
|
+
home, node, localAppPort: runtimePort(),
|
|
819
|
+
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
820
|
+
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
821
|
+
});
|
|
822
|
+
if (!started.started && started.reason !== 'already running') {
|
|
823
|
+
throw new Error(started.reason || 'avvio SSH fallito');
|
|
824
|
+
}
|
|
825
|
+
const ready = await probeHealth({
|
|
826
|
+
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId,
|
|
827
|
+
fetchImpl,
|
|
828
|
+
});
|
|
829
|
+
if (!ready || ready.status !== 'healthy') {
|
|
830
|
+
const diagnosis = (seams.readTunnelDiagnostic || nodesTunnel.readTunnelDiagnostic)(home, name, node.remotePort);
|
|
831
|
+
throw new Error((diagnosis && diagnosis.detail) || (ready && ready.detail) || 'hub non raggiungibile dopo il riavvio SSH');
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
|
|
835
|
+
try {
|
|
836
|
+
if (body.shared) {
|
|
837
|
+
await applyLocal(true);
|
|
838
|
+
await notifyHub(true);
|
|
839
|
+
} else {
|
|
840
|
+
// Revoke at the hub while -R is still alive, then remove -R locally.
|
|
841
|
+
await notifyHub(false);
|
|
842
|
+
await applyLocal(false);
|
|
843
|
+
}
|
|
844
|
+
return send(res, 200, { name, shared: body.shared });
|
|
845
|
+
} catch (e) {
|
|
846
|
+
// Share-on is transactional: a failed hub acknowledgement returns to the
|
|
847
|
+
// safe private -L-only state. Never include remote response bodies/tokens.
|
|
848
|
+
if (body.shared) {
|
|
849
|
+
try { await applyLocal(false); } catch (_) { /* best-effort safe rollback */ }
|
|
850
|
+
}
|
|
851
|
+
return send(res, 502, {
|
|
852
|
+
error: body.shared ? 'Share non attivato' : 'Share non disattivato',
|
|
853
|
+
detail: String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***'),
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
|
|
691
858
|
r.patch('/nodes/:name/visibility', mutGate, (req, res) => {
|
|
692
859
|
try {
|
|
693
860
|
const name = String(req.params.name || '');
|
|
@@ -717,53 +884,13 @@ function settingsRoutes(deps = {}) {
|
|
|
717
884
|
} catch (e) { send(res, 400, { error: String(e.message || e) }); }
|
|
718
885
|
});
|
|
719
886
|
|
|
720
|
-
//
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
for (const k of Object.keys(b)) {
|
|
728
|
-
if (!NODE_ROLE_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (schema: enabled, rendezvousSsh?, publishedPort?, keyPath?)` });
|
|
729
|
-
}
|
|
730
|
-
if (typeof b.enabled !== 'boolean') return send(res, 400, { error: 'enabled deve essere boolean' });
|
|
731
|
-
if (b.rendezvousSsh !== undefined && !nodesStore.parseSsh(b.rendezvousSsh)) {
|
|
732
|
-
return send(res, 400, { error: 'rendezvousSsh non valido (atteso user@host strict)' });
|
|
733
|
-
}
|
|
734
|
-
if (b.publishedPort !== undefined && !nodesStore.isPort(b.publishedPort)) {
|
|
735
|
-
return send(res, 400, { error: 'publishedPort deve essere un intero 1..65535' });
|
|
736
|
-
}
|
|
737
|
-
if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
|
|
738
|
-
return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
const cap = capture();
|
|
742
|
-
if (b.enabled === false) {
|
|
743
|
-
const out = nodesCmds.nodeOff({ ...cliOpts(cap) });
|
|
744
|
-
if (out.code === 0) return send(res, 200, { enabled: false, roles: out.roles });
|
|
745
|
-
return send(res, 500, { error: lastLine(cap, 'node off fallito') });
|
|
746
|
-
}
|
|
747
|
-
const out = nodesCmds.nodeOn({
|
|
748
|
-
...cliOpts(cap),
|
|
749
|
-
rendezvousSsh: b.rendezvousSsh,
|
|
750
|
-
publishedPort: b.publishedPort,
|
|
751
|
-
key: b.keyPath,
|
|
752
|
-
port: cfg.port, // porta nexus locale da esporre = quella del server attivo
|
|
753
|
-
});
|
|
754
|
-
if (out.code === 0) {
|
|
755
|
-
const resp = { enabled: true, roles: out.roles, tunnel: out.tunnel || null };
|
|
756
|
-
const line = authorizedKeysLine(cap);
|
|
757
|
-
if (line) resp.authorizedKeys = line; // pubkey con permitlisten: non un segreto
|
|
758
|
-
return send(res, 200, resp);
|
|
759
|
-
}
|
|
760
|
-
const msg = lastLine(cap, 'node on fallito');
|
|
761
|
-
if (out.reason === 'readonly') return send(res, 403, { error: msg });
|
|
762
|
-
if (out.reason === 'no rendezvous') return send(res, 400, { error: msg });
|
|
763
|
-
if (out.reason === 'permitlisten') return send(res, 409, { error: msg });
|
|
764
|
-
send(res, 500, { error: msg });
|
|
765
|
-
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
766
|
-
});
|
|
887
|
+
// Legacy compatibility endpoint: the old node-role/rendezvous flow opened a
|
|
888
|
+
// second SSH process and is intentionally retired. Existing data is kept for
|
|
889
|
+
// migration, but all new publication goes through pairing + Share on the
|
|
890
|
+
// already-connected hub.
|
|
891
|
+
r.post('/node-role', mutGate, (_req, res) => send(res, 410, {
|
|
892
|
+
error: 'node-role/rendezvous ritirato: collega un hub e usa “Condividi questo nodo”',
|
|
893
|
+
}));
|
|
767
894
|
|
|
768
895
|
// --- POST /service/regenerate — rigenera l'unit service --------------------
|
|
769
896
|
// Riusa generateService/installService (lib/cli/service.js: escaping per-platform,
|
|
@@ -818,21 +945,45 @@ function settingsRoutes(deps = {}) {
|
|
|
818
945
|
function publicPeeringRoutes(deps = {}) {
|
|
819
946
|
const cfg = deps.cfg || {};
|
|
820
947
|
const home = cfg.home || os.homedir();
|
|
948
|
+
const configPath = cfg.configPath || configJsonPath();
|
|
821
949
|
const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
|
|
822
950
|
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
823
951
|
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
824
952
|
const r = express.Router();
|
|
825
953
|
const attempts = new Map();
|
|
826
954
|
r.use(express.json({ limit: '8kb' }));
|
|
955
|
+
// Identity proof non consuma la capability e non riceve mai invite/token in
|
|
956
|
+
// chiaro. Serve a impedire che un qualunque listener HTTP sulla porta -L
|
|
957
|
+
// venga scambiato per il nodo contenuto nel link.
|
|
958
|
+
r.post('/identity', (req, res) => {
|
|
959
|
+
const key = `identity:${String(req.socket && req.socket.remoteAddress || 'local')}`;
|
|
960
|
+
const now = Date.now();
|
|
961
|
+
const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
|
|
962
|
+
recent.push(now); attempts.set(key, recent);
|
|
963
|
+
if (recent.length > 30) return res.status(429).json({ error: 'troppi tentativi' });
|
|
964
|
+
const b = req.body || {};
|
|
965
|
+
const proof = peering.capabilityIdentity({
|
|
966
|
+
invitesPath, pendingPath, capabilityId: b.capabilityId, challenge: b.challenge, now,
|
|
967
|
+
});
|
|
968
|
+
if (!proof) return res.status(404).json({ error: 'capability non valida' });
|
|
969
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
970
|
+
if (!st || !nodesStore.NODE_ID_RE.test(st.nodeId)) return res.status(503).json({ error: 'identita nodo non disponibile' });
|
|
971
|
+
return res.json({ ok: true, instanceId: st.nodeId, proof });
|
|
972
|
+
});
|
|
827
973
|
r.post('/join', (req, res) => {
|
|
828
|
-
const key = String(req.socket && req.socket.remoteAddress || 'local')
|
|
974
|
+
const key = `join:${String(req.socket && req.socket.remoteAddress || 'local')}`;
|
|
829
975
|
const now = Date.now();
|
|
830
976
|
const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
|
|
831
977
|
recent.push(now); attempts.set(key, recent);
|
|
832
978
|
if (recent.length > 10) return res.status(429).json({ error: 'troppi tentativi di pairing' });
|
|
833
979
|
const b = req.body || {};
|
|
980
|
+
const peerRoles = b.roles === undefined ? null : nodesStore.parseRoles(b.roles);
|
|
834
981
|
if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
|
|
835
|
-
|| !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
|
|
982
|
+
|| !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
|
|
983
|
+
|| (b.roles !== undefined && !peerRoles)
|
|
984
|
+
// Pairing is always private. Publishing is a separate authenticated
|
|
985
|
+
// action after the reverse channel is live and health-checked.
|
|
986
|
+
|| (b.shared !== undefined && b.shared !== false)) {
|
|
836
987
|
return res.status(400).json({ error: 'pairing request non valida' });
|
|
837
988
|
}
|
|
838
989
|
if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
|
|
@@ -847,9 +998,11 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
847
998
|
const reversePort = peering.allocateReversePort(st.nodes);
|
|
848
999
|
const credential = peering.createPending({ pendingPath, data: {
|
|
849
1000
|
name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
|
|
1001
|
+
shared: false,
|
|
850
1002
|
label: nodesStore.sanitizeLabel(b.label, name),
|
|
1003
|
+
...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
|
|
851
1004
|
} });
|
|
852
|
-
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential });
|
|
1005
|
+
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
|
|
853
1006
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
854
1007
|
});
|
|
855
1008
|
r.post('/confirm', (req, res) => {
|
|
@@ -867,8 +1020,10 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
867
1020
|
st = nodesStore.addNode(st, {
|
|
868
1021
|
name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
|
|
869
1022
|
direction: 'inbound', transport: 'inbound', autostart: true,
|
|
870
|
-
visibility: 'network', nodeId: pending.instanceId,
|
|
1023
|
+
visibility: 'network', shared: pending.shared === true, nodeId: pending.instanceId,
|
|
871
1024
|
token: pending.acceptToken, acceptToken: b.credential,
|
|
1025
|
+
...(pending.roles ? { roles: pending.roles } : {}),
|
|
1026
|
+
rolesKnown: pending.rolesKnown === true,
|
|
872
1027
|
...(pending.label ? { label: pending.label } : {}),
|
|
873
1028
|
});
|
|
874
1029
|
nodesStore.atomicWriteStore(nodesPath, st);
|