@mmmbuto/nexuscrew 0.8.9 → 0.8.11
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 +48 -20
- package/frontend/dist/assets/index-DoUIJ4GM.js +91 -0
- package/frontend/dist/assets/index-DrrRAIg6.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 +287 -147
- package/lib/cli/doctor.js +37 -14
- package/lib/cli/init.js +2 -2
- package/lib/cli/pidfile.js +3 -2
- package/lib/cli/service.js +52 -0
- package/lib/decks/store.js +5 -2
- package/lib/fleet/builtin.js +12 -1
- package/lib/mcp/server.js +161 -0
- package/lib/nodes/commands.js +95 -123
- package/lib/nodes/health.js +22 -14
- package/lib/nodes/peering.js +72 -10
- package/lib/nodes/store.js +21 -18
- package/lib/nodes/tunnel-supervisor.js +29 -9
- package/lib/nodes/tunnel.js +217 -72
- package/lib/proxy/federation.js +68 -6
- package/lib/server.js +36 -31
- package/lib/settings/routes.js +202 -95
- package/lib/update/runner.js +4 -1
- package/package.json +2 -2
- package/skills/nexuscrew-agent/SKILL.md +6 -2
- package/frontend/dist/assets/index-9Drd8g0k.css +0 -32
- package/frontend/dist/assets/index-DTmT7yhV.js +0 -91
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
|
|
@@ -59,7 +60,6 @@ const { scrubError } = require('../update/core.js');
|
|
|
59
60
|
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
|
|
60
61
|
const ROLE_KEYS = new Set(['client', 'node']);
|
|
61
62
|
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
|
|
62
|
-
const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
|
|
63
63
|
|
|
64
64
|
// Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
|
|
65
65
|
// NON usa ciecamente hostname: se l'hostname e' vuoto o 'localhost' (tipico di
|
|
@@ -155,6 +155,7 @@ function settingsRoutes(deps = {}) {
|
|
|
155
155
|
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
156
156
|
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
157
157
|
const platform = seams.platform || detectPlatform();
|
|
158
|
+
const runtimePort = typeof deps.runtimePort === 'function' ? deps.runtimePort : () => cfg.port;
|
|
158
159
|
|
|
159
160
|
const r = express.Router();
|
|
160
161
|
r.use(express.json({ limit: '8kb' }));
|
|
@@ -176,6 +177,7 @@ function settingsRoutes(deps = {}) {
|
|
|
176
177
|
spawnImpl: seams.spawnImpl, sshBin: seams.sshBin, logFd: seams.logFd,
|
|
177
178
|
httpProbe: seams.httpProbe, sshVersion: seams.sshVersion,
|
|
178
179
|
spawnSyncImpl: seams.spawnSyncImpl,
|
|
180
|
+
localAppPort: runtimePort(),
|
|
179
181
|
});
|
|
180
182
|
|
|
181
183
|
const validName = (name) => typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name);
|
|
@@ -207,13 +209,6 @@ function settingsRoutes(deps = {}) {
|
|
|
207
209
|
deviceName: defaultDeviceName(),
|
|
208
210
|
};
|
|
209
211
|
if (updateStatus) out.update = updateStatus;
|
|
210
|
-
// rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
|
|
211
|
-
// la si prende comunque SOLO dalla vista redatta.
|
|
212
|
-
const st = nodesStore.loadStore(nodesPath);
|
|
213
|
-
if (st) {
|
|
214
|
-
const view = nodesStore.redactStore(st);
|
|
215
|
-
if (view.rendezvous) out.rendezvous = view.rendezvous;
|
|
216
|
-
}
|
|
217
212
|
send(res, 200, out);
|
|
218
213
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
219
214
|
});
|
|
@@ -243,6 +238,16 @@ function settingsRoutes(deps = {}) {
|
|
|
243
238
|
if (b.port !== undefined && !nodesStore.isPort(b.port)) {
|
|
244
239
|
return send(res, 400, { error: 'port deve essere un intero 1..65535' });
|
|
245
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
|
+
}
|
|
246
251
|
if (b.wizardDone !== undefined && typeof b.wizardDone !== 'boolean') {
|
|
247
252
|
return send(res, 400, { error: 'wizardDone deve essere boolean' });
|
|
248
253
|
}
|
|
@@ -307,7 +312,7 @@ function settingsRoutes(deps = {}) {
|
|
|
307
312
|
if (typeof closeSessions === 'function') closeSessions();
|
|
308
313
|
send(res, 200, {
|
|
309
314
|
rotated: true,
|
|
310
|
-
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`',
|
|
311
316
|
});
|
|
312
317
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
313
318
|
});
|
|
@@ -324,29 +329,22 @@ function settingsRoutes(deps = {}) {
|
|
|
324
329
|
const b = req.body || {};
|
|
325
330
|
const label = nodesStore.sanitizeLabel(b.label, defaultDeviceName());
|
|
326
331
|
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
327
|
-
const rendezvous = st.rendezvous || null;
|
|
328
332
|
const extra = {};
|
|
329
333
|
if (typeof b.ssh === 'string' && b.ssh.trim()) {
|
|
330
334
|
const ssh = nodesStore.parseSshTarget(b.ssh.trim());
|
|
331
335
|
if (!ssh) return send(res, 400, { error: 'ssh non valido (atteso user@host o alias)' });
|
|
332
336
|
extra.ssh = ssh.value;
|
|
333
|
-
} else if (rendezvous && rendezvous.ssh) {
|
|
334
|
-
// Zero-field creator path: the rendezvous SSH target is reachable by
|
|
335
|
-
// definition. Its publishedPort is the REMOTE NEXUSCREW HTTP port,
|
|
336
|
-
// never the OpenSSH transport port.
|
|
337
|
-
extra.ssh = rendezvous.ssh;
|
|
338
337
|
}
|
|
339
338
|
if (b.sshPort !== undefined && b.sshPort !== null && b.sshPort !== '') {
|
|
340
339
|
if (!nodesStore.isPort(Number(b.sshPort))) return send(res, 400, { error: 'sshPort non valida (1..65535)' });
|
|
341
340
|
extra.sshPort = Number(b.sshPort);
|
|
342
341
|
}
|
|
343
342
|
if (extra.sshPort && !extra.ssh) return send(res, 400, { error: 'sshPort richiede un target SSH' });
|
|
344
|
-
|
|
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
345
|
if (b.remotePort !== undefined && b.remotePort !== null && b.remotePort !== '') {
|
|
346
346
|
if (!nodesStore.isPort(Number(b.remotePort))) return send(res, 400, { error: 'remotePort non valida (1..65535)' });
|
|
347
347
|
remotePort = Number(b.remotePort);
|
|
348
|
-
} else if (rendezvous && extra.ssh === rendezvous.ssh) {
|
|
349
|
-
remotePort = rendezvous.publishedPort;
|
|
350
348
|
}
|
|
351
349
|
if (typeof b.name === 'string' && b.name.trim()) {
|
|
352
350
|
if (!nodesStore.NODE_NAME_RE.test(b.name.trim())) return send(res, 400, { error: 'name non valido (a-z 0-9 -, max 32)' });
|
|
@@ -354,7 +352,7 @@ function settingsRoutes(deps = {}) {
|
|
|
354
352
|
}
|
|
355
353
|
if (extra.ssh && !extra.name) extra.name = nodesStore.toSlug(label);
|
|
356
354
|
send(res, 200, peering.createInvite({
|
|
357
|
-
invitesPath, instanceId: st.nodeId, port: remotePort, linkPort:
|
|
355
|
+
invitesPath, instanceId: st.nodeId, port: remotePort, linkPort: runtimePort(), label, ...extra,
|
|
358
356
|
}));
|
|
359
357
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
360
358
|
});
|
|
@@ -391,6 +389,8 @@ function settingsRoutes(deps = {}) {
|
|
|
391
389
|
const localLabel = nodesStore.sanitizeLabel(b.localLabel, defaultDeviceName());
|
|
392
390
|
const fetchImpl = seams.fetchImpl || fetch;
|
|
393
391
|
const sleep = typeof seams.pairDelay === 'function' ? seams.pairDelay : undefined;
|
|
392
|
+
const transportProbe = typeof seams.probeTransportReady === 'function'
|
|
393
|
+
? seams.probeTransportReady : peering.probeTransportReady;
|
|
394
394
|
const requestTimeoutMs = Number.isInteger(seams.pairRequestTimeoutMs) && seams.pairRequestTimeoutMs > 0
|
|
395
395
|
? seams.pairRequestTimeoutMs : 6000;
|
|
396
396
|
// Every protocol request must terminate. Readiness and federation health
|
|
@@ -404,6 +404,7 @@ function settingsRoutes(deps = {}) {
|
|
|
404
404
|
};
|
|
405
405
|
|
|
406
406
|
let provisionalPort = null;
|
|
407
|
+
let portReservation = null;
|
|
407
408
|
let rollbackCredential = null;
|
|
408
409
|
let created = false;
|
|
409
410
|
let rolledBack = false;
|
|
@@ -411,6 +412,10 @@ function settingsRoutes(deps = {}) {
|
|
|
411
412
|
// credenziale provvisoria sul peer (se emessa) e rimuove nodo+tunnel locali.
|
|
412
413
|
const rollback = async () => {
|
|
413
414
|
if (rolledBack) return; rolledBack = true;
|
|
415
|
+
if (portReservation) {
|
|
416
|
+
try { await portReservation.release(); } catch (_) { /* best-effort */ }
|
|
417
|
+
portReservation = null;
|
|
418
|
+
}
|
|
414
419
|
if (rollbackCredential && provisionalPort) {
|
|
415
420
|
try {
|
|
416
421
|
await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
|
|
@@ -452,7 +457,18 @@ function settingsRoutes(deps = {}) {
|
|
|
452
457
|
hint: 'usa il nodo esistente oppure rimuovilo prima di rifare il pairing',
|
|
453
458
|
});
|
|
454
459
|
}
|
|
455
|
-
|
|
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
|
+
}
|
|
456
472
|
provisionalPort = localPort;
|
|
457
473
|
const acceptToken = crypto.randomBytes(32).toString('base64url');
|
|
458
474
|
try {
|
|
@@ -465,15 +481,20 @@ function settingsRoutes(deps = {}) {
|
|
|
465
481
|
} catch (e) {
|
|
466
482
|
const msg = String((e && e.message) || e);
|
|
467
483
|
const isDup = msg.includes('duplicato') || msg.includes('self-reference');
|
|
468
|
-
return
|
|
484
|
+
return failRolledBack(isDup ? 409 : 400, isDup ? 'conflict' : 'validation', 'node-rejected', msg, { retryable: !isDup });
|
|
469
485
|
}
|
|
470
486
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
471
487
|
created = true;
|
|
472
488
|
const node = nodesStore.getNode(st, b.name);
|
|
473
489
|
|
|
474
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;
|
|
475
496
|
const started = nodesTunnel.startForward({
|
|
476
|
-
home, node, localAppPort:
|
|
497
|
+
home, node, localAppPort: runtimePort(),
|
|
477
498
|
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
478
499
|
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
479
500
|
});
|
|
@@ -485,13 +506,19 @@ function settingsRoutes(deps = {}) {
|
|
|
485
506
|
}
|
|
486
507
|
|
|
487
508
|
// --- ssh-ready: readiness bounded PRIMA di consumare l'invite -----------
|
|
488
|
-
const ready = await
|
|
509
|
+
const ready = await transportProbe({
|
|
510
|
+
port: localPort, capability: pair.invite, expectedInstanceId: pair.instanceId,
|
|
511
|
+
fetchImpl, sleep,
|
|
512
|
+
});
|
|
489
513
|
if (!ready.ready) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
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
|
+
});
|
|
495
522
|
}
|
|
496
523
|
|
|
497
524
|
// --- join: consuma l'invite one-time (UNA volta, mai replay) ------------
|
|
@@ -504,8 +531,11 @@ function settingsRoutes(deps = {}) {
|
|
|
504
531
|
instanceId: st.nodeId,
|
|
505
532
|
name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
|
|
506
533
|
label: localLabel,
|
|
507
|
-
port:
|
|
534
|
+
port: runtimePort(),
|
|
508
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,
|
|
509
539
|
roles: readRoles(configPath),
|
|
510
540
|
}),
|
|
511
541
|
});
|
|
@@ -543,26 +573,36 @@ function settingsRoutes(deps = {}) {
|
|
|
543
573
|
});
|
|
544
574
|
}
|
|
545
575
|
|
|
546
|
-
// --- 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.
|
|
547
579
|
st = nodesStore.loadOrInitStore(nodesPath);
|
|
548
580
|
st = nodesStore.updateNode(st, b.name, {
|
|
549
581
|
token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
|
|
582
|
+
shared: false,
|
|
550
583
|
...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
|
|
551
584
|
});
|
|
552
585
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
553
586
|
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
554
587
|
const finalStart = nodesTunnel.startForward({
|
|
555
|
-
home, node: nodesStore.getNode(st, b.name), localAppPort:
|
|
588
|
+
home, node: nodesStore.getNode(st, b.name), localAppPort: runtimePort(),
|
|
556
589
|
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
557
590
|
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
558
591
|
});
|
|
559
592
|
if (!finalStart.started && finalStart.reason !== 'already running') {
|
|
560
593
|
return failRolledBack(502, 'tunnel-final', 'tunnel-restart-failed', finalStart.reason || 'riavvio del tunnel negoziato fallito', {});
|
|
561
594
|
}
|
|
562
|
-
const readyFinal = await
|
|
595
|
+
const readyFinal = await transportProbe({
|
|
596
|
+
port: localPort, capability: joined.credential, expectedInstanceId: joined.instanceId,
|
|
597
|
+
fetchImpl, sleep,
|
|
598
|
+
});
|
|
563
599
|
if (!readyFinal.ready) {
|
|
564
|
-
|
|
565
|
-
|
|
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
|
+
});
|
|
566
606
|
}
|
|
567
607
|
|
|
568
608
|
// --- confirm: idempotente lato peer -> bounded retry ---------------------
|
|
@@ -696,7 +736,10 @@ function settingsRoutes(deps = {}) {
|
|
|
696
736
|
const out = await nodesCmds.nodesTest({ ...cliOpts(cap), name });
|
|
697
737
|
if (out.result === 'unknown-node') return send(res, 404, { error: `nodo sconosciuto "${name}"` });
|
|
698
738
|
// result distingue: ok | tunnel-down | health-ko | token-missing | token-ko
|
|
699
|
-
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
|
+
});
|
|
700
743
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
701
744
|
});
|
|
702
745
|
|
|
@@ -715,21 +758,103 @@ function settingsRoutes(deps = {}) {
|
|
|
715
758
|
const fn = action === 'up' ? nodesCmds.nodesUp
|
|
716
759
|
: action === 'down' ? nodesCmds.nodesDown
|
|
717
760
|
: nodesCmds.nodesRestart;
|
|
718
|
-
const out = fn({ ...cliOpts(cap), name });
|
|
761
|
+
const out = fn({ ...cliOpts(cap), name, persistAutostart: action === 'up' || action === 'down' });
|
|
719
762
|
if (out.code !== 0) {
|
|
720
763
|
const msg = lastLine(cap, `nodes ${action} fallito`);
|
|
721
764
|
const status = /sconosciuto/.test(msg) ? 404 : 500;
|
|
722
|
-
return send(res, status, { error: msg });
|
|
765
|
+
return send(res, status, { error: msg, ...(out.diagnostic ? { diagnostic: out.diagnostic } : {}) });
|
|
723
766
|
}
|
|
724
|
-
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 });
|
|
725
768
|
if (action === 'down') return send(res, 200, { name, stopped: out.stopped });
|
|
726
|
-
return send(res, 200, { name, restarted: true, pid: out.pid });
|
|
769
|
+
return send(res, 200, { name, restarted: true, pid: out.pid, diagnostic: out.diagnostic });
|
|
727
770
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
728
771
|
};
|
|
729
772
|
}
|
|
730
773
|
r.post('/nodes/:name/up', mutGate, lifecycleHandler('up'));
|
|
731
774
|
r.post('/nodes/:name/down', mutGate, lifecycleHandler('down'));
|
|
732
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
|
+
|
|
733
858
|
r.patch('/nodes/:name/visibility', mutGate, (req, res) => {
|
|
734
859
|
try {
|
|
735
860
|
const name = String(req.params.name || '');
|
|
@@ -759,53 +884,13 @@ function settingsRoutes(deps = {}) {
|
|
|
759
884
|
} catch (e) { send(res, 400, { error: String(e.message || e) }); }
|
|
760
885
|
});
|
|
761
886
|
|
|
762
|
-
//
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
for (const k of Object.keys(b)) {
|
|
770
|
-
if (!NODE_ROLE_KEYS.has(k)) return send(res, 400, { error: `chiave non ammessa: "${k}" (schema: enabled, rendezvousSsh?, publishedPort?, keyPath?)` });
|
|
771
|
-
}
|
|
772
|
-
if (typeof b.enabled !== 'boolean') return send(res, 400, { error: 'enabled deve essere boolean' });
|
|
773
|
-
if (b.rendezvousSsh !== undefined && !nodesStore.parseSsh(b.rendezvousSsh)) {
|
|
774
|
-
return send(res, 400, { error: 'rendezvousSsh non valido (atteso user@host strict)' });
|
|
775
|
-
}
|
|
776
|
-
if (b.publishedPort !== undefined && !nodesStore.isPort(b.publishedPort)) {
|
|
777
|
-
return send(res, 400, { error: 'publishedPort deve essere un intero 1..65535' });
|
|
778
|
-
}
|
|
779
|
-
if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
|
|
780
|
-
return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
|
|
781
|
-
}
|
|
782
|
-
|
|
783
|
-
const cap = capture();
|
|
784
|
-
if (b.enabled === false) {
|
|
785
|
-
const out = nodesCmds.nodeOff({ ...cliOpts(cap) });
|
|
786
|
-
if (out.code === 0) return send(res, 200, { enabled: false, roles: out.roles });
|
|
787
|
-
return send(res, 500, { error: lastLine(cap, 'node off fallito') });
|
|
788
|
-
}
|
|
789
|
-
const out = nodesCmds.nodeOn({
|
|
790
|
-
...cliOpts(cap),
|
|
791
|
-
rendezvousSsh: b.rendezvousSsh,
|
|
792
|
-
publishedPort: b.publishedPort,
|
|
793
|
-
key: b.keyPath,
|
|
794
|
-
port: cfg.port, // porta nexus locale da esporre = quella del server attivo
|
|
795
|
-
});
|
|
796
|
-
if (out.code === 0) {
|
|
797
|
-
const resp = { enabled: true, roles: out.roles, tunnel: out.tunnel || null };
|
|
798
|
-
const line = authorizedKeysLine(cap);
|
|
799
|
-
if (line) resp.authorizedKeys = line; // pubkey con permitlisten: non un segreto
|
|
800
|
-
return send(res, 200, resp);
|
|
801
|
-
}
|
|
802
|
-
const msg = lastLine(cap, 'node on fallito');
|
|
803
|
-
if (out.reason === 'readonly') return send(res, 403, { error: msg });
|
|
804
|
-
if (out.reason === 'no rendezvous') return send(res, 400, { error: msg });
|
|
805
|
-
if (out.reason === 'permitlisten') return send(res, 409, { error: msg });
|
|
806
|
-
send(res, 500, { error: msg });
|
|
807
|
-
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
808
|
-
});
|
|
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
|
+
}));
|
|
809
894
|
|
|
810
895
|
// --- POST /service/regenerate — rigenera l'unit service --------------------
|
|
811
896
|
// Riusa generateService/installService (lib/cli/service.js: escaping per-platform,
|
|
@@ -867,8 +952,26 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
867
952
|
const r = express.Router();
|
|
868
953
|
const attempts = new Map();
|
|
869
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
|
+
});
|
|
870
973
|
r.post('/join', (req, res) => {
|
|
871
|
-
const key = String(req.socket && req.socket.remoteAddress || 'local')
|
|
974
|
+
const key = `join:${String(req.socket && req.socket.remoteAddress || 'local')}`;
|
|
872
975
|
const now = Date.now();
|
|
873
976
|
const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
|
|
874
977
|
recent.push(now); attempts.set(key, recent);
|
|
@@ -877,7 +980,10 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
877
980
|
const peerRoles = b.roles === undefined ? null : nodesStore.parseRoles(b.roles);
|
|
878
981
|
if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
|
|
879
982
|
|| !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
|
|
880
|
-
|| (b.roles !== undefined && !peerRoles)
|
|
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)) {
|
|
881
987
|
return res.status(400).json({ error: 'pairing request non valida' });
|
|
882
988
|
}
|
|
883
989
|
if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
|
|
@@ -892,8 +998,9 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
892
998
|
const reversePort = peering.allocateReversePort(st.nodes);
|
|
893
999
|
const credential = peering.createPending({ pendingPath, data: {
|
|
894
1000
|
name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
|
|
1001
|
+
shared: false,
|
|
895
1002
|
label: nodesStore.sanitizeLabel(b.label, name),
|
|
896
|
-
...(peerRoles ? { roles: peerRoles, rolesKnown: true } : { rolesKnown: false }),
|
|
1003
|
+
...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
|
|
897
1004
|
} });
|
|
898
1005
|
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
|
|
899
1006
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
@@ -913,7 +1020,7 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
913
1020
|
st = nodesStore.addNode(st, {
|
|
914
1021
|
name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
|
|
915
1022
|
direction: 'inbound', transport: 'inbound', autostart: true,
|
|
916
|
-
visibility: 'network', nodeId: pending.instanceId,
|
|
1023
|
+
visibility: 'network', shared: pending.shared === true, nodeId: pending.instanceId,
|
|
917
1024
|
token: pending.acceptToken, acceptToken: b.credential,
|
|
918
1025
|
...(pending.roles ? { roles: pending.roles } : {}),
|
|
919
1026
|
rolesKnown: pending.rolesKnown === true,
|
package/lib/update/runner.js
CHANGED
|
@@ -29,7 +29,10 @@ async function restartRuntime(opts = {}) {
|
|
|
29
29
|
const token = opts.token || url.readToken(url.resolvePaths({ home }).tokenPath);
|
|
30
30
|
let mode = 'inactive';
|
|
31
31
|
if (commands.isServiceRunning({ platform, home })) {
|
|
32
|
-
commands.restart({ platform, home, log: () => {} });
|
|
32
|
+
const restarted = commands.restart({ platform, home, log: () => {} });
|
|
33
|
+
if (!restarted || restarted.restarted !== true) {
|
|
34
|
+
throw new Error(`restart service fallito: ${(restarted && restarted.reason) || 'esito non verificato'}`);
|
|
35
|
+
}
|
|
33
36
|
mode = 'service';
|
|
34
37
|
} else {
|
|
35
38
|
const pidPath = pidf.defaultPidfilePath(home);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmmbuto/nexuscrew",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.11",
|
|
4
4
|
"description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
|
|
5
5
|
"main": "lib/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"start": "node bin/nexuscrew.js serve",
|
|
21
21
|
"dev": "node bin/nexuscrew.js serve",
|
|
22
22
|
"build": "cd frontend && npm install && npm run build && cd ..",
|
|
23
|
-
"test": "node
|
|
23
|
+
"test": "node tests/run-isolated.js"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"express": "^4.21.0",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: nexuscrew-agent
|
|
3
|
-
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect NexusCrew session/fleet status, read its inbox, deliver a file (report, screenshot, export), send text/input to a tmux session, or recover from tmux send-keys messages that remain unsubmitted or arrive garbled. Prefer the NexusCrew MCP tools nc_notify, nc_ask, nc_status, nc_inbox, and nc_send_file when exposed; use the bundled tmux/file helpers as fallback.
|
|
3
|
+
description: Use when an AI agent connected to NexusCrew must notify or ask the human, inspect NexusCrew session/fleet status or its own deck membership, read its inbox, deliver a file (report, screenshot, export), send text/input to a tmux session, or recover from tmux send-keys messages that remain unsubmitted or arrive garbled. Prefer the NexusCrew MCP tools nc_notify, nc_ask, nc_status, nc_deck, nc_inbox, and nc_send_file when exposed; use the bundled tmux/file helpers as fallback.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# NexusCrew Agent I/O
|
|
@@ -16,6 +16,7 @@ When the client exposes the NexusCrew MCP server, use these tools directly:
|
|
|
16
16
|
| Notify the human about a result, blocker, or milestone | `nc_notify` |
|
|
17
17
|
| Ask for a decision without blocking the agent | `nc_ask` |
|
|
18
18
|
| Inspect live tmux sessions and fleet cells | `nc_status` |
|
|
19
|
+
| Read the caller's deck name(s) and member cells/tmux sessions | `nc_deck` |
|
|
19
20
|
| List files received for the current session | `nc_inbox` |
|
|
20
21
|
| Deliver an absolute file path under the user's home | `nc_send_file` |
|
|
21
22
|
|
|
@@ -24,7 +25,9 @@ Apply these rules:
|
|
|
24
25
|
- Use `nc_notify` for meaningful asynchronous updates, failures requiring attention, and completion. Do not notify for every command or duplicate routine chat commentary.
|
|
25
26
|
- Never include access tokens, credentials, private keys, push subscriptions, or other secrets in a notification, ask, file caption, or tool result.
|
|
26
27
|
- Treat `nc_ask` as non-blocking: it returns an ask ID immediately. Continue safe independent work or wait normally; the human response arrives in the originating tmux session with a `[human reply · ask#<id>]` prefix by default.
|
|
27
|
-
- Use `nc_status` instead of scraping NexusCrew state files. Use `
|
|
28
|
+
- Use `nc_status` instead of scraping NexusCrew state files. Use `nc_deck` instead of reading `decks.json`: it returns every local or authorized shared-owner deck containing the caller, preserves visual member order, identifies each deck and member by stable owner ID, includes viewer-valid Hydra routes, and reports `cell: null` when no managed Fleet match is available.
|
|
29
|
+
- Treat `nc_deck` as discovery, not authorization to contact every member. Use the verified tmux-messaging flow below for actual delivery and confirm submission visually.
|
|
30
|
+
- Use `nc_inbox` instead of guessing an inbox path when the tool is available.
|
|
28
31
|
- Pass `nc_send_file` an existing absolute regular-file path below the user's home. Let NexusCrew choose and sanitize the outbox name.
|
|
29
32
|
- Do not treat an MCP notification as a substitute for the final response required by the active client.
|
|
30
33
|
|
|
@@ -68,6 +71,7 @@ tmux capture-pane -t <session> -p | tail -8 # see the text / a running state
|
|
|
68
71
|
| Notify the human | `nc_notify` |
|
|
69
72
|
| Ask the human without blocking | `nc_ask` |
|
|
70
73
|
| Inspect NexusCrew runtime state | `nc_status` |
|
|
74
|
+
| Discover this session's deck neighbours | `nc_deck` |
|
|
71
75
|
| Give the human a file | `nc_send_file` or fallback `nc-deliver <file>...` |
|
|
72
76
|
| Read a file the human sent | `nc_inbox` or fallback to the path in the prompt |
|
|
73
77
|
| Send a prompt/command to a session | `nc-send <session> "text"` |
|