@mmmbuto/nexuscrew 0.8.44 → 0.8.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/README.md +6 -0
- package/frontend/dist/assets/{index-CNI9gSN5.js → index-DNFEGdog.js} +1 -1
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +3 -2
- package/lib/cli/commands.js +5 -2
- package/lib/cli/pidfile.js +45 -2
- package/lib/fleet/builtin.js +2 -2
- package/lib/fleet/managed.js +44 -18
- package/lib/fleet/runtime.js +24 -11
- package/lib/mcp/cells.js +27 -4
- package/lib/mcp/server.js +16 -3
- package/lib/nodes/commands.js +17 -0
- package/lib/nodes/health.js +23 -2
- package/lib/nodes/reverse-pool.js +221 -0
- package/lib/nodes/reverse-rotation.js +78 -0
- package/lib/nodes/reverse-slot-listeners.js +80 -0
- package/lib/nodes/reverse-slot-proof.js +108 -0
- package/lib/nodes/store.js +169 -11
- package/lib/nodes/tunnel-supervisor.js +8 -1
- package/lib/nodes/tunnel.js +96 -11
- package/lib/proxy/federation.js +337 -9
- package/lib/server.js +247 -1
- package/lib/settings/pairing-coordinator.js +18 -0
- package/lib/settings/public-peering-routes.js +58 -4
- package/lib/settings/routes.js +31 -4
- package/package.json +1 -1
package/lib/nodes/tunnel.js
CHANGED
|
@@ -44,7 +44,7 @@ function assertForwardSpec(node) {
|
|
|
44
44
|
if (node.keyPath !== undefined && !store.isAbsPath(node.keyPath)) throw new Error('tunnel: keyPath non valido');
|
|
45
45
|
if (node.reversePort !== undefined && !store.isPort(node.reversePort)) throw new Error('tunnel: reversePort non valida');
|
|
46
46
|
if (node.localAppPort !== undefined && !store.isPort(node.localAppPort)) throw new Error('tunnel: localAppPort non valida');
|
|
47
|
-
if (node.shared === true && node.reversePort !== undefined && !store.isPort(node.localAppPort)) {
|
|
47
|
+
if (node.shared === true && node.reversePort !== undefined && !node.reversePool && !store.isPort(node.localAppPort)) {
|
|
48
48
|
throw new Error('tunnel: Share richiede localAppPort esplicita');
|
|
49
49
|
}
|
|
50
50
|
if (!store.parseSshTarget(node.ssh)) throw new Error('tunnel: target ssh non valido');
|
|
@@ -61,7 +61,7 @@ function buildForwardArgs(node) {
|
|
|
61
61
|
// The forward channel is the connection to the hub and is always present.
|
|
62
62
|
// The reverse channel publishes this device back through the hub, so it is
|
|
63
63
|
// opt-in only. A negotiated reversePort alone must never imply consent.
|
|
64
|
-
const reverse = node.shared === true && node.reversePort !== undefined ? [
|
|
64
|
+
const reverse = node.shared === true && node.reversePort !== undefined && !node.reversePool ? [
|
|
65
65
|
'-R', `127.0.0.1:${node.reversePort}:127.0.0.1:${node.localAppPort}`,
|
|
66
66
|
] : [];
|
|
67
67
|
return SSH_BASE_OPTS.concat(transport, identity ? ['-i', identity] : [], [
|
|
@@ -71,6 +71,24 @@ function buildForwardArgs(node) {
|
|
|
71
71
|
]);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// A rotatable peer keeps its private -L on the primary supervisor and owns a
|
|
75
|
+
// reverse-only supervisor per generation. No ControlMaster and no hidden
|
|
76
|
+
// rendezvous connection: every process has its own pidfile, intent and slot.
|
|
77
|
+
function buildReverseArgs(node, { remotePort, targetPort } = {}) {
|
|
78
|
+
assertForwardSpec({ ...node, shared: false });
|
|
79
|
+
if (!store.isPort(remotePort) || !store.isPort(targetPort)) throw new Error('tunnel: reverse slot non valida');
|
|
80
|
+
if (!node.reversePool || !Array.isArray(node.reversePool.slots)
|
|
81
|
+
|| !node.reversePool.slots.some((slot) => slot && slot.port === remotePort)) {
|
|
82
|
+
throw new Error('tunnel: reverse slot non appartiene al pool');
|
|
83
|
+
}
|
|
84
|
+
const transport = node.sshPort === undefined ? [] : ['-p', String(node.sshPort)];
|
|
85
|
+
const identity = node.identityFile || node.keyPath;
|
|
86
|
+
return SSH_BASE_OPTS.concat(transport, identity ? ['-i', identity] : [], [
|
|
87
|
+
'-R', `127.0.0.1:${remotePort}:127.0.0.1:${targetPort}`,
|
|
88
|
+
node.ssh,
|
|
89
|
+
]);
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
// Backoff esponenziale + jitter (design §7). Deterministico con rng iniettato.
|
|
75
93
|
// delay = clamp(base * factor^attempt, 0..cap) * (1 +- jitter*(2*rand-1))
|
|
76
94
|
// rng()=0.5 -> jitter nullo (ritorna il valore base clamperato); test deterministici.
|
|
@@ -106,6 +124,17 @@ function tunnelLogPath(home, name) {
|
|
|
106
124
|
// runtimes never start this second connection; reconciliation can still stop a
|
|
107
125
|
// stale legacy process left by an older install.
|
|
108
126
|
const REVERSE_NAME = '__rendezvous__';
|
|
127
|
+
const REVERSE_SLOT_NAME_RE = /^reverse-([a-z0-9-]{1,32})-(\d{1,5})-(\d{1,10})$/;
|
|
128
|
+
|
|
129
|
+
function reverseTunnelName(nodeName, remotePort, generation) {
|
|
130
|
+
if (!store.NODE_NAME_RE.test(String(nodeName || '')) || !store.isPort(remotePort)
|
|
131
|
+
|| !Number.isSafeInteger(generation) || generation < 1) throw new Error('reverse tunnel name non valido');
|
|
132
|
+
return `reverse-${nodeName}-${remotePort}-${generation}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function isTunnelName(name) {
|
|
136
|
+
return store.NODE_NAME_RE.test(name) || name === REVERSE_NAME || REVERSE_SLOT_NAME_RE.test(name);
|
|
137
|
+
}
|
|
109
138
|
|
|
110
139
|
// Reconcile detached supervisors against the authoritative node store. A
|
|
111
140
|
// server crash or an older rollback could leave a valid supervisor pidfile
|
|
@@ -120,14 +149,14 @@ function tunnelPidNames(home) {
|
|
|
120
149
|
return fs.readdirSync(dir, { withFileTypes: true })
|
|
121
150
|
.filter((entry) => entry.isFile() && !entry.isSymbolicLink() && entry.name.endsWith('.pid'))
|
|
122
151
|
.map((entry) => entry.name.slice(0, -4))
|
|
123
|
-
.filter((name) =>
|
|
152
|
+
.filter((name) => isTunnelName(name))
|
|
124
153
|
.sort();
|
|
125
154
|
} catch (_) { return []; }
|
|
126
155
|
}
|
|
127
156
|
|
|
128
157
|
function reconcileTunnelSupervisors({ home = os.homedir(), configuredNames = [], stopImpl } = {}) {
|
|
129
158
|
const keep = new Set((Array.isArray(configuredNames) ? configuredNames : [])
|
|
130
|
-
.filter((name) =>
|
|
159
|
+
.filter((name) => isTunnelName(String(name || ''))));
|
|
131
160
|
const stopOne = typeof stopImpl === 'function' ? stopImpl : stopTunnel;
|
|
132
161
|
const result = { kept: [], stopped: [], cleaned: [], failed: [] };
|
|
133
162
|
const safeAbsent = new Set([
|
|
@@ -265,6 +294,19 @@ function tunnelStatePath(home, name) {
|
|
|
265
294
|
return path.join(tunnelDir(home), `${name}.state.json`);
|
|
266
295
|
}
|
|
267
296
|
|
|
297
|
+
// A reverse sidecar is allowed to be terminated only when we can prove it is
|
|
298
|
+
// this exact NexusCrew generation: same UID, argv/start-time identity and the
|
|
299
|
+
// runId recorded by its own state file. A stale or foreign pidfile is a
|
|
300
|
+
// diagnostic/quarantine condition, never a reason to signal a PID.
|
|
301
|
+
function tunnelSupervisorAttributable(home, name, meta, impl = {}) {
|
|
302
|
+
if (!meta || typeof meta.runId !== 'string' || !/^[a-f0-9]{32,64}$/.test(meta.runId)) return false;
|
|
303
|
+
if (!pidf.isAttributable(meta, impl)) return false;
|
|
304
|
+
try {
|
|
305
|
+
const state = JSON.parse((impl.readFileSyncImpl || fs.readFileSync)(tunnelStatePath(home, name), 'utf8'));
|
|
306
|
+
return state && state.supervisorPid === meta.pid && state.runId === meta.runId;
|
|
307
|
+
} catch (_) { return false; }
|
|
308
|
+
}
|
|
309
|
+
|
|
268
310
|
// Stato interrogabile del tunnel: { status: 'up'|'down', pid?, since? }.
|
|
269
311
|
function readTunnelState(home, name) {
|
|
270
312
|
const p = tunnelPidPath(home, name);
|
|
@@ -283,7 +325,7 @@ function readTunnelState(home, name) {
|
|
|
283
325
|
};
|
|
284
326
|
} catch (_) { return null; }
|
|
285
327
|
};
|
|
286
|
-
if (meta && pidf.
|
|
328
|
+
if (meta && pidf.isAttributable(meta)) {
|
|
287
329
|
try {
|
|
288
330
|
const state = JSON.parse(fs.readFileSync(tunnelStatePath(home, name), 'utf8'));
|
|
289
331
|
const transport = typeof state.transport === 'string' ? state.transport : undefined;
|
|
@@ -373,6 +415,23 @@ function removeStateIfOwned(home, name, meta) {
|
|
|
373
415
|
return false;
|
|
374
416
|
}
|
|
375
417
|
|
|
418
|
+
function writeStartingState(home, name, { pid, runId, transport } = {}) {
|
|
419
|
+
if (!Number.isFinite(pid) || typeof runId !== 'string' || !/^[a-f0-9]{32,64}$/.test(runId)) return false;
|
|
420
|
+
const statePath = tunnelStatePath(home, name);
|
|
421
|
+
const tmp = `${statePath}.tmp.${process.pid}.${runId}`;
|
|
422
|
+
try {
|
|
423
|
+
fs.writeFileSync(tmp, `${JSON.stringify({
|
|
424
|
+
status: 'starting', supervisorPid: pid, runId, transport: path.basename(String(transport || 'ssh')), updatedAt: Date.now(), attempt: 0,
|
|
425
|
+
})}\n`, { mode: 0o600 });
|
|
426
|
+
fs.chmodSync(tmp, 0o600);
|
|
427
|
+
fs.renameSync(tmp, statePath);
|
|
428
|
+
return true;
|
|
429
|
+
} catch (_) {
|
|
430
|
+
try { fs.unlinkSync(tmp); } catch (_error) {}
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
376
435
|
function supervisorExited(pid, timeoutMs = 2500, impl = {}) {
|
|
377
436
|
const deadline = Date.now() + timeoutMs;
|
|
378
437
|
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
@@ -425,7 +484,7 @@ function startTunnel(opts) {
|
|
|
425
484
|
const spawnImpl = opts.spawnImpl || spawn;
|
|
426
485
|
const spawnSyncImpl = opts.spawnSyncImpl || spawnSync;
|
|
427
486
|
const sshBin = opts.sshBin || 'ssh';
|
|
428
|
-
if (!name) throw new Error('startTunnel: name mancante');
|
|
487
|
+
if (!isTunnelName(name)) throw new Error('startTunnel: name mancante o non valido');
|
|
429
488
|
if (!Array.isArray(args)) throw new Error('startTunnel: args mancanti');
|
|
430
489
|
|
|
431
490
|
const pidPath = tunnelPidPath(home, name);
|
|
@@ -435,12 +494,18 @@ function startTunnel(opts) {
|
|
|
435
494
|
const cmd = `${process.execPath} ${supervisorArgs.join(' ')}`;
|
|
436
495
|
const existing = pidf.readPidfile(pidPath);
|
|
437
496
|
if (existing && pidf.isAlive(existing)) {
|
|
497
|
+
const strictSidecar = REVERSE_SLOT_NAME_RE.test(name);
|
|
498
|
+
if (strictSidecar && !pidf.isAttributable(existing, opts.pidfileImpl || {})) {
|
|
499
|
+
return { started: false, reason: 'unattributable existing supervisor', pid: existing.pid };
|
|
500
|
+
}
|
|
501
|
+
const stateOwned = !strictSidecar || tunnelSupervisorAttributable(home, name, existing, opts.pidfileImpl || {});
|
|
438
502
|
// An update or automatic HTTP-port fallback can change -L/-R while the
|
|
439
503
|
// detached supervisor is still alive. Keep exact matches idempotent, but
|
|
440
504
|
// replace a supervisor whose saved argv no longer matches the desired one.
|
|
441
505
|
if (!existing.cmd || existing.cmd === cmd) {
|
|
442
506
|
return { started: false, reason: 'already running', pid: existing.pid, transport: sshBin };
|
|
443
507
|
}
|
|
508
|
+
if (!stateOwned) return { started: false, reason: 'unattributable existing supervisor', pid: existing.pid };
|
|
444
509
|
const oldMeta = existing;
|
|
445
510
|
const stopped = pidf.killPidfile(pidPath);
|
|
446
511
|
if (!stopped.killed) return { started: false, reason: `running spec mismatch: ${stopped.reason || 'stop failed'}`, pid: existing.pid };
|
|
@@ -449,7 +514,7 @@ function startTunnel(opts) {
|
|
|
449
514
|
}
|
|
450
515
|
removeStateIfOwned(home, name, oldMeta);
|
|
451
516
|
}
|
|
452
|
-
pidf.cleanStale(pidPath);
|
|
517
|
+
if (!existing || !pidf.isAlive(existing)) pidf.cleanStale(pidPath);
|
|
453
518
|
|
|
454
519
|
const logPath = tunnelLogPath(home, name);
|
|
455
520
|
const statePath = tunnelStatePath(home, name);
|
|
@@ -528,6 +593,12 @@ function startTunnel(opts) {
|
|
|
528
593
|
closeOwnedFd();
|
|
529
594
|
return { started: false, reason: 'pidfile error', error: String(e && e.message || e) };
|
|
530
595
|
}
|
|
596
|
+
if (!writeStartingState(home, name, { pid, runId, transport: sshBin })) {
|
|
597
|
+
try { process.kill(pid, 'SIGTERM'); } catch (_) {}
|
|
598
|
+
cleanupIfOwned();
|
|
599
|
+
closeOwnedFd();
|
|
600
|
+
return { started: false, reason: 'statefile error' };
|
|
601
|
+
}
|
|
531
602
|
// Safe local breadcrumb: argv, host, key paths and credentials are omitted.
|
|
532
603
|
// The detached supervisor appends lifecycle events to the same 0600 file.
|
|
533
604
|
if (Number.isInteger(logFd)) {
|
|
@@ -544,6 +615,10 @@ function stopTunnel(opts) {
|
|
|
544
615
|
if (!name) throw new Error('stopTunnel: name mancante');
|
|
545
616
|
const pidPath = tunnelPidPath(home, name);
|
|
546
617
|
const meta = pidf.readPidfile(pidPath);
|
|
618
|
+
if (meta && REVERSE_SLOT_NAME_RE.test(name) && pidf.isAlive(meta)
|
|
619
|
+
&& !tunnelSupervisorAttributable(home, name, meta, opts.pidfileImpl || {})) {
|
|
620
|
+
return { stopped: false, pid: meta.pid, reason: 'unattributable supervisor' };
|
|
621
|
+
}
|
|
547
622
|
const r = pidf.killPidfile(pidPath);
|
|
548
623
|
if (r.killed && !(opts.supervisorExitedImpl || supervisorExited)(r.pid, opts.stopWaitMs || 2500)) {
|
|
549
624
|
return { stopped: false, pid: r.pid, reason: `supervisor ${r.pid} did not exit after SIGTERM` };
|
|
@@ -572,6 +647,15 @@ function startForward(opts) {
|
|
|
572
647
|
return startTunnel({ ...opts, sshBin, name: node.name, args });
|
|
573
648
|
}
|
|
574
649
|
|
|
650
|
+
function startReverseForward(opts) {
|
|
651
|
+
const node = opts.node;
|
|
652
|
+
const remotePort = opts.remotePort;
|
|
653
|
+
const generation = opts.generation;
|
|
654
|
+
const args = buildReverseArgs(node, { remotePort, targetPort: opts.targetPort });
|
|
655
|
+
const name = reverseTunnelName(node.name, remotePort, generation);
|
|
656
|
+
return startTunnel({ ...opts, sshBin: opts.sshBin || 'ssh', name, args });
|
|
657
|
+
}
|
|
658
|
+
|
|
575
659
|
// Versione client OpenSSH, solo diagnostica. Non inferire mai da questa la
|
|
576
660
|
// policy `permitlisten` del server remoto: quella si prova con il vero -R.
|
|
577
661
|
function readSshVersion(spawnSyncImpl) {
|
|
@@ -586,14 +670,15 @@ function readSshVersion(spawnSyncImpl) {
|
|
|
586
670
|
|
|
587
671
|
module.exports = {
|
|
588
672
|
SSH_BASE_OPTS,
|
|
589
|
-
buildForwardArgs, backoffDelay,
|
|
673
|
+
buildForwardArgs, buildReverseArgs, backoffDelay,
|
|
590
674
|
tunnelDir, tunnelPidPath, tunnelLogPath, tunnelStatePath, readTunnelState,
|
|
591
675
|
prepareTunnelDir, openTunnelLog,
|
|
592
676
|
tunnelPidNames, reconcileTunnelSupervisors,
|
|
593
677
|
classifySshFailure, readTunnelDiagnostic,
|
|
594
678
|
diagnoseTunnel,
|
|
595
|
-
removeStateIfOwned, supervisorExited,
|
|
596
|
-
|
|
597
|
-
|
|
679
|
+
removeStateIfOwned, writeStartingState, supervisorExited,
|
|
680
|
+
tunnelSupervisorAttributable,
|
|
681
|
+
startTunnel, stopTunnel, restartTunnel, startForward, startReverseForward,
|
|
682
|
+
REVERSE_NAME, REVERSE_SLOT_NAME_RE, reverseTunnelName, isTunnelName,
|
|
598
683
|
readSshVersion, sshBinaryAvailable,
|
|
599
684
|
};
|
package/lib/proxy/federation.js
CHANGED
|
@@ -5,8 +5,10 @@ const net = require('node:net');
|
|
|
5
5
|
const express = require('express');
|
|
6
6
|
const store = require('../nodes/store.js');
|
|
7
7
|
const topologyCache = require('../nodes/topology-cache.js');
|
|
8
|
+
const reverseRotation = require('../nodes/reverse-rotation.js');
|
|
8
9
|
const { bearerFrom } = require('../auth/middleware.js');
|
|
9
10
|
const { safeEqual } = require('../nodes/peering.js');
|
|
11
|
+
const { probeReverseSlot } = require('../nodes/reverse-slot-proof.js');
|
|
10
12
|
const {
|
|
11
13
|
sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
|
|
12
14
|
} = require('./node-proxy.js');
|
|
@@ -289,6 +291,73 @@ async function waitForHealthyPeer(opts = {}) {
|
|
|
289
291
|
return last || { status: 'down', detail: 'peer non raggiungibile' };
|
|
290
292
|
}
|
|
291
293
|
|
|
294
|
+
// Verifica locale e limitata alla porta reverse gia' assegnata al peer
|
|
295
|
+
// autenticato. Non accetta una porta dal chiamante: il peer non puo' usarla
|
|
296
|
+
// come scanner e la risposta non rivela quale processo la stia occupando.
|
|
297
|
+
function canListenLoopback(port, createServerImpl = net.createServer) {
|
|
298
|
+
if (!store.isPort(port)) return Promise.resolve({ available: false, code: 'reverse-port-invalid' });
|
|
299
|
+
return new Promise((resolve) => {
|
|
300
|
+
let settled = false;
|
|
301
|
+
const settle = (value) => {
|
|
302
|
+
if (settled) return;
|
|
303
|
+
settled = true;
|
|
304
|
+
resolve(value);
|
|
305
|
+
};
|
|
306
|
+
let server;
|
|
307
|
+
try {
|
|
308
|
+
server = createServerImpl();
|
|
309
|
+
server.once('error', (error) => {
|
|
310
|
+
settle({ available: false, code: error && error.code === 'EADDRINUSE' ? 'reverse-port-in-use' : 'reverse-port-unavailable' });
|
|
311
|
+
});
|
|
312
|
+
server.listen({ host: '127.0.0.1', port, exclusive: true }, () => {
|
|
313
|
+
server.close((error) => settle(error
|
|
314
|
+
? { available: false, code: 'reverse-port-unavailable' }
|
|
315
|
+
: { available: true }));
|
|
316
|
+
});
|
|
317
|
+
} catch (_) { settle({ available: false, code: 'reverse-port-unavailable' }); }
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Preflight best-effort da client verso il proprio hub attraverso il -L gia'
|
|
322
|
+
// autenticato. Un hub precedente che non conosce la route (404), o una rete
|
|
323
|
+
// transitoriamente guasta, conserva il percorso legacy: l'SSH con
|
|
324
|
+
// ExitOnForwardFailure resta l'autorita' finale. Un 409 del nuovo hub invece
|
|
325
|
+
// evita di spegnere il -L privato per tentare un -R gia' noto come conflittuale.
|
|
326
|
+
// Una porta gia' risposta dal peer autenticato stesso e' invece transitabile:
|
|
327
|
+
// e' il caso normale di Share che registra un reverse gia' pronto.
|
|
328
|
+
async function preflightHubReverse({ node, fetchImpl = fetch, timeoutMs = 1500 }) {
|
|
329
|
+
if (!node || !store.isPort(node.localPort) || !store.validToken(node.token)) {
|
|
330
|
+
throw new Error('parametri preflight Share non validi');
|
|
331
|
+
}
|
|
332
|
+
const ctrl = new AbortController();
|
|
333
|
+
const budget = Number.isInteger(timeoutMs) ? Math.max(100, Math.min(timeoutMs, 10000)) : 1500;
|
|
334
|
+
let timer;
|
|
335
|
+
try {
|
|
336
|
+
const request = fetchImpl(`http://127.0.0.1:${node.localPort}/federation/reverse-status`, {
|
|
337
|
+
headers: { authorization: `Bearer ${node.token}` }, signal: ctrl.signal,
|
|
338
|
+
});
|
|
339
|
+
const timeout = new Promise((_, reject) => {
|
|
340
|
+
timer = setTimeout(() => {
|
|
341
|
+
ctrl.abort();
|
|
342
|
+
const error = new Error(`hub reverse preflight timeout (${budget}ms)`);
|
|
343
|
+
error.code = 'ETIMEDOUT';
|
|
344
|
+
reject(error);
|
|
345
|
+
}, budget);
|
|
346
|
+
});
|
|
347
|
+
const response = await Promise.race([request, timeout]);
|
|
348
|
+
if (!response || response.status === 404) return { supported: false };
|
|
349
|
+
if (response.status === 409) return { supported: true, available: false, code: 'reverse-port-in-use' };
|
|
350
|
+
if (!response.ok) return { supported: false };
|
|
351
|
+
let body = null;
|
|
352
|
+
try { body = await response.json(); } catch (_) { return { supported: false }; }
|
|
353
|
+
return body && (body.available === true || body.ownedByAuthenticatedPeer === true)
|
|
354
|
+
? { supported: true, available: true, ...(body.ownedByAuthenticatedPeer === true ? { ownedByAuthenticatedPeer: true } : {}) }
|
|
355
|
+
: { supported: false };
|
|
356
|
+
} catch (_) {
|
|
357
|
+
return { supported: false };
|
|
358
|
+
} finally { clearTimeout(timer); }
|
|
359
|
+
}
|
|
360
|
+
|
|
292
361
|
// Aggiorna lo stato Share sul hub attraverso il canale -L autenticato. Non
|
|
293
362
|
// legge mai il body remoto (potrebbe contenere diagnostica non sicura) e non
|
|
294
363
|
// include credenziali negli errori. Usato sia dal toggle interattivo sia dalla
|
|
@@ -317,6 +386,61 @@ async function notifyHubShare({ node, shared, fetchImpl = fetch, timeoutMs = 500
|
|
|
317
386
|
} finally { clearTimeout(timer); }
|
|
318
387
|
}
|
|
319
388
|
|
|
389
|
+
// Internal pool-control calls travel only through the established private -L.
|
|
390
|
+
// Bodies are deliberately index/lease based: neither a caller nor a remote
|
|
391
|
+
// UI can turn them into an arbitrary loopback port probe.
|
|
392
|
+
async function hubPoolRequest({ node, endpoint, body, fetchImpl = fetch, timeoutMs = 5000 }) {
|
|
393
|
+
if (!node || !store.isPort(node.localPort) || !store.validToken(node.token)
|
|
394
|
+
|| !['verify', 'reserve', 'commit', 'settle', 'abort', 'status'].includes(endpoint)) {
|
|
395
|
+
throw new Error('parametri reverse pool non validi');
|
|
396
|
+
}
|
|
397
|
+
const ctrl = new AbortController();
|
|
398
|
+
const budget = Number.isInteger(timeoutMs) ? Math.max(100, Math.min(timeoutMs, 30000)) : 5000;
|
|
399
|
+
const timer = setTimeout(() => ctrl.abort(), budget);
|
|
400
|
+
try {
|
|
401
|
+
const method = endpoint === 'status' ? 'GET' : 'POST';
|
|
402
|
+
const response = await fetchImpl(`http://127.0.0.1:${node.localPort}/federation/reverse-pool/${endpoint}`, {
|
|
403
|
+
method, signal: ctrl.signal,
|
|
404
|
+
headers: { authorization: `Bearer ${node.token}`, ...(method === 'POST' ? { 'content-type': 'application/json' } : {}) },
|
|
405
|
+
...(method === 'POST' ? { body: JSON.stringify(body || {}) } : {}),
|
|
406
|
+
});
|
|
407
|
+
const payload = response && typeof response.json === 'function'
|
|
408
|
+
? await response.json().catch(() => ({})) : {};
|
|
409
|
+
if (!response?.ok) {
|
|
410
|
+
const error = new Error(`hub reverse pool HTTP ${response?.status || 'unknown'}`);
|
|
411
|
+
error.status = response?.status; error.code = payload && payload.code;
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
return payload || {};
|
|
415
|
+
} finally { clearTimeout(timer); }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function verifyHubPoolSlot({ node, slot, generation, fetchImpl = fetch, attempts = 3, delayMs = 200 }) {
|
|
419
|
+
const total = Number.isInteger(attempts) ? Math.max(1, Math.min(attempts, 6)) : 3;
|
|
420
|
+
let last = null;
|
|
421
|
+
for (let attempt = 0; attempt < total; attempt += 1) {
|
|
422
|
+
try { return await hubPoolRequest({ node, endpoint: 'verify', body: { slot, generation }, fetchImpl }); }
|
|
423
|
+
catch (error) { last = error; if (attempt < total - 1) await new Promise((resolve) => setTimeout(resolve, delayMs)); }
|
|
424
|
+
}
|
|
425
|
+
throw last || new Error('verifica reverse pool fallita');
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function reserveHubPoolSlot({ node, slot, fetchImpl = fetch }) {
|
|
429
|
+
return hubPoolRequest({ node, endpoint: 'reserve', body: { slot }, fetchImpl });
|
|
430
|
+
}
|
|
431
|
+
function commitHubPoolSlot({ node, leaseId, fetchImpl = fetch }) {
|
|
432
|
+
return hubPoolRequest({ node, endpoint: 'commit', body: { leaseId }, fetchImpl });
|
|
433
|
+
}
|
|
434
|
+
function settleHubPoolSlot({ node, generation, fetchImpl = fetch }) {
|
|
435
|
+
return hubPoolRequest({ node, endpoint: 'settle', body: { generation }, fetchImpl });
|
|
436
|
+
}
|
|
437
|
+
function abortHubPoolSlot({ node, leaseId, fetchImpl = fetch }) {
|
|
438
|
+
return hubPoolRequest({ node, endpoint: 'abort', body: { leaseId }, fetchImpl });
|
|
439
|
+
}
|
|
440
|
+
function getHubPoolStatus({ node, fetchImpl = fetch }) {
|
|
441
|
+
return hubPoolRequest({ node, endpoint: 'status', fetchImpl });
|
|
442
|
+
}
|
|
443
|
+
|
|
320
444
|
// Il file locale contiene lo stato desiderato. Dopo un crash in qualunque
|
|
321
445
|
// punto del toggle, il boot ristabilisce il tunnel coerente e ripete l'update
|
|
322
446
|
// del hub: ON torna pubblicato, OFF revoca record stale. Tutto e' bounded.
|
|
@@ -544,6 +668,26 @@ async function collectLocalTopology({
|
|
|
544
668
|
return { instanceId: live.instanceId, nodes };
|
|
545
669
|
}
|
|
546
670
|
|
|
671
|
+
async function probeReverseOwner(peer, fetchImpl = fetch) {
|
|
672
|
+
const pool = peer && peer.reversePool;
|
|
673
|
+
const active = pool && pool.slots && pool.slots[pool.activeSlot];
|
|
674
|
+
if (active && peer.token && peer.nodeId) {
|
|
675
|
+
const proof = await probeReverseSlot({
|
|
676
|
+
port: active.port, secret: peer.token,
|
|
677
|
+
expected: { remotePort: active.port, generation: pool.activeGeneration, instanceId: peer.nodeId },
|
|
678
|
+
fetchImpl,
|
|
679
|
+
});
|
|
680
|
+
return proof.owned
|
|
681
|
+
? { status: 'healthy', detail: 'reverse slot autenticata', slotProof: true }
|
|
682
|
+
: { status: 'degraded', detail: 'reverse slot non autenticata', slotProof: true, code: proof.code };
|
|
683
|
+
}
|
|
684
|
+
return probeHealth({
|
|
685
|
+
port: peer.localPort, token: peer.token,
|
|
686
|
+
expectedInstanceId: peer.nodeId || null,
|
|
687
|
+
fetchImpl,
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
547
691
|
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null }) {
|
|
548
692
|
const r = express.Router();
|
|
549
693
|
r.use((req, res, next) => {
|
|
@@ -561,6 +705,180 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
561
705
|
res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly(),
|
|
562
706
|
...(advertisedRoles ? { roles: advertisedRoles } : {}) });
|
|
563
707
|
});
|
|
708
|
+
// Il client puo' verificare SOLO la reverse port che il pairing gli ha
|
|
709
|
+
// assegnato. Non e' un endpoint di discovery: nessun parametro, nessuna
|
|
710
|
+
// identita' del listener e nessun dettaglio SSH vengono esposti.
|
|
711
|
+
r.get('/reverse-status', async (req, res) => {
|
|
712
|
+
const result = await canListenLoopback(req.peer.localPort);
|
|
713
|
+
if (result.available) return res.json({ available: true });
|
|
714
|
+
// Una porta gia' ascoltata dal peer che ha autenticato QUESTA richiesta
|
|
715
|
+
// non e' un conflitto: e' il reverse preesistente dello stesso dispositivo.
|
|
716
|
+
// La health vincola sia token sia instanceId e non restituisce alcun
|
|
717
|
+
// dettaglio sul processo. Un listener estraneo, legacy o non verificabile
|
|
718
|
+
// resta invece un 409 senza restart/persist/publish lato client.
|
|
719
|
+
const health = await probeReverseOwner(req.peer, fetchImpl || fetch);
|
|
720
|
+
if (health.status === 'healthy') {
|
|
721
|
+
return res.json({ available: false, ownedByAuthenticatedPeer: true });
|
|
722
|
+
}
|
|
723
|
+
return res.status(409).json({
|
|
724
|
+
available: false,
|
|
725
|
+
code: result.code === 'reverse-port-in-use' ? 'reverse-port-in-use' : 'reverse-port-unavailable',
|
|
726
|
+
});
|
|
727
|
+
});
|
|
728
|
+
// Pool verification is intentionally slot-indexed, never port-indexed: the
|
|
729
|
+
// peer can ask the hub to prove only a slot it was assigned. The hub keeps
|
|
730
|
+
// the proof result itself; a client cannot simply claim that its SSH key has
|
|
731
|
+
// three permitlisten grants.
|
|
732
|
+
r.post('/reverse-pool/verify', express.json({ limit: '1kb' }), async (req, res) => {
|
|
733
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: verifica pool bloccata' });
|
|
734
|
+
const body = req.body || {};
|
|
735
|
+
if (Object.keys(body).some((key) => !['slot', 'generation'].includes(key))
|
|
736
|
+
|| !Number.isInteger(body.slot) || !Number.isSafeInteger(body.generation) || body.generation < 1) {
|
|
737
|
+
return res.status(400).json({ error: 'body non valido: attesi slot e generation' });
|
|
738
|
+
}
|
|
739
|
+
const peer = req.peer;
|
|
740
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
741
|
+
const node = store.getNode(current, peer.name);
|
|
742
|
+
const pool = node && node.reversePool;
|
|
743
|
+
const candidate = pool && pool.slots[body.slot];
|
|
744
|
+
if (!candidate || candidate.generation !== body.generation) {
|
|
745
|
+
return res.status(409).json({ error: 'slot reverse non assegnata a questa generation', code: 'reverse-slot-stale' });
|
|
746
|
+
}
|
|
747
|
+
const proof = await probeReverseSlot({
|
|
748
|
+
port: candidate.port, secret: node.token,
|
|
749
|
+
expected: { remotePort: candidate.port, generation: candidate.generation, instanceId: node.nodeId },
|
|
750
|
+
fetchImpl: fetchImpl || fetch,
|
|
751
|
+
});
|
|
752
|
+
const verifiedSlots = proof.owned
|
|
753
|
+
? [...new Set([...pool.verifiedSlots, body.slot])].sort((a, b) => a - b)
|
|
754
|
+
: [...pool.verifiedSlots];
|
|
755
|
+
const updatedPool = {
|
|
756
|
+
...pool,
|
|
757
|
+
verifiedSlots,
|
|
758
|
+
verification: verifiedSlots.length === pool.slots.length ? 'verified' : 'unverifiable',
|
|
759
|
+
};
|
|
760
|
+
store.atomicWriteStore(nodesPath, store.setNodeReversePool(current, node.name, updatedPool));
|
|
761
|
+
if (!proof.owned) return res.status(409).json({ error: 'slot reverse non autenticata', code: proof.code });
|
|
762
|
+
return res.json({ verified: true, slot: body.slot, verification: updatedPool.verification });
|
|
763
|
+
});
|
|
764
|
+
// The peer proposes a slot through its still-live -L; the hub alone grants
|
|
765
|
+
// the lease and generation. There is no port parameter, scanner or tunnel
|
|
766
|
+
// termination path here.
|
|
767
|
+
r.post('/reverse-pool/reserve', express.json({ limit: '1kb' }), (req, res) => {
|
|
768
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: rotazione pool bloccata' });
|
|
769
|
+
const body = req.body || {};
|
|
770
|
+
if (Object.keys(body).some((key) => key !== 'slot') || !Number.isInteger(body.slot)) {
|
|
771
|
+
return res.status(400).json({ error: 'body non valido: atteso slot' });
|
|
772
|
+
}
|
|
773
|
+
try {
|
|
774
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
775
|
+
const node = store.getNode(current, req.peer.name);
|
|
776
|
+
let pool = node && node.reversePool;
|
|
777
|
+
// A crashed peer may leave a reservation behind. Its lease has no
|
|
778
|
+
// privilege after expiry, so clear it before considering the requested
|
|
779
|
+
// slot; this is state cleanup only, never SSH/process cleanup.
|
|
780
|
+
if (pool?.rotation?.phase === 'prepared' && Date.now() > pool.rotation.expiresAt) {
|
|
781
|
+
pool = reverseRotation.abortPrepared(pool);
|
|
782
|
+
}
|
|
783
|
+
const prepared = pool && reverseRotation.prepareRotation(pool, { slot: body.slot });
|
|
784
|
+
if (!prepared) return res.status(409).json({ error: 'pool non verificato o slot non disponibile', code: 'reverse-rotation-not-ready' });
|
|
785
|
+
store.atomicWriteStore(nodesPath, store.setNodeReversePool(current, node.name, prepared));
|
|
786
|
+
return res.json({ leaseId: prepared.rotation.leaseId, slot: prepared.rotation.slot,
|
|
787
|
+
generation: prepared.rotation.generation, expiresAt: prepared.rotation.expiresAt });
|
|
788
|
+
} catch (error) { return res.status(500).json({ error: String(error.message || error) }); }
|
|
789
|
+
});
|
|
790
|
+
r.post('/reverse-pool/abort', express.json({ limit: '1kb' }), (req, res) => {
|
|
791
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: rotazione pool bloccata' });
|
|
792
|
+
const body = req.body || {};
|
|
793
|
+
if (Object.keys(body).some((key) => key !== 'leaseId') || typeof body.leaseId !== 'string') {
|
|
794
|
+
return res.status(400).json({ error: 'body non valido: atteso leaseId' });
|
|
795
|
+
}
|
|
796
|
+
try {
|
|
797
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
798
|
+
const node = store.getNode(current, req.peer.name);
|
|
799
|
+
const pool = node && node.reversePool;
|
|
800
|
+
if (!pool || pool.rotation?.phase !== 'prepared' || pool.rotation.leaseId !== body.leaseId) {
|
|
801
|
+
return res.status(409).json({ error: 'lease reverse non valida o gia conclusa', code: 'reverse-lease-stale' });
|
|
802
|
+
}
|
|
803
|
+
const aborted = reverseRotation.abortPrepared(pool);
|
|
804
|
+
if (!aborted) return res.status(409).json({ error: 'lease reverse non annullabile', code: 'reverse-lease-stale' });
|
|
805
|
+
store.atomicWriteStore(nodesPath, store.setNodeReversePool(current, node.name, aborted));
|
|
806
|
+
return res.json({ aborted: true });
|
|
807
|
+
} catch (error) { return res.status(500).json({ error: String(error.message || error) }); }
|
|
808
|
+
});
|
|
809
|
+
r.post('/reverse-pool/commit', express.json({ limit: '1kb' }), async (req, res) => {
|
|
810
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: rotazione pool bloccata' });
|
|
811
|
+
const body = req.body || {};
|
|
812
|
+
if (Object.keys(body).some((key) => key !== 'leaseId') || typeof body.leaseId !== 'string') {
|
|
813
|
+
return res.status(400).json({ error: 'body non valido: atteso leaseId' });
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
817
|
+
const node = store.getNode(current, req.peer.name);
|
|
818
|
+
const prepared = node && node.reversePool;
|
|
819
|
+
const rotation = prepared && prepared.rotation;
|
|
820
|
+
if (!rotation || rotation.phase !== 'prepared' || rotation.leaseId !== body.leaseId) {
|
|
821
|
+
return res.status(409).json({ error: 'lease reverse non valida o scaduta', code: 'reverse-lease-stale' });
|
|
822
|
+
}
|
|
823
|
+
const slot = prepared.slots[rotation.slot];
|
|
824
|
+
let proof = { owned: false, code: 'reverse-slot-proof-unavailable' };
|
|
825
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
826
|
+
proof = await probeReverseSlot({
|
|
827
|
+
port: slot.port, secret: node.token,
|
|
828
|
+
expected: { remotePort: slot.port, generation: rotation.generation, instanceId: node.nodeId },
|
|
829
|
+
fetchImpl: fetchImpl || fetch,
|
|
830
|
+
});
|
|
831
|
+
if (proof.owned || attempt === 2) break;
|
|
832
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
833
|
+
}
|
|
834
|
+
if (!proof.owned) {
|
|
835
|
+
const abandoned = reverseRotation.quarantineSlot(prepared, { slot: rotation.slot }) || prepared;
|
|
836
|
+
const invalidated = { ...abandoned, verification: 'invalidated', verifiedSlots: [] };
|
|
837
|
+
store.atomicWriteStore(nodesPath, store.setNodeReversePool(current, node.name, invalidated));
|
|
838
|
+
return res.status(409).json({ error: 'candidate reverse non autenticata', code: proof.code });
|
|
839
|
+
}
|
|
840
|
+
const committed = reverseRotation.commitRotation(prepared, { leaseId: body.leaseId });
|
|
841
|
+
if (!committed) return res.status(409).json({ error: 'lease reverse scaduta', code: 'reverse-lease-expired' });
|
|
842
|
+
// The hub routes the peer through the newly proven slot immediately. The
|
|
843
|
+
// old slot is only draining and is never reassigned to another peer.
|
|
844
|
+
const updated = store.updateNode(current, node.name, {
|
|
845
|
+
localPort: committed.slots[committed.activeSlot].port,
|
|
846
|
+
reversePool: committed,
|
|
847
|
+
});
|
|
848
|
+
store.atomicWriteStore(nodesPath, updated);
|
|
849
|
+
return res.json({ committed: true, slot: committed.activeSlot, generation: committed.activeGeneration,
|
|
850
|
+
graceUntil: committed.rotation.graceUntil });
|
|
851
|
+
} catch (error) { return res.status(500).json({ error: String(error.message || error) }); }
|
|
852
|
+
});
|
|
853
|
+
r.post('/reverse-pool/settle', express.json({ limit: '1kb' }), (req, res) => {
|
|
854
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: rotazione pool bloccata' });
|
|
855
|
+
const body = req.body || {};
|
|
856
|
+
if (Object.keys(body).some((key) => key !== 'generation') || !Number.isSafeInteger(body.generation) || body.generation < 1) {
|
|
857
|
+
return res.status(400).json({ error: 'body non valido: atteso generation' });
|
|
858
|
+
}
|
|
859
|
+
try {
|
|
860
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
861
|
+
const node = store.getNode(current, req.peer.name);
|
|
862
|
+
if (!node?.reversePool || node.reversePool.activeGeneration !== body.generation) {
|
|
863
|
+
return res.status(409).json({ error: 'generation reverse non corrente', code: 'reverse-generation-stale' });
|
|
864
|
+
}
|
|
865
|
+
const settled = reverseRotation.settleGrace(node.reversePool);
|
|
866
|
+
if (!settled) return res.status(409).json({ error: 'grace reverse non conclusa', code: 'reverse-grace-pending' });
|
|
867
|
+
store.atomicWriteStore(nodesPath, store.setNodeReversePool(current, node.name, settled));
|
|
868
|
+
return res.json({ settled: true, generation: settled.activeGeneration });
|
|
869
|
+
} catch (error) { return res.status(500).json({ error: String(error.message || error) }); }
|
|
870
|
+
});
|
|
871
|
+
r.get('/reverse-pool/status', (req, res) => {
|
|
872
|
+
try {
|
|
873
|
+
const current = store.loadStoreStrict(nodesPath);
|
|
874
|
+
const node = store.getNode(current, req.peer.name);
|
|
875
|
+
if (!node?.reversePool) return res.status(409).json({ error: 'pool reverse non configurato', code: 'reverse-pool-missing' });
|
|
876
|
+
// This travels only on the peer's authenticated private -L. It includes
|
|
877
|
+
// no bearer material and lets a restarted peer converge on the hub's
|
|
878
|
+
// committed generation instead of reopening an obsolete slot.
|
|
879
|
+
return res.json({ pool: node.reversePool });
|
|
880
|
+
} catch (error) { return res.status(500).json({ error: String(error.message || error) }); }
|
|
881
|
+
});
|
|
564
882
|
r.get('/topology', async (req, res) => {
|
|
565
883
|
const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
|
|
566
884
|
const visited = String(req.query.visited || '').split(',');
|
|
@@ -577,14 +895,23 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
577
895
|
}
|
|
578
896
|
try {
|
|
579
897
|
if (body.shared) {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
}
|
|
898
|
+
let health;
|
|
899
|
+
if (req.peer.reversePool) {
|
|
900
|
+
for (let attempt = 0; attempt < 6; attempt += 1) {
|
|
901
|
+
health = await probeReverseOwner(req.peer, fetchImpl || fetch);
|
|
902
|
+
if (health.status === 'healthy') break;
|
|
903
|
+
if (attempt < 5) await new Promise((resolve) => setTimeout(resolve, 200));
|
|
904
|
+
}
|
|
905
|
+
} else {
|
|
906
|
+
health = await waitForHealthyPeer({
|
|
907
|
+
port: req.peer.localPort,
|
|
908
|
+
token: req.peer.token,
|
|
909
|
+
expectedInstanceId: req.peer.nodeId || null,
|
|
910
|
+
fetchImpl: fetchImpl || fetch,
|
|
911
|
+
attempts: 6,
|
|
912
|
+
delayMs: 200,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
588
915
|
if (health.status !== 'healthy') {
|
|
589
916
|
return res.status(409).json({
|
|
590
917
|
error: 'canale share non raggiungibile',
|
|
@@ -656,5 +983,6 @@ module.exports = {
|
|
|
656
983
|
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
|
|
657
984
|
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
658
985
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
659
|
-
probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare, runShareRevokeBoot,
|
|
986
|
+
probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
|
|
987
|
+
hubPoolRequest, verifyHubPoolSlot, reserveHubPoolSlot, commitHubPoolSlot, settleHubPoolSlot, abortHubPoolSlot, getHubPoolStatus,
|
|
660
988
|
};
|