@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.
@@ -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
  };