@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.
@@ -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) => store.NODE_NAME_RE.test(name) || name === REVERSE_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) => store.NODE_NAME_RE.test(String(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.isAlive(meta)) {
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
- startTunnel, stopTunnel, restartTunnel, startForward,
597
- REVERSE_NAME,
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
  };
@@ -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
- const health = await waitForHealthyPeer({
581
- port: req.peer.localPort,
582
- token: req.peer.token,
583
- expectedInstanceId: req.peer.nodeId || null,
584
- fetchImpl: fetchImpl || fetch,
585
- attempts: 6,
586
- delayMs: 200,
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
  };