@mmmbuto/nexuscrew 0.8.43 → 0.8.45
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/CHANGELOG.md +41 -0
- package/README.md +14 -3
- package/frontend/dist/assets/{index-JrKi-YpL.css → index-BAq6N1Md.css} +1 -1
- package/frontend/dist/assets/{index-DNepgdI5.js → index-DNFEGdog.js} +32 -32
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +3 -2
- package/lib/cli/commands.js +5 -2
- package/lib/cli/pidfile.js +45 -2
- package/lib/fleet/builtin.js +2 -2
- package/lib/fleet/managed.js +44 -18
- package/lib/fleet/runtime.js +24 -11
- package/lib/mcp/cells.js +27 -4
- package/lib/mcp/server.js +16 -3
- package/lib/nodes/commands.js +17 -0
- package/lib/nodes/health.js +23 -2
- package/lib/nodes/reverse-pool.js +221 -0
- package/lib/nodes/reverse-rotation.js +78 -0
- package/lib/nodes/reverse-slot-listeners.js +80 -0
- package/lib/nodes/reverse-slot-proof.js +108 -0
- package/lib/nodes/store.js +169 -11
- package/lib/nodes/tunnel-supervisor.js +8 -1
- package/lib/nodes/tunnel.js +96 -11
- package/lib/proxy/federation.js +337 -9
- package/lib/server.js +247 -1
- package/lib/settings/pairing-coordinator.js +18 -0
- package/lib/settings/public-peering-routes.js +58 -4
- package/lib/settings/routes.js +31 -4
- package/package.json +1 -1
package/lib/proxy/federation.js
CHANGED
|
@@ -5,8 +5,10 @@ const net = require('node:net');
|
|
|
5
5
|
const express = require('express');
|
|
6
6
|
const store = require('../nodes/store.js');
|
|
7
7
|
const topologyCache = require('../nodes/topology-cache.js');
|
|
8
|
+
const reverseRotation = require('../nodes/reverse-rotation.js');
|
|
8
9
|
const { bearerFrom } = require('../auth/middleware.js');
|
|
9
10
|
const { safeEqual } = require('../nodes/peering.js');
|
|
11
|
+
const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
|
|
10
12
|
const {
|
|
11
13
|
sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
|
|
12
14
|
} = require('./node-proxy.js');
|
|
@@ -289,6 +291,73 @@ async function waitForHealthyPeer(opts = {}) {
|
|
|
289
291
|
return last || { status: 'down', detail: 'peer non raggiungibile' };
|
|
290
292
|
}
|
|
291
293
|
|
|
294
|
+
// Verifica locale e limitata alla porta reverse gia' assegnata al peer
|
|
295
|
+
// autenticato. Non accetta una porta dal chiamante: il peer non puo' usarla
|
|
296
|
+
// come scanner e la risposta non rivela quale processo la stia occupando.
|
|
297
|
+
function canListenLoopback(port, createServerImpl = net.createServer) {
|
|
298
|
+
if (!store.isPort(port)) return Promise.resolve({ available: false, code: 'reverse-port-invalid' });
|
|
299
|
+
return new Promise((resolve) => {
|
|
300
|
+
let settled = false;
|
|
301
|
+
const settle = (value) => {
|
|
302
|
+
if (settled) return;
|
|
303
|
+
settled = true;
|
|
304
|
+
resolve(value);
|
|
305
|
+
};
|
|
306
|
+
let server;
|
|
307
|
+
try {
|
|
308
|
+
server = createServerImpl();
|
|
309
|
+
server.once('error', (error) => {
|
|
310
|
+
settle({ available: false, code: error && error.code === 'EADDRINUSE' ? 'reverse-port-in-use' : 'reverse-port-unavailable' });
|
|
311
|
+
});
|
|
312
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {
|
|
313
|
+
server.close((error) => settle(error
|
|
314
|
+
? { available: false, code: 'reverse-port-unavailable' }
|
|
315
|
+
: { available: true }));
|
|
316
|
+
});
|
|
317
|
+
} catch (_) { settle({ available: false, code: 'reverse-port-unavailable' }); }
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Preflight best-effort da client verso il proprio hub attraverso il -L gia'
|
|
322
|
+
// autenticato. Un hub precedente che non conosce la route (404), o una rete
|
|
323
|
+
// transitoriamente guasta, conserva il percorso legacy: l'SSH con
|
|
324
|
+
// ExitOnForwardFailure resta l'autorita' finale. Un 409 del nuovo hub invece
|
|
325
|
+
// evita di spegnere il -L privato per tentare un -R gia' noto come conflittuale.
|
|
326
|
+
// Una porta gia' risposta dal peer autenticato stesso e' invece transitabile:
|
|
327
|
+
// e' il caso normale di Share che registra un reverse gia' pronto.
|
|
328
|
+
async function preflightHubReverse({ node, fetchImpl = fetch, timeoutMs = 1500 }) {
|
|
329
|
+
if (!node || !store.isPort(node.localPort) || !store.validToken(node.token)) {
|
|
330
|
+
throw new Error('parametri preflight Share non validi');
|
|
331
|
+
}
|
|
332
|
+
const ctrl = new AbortController();
|
|
333
|
+
const budget = Number.isInteger(timeoutMs) ? Math.max(100, Math.min(timeoutMs, 10000)) : 1500;
|
|
334
|
+
let timer;
|
|
335
|
+
try {
|
|
336
|
+
const request = fetchImpl(`http://127.0.0.1:${node.localPort}/federation/reverse-status`, {
|
|
337
|
+
headers: { authorization: `Bearer ${node.token}` }, signal: ctrl.signal,
|
|
338
|
+
});
|
|
339
|
+
const timeout = new Promise((_, reject) => {
|
|
340
|
+
timer = setTimeout(() => {
|
|
341
|
+
ctrl.abort();
|
|
342
|
+
const error = new Error(`hub reverse preflight timeout (${budget}ms)`);
|
|
343
|
+
error.code = 'ETIMEDOUT';
|
|
344
|
+
reject(error);
|
|
345
|
+
}, budget);
|
|
346
|
+
});
|
|
347
|
+
const response = await Promise.race([request, timeout]);
|
|
348
|
+
if (!response || response.status === 404) return { supported: false };
|
|
349
|
+
if (response.status === 409) return { supported: true, available: false, code: 'reverse-port-in-use' };
|
|
350
|
+
if (!response.ok) return { supported: false };
|
|
351
|
+
let body = null;
|
|
352
|
+
try { body = await response.json(); } catch (_) { return { supported: false }; }
|
|
353
|
+
return body && (body.available === true || body.ownedByAuthenticatedPeer === true)
|
|
354
|
+
? { supported: true, available: true, ...(body.ownedByAuthenticatedPeer === true ? { ownedByAuthenticatedPeer: true } : {}) }
|
|
355
|
+
: { supported: false };
|
|
356
|
+
} catch (_) {
|
|
357
|
+
return { supported: false };
|
|
358
|
+
} finally { clearTimeout(timer); }
|
|
359
|
+
}
|
|
360
|
+
|
|
292
361
|
// Aggiorna lo stato Share sul hub attraverso il canale -L autenticato. Non
|
|
293
362
|
// legge mai il body remoto (potrebbe contenere diagnostica non sicura) e non
|
|
294
363
|
// include credenziali negli errori. Usato sia dal toggle interattivo sia dalla
|
|
@@ -317,6 +386,61 @@ async function notifyHubShare({ node, shared, fetchImpl = fetch, timeoutMs = 500
|
|
|
317
386
|
} finally { clearTimeout(timer); }
|
|
318
387
|
}
|
|
319
388
|
|
|
389
|
+
// Internal pool-control calls travel only through the established private -L.
|
|
390
|
+
// Bodies are deliberately index/lease based: neither a caller nor a remote
|
|
391
|
+
// UI can turn them into an arbitrary loopback port probe.
|
|
392
|
+
async function hubPoolRequest({ node, endpoint, body, fetchImpl = fetch, timeoutMs = 5000 }) {
|
|
393
|
+
if (!node || !store.isPort(node.localPort) || !store.validToken(node.token)
|
|
394
|
+
|| !['verify', 'reserve', 'commit', 'settle', 'abort', 'status'].includes(endpoint)) {
|
|
395
|
+
throw new Error('parametri reverse pool non validi');
|
|
396
|
+
}
|
|
397
|
+
const ctrl = new AbortController();
|
|
398
|
+
const budget = Number.isInteger(timeoutMs) ? Math.max(100, Math.min(timeoutMs, 30000)) : 5000;
|
|
399
|
+
const timer = setTimeout(() => ctrl.abort(), budget);
|
|
400
|
+
try {
|
|
401
|
+
const method = endpoint === 'status' ? 'GET' : 'POST';
|
|
402
|
+
const response = await fetchImpl(`http://127.0.0.1:${node.localPort}/federation/reverse-pool/${endpoint}`, {
|
|
403
|
+
method, signal: ctrl.signal,
|
|
404
|
+
headers: { authorization: `Bearer ${node.token}`, ...(method === 'POST' ? { 'content-type': 'application/json' } : {}) },
|
|
405
|
+
...(method === 'POST' ? { body: JSON.stringify(body || {}) } : {}),
|
|
406
|
+
});
|
|
407
|
+
const payload = response && typeof response.json === 'function'
|
|
408
|
+
? await response.json().catch(() => ({})) : {};
|
|
409
|
+
if (!response?.ok) {
|
|
410
|
+
const error = new Error(`hub reverse pool HTTP ${response?.status || 'unknown'}`);
|
|
411
|
+
error.status = response?.status; error.code = payload && payload.code;
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
return payload || {};
|
|
415
|
+
} finally { clearTimeout(timer); }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function verifyHubPoolSlot({ node, slot, generation, fetchImpl = fetch, attempts = 3, delayMs = 200 }) {
|
|
419
|
+
const total = Number.isInteger(attempts) ? Math.max(1, Math.min(attempts, 6)) : 3;
|
|
420
|
+
let last = null;
|
|
421
|
+
for (let attempt = 0; attempt < total; attempt += 1) {
|
|
422
|
+
try { return await hubPoolRequest({ node, endpoint: 'verify', body: { slot, generation }, fetchImpl }); }
|
|
423
|
+
catch (error) { last = error; if (attempt < total - 1) await new Promise((resolve) => setTimeout(resolve, delayMs)); }
|
|
424
|
+
}
|
|
425
|
+
throw last || new Error('verifica reverse pool fallita');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function reserveHubPoolSlot({ node, slot, fetchImpl = fetch }) {
|
|
429
|
+
return hubPoolRequest({ node, endpoint: 'reserve', body: { slot }, fetchImpl });
|
|
430
|
+
}
|
|
431
|
+
function commitHubPoolSlot({ node, leaseId, fetchImpl = fetch }) {
|
|
432
|
+
return hubPoolRequest({ node, endpoint: 'commit', body: { leaseId }, fetchImpl });
|
|
433
|
+
}
|
|
434
|
+
function settleHubPoolSlot({ node, generation, fetchImpl = fetch }) {
|
|
435
|
+
return hubPoolRequest({ node, endpoint: 'settle', body: { generation }, fetchImpl });
|
|
436
|
+
}
|
|
437
|
+
function abortHubPoolSlot({ node, leaseId, fetchImpl = fetch }) {
|
|
438
|
+
return hubPoolRequest({ node, endpoint: 'abort', body: { leaseId }, fetchImpl });
|
|
439
|
+
}
|
|
440
|
+
function getHubPoolStatus({ node, fetchImpl = fetch }) {
|
|
441
|
+
return hubPoolRequest({ node, endpoint: 'status', fetchImpl });
|
|
442
|
+
}
|
|
443
|
+
|
|
320
444
|
// Il file locale contiene lo stato desiderato. Dopo un crash in qualunque
|
|
321
445
|
// punto del toggle, il boot ristabilisce il tunnel coerente e ripete l'update
|
|
322
446
|
// del hub: ON torna pubblicato, OFF revoca record stale. Tutto e' bounded.
|
|
@@ -544,6 +668,26 @@ async function collectLocalTopology({
|
|
|
544
668
|
return { instanceId: live.instanceId, nodes };
|
|
545
669
|
}
|
|
546
670
|
|
|
671
|
+
async function probeReverseOwner(peer, fetchImpl = fetch) {
|
|
672
|
+
const pool = peer && peer.reversePool;
|
|
673
|
+
const active = pool && pool.slots && pool.slots[pool.activeSlot];
|
|
674
|
+
if (active && peer.token && peer.nodeId) {
|
|
675
|
+
const proof = await probeReverseSlot({
|
|
676
|
+
port: active.port, secret: peer.token,
|
|
677
|
+
expected: { remotePort: active.port, generation: pool.activeGeneration, instanceId: peer.nodeId },
|
|
678
|
+
fetchImpl,
|
|
679
|
+
});
|
|
680
|
+
return proof.owned
|
|
681
|
+
? { status: 'healthy', detail: 'reverse slot autenticata', slotProof: true }
|
|
682
|
+
: { status: 'degraded', detail: 'reverse slot non autenticata', slotProof: true, code: proof.code };
|
|
683
|
+
}
|
|
684
|
+
return probeHealth({
|
|
685
|
+
port: peer.localPort, token: peer.token,
|
|
686
|
+
expectedInstanceId: peer.nodeId || null,
|
|
687
|
+
fetchImpl,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
547
691
|
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null }) {
|
|
548
692
|
const r = express.Router();
|
|
549
693
|
r.use((req, res, next) => {
|
|
@@ -561,6 +705,180 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
561
705
|
res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly(),
|
|
562
706
|
...(advertisedRoles ? { roles: advertisedRoles } : {}) });
|
|
563
707
|
});
|
|
708
|
+
// Il client puo' verificare SOLO la reverse port che il pairing gli ha
|
|
709
|
+
// assegnato. Non e' un endpoint di discovery: nessun parametro, nessuna
|
|
710
|
+
// identita' del listener e nessun dettaglio SSH vengono esposti.
|
|
711
|
+
r.get('/reverse-status', async (req, res) => {
|
|
712
|
+
const result = await canListenLoopback(req.peer.localPort);
|
|
713
|
+
if (result.available) return res.json({ available: true });
|
|
714
|
+
// Una porta gia' ascoltata dal peer che ha autenticato QUESTA richiesta
|
|
715
|
+
// non e' un conflitto: e' il reverse preesistente dello stesso dispositivo.
|
|
716
|
+
// La health vincola sia token sia instanceId e non restituisce alcun
|
|
717
|
+
// dettaglio sul processo. Un listener estraneo, legacy o non verificabile
|
|
718
|
+
// resta invece un 409 senza restart/persist/publish lato client.
|
|
719
|
+
const health = await probeReverseOwner(req.peer, fetchImpl || fetch);
|
|
720
|
+
if (health.status === 'healthy') {
|
|
721
|
+
return res.json({ available: false, ownedByAuthenticatedPeer: true });
|
|
722
|
+
}
|
|
723
|
+
return res.status(409).json({
|
|
724
|
+
available: false,
|
|
725
|
+
code: result.code === 'reverse-port-in-use' ? 'reverse-port-in-use' : 'reverse-port-unavailable',
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
// Pool verification is intentionally slot-indexed, never port-indexed: the
|
|
729
|
+
// peer can ask the hub to prove only a slot it was assigned. The hub keeps
|
|
730
|
+
// the proof result itself; a client cannot simply claim that its SSH key has
|
|
731
|
+
// three permitlisten grants.
|
|
732
|
+
r.post('/reverse-pool/verify', express.json({ limit: '1kb' }), async (req, res) => {
|
|
733
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: verifica pool bloccata' });
|
|
734
|
+
const body = req.body || {};
|
|
735
|
+
if (Object.keys(body).some((key) => !['slot', 'generation'].includes(key))
|
|
736
|
+
|| !Number.isInteger(body.slot) || !Number.isSafeInteger(body.generation) || body.generation < 1) {
|
|
737
|
+
return res.status(400).json({ error: 'body non valido: attesi slot e generation' });
|
|
738
|
+
}
|
|
739
|
+
const peer = req.peer;
|
|
740
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
741
|
+
const node = store.getNode(current, peer.name);
|
|
742
|
+
const pool = node && node.reversePool;
|
|
743
|
+
const candidate = pool && pool.slots[body.slot];
|
|
744
|
+
if (!candidate || candidate.generation !== body.generation) {
|
|
745
|
+
return res.status(409).json({ error: 'slot reverse non assegnata a questa generation', code: 'reverse-slot-stale' });
|
|
746
|
+
}
|
|
747
|
+
const proof = await probeReverseSlot({
|
|
748
|
+
port: candidate.port, secret: node.token,
|
|
749
|
+
expected: { remotePort: candidate.port, generation: candidate.generation, instanceId: node.nodeId },
|
|
750
|
+
fetchImpl: fetchImpl || fetch,
|
|
751
|
+
});
|
|
752
|
+
const verifiedSlots = proof.owned
|
|
753
|
+
? [...new Set([...pool.verifiedSlots, body.slot])].sort((a, b) => a - b)
|
|
754
|
+
: [...pool.verifiedSlots];
|
|
755
|
+
const updatedPool = {
|
|
756
|
+
...pool,
|
|
757
|
+
verifiedSlots,
|
|
758
|
+
verification: verifiedSlots.length === pool.slots.length ? 'verified' : 'unverifiable',
|
|
759
|
+
};
|
|
760
|
+
store.atomicWriteStore(nodesPath, store.setNodeReversePool(current, node.name, updatedPool));
|
|
761
|
+
if (!proof.owned) return res.status(409).json({ error: 'slot reverse non autenticata', code: proof.code });
|
|
762
|
+
return res.json({ verified: true, slot: body.slot, verification: updatedPool.verification });
|
|
763
|
+
});
|
|
764
|
+
// The peer proposes a slot through its still-live -L; the hub alone grants
|
|
765
|
+
// the lease and generation. There is no port parameter, scanner or tunnel
|
|
766
|
+
// termination path here.
|
|
767
|
+
r.post('/reverse-pool/reserve', express.json({ limit: '1kb' }), (req, res) => {
|
|
768
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: rotazione pool bloccata' });
|
|
769
|
+
const body = req.body || {};
|
|
770
|
+
if (Object.keys(body).some((key) => key !== 'slot') || !Number.isInteger(body.slot)) {
|
|
771
|
+
return res.status(400).json({ error: 'body non valido: atteso slot' });
|
|
772
|
+
}
|
|
773
|
+
try {
|
|
774
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
775
|
+
const node = store.getNode(current, req.peer.name);
|
|
776
|
+
let pool = node && node.reversePool;
|
|
777
|
+
// A crashed peer may leave a reservation behind. Its lease has no
|
|
778
|
+
// privilege after expiry, so clear it before considering the requested
|
|
779
|
+
// slot; this is state cleanup only, never SSH/process cleanup.
|
|
780
|
+
if (pool?.rotation?.phase === 'prepared' && Date.now() > pool.rotation.expiresAt) {
|
|
781
|
+
pool = reverseRotation.abortPrepared(pool);
|
|
782
|
+
}
|
|
783
|
+
const prepared = pool && reverseRotation.prepareRotation(pool, { slot: body.slot });
|
|
784
|
+
if (!prepared) return res.status(409).json({ error: 'pool non verificato o slot non disponibile', code: 'reverse-rotation-not-ready' });
|
|
785
|
+
store.atomicWriteStore(nodesPath, store.setNodeReversePool(current, node.name, prepared));
|
|
786
|
+
return res.json({ leaseId: prepared.rotation.leaseId, slot: prepared.rotation.slot,
|
|
787
|
+
generation: prepared.rotation.generation, expiresAt: prepared.rotation.expiresAt });
|
|
788
|
+
} catch (error) { return res.status(500).json({ error: String(error.message || error) }); }
|
|
789
|
+
});
|
|
790
|
+
r.post('/reverse-pool/abort', express.json({ limit: '1kb' }), (req, res) => {
|
|
791
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: rotazione pool bloccata' });
|
|
792
|
+
const body = req.body || {};
|
|
793
|
+
if (Object.keys(body).some((key) => key !== 'leaseId') || typeof body.leaseId !== 'string') {
|
|
794
|
+
return res.status(400).json({ error: 'body non valido: atteso leaseId' });
|
|
795
|
+
}
|
|
796
|
+
try {
|
|
797
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
798
|
+
const node = store.getNode(current, req.peer.name);
|
|
799
|
+
const pool = node && node.reversePool;
|
|
800
|
+
if (!pool || pool.rotation?.phase !== 'prepared' || pool.rotation.leaseId !== body.leaseId) {
|
|
801
|
+
return res.status(409).json({ error: 'lease reverse non valida o gia conclusa', code: 'reverse-lease-stale' });
|
|
802
|
+
}
|
|
803
|
+
const aborted = reverseRotation.abortPrepared(pool);
|
|
804
|
+
if (!aborted) return res.status(409).json({ error: 'lease reverse non annullabile', code: 'reverse-lease-stale' });
|
|
805
|
+
store.atomicWriteStore(nodesPath, store.setNodeReversePool(current, node.name, aborted));
|
|
806
|
+
return res.json({ aborted: true });
|
|
807
|
+
} catch (error) { return res.status(500).json({ error: String(error.message || error) }); }
|
|
808
|
+
});
|
|
809
|
+
r.post('/reverse-pool/commit', express.json({ limit: '1kb' }), async (req, res) => {
|
|
810
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: rotazione pool bloccata' });
|
|
811
|
+
const body = req.body || {};
|
|
812
|
+
if (Object.keys(body).some((key) => key !== 'leaseId') || typeof body.leaseId !== 'string') {
|
|
813
|
+
return res.status(400).json({ error: 'body non valido: atteso leaseId' });
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
817
|
+
const node = store.getNode(current, req.peer.name);
|
|
818
|
+
const prepared = node && node.reversePool;
|
|
819
|
+
const rotation = prepared && prepared.rotation;
|
|
820
|
+
if (!rotation || rotation.phase !== 'prepared' || rotation.leaseId !== body.leaseId) {
|
|
821
|
+
return res.status(409).json({ error: 'lease reverse non valida o scaduta', code: 'reverse-lease-stale' });
|
|
822
|
+
}
|
|
823
|
+
const slot = prepared.slots[rotation.slot];
|
|
824
|
+
let proof = { owned: false, code: 'reverse-slot-proof-unavailable' };
|
|
825
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
826
|
+
proof = await probeReverseSlot({
|
|
827
|
+
port: slot.port, secret: node.token,
|
|
828
|
+
expected: { remotePort: slot.port, generation: rotation.generation, instanceId: node.nodeId },
|
|
829
|
+
fetchImpl: fetchImpl || fetch,
|
|
830
|
+
});
|
|
831
|
+
if (proof.owned || attempt === 2) break;
|
|
832
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
833
|
+
}
|
|
834
|
+
if (!proof.owned) {
|
|
835
|
+
const abandoned = reverseRotation.quarantineSlot(prepared, { slot: rotation.slot }) || prepared;
|
|
836
|
+
const invalidated = { ...abandoned, verification: 'invalidated', verifiedSlots: [] };
|
|
837
|
+
store.atomicWriteStore(nodesPath, store.setNodeReversePool(current, node.name, invalidated));
|
|
838
|
+
return res.status(409).json({ error: 'candidate reverse non autenticata', code: proof.code });
|
|
839
|
+
}
|
|
840
|
+
const committed = reverseRotation.commitRotation(prepared, { leaseId: body.leaseId });
|
|
841
|
+
if (!committed) return res.status(409).json({ error: 'lease reverse scaduta', code: 'reverse-lease-expired' });
|
|
842
|
+
// The hub routes the peer through the newly proven slot immediately. The
|
|
843
|
+
// old slot is only draining and is never reassigned to another peer.
|
|
844
|
+
const updated = store.updateNode(current, node.name, {
|
|
845
|
+
localPort: committed.slots[committed.activeSlot].port,
|
|
846
|
+
reversePool: committed,
|
|
847
|
+
});
|
|
848
|
+
store.atomicWriteStore(nodesPath, updated);
|
|
849
|
+
return res.json({ committed: true, slot: committed.activeSlot, generation: committed.activeGeneration,
|
|
850
|
+
graceUntil: committed.rotation.graceUntil });
|
|
851
|
+
} catch (error) { return res.status(500).json({ error: String(error.message || error) }); }
|
|
852
|
+
});
|
|
853
|
+
r.post('/reverse-pool/settle', express.json({ limit: '1kb' }), (req, res) => {
|
|
854
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: rotazione pool bloccata' });
|
|
855
|
+
const body = req.body || {};
|
|
856
|
+
if (Object.keys(body).some((key) => key !== 'generation') || !Number.isSafeInteger(body.generation) || body.generation < 1) {
|
|
857
|
+
return res.status(400).json({ error: 'body non valido: atteso generation' });
|
|
858
|
+
}
|
|
859
|
+
try {
|
|
860
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
861
|
+
const node = store.getNode(current, req.peer.name);
|
|
862
|
+
if (!node?.reversePool || node.reversePool.activeGeneration !== body.generation) {
|
|
863
|
+
return res.status(409).json({ error: 'generation reverse non corrente', code: 'reverse-generation-stale' });
|
|
864
|
+
}
|
|
865
|
+
const settled = reverseRotation.settleGrace(node.reversePool);
|
|
866
|
+
if (!settled) return res.status(409).json({ error: 'grace reverse non conclusa', code: 'reverse-grace-pending' });
|
|
867
|
+
store.atomicWriteStore(nodesPath, store.setNodeReversePool(current, node.name, settled));
|
|
868
|
+
return res.json({ settled: true, generation: settled.activeGeneration });
|
|
869
|
+
} catch (error) { return res.status(500).json({ error: String(error.message || error) }); }
|
|
870
|
+
});
|
|
871
|
+
r.get('/reverse-pool/status', (req, res) => {
|
|
872
|
+
try {
|
|
873
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
874
|
+
const node = store.getNode(current, req.peer.name);
|
|
875
|
+
if (!node?.reversePool) return res.status(409).json({ error: 'pool reverse non configurato', code: 'reverse-pool-missing' });
|
|
876
|
+
// This travels only on the peer's authenticated private -L. It includes
|
|
877
|
+
// no bearer material and lets a restarted peer converge on the hub's
|
|
878
|
+
// committed generation instead of reopening an obsolete slot.
|
|
879
|
+
return res.json({ pool: node.reversePool });
|
|
880
|
+
} catch (error) { return res.status(500).json({ error: String(error.message || error) }); }
|
|
881
|
+
});
|
|
564
882
|
r.get('/topology', async (req, res) => {
|
|
565
883
|
const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
|
|
566
884
|
const visited = String(req.query.visited || '').split(',');
|
|
@@ -577,14 +895,23 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
577
895
|
}
|
|
578
896
|
try {
|
|
579
897
|
if (body.shared) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
898
|
+
let health;
|
|
899
|
+
if (req.peer.reversePool) {
|
|
900
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
901
|
+
health = await probeReverseOwner(req.peer, fetchImpl || fetch);
|
|
902
|
+
if (health.status === 'healthy') break;
|
|
903
|
+
if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 200));
|
|
904
|
+
}
|
|
905
|
+
} else {
|
|
906
|
+
health = await waitForHealthyPeer({
|
|
907
|
+
port: req.peer.localPort,
|
|
908
|
+
token: req.peer.token,
|
|
909
|
+
expectedInstanceId: req.peer.nodeId || null,
|
|
910
|
+
fetchImpl: fetchImpl || fetch,
|
|
911
|
+
attempts: 6,
|
|
912
|
+
delayMs: 200,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
588
915
|
if (health.status !== 'healthy') {
|
|
589
916
|
return res.status(409).json({
|
|
590
917
|
error: 'canale share non raggiungibile',
|
|
@@ -656,5 +983,6 @@ module.exports = {
|
|
|
656
983
|
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
|
|
657
984
|
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
658
985
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
659
|
-
probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare, runShareRevokeBoot,
|
|
986
|
+
probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
|
|
987
|
+
hubPoolRequest, verifyHubPoolSlot, reserveHubPoolSlot, commitHubPoolSlot, settleHubPoolSlot, abortHubPoolSlot, getHubPoolStatus,
|
|
660
988
|
};
|