@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/CHANGELOG.md +51 -0
- package/README.md +7 -1
- package/frontend/dist/assets/{index-CNI9gSN5.js → index-DMG-rioF.js} +3 -3
- 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 +21 -4
- package/lib/fleet/managed.js +89 -23
- package/lib/fleet/runtime.js +24 -11
- package/lib/mcp/cells.js +27 -4
- package/lib/mcp/server.js +16 -3
- package/lib/nodes/commands.js +17 -0
- package/lib/nodes/health.js +23 -2
- package/lib/nodes/reverse-pool.js +221 -0
- package/lib/nodes/reverse-rotation.js +78 -0
- package/lib/nodes/reverse-slot-listeners.js +80 -0
- package/lib/nodes/reverse-slot-proof.js +108 -0
- package/lib/nodes/store.js +169 -11
- package/lib/nodes/tunnel-supervisor.js +8 -1
- package/lib/nodes/tunnel.js +96 -11
- package/lib/proxy/federation.js +337 -9
- package/lib/server.js +247 -1
- package/lib/settings/pairing-coordinator.js +18 -0
- package/lib/settings/public-peering-routes.js +58 -4
- package/lib/settings/routes.js +31 -4
- package/package.json +1 -1
package/lib/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(
|
|
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
|
-
|
|
96
|
-
|
|
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:
|
|
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 });
|
package/lib/settings/routes.js
CHANGED
|
@@ -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
|
-
|
|
727
|
-
|
|
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
|
});
|