@mmmbuto/nexuscrew 0.8.16 → 0.8.17
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 +280 -407
- package/frontend/dist/assets/index-CffPTRyq.js +91 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +11 -269
- package/lib/cli/fleet-service.js +4 -0
- package/lib/cli/runtime-lifecycle.js +299 -0
- package/lib/cli/service.js +4 -1
- package/lib/fleet/builtin.js +56 -434
- package/lib/fleet/launch.js +227 -0
- package/lib/fleet/runtime.js +269 -0
- package/lib/mcp/cells.js +154 -0
- package/lib/mcp/server.js +11 -422
- package/lib/mcp/tools.js +300 -0
- package/lib/runtime/env.js +38 -1
- package/lib/settings/pairing-coordinator.js +325 -0
- package/lib/settings/public-peering-routes.js +126 -0
- package/lib/settings/routes.js +16 -396
- package/package.json +1 -1
- package/frontend/dist/assets/index-B9RXe5E4.js +0 -91
package/lib/settings/routes.js
CHANGED
|
@@ -47,7 +47,7 @@ const nodesStore = require('../nodes/store.js');
|
|
|
47
47
|
const nodesCmds = require('../nodes/commands.js');
|
|
48
48
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
49
49
|
const peering = require('../nodes/peering.js');
|
|
50
|
-
const {
|
|
50
|
+
const { waitForHealthyPeer } = require('../proxy/federation.js');
|
|
51
51
|
const { rotateToken } = require('../auth/token.js');
|
|
52
52
|
const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
|
|
53
53
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
|
|
@@ -56,6 +56,11 @@ const { configJsonPath } = require('../config.js');
|
|
|
56
56
|
const VERSION = require('../../package.json').version;
|
|
57
57
|
const { scrubError } = require('../update/core.js');
|
|
58
58
|
|
|
59
|
+
// Sottomoduli estratti da questo file (modularizzazione behavior-preserving):
|
|
60
|
+
// il transaction handler di /nodes/pair e la superficie pubblica di peering.
|
|
61
|
+
const { createPairHandler } = require('./pairing-coordinator.js');
|
|
62
|
+
const { publicPeeringRoutes } = require('./public-peering-routes.js');
|
|
63
|
+
|
|
59
64
|
// Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
|
|
60
65
|
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate']);
|
|
61
66
|
const ROLE_KEYS = new Set(['client', 'node']);
|
|
@@ -357,292 +362,16 @@ function settingsRoutes(deps = {}) {
|
|
|
357
362
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
358
363
|
});
|
|
359
364
|
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
r.post('/nodes/pair', mutGate, async (req, res) => {
|
|
371
|
-
const b = req.body || {};
|
|
372
|
-
const redactSecrets = (s) => String(s || '')
|
|
373
|
-
.replace(/Bearer\s+\S+/gi, 'Bearer ***')
|
|
374
|
-
.replace(/[A-Za-z0-9_-]{40,}/g, '***');
|
|
375
|
-
const fail = (status, stage, code, detail, extra = {}) => send(res, status, {
|
|
376
|
-
error: redactSecrets(detail), stage, code, detail: redactSecrets(detail), retryable: false, ...extra,
|
|
377
|
-
});
|
|
378
|
-
if (!validName(b.name)) return fail(400, 'validation', 'bad-name', 'name non valido (slug a-z, 0-9, -, max 32)', { retryable: true });
|
|
379
|
-
if (!nodesStore.parseSshTarget(b.ssh)) return fail(400, 'validation', 'bad-ssh', 'alias SSH non valido (atteso user@host o Host alias)', { retryable: true });
|
|
380
|
-
const pair = peering.parsePairingUrl(b.pairingUrl);
|
|
381
|
-
if (!pair) return fail(400, 'validation', 'bad-link', 'link di pairing non valido o corrotto', { retryable: true, hint: 'rigenera il link sul dispositivo che invita' });
|
|
382
|
-
if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return fail(400, 'validation', 'bad-ssh-port', 'sshPort non valida (1..65535)', { retryable: true });
|
|
383
|
-
if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return fail(400, 'validation', 'bad-identity-file', 'identityFile non valido (path assoluto)', { retryable: true });
|
|
384
|
-
if (b.label !== undefined && !nodesStore.validLabel(b.label)) return fail(400, 'validation', 'bad-label', 'label non valida (max 64 char, niente a capo)', { retryable: true });
|
|
385
|
-
if (b.localLabel !== undefined && !nodesStore.validLabel(b.localLabel)) return fail(400, 'validation', 'bad-label', 'localLabel non valida (max 64 char, niente a capo)', { retryable: true });
|
|
386
|
-
// label umana del peer come lo vedro' io (display); se assente usa lo slug.
|
|
387
|
-
const peerLabel = nodesStore.sanitizeLabel(b.label, b.name);
|
|
388
|
-
// etichetta umana con cui il peer vedra' questo dispositivo; default sensato.
|
|
389
|
-
const localLabel = nodesStore.sanitizeLabel(b.localLabel, defaultDeviceName());
|
|
390
|
-
const fetchImpl = seams.fetchImpl || fetch;
|
|
391
|
-
const sleep = typeof seams.pairDelay === 'function' ? seams.pairDelay : undefined;
|
|
392
|
-
const transportProbe = typeof seams.probeTransportReady === 'function'
|
|
393
|
-
? seams.probeTransportReady : peering.probeTransportReady;
|
|
394
|
-
const requestTimeoutMs = Number.isInteger(seams.pairRequestTimeoutMs) && seams.pairRequestTimeoutMs > 0
|
|
395
|
-
? seams.pairRequestTimeoutMs : 6000;
|
|
396
|
-
// Every protocol request must terminate. Readiness and federation health
|
|
397
|
-
// already have their own bounded probes; join/confirm/cancel use this
|
|
398
|
-
// wrapper so a half-open peer cannot leave the PWA waiting forever.
|
|
399
|
-
const pairFetch = async (url, opts = {}, timeoutMs = requestTimeoutMs) => {
|
|
400
|
-
const ctrl = new AbortController();
|
|
401
|
-
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
402
|
-
try { return await fetchImpl(url, { ...opts, signal: ctrl.signal }); }
|
|
403
|
-
finally { clearTimeout(timer); }
|
|
404
|
-
};
|
|
405
|
-
|
|
406
|
-
let provisionalPort = null;
|
|
407
|
-
let portReservation = null;
|
|
408
|
-
let rollbackCredential = null;
|
|
409
|
-
let created = false;
|
|
410
|
-
let rolledBack = false;
|
|
411
|
-
// Rollback locale/remoto ESATTAMENTE una volta, best-effort: cancella la
|
|
412
|
-
// credenziale provvisoria sul peer (se emessa) e rimuove nodo+tunnel locali.
|
|
413
|
-
const rollback = async () => {
|
|
414
|
-
if (rolledBack) return; rolledBack = true;
|
|
415
|
-
if (portReservation) {
|
|
416
|
-
try { await portReservation.release(); } catch (_) { /* best-effort */ }
|
|
417
|
-
portReservation = null;
|
|
418
|
-
}
|
|
419
|
-
if (rollbackCredential && provisionalPort) {
|
|
420
|
-
try {
|
|
421
|
-
await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
|
|
422
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
423
|
-
body: JSON.stringify({ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId, credential: rollbackCredential }),
|
|
424
|
-
}, Math.min(requestTimeoutMs, 3000));
|
|
425
|
-
} catch (_) { /* best-effort */ }
|
|
426
|
-
}
|
|
427
|
-
try {
|
|
428
|
-
const st = nodesStore.loadStore(nodesPath);
|
|
429
|
-
const n = st && nodesStore.getNode(st, b.name);
|
|
430
|
-
if (n && created) {
|
|
431
|
-
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
432
|
-
nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, b.name));
|
|
433
|
-
}
|
|
434
|
-
} catch (_) { /* best-effort */ }
|
|
435
|
-
};
|
|
436
|
-
const failRolledBack = async (status, stage, code, detail, extra = {}) => {
|
|
437
|
-
await rollback();
|
|
438
|
-
return fail(status, stage, code, detail, extra);
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
try {
|
|
442
|
-
// --- conflict: il nome non deve gia' esistere --------------------------
|
|
443
|
-
let st = nodesStore.loadOrInitStore(nodesPath);
|
|
444
|
-
if (st.nodes.some((n) => n.name === b.name)) {
|
|
445
|
-
return fail(409, 'conflict', 'name-exists', `nodo "${b.name}" gia' presente`, {
|
|
446
|
-
retryable: true, hint: 'scegli un altro nome nelle opzioni avanzate e riprova',
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
if (st.nodeId === pair.instanceId) {
|
|
450
|
-
return fail(409, 'conflict', 'self-pairing', 'il link appartiene a questa stessa installazione', {
|
|
451
|
-
hint: 'genera il link sul nodo remoto che vuoi collegare',
|
|
452
|
-
});
|
|
453
|
-
}
|
|
454
|
-
const knownPeer = st.nodes.find((n) => n.nodeId === pair.instanceId);
|
|
455
|
-
if (knownPeer) {
|
|
456
|
-
return fail(409, 'conflict', 'peer-exists', `questa installazione e' gia' collegata come "${knownPeer.name}"`, {
|
|
457
|
-
hint: 'usa il nodo esistente oppure rimuovilo prima di rifare il pairing',
|
|
458
|
-
});
|
|
459
|
-
}
|
|
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
|
-
}
|
|
472
|
-
provisionalPort = localPort;
|
|
473
|
-
const acceptToken = crypto.randomBytes(32).toString('base64url');
|
|
474
|
-
try {
|
|
475
|
-
st = nodesStore.addNode(st, {
|
|
476
|
-
name: b.name, ssh: b.ssh, sshPort: b.sshPort,
|
|
477
|
-
remotePort: pair.port, localPort, identityFile: b.identityFile,
|
|
478
|
-
transport: 'auto', autostart: true, visibility: 'network',
|
|
479
|
-
direction: 'outbound', acceptToken, label: peerLabel,
|
|
480
|
-
});
|
|
481
|
-
} catch (e) {
|
|
482
|
-
const msg = String((e && e.message) || e);
|
|
483
|
-
const isDup = msg.includes('duplicato') || msg.includes('self-reference');
|
|
484
|
-
return failRolledBack(isDup ? 409 : 400, isDup ? 'conflict' : 'validation', 'node-rejected', msg, { retryable: !isDup });
|
|
485
|
-
}
|
|
486
|
-
nodesStore.atomicWriteStore(nodesPath, st);
|
|
487
|
-
created = true;
|
|
488
|
-
const node = nodesStore.getNode(st, b.name);
|
|
489
|
-
|
|
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;
|
|
496
|
-
const started = nodesTunnel.startForward({
|
|
497
|
-
home, node, localAppPort: runtimePort(),
|
|
498
|
-
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
499
|
-
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
500
|
-
});
|
|
501
|
-
if (!started.started && started.reason !== 'already running') {
|
|
502
|
-
return failRolledBack(502, 'ssh-start', 'tunnel-start-failed', started.reason || 'avvio del tunnel SSH fallito', {
|
|
503
|
-
retryable: true,
|
|
504
|
-
hint: 'verifica ssh e target/alias su questo dispositivo; il link NON e\' stato consumato e puoi riprovare',
|
|
505
|
-
});
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// --- ssh-ready: readiness bounded PRIMA di consumare l'invite -----------
|
|
509
|
-
const ready = await transportProbe({
|
|
510
|
-
port: localPort, capability: pair.invite, expectedInstanceId: pair.instanceId,
|
|
511
|
-
fetchImpl, sleep,
|
|
512
|
-
});
|
|
513
|
-
if (!ready.ready) {
|
|
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
|
-
});
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// --- join: consuma l'invite one-time (UNA volta, mai replay) ------------
|
|
525
|
-
let jr;
|
|
526
|
-
try {
|
|
527
|
-
jr = await pairFetch(`http://127.0.0.1:${localPort}/pair/join`, {
|
|
528
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
529
|
-
body: JSON.stringify({
|
|
530
|
-
invite: pair.invite,
|
|
531
|
-
instanceId: st.nodeId,
|
|
532
|
-
name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
|
|
533
|
-
label: localLabel,
|
|
534
|
-
port: runtimePort(),
|
|
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),
|
|
540
|
-
}),
|
|
541
|
-
});
|
|
542
|
-
} catch (e) {
|
|
543
|
-
// Risposta persa DOPO l'invio: l'invite potrebbe essere stato consumato.
|
|
544
|
-
// Un join ambiguo non si rigioca mai.
|
|
545
|
-
return failRolledBack(502, 'join', 'join-ambiguous',
|
|
546
|
-
'risposta di join persa: l\'invito potrebbe essere gia\' stato consumato', {
|
|
547
|
-
hint: 'rigenera un nuovo link sul dispositivo che invita e riprova',
|
|
548
|
-
});
|
|
549
|
-
}
|
|
550
|
-
const joined = await jr.json().catch(() => ({}));
|
|
551
|
-
if (jr.status === 410) {
|
|
552
|
-
return failRolledBack(502, 'join', 'invite-expired', 'invito scaduto o gia\' usato (one-time)', {
|
|
553
|
-
hint: 'rigenera un nuovo link sul dispositivo che invita',
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
if (!jr.ok) {
|
|
557
|
-
return failRolledBack(502, 'join', 'join-rejected', joined.error || `join rifiutato dal peer (HTTP ${jr.status})`, {
|
|
558
|
-
hint: 'rigenera un nuovo link e riprova',
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
const joinedRoles = joined.roles === undefined ? null : nodesStore.parseRoles(joined.roles);
|
|
562
|
-
if (!nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
|
|
563
|
-
|| !nodesStore.NODE_ID_RE.test(joined.instanceId) || (joined.roles !== undefined && !joinedRoles)) {
|
|
564
|
-
return failRolledBack(502, 'join', 'join-invalid-response', 'risposta di join non valida dal peer', {
|
|
565
|
-
hint: 'versioni NexusCrew incompatibili? aggiorna entrambi i nodi',
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
rollbackCredential = joined.credential;
|
|
569
|
-
if (joined.instanceId !== pair.instanceId) {
|
|
570
|
-
return failRolledBack(502, 'join', 'peer-identity-mismatch',
|
|
571
|
-
'l\'identita\' del peer raggiunto non coincide con quella contenuta nel link', {
|
|
572
|
-
hint: 'controlla che il target SSH punti al nodo che ha generato il link',
|
|
573
|
-
});
|
|
574
|
-
}
|
|
575
|
-
|
|
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.
|
|
579
|
-
st = nodesStore.loadOrInitStore(nodesPath);
|
|
580
|
-
st = nodesStore.updateNode(st, b.name, {
|
|
581
|
-
token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
|
|
582
|
-
shared: false,
|
|
583
|
-
...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
|
|
584
|
-
});
|
|
585
|
-
nodesStore.atomicWriteStore(nodesPath, st);
|
|
586
|
-
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
587
|
-
const finalStart = nodesTunnel.startForward({
|
|
588
|
-
home, node: nodesStore.getNode(st, b.name), localAppPort: runtimePort(),
|
|
589
|
-
spawnImpl: seams.spawnImpl, spawnSyncImpl: seams.spawnSyncImpl,
|
|
590
|
-
sshBin: seams.sshBin, logFd: seams.logFd,
|
|
591
|
-
});
|
|
592
|
-
if (!finalStart.started && finalStart.reason !== 'already running') {
|
|
593
|
-
return failRolledBack(502, 'tunnel-final', 'tunnel-restart-failed', finalStart.reason || 'riavvio del tunnel negoziato fallito', {});
|
|
594
|
-
}
|
|
595
|
-
const readyFinal = await transportProbe({
|
|
596
|
-
port: localPort, capability: joined.credential, expectedInstanceId: joined.instanceId,
|
|
597
|
-
fetchImpl, sleep,
|
|
598
|
-
});
|
|
599
|
-
if (!readyFinal.ready) {
|
|
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
|
-
});
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
// --- confirm: idempotente lato peer -> bounded retry ---------------------
|
|
609
|
-
let confirmed = null;
|
|
610
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
611
|
-
try {
|
|
612
|
-
confirmed = await pairFetch(`http://127.0.0.1:${localPort}/pair/confirm`, {
|
|
613
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
614
|
-
body: JSON.stringify({ credential: joined.credential }),
|
|
615
|
-
}, Math.min(requestTimeoutMs, 3500));
|
|
616
|
-
if (confirmed.ok) break;
|
|
617
|
-
} catch (_) { /* retry: confirm e' idempotente lato peer */ }
|
|
618
|
-
if (attempt < 2) await (sleep ? sleep() : new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1))));
|
|
619
|
-
}
|
|
620
|
-
if (!confirmed || !confirmed.ok) {
|
|
621
|
-
const x = confirmed ? await confirmed.json().catch(() => ({})) : {};
|
|
622
|
-
return failRolledBack(502, 'confirm', 'confirm-failed',
|
|
623
|
-
x.error || `conferma pairing fallita (HTTP ${confirmed ? confirmed.status : 'irraggiungibile'})`, {
|
|
624
|
-
hint: 'rigenera un nuovo link e riprova',
|
|
625
|
-
});
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// --- health: federazione AUTENTICATA verificata prima di paired:true ----
|
|
629
|
-
const health = await probeHealth({
|
|
630
|
-
port: localPort, token: joined.credential, expectedInstanceId: joined.instanceId,
|
|
631
|
-
fetchImpl, now: Date.now(),
|
|
632
|
-
});
|
|
633
|
-
if (!health || health.status !== 'healthy') {
|
|
634
|
-
return failRolledBack(502, 'health', 'federation-health-failed',
|
|
635
|
-
(health && health.detail) || 'health federato non verificabile dopo la conferma', {
|
|
636
|
-
hint: 'pairing annullato e ripulito: rigenera il link e riprova',
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto', health: { status: health.status } });
|
|
641
|
-
} catch (e) {
|
|
642
|
-
await rollback();
|
|
643
|
-
fail(502, 'internal', 'unexpected', String((e && e.message) || e), {});
|
|
644
|
-
}
|
|
645
|
-
});
|
|
365
|
+
// --- POST /nodes/pair — hydra join in stadi espliciti ---------------------
|
|
366
|
+
// Transaction handler estratto in lib/settings/pairing-coordinator.js
|
|
367
|
+
// (createPairHandler): probe di readiness bounded, consumo one-time dell'invite,
|
|
368
|
+
// rollback locale/remoto esattamente una volta, health federato autenticato
|
|
369
|
+
// prima di paired:true. Qui resta solo la registrazione dietro mutGate;
|
|
370
|
+
// token/credenziali MAI nel payload (redazione nel handler estratto).
|
|
371
|
+
r.post('/nodes/pair', mutGate, createPairHandler({
|
|
372
|
+
send, validName, defaultDeviceName,
|
|
373
|
+
nodesPath, configPath, home, seams, runtimePort,
|
|
374
|
+
}));
|
|
646
375
|
|
|
647
376
|
// --- POST /nodes — nodes add (riusa nodesCmds.nodesAdd) --------------------
|
|
648
377
|
r.post('/nodes', mutGate, (req, res) => {
|
|
@@ -941,113 +670,4 @@ function settingsRoutes(deps = {}) {
|
|
|
941
670
|
return r;
|
|
942
671
|
}
|
|
943
672
|
|
|
944
|
-
// Public only because the one-time invite itself is the capability. The route
|
|
945
|
-
// exposes no generic API and creates a scoped peer credential, never a UI token.
|
|
946
|
-
function publicPeeringRoutes(deps = {}) {
|
|
947
|
-
const cfg = deps.cfg || {};
|
|
948
|
-
const home = cfg.home || os.homedir();
|
|
949
|
-
const configPath = cfg.configPath || configJsonPath();
|
|
950
|
-
const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
|
|
951
|
-
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
952
|
-
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
953
|
-
const r = express.Router();
|
|
954
|
-
const attempts = new Map();
|
|
955
|
-
r.use(express.json({ limit: '8kb' }));
|
|
956
|
-
// Identity proof non consuma la capability e non riceve mai invite/token in
|
|
957
|
-
// chiaro. Serve a impedire che un qualunque listener HTTP sulla porta -L
|
|
958
|
-
// venga scambiato per il nodo contenuto nel link.
|
|
959
|
-
r.post('/identity', (req, res) => {
|
|
960
|
-
const key = `identity:${String(req.socket && req.socket.remoteAddress || 'local')}`;
|
|
961
|
-
const now = Date.now();
|
|
962
|
-
const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
|
|
963
|
-
recent.push(now); attempts.set(key, recent);
|
|
964
|
-
if (recent.length > 30) return res.status(429).json({ error: 'troppi tentativi' });
|
|
965
|
-
const b = req.body || {};
|
|
966
|
-
const proof = peering.capabilityIdentity({
|
|
967
|
-
invitesPath, pendingPath, capabilityId: b.capabilityId, challenge: b.challenge, now,
|
|
968
|
-
});
|
|
969
|
-
if (!proof) return res.status(404).json({ error: 'capability non valida' });
|
|
970
|
-
const st = nodesStore.loadStore(nodesPath);
|
|
971
|
-
if (!st || !nodesStore.NODE_ID_RE.test(st.nodeId)) return res.status(503).json({ error: 'identita nodo non disponibile' });
|
|
972
|
-
return res.json({ ok: true, instanceId: st.nodeId, proof });
|
|
973
|
-
});
|
|
974
|
-
r.post('/join', (req, res) => {
|
|
975
|
-
const key = `join:${String(req.socket && req.socket.remoteAddress || 'local')}`;
|
|
976
|
-
const now = Date.now();
|
|
977
|
-
const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
|
|
978
|
-
recent.push(now); attempts.set(key, recent);
|
|
979
|
-
if (recent.length > 10) return res.status(429).json({ error: 'troppi tentativi di pairing' });
|
|
980
|
-
const b = req.body || {};
|
|
981
|
-
const peerRoles = b.roles === undefined ? null : nodesStore.parseRoles(b.roles);
|
|
982
|
-
if (!nodesStore.validToken(b.invite) || !nodesStore.NODE_ID_RE.test(b.instanceId)
|
|
983
|
-
|| !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)
|
|
984
|
-
|| (b.roles !== undefined && !peerRoles)
|
|
985
|
-
// Pairing is always private. Publishing is a separate authenticated
|
|
986
|
-
// action after the reverse channel is live and health-checked.
|
|
987
|
-
|| (b.shared !== undefined && b.shared !== false)) {
|
|
988
|
-
return res.status(400).json({ error: 'pairing request non valida' });
|
|
989
|
-
}
|
|
990
|
-
if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
|
|
991
|
-
return res.status(400).json({ error: 'label non valida' });
|
|
992
|
-
}
|
|
993
|
-
if (!peering.consumeInvite({ invitesPath, invite: b.invite })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
|
|
994
|
-
try {
|
|
995
|
-
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
996
|
-
if (st.nodeId === b.instanceId || st.nodes.some((n) => n.nodeId === b.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
|
|
997
|
-
let name = b.name;
|
|
998
|
-
for (let i = 2; nodesStore.getNode(st, name); i += 1) name = `${b.name.slice(0, 28)}-${i}`;
|
|
999
|
-
const reversePort = peering.allocateReversePort(st.nodes);
|
|
1000
|
-
const credential = peering.createPending({ pendingPath, data: {
|
|
1001
|
-
name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
|
|
1002
|
-
shared: false,
|
|
1003
|
-
label: nodesStore.sanitizeLabel(b.label, name),
|
|
1004
|
-
...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
|
|
1005
|
-
} });
|
|
1006
|
-
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
|
|
1007
|
-
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1008
|
-
});
|
|
1009
|
-
r.post('/confirm', (req, res) => {
|
|
1010
|
-
const b = req.body || {};
|
|
1011
|
-
if (!nodesStore.validToken(b.credential)) return res.status(400).json({ error: 'confirm non valido' });
|
|
1012
|
-
const pending = peering.consumePending({ pendingPath, credential: b.credential });
|
|
1013
|
-
if (!pending) {
|
|
1014
|
-
const st = nodesStore.loadStore(nodesPath);
|
|
1015
|
-
if (st && st.nodes.some((n) => n.acceptToken && peering.safeEqual(n.acceptToken, b.credential))) return res.json({ confirmed: true, idempotent: true });
|
|
1016
|
-
return res.status(410).json({ error: 'pairing pending scaduto o gia usato' });
|
|
1017
|
-
}
|
|
1018
|
-
try {
|
|
1019
|
-
let st = nodesStore.loadOrInitStore(nodesPath);
|
|
1020
|
-
if (st.nodeId === pending.instanceId || st.nodes.some((n) => n.nodeId === pending.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
|
|
1021
|
-
st = nodesStore.addNode(st, {
|
|
1022
|
-
name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
|
|
1023
|
-
direction: 'inbound', transport: 'inbound', autostart: true,
|
|
1024
|
-
visibility: 'network', shared: pending.shared === true, nodeId: pending.instanceId,
|
|
1025
|
-
token: pending.acceptToken, acceptToken: b.credential,
|
|
1026
|
-
...(pending.roles ? { roles: pending.roles } : {}),
|
|
1027
|
-
rolesKnown: pending.rolesKnown === true,
|
|
1028
|
-
...(pending.label ? { label: pending.label } : {}),
|
|
1029
|
-
});
|
|
1030
|
-
nodesStore.atomicWriteStore(nodesPath, st);
|
|
1031
|
-
res.json({ confirmed: true });
|
|
1032
|
-
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1033
|
-
});
|
|
1034
|
-
r.post('/cancel', (req, res) => {
|
|
1035
|
-
const b = req.body || {};
|
|
1036
|
-
if (!nodesStore.NODE_ID_RE.test(b.instanceId) || !nodesStore.validToken(b.credential)) return res.status(400).json({ error: 'cancel non valido' });
|
|
1037
|
-
try {
|
|
1038
|
-
const pending = peering.consumePending({ pendingPath, credential: b.credential });
|
|
1039
|
-
if (!pending || pending.instanceId !== b.instanceId) {
|
|
1040
|
-
const st = nodesStore.loadStore(nodesPath);
|
|
1041
|
-
const peer = st && st.nodes.find((n) => n.nodeId === b.instanceId && n.acceptToken && peering.safeEqual(n.acceptToken, b.credential));
|
|
1042
|
-
if (!peer) return res.status(404).json({ error: 'pair non trovato' });
|
|
1043
|
-
nodesStore.atomicWriteStore(nodesPath, nodesStore.removeNode(st, peer.name));
|
|
1044
|
-
}
|
|
1045
|
-
res.json({ cancelled: true });
|
|
1046
|
-
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
1047
|
-
});
|
|
1048
|
-
return r;
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
function validPeerName(name) { return typeof name === 'string' && nodesStore.NODE_NAME_RE.test(name); }
|
|
1052
|
-
|
|
1053
673
|
module.exports = { settingsRoutes, publicPeeringRoutes, deepScrubTokens };
|