@mmmbuto/nexuscrew 0.8.44 → 0.8.46

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/lib/server.js CHANGED
@@ -29,6 +29,8 @@ const nodesStore = require('./nodes/store.js');
29
29
  const nodesTunnel = require('./nodes/tunnel.js');
30
30
  const nodesHealth = require('./nodes/health.js');
31
31
  const nodesInventory = require('./nodes/inventory.js');
32
+ const { createReverseSlotListeners } = require('./nodes/reverse-slot-listeners.js');
33
+ const reverseRotation = require('./nodes/reverse-rotation.js');
32
34
  const topologyCache = require('./nodes/topology-cache.js');
33
35
  const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
34
36
  const federation = require('./proxy/federation.js');
@@ -160,6 +162,10 @@ function createServer(opts = {}) {
160
162
  // route federata da un POST diretto di chi possiede il token della UI.
161
163
  const hopSecret = createHopSecret();
162
164
  const audioNonceCache = createNonceCache();
165
+ let reverseSlotListeners = null;
166
+ const rotatableReverse = new Map(); // node -> Map("port:generation", tracked listener/supervisor)
167
+ const reverseWatchers = new Map();
168
+ const reverseRotationInFlight = new Set();
163
169
  function resolveNode(name) {
164
170
  const st = nodesStore.loadStore(nodesPath);
165
171
  if (!st) return null;
@@ -170,6 +176,229 @@ function createServer(opts = {}) {
170
176
 
171
177
  const runtimePort = () => (server && server.address() ? server.address().port : cfg.port);
172
178
  let tunnelsStarted = false;
179
+ function reverseKey(remotePort, generation) { return `${remotePort}:${generation}`; }
180
+ function nodeReverseEntries(name) { return rotatableReverse.get(name) || new Map(); }
181
+ async function startRotatableReverse(node, { slot = node?.reversePool?.activeSlot, generation = null } = {}) {
182
+ if (!reverseSlotListeners || node.shared !== true || !node.reversePool || !node.acceptToken || !node.nodeId) return null;
183
+ const active = node.reversePool.slots[slot];
184
+ const expectedGeneration = generation === null ? active?.generation : generation;
185
+ if (!active || !Number.isSafeInteger(expectedGeneration) || expectedGeneration < 1) throw new Error('reverse pool slot non valida');
186
+ const entries = nodeReverseEntries(node.name);
187
+ const key = reverseKey(active.port, expectedGeneration);
188
+ const existing = entries.get(key);
189
+ if (existing) return existing;
190
+ const listener = await reverseSlotListeners.open({
191
+ nodeName: node.name, remotePort: active.port, generation: expectedGeneration,
192
+ instanceId: node.nodeId, secret: node.acceptToken,
193
+ });
194
+ let launched = nodesTunnel.startReverseForward({
195
+ home: cfg.home || os.homedir(), node, remotePort: active.port, generation: expectedGeneration,
196
+ targetPort: listener.localPort, spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
197
+ sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
198
+ });
199
+ if (!launched.started && launched.reason === 'already running') {
200
+ const stopped = nodesTunnel.stopTunnel({ home: cfg.home || os.homedir(), name: nodesTunnel.reverseTunnelName(node.name, active.port, expectedGeneration) });
201
+ if (!stopped.stopped) {
202
+ await reverseSlotListeners.closePort(listener.localPort);
203
+ throw new Error(stopped.reason || 'supervisor reverse preesistente non attribuibile');
204
+ }
205
+ launched = nodesTunnel.startReverseForward({
206
+ home: cfg.home || os.homedir(), node, remotePort: active.port, generation: expectedGeneration,
207
+ targetPort: listener.localPort, spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
208
+ sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
209
+ });
210
+ }
211
+ if (!launched.started && launched.reason !== 'already running') {
212
+ await reverseSlotListeners.closePort(listener.localPort);
213
+ throw new Error(launched.reason || 'reverse slot supervisor non avviato');
214
+ }
215
+ const tracked = { ...listener, remotePort: active.port, generation: expectedGeneration };
216
+ entries.set(key, tracked);
217
+ rotatableReverse.set(node.name, entries);
218
+ if (slot === node.reversePool.activeSlot && node.reversePool.verification === 'verified') ensureReverseWatcher(node.name);
219
+ return tracked;
220
+ }
221
+ async function stopRotatableReverse(name, { remotePort = null, generation = null } = {}) {
222
+ const entries = rotatableReverse.get(name);
223
+ if (!entries) {
224
+ if (!Number.isInteger(remotePort) || !Number.isSafeInteger(generation)) return false;
225
+ const result = nodesTunnel.stopTunnel({ home: cfg.home || os.homedir(), name: nodesTunnel.reverseTunnelName(name, remotePort, generation) });
226
+ return result.stopped || ['no pidfile', 'stale (pid dead)'].includes(result.reason);
227
+ }
228
+ const selected = [...entries.values()].filter((entry) => (remotePort === null || entry.remotePort === remotePort)
229
+ && (generation === null || entry.generation === generation));
230
+ let stopped = false;
231
+ for (const existing of selected) {
232
+ const result = nodesTunnel.stopTunnel({ home: cfg.home || os.homedir(), name: nodesTunnel.reverseTunnelName(name, existing.remotePort, existing.generation) });
233
+ // If we cannot prove ownership we neither signal nor close the listener:
234
+ // the channel is quarantined for diagnostics rather than broken by us.
235
+ if (!result.stopped && !['no pidfile', 'stale (pid dead)'].includes(result.reason)) continue;
236
+ entries.delete(reverseKey(existing.remotePort, existing.generation));
237
+ await reverseSlotListeners?.closePort(existing.localPort);
238
+ stopped = true;
239
+ }
240
+ if (!entries.size) rotatableReverse.delete(name);
241
+ if (remotePort === null && generation === null) {
242
+ const watcher = reverseWatchers.get(name);
243
+ if (watcher) clearInterval(watcher);
244
+ reverseWatchers.delete(name);
245
+ }
246
+ return stopped;
247
+ }
248
+ async function verifyRotatablePool(node) {
249
+ const pool = node?.reversePool;
250
+ if (!pool || !node.shared || !node.token || !node.nodeId) return { verified: false, code: 'reverse-pool-not-ready' };
251
+ const proven = [];
252
+ for (let slot = 0; slot < pool.slots.length; slot += 1) {
253
+ const candidate = pool.slots[slot];
254
+ let temporary = false;
255
+ try {
256
+ await startRotatableReverse(node, { slot, generation: candidate.generation });
257
+ temporary = slot !== pool.activeSlot;
258
+ await federation.verifyHubPoolSlot({ node, slot, generation: candidate.generation, fetchImpl: healthFetch });
259
+ proven.push(slot);
260
+ } catch (_) {
261
+ break;
262
+ } finally {
263
+ if (temporary) await stopRotatableReverse(node.name, { remotePort: candidate.port, generation: candidate.generation });
264
+ }
265
+ }
266
+ const current = nodesStore.loadStoreStrict(nodesPath);
267
+ const fresh = nodesStore.getNode(current, node.name);
268
+ if (!fresh?.reversePool) return { verified: false, code: 'reverse-pool-missing' };
269
+ const verifiedSlots = [...new Set(proven)].sort((a, b) => a - b);
270
+ const verification = verifiedSlots.length === fresh.reversePool.slots.length ? 'verified' : 'unverifiable';
271
+ const updatedPool = { ...fresh.reversePool, verifiedSlots, verification };
272
+ nodesStore.atomicWriteStore(nodesPath, nodesStore.setNodeReversePool(current, fresh.name, updatedPool));
273
+ diagnostics.record(verification === 'verified' ? 'info' : 'warn', 'reverse-pool',
274
+ verification === 'verified' ? 'REVERSE_POOL_VERIFIED' : 'REVERSE_POOL_UNVERIFIABLE',
275
+ verification === 'verified' ? 'Reverse pool verified' : 'Reverse pool could not be fully verified', { node: fresh.name, verifiedSlots: verifiedSlots.length });
276
+ if (verification === 'verified') ensureReverseWatcher(fresh.name);
277
+ return { verified: verification === 'verified', verification, verifiedSlots };
278
+ }
279
+ async function settleRotatableGrace(name, generation) {
280
+ const current = nodesStore.loadStoreStrict(nodesPath);
281
+ const node = nodesStore.getNode(current, name);
282
+ const pool = node?.reversePool;
283
+ if (!pool || pool.rotation?.phase !== 'switched' || pool.activeGeneration !== generation) return;
284
+ const old = pool.slots[pool.rotation.oldSlot];
285
+ const stopped = await stopRotatableReverse(name, { remotePort: old.port, generation: pool.rotation.oldGeneration });
286
+ if (!stopped) {
287
+ diagnostics.record('warn', 'reverse-pool', 'REVERSE_POOL_OLD_QUARANTINED', 'Old reverse slot was not attributable for shutdown', { node: name, slot: pool.rotation.oldSlot });
288
+ return;
289
+ }
290
+ try {
291
+ await federation.settleHubPoolSlot({ node, generation, fetchImpl: healthFetch });
292
+ const freshStore = nodesStore.loadStoreStrict(nodesPath);
293
+ const fresh = nodesStore.getNode(freshStore, name);
294
+ const settled = fresh?.reversePool && reverseRotation.settleGrace(fresh.reversePool, { now: Date.now() + 1 });
295
+ if (settled) nodesStore.atomicWriteStore(nodesPath, nodesStore.setNodeReversePool(freshStore, name, settled));
296
+ } catch (_) {
297
+ diagnostics.record('warn', 'reverse-pool', 'REVERSE_POOL_GRACE_PENDING', 'Reverse pool grace settlement pending', { node: name });
298
+ }
299
+ }
300
+ async function reconcileRotatablePool(node) {
301
+ if (!node?.reversePool || node.shared !== true) return node;
302
+ try {
303
+ const result = await federation.getHubPoolStatus({ node, fetchImpl: healthFetch });
304
+ const remote = nodesStore.parseReversePool(result && result.pool);
305
+ if (!remote || remote.base !== node.reversePool.base) throw new Error('stato pool remoto non valido');
306
+ const current = nodesStore.loadStoreStrict(nodesPath);
307
+ const fresh = nodesStore.getNode(current, node.name);
308
+ if (!fresh?.reversePool) return node;
309
+ if (JSON.stringify(fresh.reversePool) === JSON.stringify(remote)) return fresh;
310
+ const updated = nodesStore.setNodeReversePool(current, fresh.name, remote);
311
+ nodesStore.atomicWriteStore(nodesPath, updated);
312
+ diagnostics.record('info', 'reverse-pool', 'REVERSE_POOL_RECONCILED', 'Reverse pool reconciled from hub generation', {
313
+ node: fresh.name, slot: remote.activeSlot, generation: remote.activeGeneration,
314
+ });
315
+ return nodesStore.getNode(updated, fresh.name);
316
+ } catch (_) {
317
+ // The saved local generation remains authoritative while the private
318
+ // path is down. This is a retryable reconciliation, never a reason to
319
+ // stop a healthy existing slot or invent a new one.
320
+ return node;
321
+ }
322
+ }
323
+ async function rotateRotatableReverse(name) {
324
+ if (reverseRotationInFlight.has(name)) return { status: 'already-running' };
325
+ reverseRotationInFlight.add(name);
326
+ try {
327
+ const now = Date.now();
328
+ let current = nodesStore.loadStoreStrict(nodesPath);
329
+ let node = nodesStore.getNode(current, name);
330
+ const pool = node?.reversePool;
331
+ if (!node || node.shared !== true || !pool || pool.verification !== 'verified'
332
+ || !Array.isArray(pool.verifiedSlots) || pool.verifiedSlots.length !== pool.slots.length) {
333
+ return { status: 'skipped', reason: 'pool-not-verified' };
334
+ }
335
+ if (Number.isSafeInteger(pool.lastAutoRotationAt) && now - pool.lastAutoRotationAt < 10 * 60 * 1000) {
336
+ return { status: 'skipped', reason: 'rate-limited' };
337
+ }
338
+ const slot = reverseRotation.nextReadySlot(pool);
339
+ if (!Number.isInteger(slot)) {
340
+ diagnostics.record('error', 'reverse-pool', 'REVERSE_POOL_EXHAUSTED', 'No verified reverse slot remains', { node: name });
341
+ return { status: 'skipped', reason: 'pool-exhausted' };
342
+ }
343
+ const lease = await federation.reserveHubPoolSlot({ node, slot, fetchImpl: healthFetch });
344
+ if (lease.slot !== slot || !Number.isSafeInteger(lease.generation) || typeof lease.leaseId !== 'string') {
345
+ throw new Error('hub reverse lease non valida');
346
+ }
347
+ const prepared = reverseRotation.prepareRotation(pool, {
348
+ slot, now, leaseId: lease.leaseId,
349
+ leaseMs: Math.max(1, Math.min(60_000, Number(lease.expiresAt) - now)),
350
+ });
351
+ if (!prepared || prepared.rotation.generation !== lease.generation) throw new Error('lease reverse non coerente con il pool locale');
352
+ prepared.lastAutoRotationAt = now;
353
+ current = nodesStore.updateNode(current, name, { reversePool: prepared });
354
+ nodesStore.atomicWriteStore(nodesPath, current);
355
+ node = nodesStore.getNode(current, name);
356
+ const candidate = node.reversePool.slots[slot];
357
+ try {
358
+ await startRotatableReverse(node, { slot, generation: candidate.generation });
359
+ const committedRemote = await federation.commitHubPoolSlot({ node, leaseId: lease.leaseId, fetchImpl: healthFetch });
360
+ const committed = reverseRotation.commitRotation(node.reversePool, { leaseId: lease.leaseId, now, graceMs: Math.max(0, Number(committedRemote.graceUntil) - now) });
361
+ if (!committed || committed.activeGeneration !== committedRemote.generation) throw new Error('commit reverse non coerente');
362
+ committed.lastAutoRotationAt = now;
363
+ const afterStore = nodesStore.loadStoreStrict(nodesPath);
364
+ nodesStore.atomicWriteStore(nodesPath, nodesStore.setNodeReversePool(afterStore, name, committed));
365
+ const delay = Math.max(0, committed.rotation.graceUntil - Date.now()) + 50;
366
+ const timer = setTimeout(() => { void settleRotatableGrace(name, committed.activeGeneration); }, delay);
367
+ timer.unref?.();
368
+ diagnostics.record('warn', 'reverse-pool', 'REVERSE_POOL_SWITCHED', 'Reverse pool switched after verified conflict', { node: name, slot, generation: committed.activeGeneration });
369
+ return { status: 'switched', slot, generation: committed.activeGeneration };
370
+ } catch (error) {
371
+ await federation.abortHubPoolSlot({ node, leaseId: lease.leaseId, fetchImpl: healthFetch }).catch(() => {});
372
+ await stopRotatableReverse(name, { remotePort: candidate.port, generation: candidate.generation });
373
+ const failedStore = nodesStore.loadStoreStrict(nodesPath);
374
+ const failedNode = nodesStore.getNode(failedStore, name);
375
+ const quarantined = failedNode?.reversePool && reverseRotation.quarantineSlot(failedNode.reversePool, { slot });
376
+ if (quarantined) {
377
+ quarantined.verification = 'invalidated'; quarantined.verifiedSlots = []; quarantined.lastAutoRotationAt = now;
378
+ nodesStore.atomicWriteStore(nodesPath, nodesStore.setNodeReversePool(failedStore, name, quarantined));
379
+ }
380
+ diagnostics.record('error', 'reverse-pool', 'REVERSE_POOL_CANDIDATE_FAILED', 'Reverse candidate failed; pool invalidated', { node: name, slot });
381
+ throw error;
382
+ }
383
+ } finally { reverseRotationInFlight.delete(name); }
384
+ }
385
+ function ensureReverseWatcher(name) {
386
+ if (reverseWatchers.has(name)) return;
387
+ const timer = setInterval(() => {
388
+ if (reverseRotationInFlight.has(name)) return;
389
+ const current = nodesStore.loadStore(nodesPath);
390
+ const node = current && nodesStore.getNode(current, name);
391
+ const pool = node?.reversePool;
392
+ if (!node || node.shared !== true || !pool || pool.verification !== 'verified') return;
393
+ const active = pool.slots[pool.activeSlot];
394
+ const state = nodesTunnel.readTunnelState(cfg.home || os.homedir(), nodesTunnel.reverseTunnelName(name, active.port, pool.activeGeneration));
395
+ if (state.phase === 'degraded') {
396
+ void rotateRotatableReverse(name).catch(() => {});
397
+ }
398
+ }, 5000);
399
+ timer.unref?.();
400
+ reverseWatchers.set(name, timer);
401
+ }
173
402
  function startManagedTunnels() {
174
403
  if (tunnelsStarted) return;
175
404
  tunnelsStarted = true;
@@ -210,7 +439,14 @@ function createServer(opts = {}) {
210
439
  // senza ritardare il listen del server.
211
440
  const reconcileShare = cfg.reconcilePeerShareImpl || federation.reconcilePeerShare;
212
441
  if (node.shared === true) {
213
- Promise.resolve(reconcileShare({
442
+ Promise.resolve(node.reversePool ? reconcileRotatablePool(node) : node).then((effectiveNode) => {
443
+ if (effectiveNode?.reversePool?.rotation?.phase === 'switched') {
444
+ const delay = Math.max(0, effectiveNode.reversePool.rotation.graceUntil - Date.now()) + 50;
445
+ const timer = setTimeout(() => { void settleRotatableGrace(effectiveNode.name, effectiveNode.reversePool.activeGeneration); }, delay);
446
+ timer.unref?.();
447
+ }
448
+ return effectiveNode?.reversePool ? startRotatableReverse(effectiveNode) : null;
449
+ }).then(() => reconcileShare({
214
450
  node, shared: true, fetchImpl: healthFetch,
215
451
  healthAttempts: 3, notifyAttempts: 3, delayMs: 200,
216
452
  })).catch((e) => {
@@ -237,10 +473,17 @@ function createServer(opts = {}) {
237
473
  }
238
474
 
239
475
  const app = express();
476
+ reverseSlotListeners = createReverseSlotListeners({ app, diagnostics, createServerImpl: cfg.reverseSlotCreateServerImpl });
240
477
  const distDir = path.join(__dirname, '..', 'frontend', 'dist');
241
478
  // no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
242
479
  app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
243
480
  app.use('/pair', publicPeeringRoutes({ cfg, nodesPath }));
481
+ // This route is deliberately outside the bearer-authenticated federation
482
+ // router. It emits a MAC only on a listener owned by the expected slot and
483
+ // never accepts/sends a bearer to a suspicious loopback listener.
484
+ app.post('/reverse-slot-proof', express.json({ limit: '2kb' }), (req, res) => {
485
+ if (!reverseSlotListeners.respond(req, res)) res.status(404).json({ error: 'reverse slot non disponibile' });
486
+ });
244
487
 
245
488
  // Tutte le /api dietro Bearer: sul loopback il gate vero è il tunnel,
246
489
  // ma il token chiude anche altri processi locali della stessa macchina.
@@ -452,6 +695,7 @@ function createServer(opts = {}) {
452
695
  // Stesso adapter/coda dell'API Audio: Settings puo' fare solo la prova
453
696
  // locale a frase fissa e lo stop sovrano, mai una seconda sintesi parallela.
454
697
  audio: { adapter: audioAdapter, queue: audioQueue },
698
+ reverseSlots: { ensure: startRotatableReverse, close: stopRotatableReverse, verify: verifyRotatablePool },
455
699
  }));
456
700
  api.use('/diagnostics', diagnosticsRoutes({ diagnostics, readonly: proxyReadonly }));
457
701
  api.get('/topology', async (_req, res) => {
@@ -532,6 +776,8 @@ function createServer(opts = {}) {
532
776
  server.on('close', () => {
533
777
  diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
534
778
  watcher.close(); previews.close(); eventsHub.closeAll(); updater.close();
779
+ for (const timer of reverseWatchers.values()) clearInterval(timer);
780
+ reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
535
781
  });
536
782
  // noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
537
783
  // (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
@@ -257,6 +257,11 @@ function createPairHandler(deps) {
257
257
  });
258
258
  }
259
259
  const joinedRoles = joined.roles === undefined ? null : nodesStore.parseRoles(joined.roles);
260
+ const joinedPool = joined.reversePool && typeof joined.reversePool === 'object' && !Array.isArray(joined.reversePool)
261
+ && nodesStore.isPort(joined.reversePool.base) && Array.isArray(joined.reversePool.slots)
262
+ && joined.reversePool.slots.length === 3
263
+ && joined.reversePool.slots.every(nodesStore.isPort)
264
+ ? nodesStore.reversePoolDefault(joined.reversePool.base) : null;
260
265
  if (!nodesStore.validToken(joined.credential) || !nodesStore.isPort(joined.reversePort)
261
266
  || !nodesStore.NODE_ID_RE.test(joined.instanceId) || (joined.roles !== undefined && !joinedRoles)) {
262
267
  return failRolledBack(502, 'join', 'join-invalid-response', 'risposta di join non valida dal peer', {
@@ -270,14 +275,27 @@ function createPairHandler(deps) {
270
275
  hint: 'controlla che il target SSH punti al nodo che ha generato il link',
271
276
  });
272
277
  }
278
+ if (joined.reversePool && (!joinedPool
279
+ || joined.reversePort !== joinedPool.base
280
+ || joinedPool.slots.some((slot, index) => slot.port !== joined.reversePool.slots[index]))) {
281
+ return failRolledBack(502, 'join', 'join-invalid-reverse-pool', 'pool reverse non valido dal peer', {
282
+ hint: 'versioni NexusCrew incompatibili? aggiorna entrambi i nodi',
283
+ });
284
+ }
273
285
 
274
286
  // --- tunnel-final: connessione privata, solo -L -------------------------
275
287
  // reversePort resta negoziata per un futuro Share opt-in, ma il builder
276
288
  // non emette -R finche' shared non diventa true.
277
289
  st = nodesStore.loadStoreStrict(nodesPath);
290
+ if (joinedPool && st.schemaVersion < nodesStore.SCHEMA_VERSION) {
291
+ // This device is a client of the hub-owned pool. It persists the
292
+ // assigned slots but intentionally has no allocation anchor/ledger.
293
+ st = nodesStore.upgradeToReversePoolSchema(st);
294
+ }
278
295
  st = nodesStore.updateNode(st, b.name, {
279
296
  token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
280
297
  shared: false,
298
+ ...(joinedPool ? { reversePool: joinedPool } : {}),
281
299
  ...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
282
300
  });
283
301
  nodesStore.atomicWriteStore(nodesPath, st);
@@ -13,6 +13,7 @@ const express = require('express');
13
13
 
14
14
  const nodesStore = require('../nodes/store.js');
15
15
  const peering = require('../nodes/peering.js');
16
+ const reversePool = require('../nodes/reverse-pool.js');
16
17
  const { readRoles } = require('../cli/commands.js');
17
18
  const { configJsonPath } = require('../config.js');
18
19
 
@@ -25,6 +26,7 @@ function publicPeeringRoutes(deps = {}) {
25
26
  const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
26
27
  const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
27
28
  const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
29
+ const reversePoolLedgerPath = deps.reversePoolLedgerPath || cfg.reversePoolLedgerPath || reversePool.defaultLedgerPath(home);
28
30
  const r = express.Router();
29
31
  const attempts = new Map();
30
32
  r.use(express.json({ limit: '8kb' }));
@@ -92,11 +94,60 @@ function publicPeeringRoutes(deps = {}) {
92
94
  });
93
95
  }
94
96
  const name = b.name;
95
- const reversePort = await peering.allocateAvailableReversePort(st.nodes, pending, {
96
- ...(deps.createServerImpl ? { createServerImpl: deps.createServerImpl } : {}),
97
+ // The hub owns allocation. Establish an empty v3 anchor before any
98
+ // allocation event; from here onward each allocated pool is written to
99
+ // the append-only ledger first and the anchor second. A ledger ahead of
100
+ // its anchor is therefore a safe interrupted write and is reconciled;
101
+ // a missing/behind prefix never becomes a reason to reuse a port.
102
+ let poolStore = st;
103
+ let ledger = reversePool.loadLedger(reversePoolLedgerPath);
104
+ if (poolStore.schemaVersion < nodesStore.SCHEMA_VERSION || poolStore.reversePoolLedgerInitialized !== true) {
105
+ if (ledger) {
106
+ const error = new Error('reverse pool ledger presente senza anchor: nuove assegnazioni bloccate');
107
+ error.code = 'reverse-pool-anchor-missing'; error.status = 503; throw error;
108
+ }
109
+ ledger = reversePool.emptyLedger();
110
+ poolStore = nodesStore.upgradeToReversePoolSchema(poolStore, reversePool.ledgerHead(ledger));
111
+ nodesStore.atomicWriteStore(nodesPath, poolStore);
112
+ reversePool.atomicWriteLedger(reversePoolLedgerPath, ledger);
113
+ } else {
114
+ const anchor = poolStore.reversePoolAnchor;
115
+ if (!ledger) {
116
+ // A zero anchor is the only safe state where an absent ledger can be
117
+ // initialized: no allocation has ever been anchored. Existing
118
+ // private peers remain usable even when this path refuses new ones.
119
+ const parsed = reversePool.parseAnchor(anchor);
120
+ const empty = parsed && parsed.seq === 0 && parsed.digest === reversePool.genesisDigest(parsed.epoch)
121
+ ? reversePool.emptyLedger(parsed.epoch) : null;
122
+ if (!empty) {
123
+ const error = new Error('reverse pool ledger assente dopo assegnazioni: nuove assegnazioni bloccate');
124
+ error.code = 'reverse-pool-ledger-missing'; error.status = 503; throw error;
125
+ }
126
+ ledger = reversePool.atomicWriteLedger(reversePoolLedgerPath, empty);
127
+ }
128
+ const checked = reversePool.validateLedgerAnchor(ledger, anchor);
129
+ if (!checked.ok) {
130
+ const error = new Error(`reverse pool ledger non sicuro: ${checked.code}`);
131
+ error.code = checked.code; error.status = 503; throw error;
132
+ }
133
+ if (checked.advanceAnchor) {
134
+ poolStore = { ...poolStore, reversePoolAnchor: checked.advanceAnchor };
135
+ poolStore = nodesStore.atomicWriteStore(nodesPath, poolStore);
136
+ }
137
+ }
138
+ const allocation = await reversePool.allocateAvailableReversePool(poolStore.nodes, ledger, {
139
+ canBind: (port) => peering.canBindReversePort(port, deps.createServerImpl),
140
+ });
141
+ ledger = reversePool.appendLedger(ledger, { type: 'allocated', base: allocation.base });
142
+ reversePool.atomicWriteLedger(reversePoolLedgerPath, ledger);
143
+ poolStore = nodesStore.atomicWriteStore(nodesPath, {
144
+ ...poolStore,
145
+ reversePoolAnchor: reversePool.ledgerHead(ledger),
97
146
  });
147
+ const reversePort = allocation.base;
148
+ const assignedPool = nodesStore.reversePoolDefault(reversePort);
98
149
  credential = peering.createPending({ pendingPath, data: {
99
- name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
150
+ name, remotePort: b.port, reversePort, reversePool: assignedPool, instanceId: b.instanceId, acceptToken: b.acceptToken,
100
151
  shared: false,
101
152
  label: nodesStore.sanitizeLabel(b.label, name),
102
153
  ...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
@@ -105,7 +156,9 @@ function publicPeeringRoutes(deps = {}) {
105
156
  peering.consumePending({ pendingPath, credential, now });
106
157
  return res.status(410).json({ error: 'invito scaduto o gia usato' });
107
158
  }
108
- res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
159
+ res.json({ paired: true, instanceId: poolStore.nodeId, reversePort,
160
+ reversePool: { base: assignedPool.base, slots: assignedPool.slots.map((slot) => slot.port) },
161
+ credential, roles: readRoles(configPath) });
109
162
  } catch (e) {
110
163
  if (credential) try { peering.consumePending({ pendingPath, credential, now }); } catch (_) {}
111
164
  res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) });
@@ -138,6 +191,7 @@ function publicPeeringRoutes(deps = {}) {
138
191
  ...(pending.roles ? { roles: pending.roles } : {}),
139
192
  rolesKnown: pending.rolesKnown === true,
140
193
  ...(pending.label ? { label: pending.label } : {}),
194
+ ...(pending.reversePool ? { reversePool: pending.reversePool } : {}),
141
195
  });
142
196
  nodesStore.atomicWriteStore(nodesPath, st);
143
197
  res.json({ confirmed: true });
@@ -52,7 +52,7 @@ const audioGroups = require('../audio/groups.js');
52
52
  const nodesCmds = require('../nodes/commands.js');
53
53
  const nodesTunnel = require('../nodes/tunnel.js');
54
54
  const peering = require('../nodes/peering.js');
55
- const { waitForHealthyPeer, notifyHubShare, reconcilePeerShare } = require('../proxy/federation.js');
55
+ const { waitForHealthyPeer, preflightHubReverse, notifyHubShare, reconcilePeerShare } = require('../proxy/federation.js');
56
56
  const { rotateToken } = require('../auth/token.js');
57
57
  const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
58
58
  const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
@@ -174,6 +174,7 @@ function settingsRoutes(deps = {}) {
174
174
  // test unitari di Settings costruiti senza il server: non deve creare una
175
175
  // seconda coda che dichiara un test riuscito ma non parla sul nodo vero.
176
176
  const audioService = deps.audio && typeof deps.audio === 'object' ? deps.audio : null;
177
+ const reverseSlots = deps.reverseSlots && typeof deps.reverseSlots === 'object' ? deps.reverseSlots : null;
177
178
 
178
179
  const r = express.Router();
179
180
  r.use(express.json({ limit: '8kb' }));
@@ -595,6 +596,7 @@ function settingsRoutes(deps = {}) {
595
596
  }
596
597
  const wasShared = node.shared === true;
597
598
  let persistedDesired = wasShared;
599
+ let shareOnAttempted = false;
598
600
  const fetchImpl = seams.fetchImpl || fetch;
599
601
  const notifyHub = (shared) => notifyHubShare({ node, shared, fetchImpl, timeoutMs: 5000 });
600
602
  const ensureLocal = async ({ restart = false } = {}) => {
@@ -623,6 +625,12 @@ function settingsRoutes(deps = {}) {
623
625
  if (diagnosis && typeof diagnosis.hint === 'string') failure.hint = diagnosis.hint;
624
626
  throw failure;
625
627
  }
628
+ if (node.shared === true && node.reversePool && typeof reverseSlots?.ensure === 'function') {
629
+ await reverseSlots.ensure(node);
630
+ if (typeof reverseSlots.verify === 'function' && node.reversePool.verification !== 'verified') {
631
+ await reverseSlots.verify(node);
632
+ }
633
+ }
626
634
  return started;
627
635
  };
628
636
  const applyLocal = async (shared) => {
@@ -631,6 +639,7 @@ function settingsRoutes(deps = {}) {
631
639
  nodesStore.atomicWriteStore(nodesPath, st);
632
640
  persistedDesired = shared;
633
641
  node = nodesStore.getNode(st, name);
642
+ if (!shared && node.reversePool && typeof reverseSlots?.close === 'function') await reverseSlots.close(name);
634
643
  await ensureLocal({ restart: true });
635
644
  };
636
645
  const revokeHub = () => reconcilePeerShare({
@@ -650,6 +659,7 @@ function settingsRoutes(deps = {}) {
650
659
  detail: scrubError(revokeErr),
651
660
  });
652
661
  }
662
+ if (node.reversePool && typeof reverseSlots?.close === 'function') await reverseSlots.close(name);
653
663
  try {
654
664
  // A changed ON->OFF spec is restarted explicitly; same-state OFF uses
655
665
  // the idempotent spec-aware path, which only replaces a stale -R.
@@ -698,6 +708,19 @@ function settingsRoutes(deps = {}) {
698
708
  return reconcileOff({ unchanged: true });
699
709
  }
700
710
  if (body.shared) {
711
+ // Il -L privato e' ancora vivo: chiedi al nuovo hub, con il token del
712
+ // peer, se la SOLA porta reverse assegnata e' gia' occupata. Un 404 o
713
+ // un guasto preflight mantiene compatibilita' con hub precedenti; un
714
+ // conflitto certo evita restart/rollback inutili del tunnel privato.
715
+ const reverse = await preflightHubReverse({ node, fetchImpl, timeoutMs: 1500 });
716
+ if (reverse.supported && reverse.available === false) {
717
+ const conflict = new Error('la porta reverse assegnata a questo peer è già occupata sul nodo hub');
718
+ conflict.status = 409;
719
+ conflict.code = 'reverse-port-conflict';
720
+ conflict.hint = 'riconnetti o riassocia il peer proprietario del listener; non terminare tunnel sconosciuti sul hub';
721
+ throw conflict;
722
+ }
723
+ shareOnAttempted = true;
701
724
  await applyLocal(true);
702
725
  await notifyHub(true);
703
726
  return send(res, 200, { name, shared: true });
@@ -718,15 +741,19 @@ function settingsRoutes(deps = {}) {
718
741
  } catch (e) {
719
742
  // Share-on is transactional: a failed hub acknowledgement returns to the
720
743
  // safe private -L-only state. Never include remote response bodies/tokens.
721
- if (body.shared && !wasShared) {
744
+ if (body.shared && !wasShared && shareOnAttempted) {
722
745
  try { await applyLocal(false); } catch (_) { /* best-effort safe rollback */ }
723
746
  }
724
747
  const offPersisted = body.shared === false && persistedDesired === false;
725
748
  const redact = (value) => String(value || '').replace(/Bearer\s+\S+/gi, 'Bearer ***');
726
- return send(res, 502, {
727
- error: body.shared ? 'Share non attivato'
749
+ const status = e && e.status === 409 ? 409 : 502;
750
+ return send(res, status, {
751
+ error: body.shared ? (e && e.code === 'reverse-port-conflict'
752
+ ? 'Share non attivato: porta reverse già occupata sul nodo hub'
753
+ : 'Share non attivato')
728
754
  : offPersisted ? 'Share disattivato localmente; hub non riconciliato' : 'Share non disattivato',
729
755
  ...(offPersisted ? { shared: false, reconcilePending: true } : {}),
756
+ ...(e && typeof e.code === 'string' ? { code: e.code } : {}),
730
757
  detail: redact(e && e.message || e),
731
758
  ...(e && typeof e.hint === 'string' ? { hint: redact(e.hint) } : {}),
732
759
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.44",
3
+ "version": "0.8.46",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {