@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.
@@ -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
+ };
@@ -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 = 2;
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 (d.schemaVersion !== SCHEMA_VERSION && d.schemaVersion !== LEGACY_SCHEMA_VERSION) return null;
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, SCHEMA_VERSION);
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: SCHEMA_VERSION, nodes: store.nodes.concat([node]) };
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: SCHEMA_VERSION, nodes };
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: SCHEMA_VERSION, nodes };
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 }, SCHEMA_VERSION);
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: SCHEMA_VERSION, nodes };
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
- writeState('transport-probing', { sshPid: child.pid, detail: 'local forward unavailable' });
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;