@mmmbuto/nexuscrew 0.8.44 → 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 +29 -0
- package/README.md +6 -0
- package/frontend/dist/assets/{index-CNI9gSN5.js → index-DNFEGdog.js} +1 -1
- package/frontend/dist/index.html +1 -1
- 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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Pure state transitions for the three-slot reverse pool. SSH side effects
|
|
4
|
+
// live outside this module: callers reserve, prove the candidate, commit on
|
|
5
|
+
// the hub, then let the peer drain the old sidecar. Keeping the transitions
|
|
6
|
+
// pure makes crash/replay cases explicit and testable.
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
|
|
9
|
+
const LEASE_MS = 60_000;
|
|
10
|
+
const GRACE_MS = 30_000;
|
|
11
|
+
|
|
12
|
+
function clone(pool) { return JSON.parse(JSON.stringify(pool)); }
|
|
13
|
+
function validSlot(pool, slot) { return Number.isInteger(slot) && pool && Array.isArray(pool.slots) && slot >= 0 && slot < pool.slots.length; }
|
|
14
|
+
|
|
15
|
+
function nextReadySlot(pool) {
|
|
16
|
+
if (!pool || !Array.isArray(pool.slots)) return null;
|
|
17
|
+
return pool.slots.findIndex((slot, index) => index !== pool.activeSlot && slot && slot.state === 'ready');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function prepareRotation(pool, { slot = nextReadySlot(pool), now = Date.now(), leaseId = crypto.randomBytes(16).toString('hex'), leaseMs = LEASE_MS } = {}) {
|
|
21
|
+
if (!pool || pool.verification !== 'verified' || !Array.isArray(pool.verifiedSlots)
|
|
22
|
+
|| pool.verifiedSlots.length !== pool.slots.length || pool.rotation?.phase !== 'active' || !validSlot(pool, slot)
|
|
23
|
+
|| pool.slots[slot].state !== 'ready' || !Number.isSafeInteger(now) || !Number.isSafeInteger(leaseMs) || leaseMs < 1
|
|
24
|
+
|| typeof leaseId !== 'string' || !/^[a-f0-9]{32,64}$/.test(leaseId)) return null;
|
|
25
|
+
const next = clone(pool);
|
|
26
|
+
const generation = next.activeGeneration + 1;
|
|
27
|
+
next.slots[slot] = { ...next.slots[slot], state: 'reserved', generation };
|
|
28
|
+
next.rotation = { phase: 'prepared', generation, slot, leaseId, expiresAt: now + leaseMs };
|
|
29
|
+
return next;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function abortPrepared(pool, { now = Date.now() } = {}) {
|
|
33
|
+
if (!pool || pool.rotation?.phase !== 'prepared' || !Number.isSafeInteger(now)) return null;
|
|
34
|
+
const next = clone(pool);
|
|
35
|
+
const slot = next.rotation.slot;
|
|
36
|
+
next.slots[slot] = { ...next.slots[slot], state: 'ready' };
|
|
37
|
+
next.rotation = { phase: 'active', generation: next.activeGeneration, slot: next.activeSlot };
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function commitRotation(pool, { leaseId, now = Date.now(), graceMs = GRACE_MS } = {}) {
|
|
42
|
+
if (!pool || pool.rotation?.phase !== 'prepared' || typeof leaseId !== 'string' || leaseId !== pool.rotation.leaseId
|
|
43
|
+
|| !Number.isSafeInteger(now) || now > pool.rotation.expiresAt || !Number.isSafeInteger(graceMs) || graceMs < 0) return null;
|
|
44
|
+
const next = clone(pool);
|
|
45
|
+
const oldSlot = next.activeSlot;
|
|
46
|
+
const oldGeneration = next.activeGeneration;
|
|
47
|
+
const slot = next.rotation.slot;
|
|
48
|
+
next.slots[oldSlot] = { ...next.slots[oldSlot], state: 'draining' };
|
|
49
|
+
next.slots[slot] = { ...next.slots[slot], state: 'active', generation: next.rotation.generation };
|
|
50
|
+
next.activeSlot = slot;
|
|
51
|
+
next.activeGeneration = next.rotation.generation;
|
|
52
|
+
next.rotation = {
|
|
53
|
+
phase: 'switched', generation: next.activeGeneration, slot,
|
|
54
|
+
oldSlot, oldGeneration, graceUntil: now + graceMs,
|
|
55
|
+
};
|
|
56
|
+
return next;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function settleGrace(pool, { now = Date.now() } = {}) {
|
|
60
|
+
if (!pool || pool.rotation?.phase !== 'switched' || !Number.isSafeInteger(now) || now < pool.rotation.graceUntil) return null;
|
|
61
|
+
const next = clone(pool);
|
|
62
|
+
const oldSlot = next.rotation.oldSlot;
|
|
63
|
+
if (next.slots[oldSlot]?.state === 'draining') next.slots[oldSlot] = { ...next.slots[oldSlot], state: 'ready' };
|
|
64
|
+
next.rotation = { phase: 'active', generation: next.activeGeneration, slot: next.activeSlot };
|
|
65
|
+
return next;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function quarantineSlot(pool, { slot, now = Date.now() } = {}) {
|
|
69
|
+
if (!pool || !validSlot(pool, slot) || slot === pool.activeSlot || !Number.isSafeInteger(now)) return null;
|
|
70
|
+
const next = clone(pool);
|
|
71
|
+
next.slots[slot] = { ...next.slots[slot], state: 'quarantined' };
|
|
72
|
+
if (next.rotation?.phase === 'prepared' && next.rotation.slot === slot) {
|
|
73
|
+
next.rotation = { phase: 'abandoned', generation: next.activeGeneration, slot: next.activeSlot };
|
|
74
|
+
}
|
|
75
|
+
return next;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
module.exports = { LEASE_MS, GRACE_MS, nextReadySlot, prepareRotation, abortPrepared, commitRotation, settleGrace, quarantineSlot };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Per-slot loopback listeners owned by this NexusCrew process. They run the
|
|
3
|
+
// same Express application as the primary listener, but expose a distinct TCP
|
|
4
|
+
// destination to every -R. That physical distinction is what makes an
|
|
5
|
+
// old-port -> new-port relay unable to obtain a slot MAC.
|
|
6
|
+
const http = require('node:http');
|
|
7
|
+
const proof = require('./reverse-slot-proof.js');
|
|
8
|
+
|
|
9
|
+
function listen(server, options) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
server.once('error', reject);
|
|
12
|
+
server.once('listening', () => {
|
|
13
|
+
server.removeListener('error', reject);
|
|
14
|
+
resolve(server.address());
|
|
15
|
+
});
|
|
16
|
+
server.listen(options);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function close(server) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
try { server.close(() => resolve()); } catch (_) { resolve(); }
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function createReverseSlotListeners({ app, createServerImpl = http.createServer, diagnostics } = {}) {
|
|
27
|
+
if (typeof app !== 'function') throw new Error('reverse slot listeners richiede app HTTP');
|
|
28
|
+
const listeners = new Map(); // local target port -> owned server + immutable expected tuple
|
|
29
|
+
|
|
30
|
+
async function open({ nodeName, remotePort, generation, instanceId, secret }) {
|
|
31
|
+
if (typeof nodeName !== 'string' || !proof.newProbe({ remotePort, generation, instanceId }, () => Buffer.alloc(24))) {
|
|
32
|
+
throw new Error('reverse slot listener spec non valida');
|
|
33
|
+
}
|
|
34
|
+
if (typeof secret !== 'string' || !secret) throw new Error('reverse slot listener credential mancante');
|
|
35
|
+
const server = createServerImpl(app);
|
|
36
|
+
let address;
|
|
37
|
+
try { address = await listen(server, { host: '127.0.0.1', port: 0, exclusive: true }); }
|
|
38
|
+
catch (error) { try { server.close(); } catch (_) {} throw error; }
|
|
39
|
+
const localPort = address && address.port;
|
|
40
|
+
if (!Number.isInteger(localPort)) { await close(server); throw new Error('reverse slot listener non ha una porta locale'); }
|
|
41
|
+
listeners.set(localPort, {
|
|
42
|
+
server, nodeName, secret,
|
|
43
|
+
expected: { remotePort, generation, instanceId },
|
|
44
|
+
});
|
|
45
|
+
diagnostics?.record?.('info', 'reverse-pool', 'REVERSE_SLOT_LISTENER_READY', 'Reverse slot listener ready', {
|
|
46
|
+
node: nodeName, remotePort, generation,
|
|
47
|
+
});
|
|
48
|
+
return { localPort, remotePort, generation };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function closePort(localPort) {
|
|
52
|
+
const entry = listeners.get(localPort);
|
|
53
|
+
if (!entry) return false;
|
|
54
|
+
listeners.delete(localPort);
|
|
55
|
+
await close(entry.server);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function closeAll() {
|
|
60
|
+
await Promise.all([...listeners.keys()].map(closePort));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function respond(req, res) {
|
|
64
|
+
const localPort = req && req.socket && req.socket.localPort;
|
|
65
|
+
const entry = listeners.get(localPort);
|
|
66
|
+
if (!entry) return false;
|
|
67
|
+
const body = req.body || {};
|
|
68
|
+
const response = proof.respondSlotProof({ secret: entry.secret, expected: entry.expected, request: body });
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
res.status(409).json({ error: 'reverse slot proof non valida', code: response.code });
|
|
71
|
+
} else {
|
|
72
|
+
res.json(response);
|
|
73
|
+
}
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return { open, closePort, closeAll, respond, size: () => listeners.size };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { createReverseSlotListeners };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// No-bearer ownership proof for one reverse slot. The local listener knows
|
|
3
|
+
// which remote -R slot targets it; a request relayed from another slot therefore
|
|
4
|
+
// reaches a listener with different expectedPort and is rejected before a MAC
|
|
5
|
+
// is emitted. The hub sends only public challenge material to an untrusted
|
|
6
|
+
// loopback listener, never the peer credential itself.
|
|
7
|
+
const crypto = require('node:crypto');
|
|
8
|
+
|
|
9
|
+
const PROBE_ID_RE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
10
|
+
const NONCE_RE = /^[A-Za-z0-9_-]{16,128}$/;
|
|
11
|
+
const INSTANCE_RE = /^[a-f0-9]{16,64}$/;
|
|
12
|
+
|
|
13
|
+
function isPort(port) { return Number.isInteger(port) && port >= 1 && port <= 65535; }
|
|
14
|
+
function safeEqual(a, b) {
|
|
15
|
+
const aa = Buffer.from(String(a || ''));
|
|
16
|
+
const bb = Buffer.from(String(b || ''));
|
|
17
|
+
return aa.length === bb.length && crypto.timingSafeEqual(aa, bb);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseTuple(value) {
|
|
21
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
22
|
+
|| Object.keys(value).some((key) => !['probeId', 'nonce', 'dialedPort', 'generation', 'instanceId'].includes(key))
|
|
23
|
+
|| !PROBE_ID_RE.test(String(value.probeId || '')) || !NONCE_RE.test(String(value.nonce || ''))
|
|
24
|
+
|| !isPort(value.dialedPort) || !Number.isSafeInteger(value.generation) || value.generation < 1
|
|
25
|
+
|| !INSTANCE_RE.test(String(value.instanceId || ''))) return null;
|
|
26
|
+
return {
|
|
27
|
+
probeId: value.probeId, nonce: value.nonce, dialedPort: value.dialedPort,
|
|
28
|
+
generation: value.generation, instanceId: value.instanceId,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function canonicalTuple(tuple) {
|
|
33
|
+
const parsed = parseTuple(tuple);
|
|
34
|
+
return parsed ? `nexuscrew-reverse-slot-proof/v1\0${JSON.stringify(parsed)}` : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function signSlotProof(secret, tuple) {
|
|
38
|
+
const canonical = canonicalTuple(tuple);
|
|
39
|
+
if (typeof secret !== 'string' || !secret || !canonical) return null;
|
|
40
|
+
return crypto.createHmac('sha256', secret).update(canonical).digest('base64url');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function expectedTuple(expected, request) {
|
|
44
|
+
const tuple = parseTuple(request && {
|
|
45
|
+
probeId: request.probeId,
|
|
46
|
+
nonce: request.nonce,
|
|
47
|
+
dialedPort: request.dialedPort,
|
|
48
|
+
generation: request.generation,
|
|
49
|
+
instanceId: request.instanceId,
|
|
50
|
+
});
|
|
51
|
+
if (!tuple || !expected || tuple.dialedPort !== expected.remotePort
|
|
52
|
+
|| tuple.generation !== expected.generation || tuple.instanceId !== expected.instanceId) return null;
|
|
53
|
+
return tuple;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function respondSlotProof({ secret, expected, request }) {
|
|
57
|
+
const tuple = expectedTuple(expected, request);
|
|
58
|
+
const mac = tuple && signSlotProof(secret, tuple);
|
|
59
|
+
return mac ? { ok: true, ...tuple, mac } : { ok: false, code: 'reverse-slot-proof-mismatch' };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function verifySlotProof({ secret, expected, challenge, response }) {
|
|
63
|
+
const issued = expectedTuple(expected, challenge);
|
|
64
|
+
const tuple = expectedTuple(expected, response);
|
|
65
|
+
const issuedCanonical = canonicalTuple(issued);
|
|
66
|
+
const responseCanonical = canonicalTuple(tuple);
|
|
67
|
+
if (!issued || !tuple || !issuedCanonical || !responseCanonical
|
|
68
|
+
|| !safeEqual(issuedCanonical, responseCanonical)
|
|
69
|
+
|| !response || typeof response.mac !== 'string') {
|
|
70
|
+
return { owned: false, code: 'reverse-slot-proof-mismatch' };
|
|
71
|
+
}
|
|
72
|
+
const expectedMac = signSlotProof(secret, tuple);
|
|
73
|
+
if (!expectedMac || !safeEqual(expectedMac, response.mac)) return { owned: false, code: 'reverse-slot-proof-invalid' };
|
|
74
|
+
return { owned: true, code: 'reverse-slot-owned', tuple };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function newProbe(expected, randomBytes = crypto.randomBytes) {
|
|
78
|
+
if (!expected || !isPort(expected.remotePort) || !Number.isSafeInteger(expected.generation)
|
|
79
|
+
|| expected.generation < 1 || !INSTANCE_RE.test(String(expected.instanceId || ''))) return null;
|
|
80
|
+
return {
|
|
81
|
+
probeId: randomBytes(18).toString('base64url'),
|
|
82
|
+
nonce: randomBytes(24).toString('base64url'),
|
|
83
|
+
dialedPort: expected.remotePort,
|
|
84
|
+
generation: expected.generation,
|
|
85
|
+
instanceId: expected.instanceId,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function probeReverseSlot({ port, secret, expected, fetchImpl = fetch, timeoutMs = 1500, randomBytes } = {}) {
|
|
90
|
+
if (!isPort(port) || typeof secret !== 'string' || !secret) return { owned: false, code: 'reverse-slot-proof-invalid-input' };
|
|
91
|
+
const probe = newProbe(expected, randomBytes);
|
|
92
|
+
if (!probe) return { owned: false, code: 'reverse-slot-proof-invalid-input' };
|
|
93
|
+
const ctrl = new AbortController();
|
|
94
|
+
const timer = setTimeout(() => ctrl.abort(), Math.max(1, Math.min(timeoutMs, 5000)));
|
|
95
|
+
try {
|
|
96
|
+
const response = await fetchImpl(`http://127.0.0.1:${port}/reverse-slot-proof`, {
|
|
97
|
+
method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(probe), signal: ctrl.signal,
|
|
98
|
+
});
|
|
99
|
+
if (!response || response.status !== 200) return { owned: false, code: 'reverse-slot-proof-unavailable' };
|
|
100
|
+
const body = await response.json().catch(() => null);
|
|
101
|
+
return verifySlotProof({ secret, expected, challenge: probe, response: body });
|
|
102
|
+
} catch (_) { return { owned: false, code: 'reverse-slot-proof-unavailable' }; }
|
|
103
|
+
finally { clearTimeout(timer); }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
parseTuple, canonicalTuple, signSlotProof, respondSlotProof, verifySlotProof, newProbe, probeReverseSlot,
|
|
108
|
+
};
|
package/lib/nodes/store.js
CHANGED
|
@@ -17,8 +17,10 @@ const fs = require('node:fs');
|
|
|
17
17
|
const os = require('node:os');
|
|
18
18
|
const path = require('node:path');
|
|
19
19
|
const crypto = require('node:crypto');
|
|
20
|
+
const reversePool = require('./reverse-pool.js');
|
|
20
21
|
|
|
21
|
-
const SCHEMA_VERSION =
|
|
22
|
+
const SCHEMA_VERSION = 3;
|
|
23
|
+
const PREVIOUS_SCHEMA_VERSION = 2;
|
|
22
24
|
const LEGACY_SCHEMA_VERSION = 1;
|
|
23
25
|
const MAX_NODES = 64;
|
|
24
26
|
const MAX_TOKEN_LEN = 4096;
|
|
@@ -105,8 +107,89 @@ const LABEL_MAX = 64;
|
|
|
105
107
|
const NODE_KEYS = new Set([
|
|
106
108
|
'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
|
|
107
109
|
'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
|
|
108
|
-
'direction', 'reversePort', 'shared', 'label',
|
|
110
|
+
'direction', 'reversePort', 'shared', 'label', 'reversePool',
|
|
109
111
|
]);
|
|
112
|
+
|
|
113
|
+
const REVERSE_POOL_SLOT_STATES = new Set(['active', 'ready', 'reserved', 'draining', 'quarantined', 'retired']);
|
|
114
|
+
const REVERSE_POOL_VERIFICATIONS = new Set(['verified', 'unverifiable', 'missing', 'invalidated']);
|
|
115
|
+
const REVERSE_POOL_PHASES = new Set(['active', 'prepared', 'switched', 'abandoned']);
|
|
116
|
+
|
|
117
|
+
function parseReversePool(value) {
|
|
118
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
119
|
+
const keys = Object.keys(value);
|
|
120
|
+
if (keys.some((key) => !['base', 'slots', 'activeSlot', 'activeGeneration', 'verification', 'verifiedSlots', 'rotation', 'lastAutoRotationAt'].includes(key))) return null;
|
|
121
|
+
const ports = reversePool.reversePoolForBase(value.base);
|
|
122
|
+
if (!ports || !Array.isArray(value.slots) || value.slots.length !== ports.length
|
|
123
|
+
|| !Number.isInteger(value.activeSlot) || value.activeSlot < 0 || value.activeSlot >= ports.length
|
|
124
|
+
|| !Number.isSafeInteger(value.activeGeneration) || value.activeGeneration < 1
|
|
125
|
+
|| !REVERSE_POOL_VERIFICATIONS.has(value.verification)) return null;
|
|
126
|
+
if (value.lastAutoRotationAt !== undefined && (!Number.isSafeInteger(value.lastAutoRotationAt) || value.lastAutoRotationAt < 0)) return null;
|
|
127
|
+
const verifiedSlots = value.verifiedSlots === undefined ? [] : value.verifiedSlots;
|
|
128
|
+
if (!Array.isArray(verifiedSlots) || verifiedSlots.length > ports.length
|
|
129
|
+
|| new Set(verifiedSlots).size !== verifiedSlots.length
|
|
130
|
+
|| verifiedSlots.some((slot) => !Number.isInteger(slot) || slot < 0 || slot >= ports.length)) return null;
|
|
131
|
+
if (value.verification === 'verified' && verifiedSlots.length !== ports.length) return null;
|
|
132
|
+
const slots = value.slots.map((slot, index) => {
|
|
133
|
+
if (!slot || typeof slot !== 'object' || Array.isArray(slot) || Object.keys(slot).some((key) => !['port', 'state', 'generation'].includes(key))) return null;
|
|
134
|
+
if (slot.port !== ports[index] || !REVERSE_POOL_SLOT_STATES.has(slot.state)
|
|
135
|
+
|| !Number.isSafeInteger(slot.generation) || slot.generation < 1) return null;
|
|
136
|
+
return { port: slot.port, state: slot.state, generation: slot.generation };
|
|
137
|
+
});
|
|
138
|
+
if (slots.some((slot) => !slot) || slots[value.activeSlot].state !== 'active'
|
|
139
|
+
|| slots[value.activeSlot].generation !== value.activeGeneration) return null;
|
|
140
|
+
let rotation = { phase: 'active', generation: value.activeGeneration, slot: value.activeSlot };
|
|
141
|
+
if (value.rotation !== undefined) {
|
|
142
|
+
const r = value.rotation;
|
|
143
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)
|
|
144
|
+
|| Object.keys(r).some((key) => !['phase', 'generation', 'slot', 'leaseId', 'expiresAt', 'oldSlot', 'oldGeneration', 'graceUntil'].includes(key))
|
|
145
|
+
|| !REVERSE_POOL_PHASES.has(r.phase) || !Number.isSafeInteger(r.generation) || r.generation < 1
|
|
146
|
+
|| !Number.isInteger(r.slot) || r.slot < 0 || r.slot >= ports.length) return null;
|
|
147
|
+
if (r.phase === 'prepared') {
|
|
148
|
+
if (typeof r.leaseId !== 'string' || !/^[a-f0-9]{32,64}$/.test(r.leaseId)
|
|
149
|
+
|| !Number.isSafeInteger(r.expiresAt) || r.expiresAt < 0) return null;
|
|
150
|
+
} else if (r.phase === 'switched') {
|
|
151
|
+
if (!Number.isInteger(r.oldSlot) || r.oldSlot < 0 || r.oldSlot >= ports.length || r.oldSlot === r.slot
|
|
152
|
+
|| !Number.isSafeInteger(r.oldGeneration) || r.oldGeneration < 1
|
|
153
|
+
|| !Number.isSafeInteger(r.graceUntil) || r.graceUntil < 0
|
|
154
|
+
|| r.leaseId !== undefined || r.expiresAt !== undefined) return null;
|
|
155
|
+
} else if (r.leaseId !== undefined || r.expiresAt !== undefined || r.oldSlot !== undefined
|
|
156
|
+
|| r.oldGeneration !== undefined || r.graceUntil !== undefined) return null;
|
|
157
|
+
rotation = { phase: r.phase, generation: r.generation, slot: r.slot,
|
|
158
|
+
...(r.phase === 'prepared' ? { leaseId: r.leaseId, expiresAt: r.expiresAt } : {}),
|
|
159
|
+
...(r.phase === 'switched' ? { oldSlot: r.oldSlot, oldGeneration: r.oldGeneration, graceUntil: r.graceUntil } : {}) };
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
base: value.base,
|
|
163
|
+
slots,
|
|
164
|
+
activeSlot: value.activeSlot,
|
|
165
|
+
activeGeneration: value.activeGeneration,
|
|
166
|
+
verification: value.verification,
|
|
167
|
+
verifiedSlots: [...verifiedSlots].sort((a, b) => a - b),
|
|
168
|
+
rotation,
|
|
169
|
+
...(value.lastAutoRotationAt === undefined ? {} : { lastAutoRotationAt: value.lastAutoRotationAt }),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function parseReversePoolAnchor(value) {
|
|
174
|
+
return reversePool.parseAnchor(value);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function reversePoolDefault(base, { verification = 'unverifiable', generation = 1 } = {}) {
|
|
178
|
+
const ports = reversePool.reversePoolForBase(base);
|
|
179
|
+
if (!ports || !REVERSE_POOL_VERIFICATIONS.has(verification) || !Number.isSafeInteger(generation) || generation < 1) {
|
|
180
|
+
throw new Error('reverse pool non valida');
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
base,
|
|
184
|
+
slots: ports.map((port, index) => ({ port, state: index === 0 ? 'active' : 'ready', generation })),
|
|
185
|
+
activeSlot: 0,
|
|
186
|
+
activeGeneration: generation,
|
|
187
|
+
verification,
|
|
188
|
+
verifiedSlots: verification === 'verified' ? ports.map((_, index) => index) : [],
|
|
189
|
+
rotation: { phase: 'active', generation, slot: 0 },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
110
193
|
function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
111
194
|
if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
|
|
112
195
|
for (const k of Object.keys(n)) { if (!NODE_KEYS.has(k)) return null; } // schema chiuso
|
|
@@ -170,6 +253,12 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
170
253
|
if (!isPort(n.reversePort)) return null;
|
|
171
254
|
out.reversePort = n.reversePort;
|
|
172
255
|
}
|
|
256
|
+
if (n.reversePool !== undefined) {
|
|
257
|
+
if (schemaVersion < SCHEMA_VERSION) return null;
|
|
258
|
+
const parsedPool = parseReversePool(n.reversePool);
|
|
259
|
+
if (!parsedPool) return null;
|
|
260
|
+
out.reversePool = parsedPool;
|
|
261
|
+
}
|
|
173
262
|
if (n.selected !== undefined) {
|
|
174
263
|
if (!Array.isArray(n.selected) || n.selected.length > MAX_NODES) return null;
|
|
175
264
|
const selected = [...new Set(n.selected)];
|
|
@@ -222,7 +311,11 @@ function parseStore(raw) {
|
|
|
222
311
|
return null;
|
|
223
312
|
}
|
|
224
313
|
|
|
225
|
-
if (
|
|
314
|
+
if (![SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(d.schemaVersion)) return null;
|
|
315
|
+
const rootKeys = d.schemaVersion === SCHEMA_VERSION
|
|
316
|
+
? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized'])
|
|
317
|
+
: new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous']);
|
|
318
|
+
if (Object.keys(d).some((key) => !rootKeys.has(key))) return null;
|
|
226
319
|
if (typeof d.nodeId !== 'string' || !NODE_ID_RE.test(d.nodeId)) return null;
|
|
227
320
|
if (!Array.isArray(d.nodes) || d.nodes.length > MAX_NODES) return null;
|
|
228
321
|
|
|
@@ -252,6 +345,24 @@ function parseStore(raw) {
|
|
|
252
345
|
if (!rdv) return null;
|
|
253
346
|
out.rendezvous = rdv;
|
|
254
347
|
}
|
|
348
|
+
if (d.schemaVersion === SCHEMA_VERSION) {
|
|
349
|
+
// This marker distinguishes a fresh v3 peer (which may have received a
|
|
350
|
+
// pool from another hub) from a hub that has once owned allocations. If
|
|
351
|
+
// the latter loses its anchor, it must not silently start a new ledger.
|
|
352
|
+
if (d.reversePoolLedgerInitialized !== undefined && d.reversePoolLedgerInitialized !== true) return null;
|
|
353
|
+
if (d.reversePoolLedgerInitialized === true) out.reversePoolLedgerInitialized = true;
|
|
354
|
+
if (d.reversePoolAnchor !== undefined) {
|
|
355
|
+
if (d.reversePoolLedgerInitialized !== true) return null;
|
|
356
|
+
const anchor = parseReversePoolAnchor(d.reversePoolAnchor);
|
|
357
|
+
if (!anchor) return null;
|
|
358
|
+
out.reversePoolAnchor = anchor;
|
|
359
|
+
}
|
|
360
|
+
// The allocator and monotonic ledger live on the hub, which owns inbound
|
|
361
|
+
// peers. An outbound peer persists the pool negotiated by that hub but
|
|
362
|
+
// must not invent a second local allocation ledger for it.
|
|
363
|
+
if (nodes.some((node) => node.direction === 'inbound' && node.reversePool)
|
|
364
|
+
&& (!out.reversePoolAnchor || d.reversePoolLedgerInitialized !== true)) return null;
|
|
365
|
+
}
|
|
255
366
|
return out;
|
|
256
367
|
} catch (_) {
|
|
257
368
|
return null; // fail-closed: qualunque eccezione inattesa -> null, MAI throw
|
|
@@ -366,8 +477,12 @@ function getNode(store, name) {
|
|
|
366
477
|
return store.nodes.find((n) => n.name === name) || null;
|
|
367
478
|
}
|
|
368
479
|
|
|
480
|
+
function mutationSchemaVersion(store) {
|
|
481
|
+
return store && store.schemaVersion === SCHEMA_VERSION ? SCHEMA_VERSION : PREVIOUS_SCHEMA_VERSION;
|
|
482
|
+
}
|
|
483
|
+
|
|
369
484
|
function addNode(store, entry) {
|
|
370
|
-
const node = parseNode(entry,
|
|
485
|
+
const node = parseNode(entry, mutationSchemaVersion(store));
|
|
371
486
|
if (!node) throw new Error('nodo non valido (schema strict): controlla name/ssh/remotePort/localPort');
|
|
372
487
|
if (store.nodes.some((n) => n.name === node.name)) {
|
|
373
488
|
throw new Error(`nodo duplicato: name "${node.name}" gia' presente`);
|
|
@@ -381,7 +496,7 @@ function addNode(store, entry) {
|
|
|
381
496
|
throw new Error(`nodo duplicato: nodeId "${node.nodeId}" gia' presente`);
|
|
382
497
|
}
|
|
383
498
|
}
|
|
384
|
-
return { ...store, schemaVersion:
|
|
499
|
+
return { ...store, schemaVersion: mutationSchemaVersion(store), nodes: store.nodes.concat([node]) };
|
|
385
500
|
}
|
|
386
501
|
|
|
387
502
|
function removeNode(store, name) {
|
|
@@ -389,7 +504,7 @@ function removeNode(store, name) {
|
|
|
389
504
|
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
390
505
|
const nodes = store.nodes.slice();
|
|
391
506
|
nodes.splice(idx, 1);
|
|
392
|
-
return { ...store, schemaVersion:
|
|
507
|
+
return { ...store, schemaVersion: mutationSchemaVersion(store), nodes };
|
|
393
508
|
}
|
|
394
509
|
|
|
395
510
|
function setNodeToken(store, name, token) {
|
|
@@ -398,17 +513,47 @@ function setNodeToken(store, name, token) {
|
|
|
398
513
|
if (!validToken(token)) throw new Error('token non valido (vuoto, multilinea o troppo lungo)');
|
|
399
514
|
const nodes = store.nodes.slice();
|
|
400
515
|
nodes[idx] = { ...nodes[idx], token };
|
|
401
|
-
return { ...store, schemaVersion:
|
|
516
|
+
return { ...store, schemaVersion: mutationSchemaVersion(store), nodes };
|
|
402
517
|
}
|
|
403
518
|
|
|
404
519
|
function updateNode(store, name, patch) {
|
|
405
520
|
const idx = store.nodes.findIndex((n) => n.name === name);
|
|
406
521
|
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
407
|
-
const parsed = parseNode({ ...store.nodes[idx], ...patch },
|
|
522
|
+
const parsed = parseNode({ ...store.nodes[idx], ...patch }, mutationSchemaVersion(store));
|
|
408
523
|
if (!parsed) throw new Error('aggiornamento nodo non valido');
|
|
409
524
|
const nodes = store.nodes.slice();
|
|
410
525
|
nodes[idx] = parsed;
|
|
411
|
-
return { ...store, schemaVersion:
|
|
526
|
+
return { ...store, schemaVersion: mutationSchemaVersion(store), nodes };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// The upgrade is explicit: normal legacy pairing remains schema v2 and keeps
|
|
530
|
+
// working with a previous NexusCrew binary. Enabling rotation is the only
|
|
531
|
+
// path that writes the v3 anchor and therefore requires both peers to support
|
|
532
|
+
// the capability.
|
|
533
|
+
function upgradeToReversePoolSchema(store, anchor = undefined) {
|
|
534
|
+
const current = parseStore(store);
|
|
535
|
+
if (!current) throw new Error('nodes store non valido');
|
|
536
|
+
const parsedAnchor = anchor === undefined ? null : parseReversePoolAnchor(anchor);
|
|
537
|
+
if (anchor !== undefined && !parsedAnchor) throw new Error('reverse pool anchor non valida');
|
|
538
|
+
const next = {
|
|
539
|
+
...current,
|
|
540
|
+
schemaVersion: SCHEMA_VERSION,
|
|
541
|
+
...(parsedAnchor ? { reversePoolLedgerInitialized: true, reversePoolAnchor: parsedAnchor } : {}),
|
|
542
|
+
};
|
|
543
|
+
const parsed = parseStore(next);
|
|
544
|
+
if (!parsed) throw new Error('upgrade reverse pool non valido');
|
|
545
|
+
return parsed;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function setNodeReversePool(store, name, value) {
|
|
549
|
+
const current = store && getNode(store, name);
|
|
550
|
+
if (!store || store.schemaVersion !== SCHEMA_VERSION || !current) {
|
|
551
|
+
throw new Error('reverse pool richiede schema v3');
|
|
552
|
+
}
|
|
553
|
+
if (current.direction === 'inbound' && !parseReversePoolAnchor(store.reversePoolAnchor)) {
|
|
554
|
+
throw new Error('reverse pool inbound richiede anchor valida');
|
|
555
|
+
}
|
|
556
|
+
return updateNode(store, name, { reversePool: value });
|
|
412
557
|
}
|
|
413
558
|
|
|
414
559
|
// --- Redazione (view sicura per status/list: MAI il token) ------------------
|
|
@@ -434,6 +579,18 @@ function redactNode(n) {
|
|
|
434
579
|
if (n.visibility === 'selected') out.selected = [...(n.selected || [])];
|
|
435
580
|
if (n.sshPort !== undefined) out.sshPort = n.sshPort;
|
|
436
581
|
if (n.nodeId) out.nodeId = n.nodeId;
|
|
582
|
+
if (n.reversePool) {
|
|
583
|
+
out.reversePool = {
|
|
584
|
+
base: n.reversePool.base,
|
|
585
|
+
slots: n.reversePool.slots.map((slot) => ({ ...slot })),
|
|
586
|
+
activeSlot: n.reversePool.activeSlot,
|
|
587
|
+
activeGeneration: n.reversePool.activeGeneration,
|
|
588
|
+
verification: n.reversePool.verification,
|
|
589
|
+
verifiedSlots: [...n.reversePool.verifiedSlots],
|
|
590
|
+
rotation: { ...n.reversePool.rotation },
|
|
591
|
+
...(n.reversePool.lastAutoRotationAt === undefined ? {} : { lastAutoRotationAt: n.reversePool.lastAutoRotationAt }),
|
|
592
|
+
};
|
|
593
|
+
}
|
|
437
594
|
return out;
|
|
438
595
|
}
|
|
439
596
|
|
|
@@ -577,10 +734,11 @@ function suggestNodeName(input, existing = []) {
|
|
|
577
734
|
module.exports = {
|
|
578
735
|
// parse/validate
|
|
579
736
|
parseStore, parseNode, parseRendezvous, parseRoles, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
|
|
737
|
+
parseReversePool, parseReversePoolAnchor, reversePoolDefault,
|
|
580
738
|
// I/O
|
|
581
739
|
defaultNodesPath, loadStore, loadStoreStrict, initStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
|
|
582
740
|
// mutazioni
|
|
583
|
-
getNode, addNode, removeNode, setNodeToken, updateNode,
|
|
741
|
+
getNode, addNode, removeNode, setNodeToken, updateNode, upgradeToReversePoolSchema, setNodeReversePool,
|
|
584
742
|
// redazione
|
|
585
743
|
redactNode, redactStore, hasPairedPeers,
|
|
586
744
|
// migrazione
|
|
@@ -588,5 +746,5 @@ module.exports = {
|
|
|
588
746
|
// label / slug
|
|
589
747
|
nodeLabel, validLabel, sanitizeLabel, toSlug, deriveNodeHandle, suggestNodeName, LABEL_MAX,
|
|
590
748
|
// costanti
|
|
591
|
-
SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
749
|
+
SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
592
750
|
};
|
|
@@ -89,7 +89,14 @@ function clearForwardProbe() {
|
|
|
89
89
|
function probeForward(expectedChild) {
|
|
90
90
|
if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
|
|
91
91
|
if (!forwardPort) {
|
|
92
|
-
|
|
92
|
+
// A reverse-only sidecar has no local -L to probe. With
|
|
93
|
+
// ExitOnForwardFailure enabled, surviving the stability window proves that
|
|
94
|
+
// ssh accepted its -R request; the hub still performs the stronger MAC
|
|
95
|
+
// ownership probe before it publishes Share.
|
|
96
|
+
attempt = 0;
|
|
97
|
+
reverseFailures = 0;
|
|
98
|
+
logEvent(`reverse forward ready stableMs=${stableMs}`);
|
|
99
|
+
if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'reverse-forward' })) stop();
|
|
93
100
|
return;
|
|
94
101
|
}
|
|
95
102
|
let settled = false;
|