@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.
@@ -17,8 +17,10 @@ const fs = require('node:fs');
17
17
  const os = require('node:os');
18
18
  const path = require('node:path');
19
19
  const crypto = require('node:crypto');
20
+ const reversePool = require('./reverse-pool.js');
20
21
 
21
- const SCHEMA_VERSION = 2;
22
+ const SCHEMA_VERSION = 3;
23
+ const PREVIOUS_SCHEMA_VERSION = 2;
22
24
  const LEGACY_SCHEMA_VERSION = 1;
23
25
  const MAX_NODES = 64;
24
26
  const MAX_TOKEN_LEN = 4096;
@@ -105,8 +107,89 @@ const LABEL_MAX = 64;
105
107
  const NODE_KEYS = new Set([
106
108
  'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
107
109
  'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
108
- 'direction', 'reversePort', 'shared', 'label',
110
+ 'direction', 'reversePort', 'shared', 'label', 'reversePool',
109
111
  ]);
112
+
113
+ const REVERSE_POOL_SLOT_STATES = new Set(['active', 'ready', 'reserved', 'draining', 'quarantined', 'retired']);
114
+ const REVERSE_POOL_VERIFICATIONS = new Set(['verified', 'unverifiable', 'missing', 'invalidated']);
115
+ const REVERSE_POOL_PHASES = new Set(['active', 'prepared', 'switched', 'abandoned']);
116
+
117
+ function parseReversePool(value) {
118
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
119
+ const keys = Object.keys(value);
120
+ if (keys.some((key) => !['base', 'slots', 'activeSlot', 'activeGeneration', 'verification', 'verifiedSlots', 'rotation', 'lastAutoRotationAt'].includes(key))) return null;
121
+ const ports = reversePool.reversePoolForBase(value.base);
122
+ if (!ports || !Array.isArray(value.slots) || value.slots.length !== ports.length
123
+ || !Number.isInteger(value.activeSlot) || value.activeSlot < 0 || value.activeSlot >= ports.length
124
+ || !Number.isSafeInteger(value.activeGeneration) || value.activeGeneration < 1
125
+ || !REVERSE_POOL_VERIFICATIONS.has(value.verification)) return null;
126
+ if (value.lastAutoRotationAt !== undefined && (!Number.isSafeInteger(value.lastAutoRotationAt) || value.lastAutoRotationAt < 0)) return null;
127
+ const verifiedSlots = value.verifiedSlots === undefined ? [] : value.verifiedSlots;
128
+ if (!Array.isArray(verifiedSlots) || verifiedSlots.length > ports.length
129
+ || new Set(verifiedSlots).size !== verifiedSlots.length
130
+ || verifiedSlots.some((slot) => !Number.isInteger(slot) || slot < 0 || slot >= ports.length)) return null;
131
+ if (value.verification === 'verified' && verifiedSlots.length !== ports.length) return null;
132
+ const slots = value.slots.map((slot, index) => {
133
+ if (!slot || typeof slot !== 'object' || Array.isArray(slot) || Object.keys(slot).some((key) => !['port', 'state', 'generation'].includes(key))) return null;
134
+ if (slot.port !== ports[index] || !REVERSE_POOL_SLOT_STATES.has(slot.state)
135
+ || !Number.isSafeInteger(slot.generation) || slot.generation < 1) return null;
136
+ return { port: slot.port, state: slot.state, generation: slot.generation };
137
+ });
138
+ if (slots.some((slot) => !slot) || slots[value.activeSlot].state !== 'active'
139
+ || slots[value.activeSlot].generation !== value.activeGeneration) return null;
140
+ let rotation = { phase: 'active', generation: value.activeGeneration, slot: value.activeSlot };
141
+ if (value.rotation !== undefined) {
142
+ const r = value.rotation;
143
+ if (!r || typeof r !== 'object' || Array.isArray(r)
144
+ || Object.keys(r).some((key) => !['phase', 'generation', 'slot', 'leaseId', 'expiresAt', 'oldSlot', 'oldGeneration', 'graceUntil'].includes(key))
145
+ || !REVERSE_POOL_PHASES.has(r.phase) || !Number.isSafeInteger(r.generation) || r.generation < 1
146
+ || !Number.isInteger(r.slot) || r.slot < 0 || r.slot >= ports.length) return null;
147
+ if (r.phase === 'prepared') {
148
+ if (typeof r.leaseId !== 'string' || !/^[a-f0-9]{32,64}$/.test(r.leaseId)
149
+ || !Number.isSafeInteger(r.expiresAt) || r.expiresAt < 0) return null;
150
+ } else if (r.phase === 'switched') {
151
+ if (!Number.isInteger(r.oldSlot) || r.oldSlot < 0 || r.oldSlot >= ports.length || r.oldSlot === r.slot
152
+ || !Number.isSafeInteger(r.oldGeneration) || r.oldGeneration < 1
153
+ || !Number.isSafeInteger(r.graceUntil) || r.graceUntil < 0
154
+ || r.leaseId !== undefined || r.expiresAt !== undefined) return null;
155
+ } else if (r.leaseId !== undefined || r.expiresAt !== undefined || r.oldSlot !== undefined
156
+ || r.oldGeneration !== undefined || r.graceUntil !== undefined) return null;
157
+ rotation = { phase: r.phase, generation: r.generation, slot: r.slot,
158
+ ...(r.phase === 'prepared' ? { leaseId: r.leaseId, expiresAt: r.expiresAt } : {}),
159
+ ...(r.phase === 'switched' ? { oldSlot: r.oldSlot, oldGeneration: r.oldGeneration, graceUntil: r.graceUntil } : {}) };
160
+ }
161
+ return {
162
+ base: value.base,
163
+ slots,
164
+ activeSlot: value.activeSlot,
165
+ activeGeneration: value.activeGeneration,
166
+ verification: value.verification,
167
+ verifiedSlots: [...verifiedSlots].sort((a, b) => a - b),
168
+ rotation,
169
+ ...(value.lastAutoRotationAt === undefined ? {} : { lastAutoRotationAt: value.lastAutoRotationAt }),
170
+ };
171
+ }
172
+
173
+ function parseReversePoolAnchor(value) {
174
+ return reversePool.parseAnchor(value);
175
+ }
176
+
177
+ function reversePoolDefault(base, { verification = 'unverifiable', generation = 1 } = {}) {
178
+ const ports = reversePool.reversePoolForBase(base);
179
+ if (!ports || !REVERSE_POOL_VERIFICATIONS.has(verification) || !Number.isSafeInteger(generation) || generation < 1) {
180
+ throw new Error('reverse pool non valida');
181
+ }
182
+ return {
183
+ base,
184
+ slots: ports.map((port, index) => ({ port, state: index === 0 ? 'active' : 'ready', generation })),
185
+ activeSlot: 0,
186
+ activeGeneration: generation,
187
+ verification,
188
+ verifiedSlots: verification === 'verified' ? ports.map((_, index) => index) : [],
189
+ rotation: { phase: 'active', generation, slot: 0 },
190
+ };
191
+ }
192
+
110
193
  function parseNode(n, schemaVersion = SCHEMA_VERSION) {
111
194
  if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
112
195
  for (const k of Object.keys(n)) { if (!NODE_KEYS.has(k)) return null; } // schema chiuso
@@ -170,6 +253,12 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
170
253
  if (!isPort(n.reversePort)) return null;
171
254
  out.reversePort = n.reversePort;
172
255
  }
256
+ if (n.reversePool !== undefined) {
257
+ if (schemaVersion < SCHEMA_VERSION) return null;
258
+ const parsedPool = parseReversePool(n.reversePool);
259
+ if (!parsedPool) return null;
260
+ out.reversePool = parsedPool;
261
+ }
173
262
  if (n.selected !== undefined) {
174
263
  if (!Array.isArray(n.selected) || n.selected.length > MAX_NODES) return null;
175
264
  const selected = [...new Set(n.selected)];
@@ -222,7 +311,11 @@ function parseStore(raw) {
222
311
  return null;
223
312
  }
224
313
 
225
- if (d.schemaVersion !== SCHEMA_VERSION && d.schemaVersion !== LEGACY_SCHEMA_VERSION) return null;
314
+ if (![SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION].includes(d.schemaVersion)) return null;
315
+ const rootKeys = d.schemaVersion === SCHEMA_VERSION
316
+ ? new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous', 'reversePoolAnchor', 'reversePoolLedgerInitialized'])
317
+ : new Set(['schemaVersion', 'nodeId', 'nodes', 'rendezvous']);
318
+ if (Object.keys(d).some((key) => !rootKeys.has(key))) return null;
226
319
  if (typeof d.nodeId !== 'string' || !NODE_ID_RE.test(d.nodeId)) return null;
227
320
  if (!Array.isArray(d.nodes) || d.nodes.length > MAX_NODES) return null;
228
321
 
@@ -252,6 +345,24 @@ function parseStore(raw) {
252
345
  if (!rdv) return null;
253
346
  out.rendezvous = rdv;
254
347
  }
348
+ if (d.schemaVersion === SCHEMA_VERSION) {
349
+ // This marker distinguishes a fresh v3 peer (which may have received a
350
+ // pool from another hub) from a hub that has once owned allocations. If
351
+ // the latter loses its anchor, it must not silently start a new ledger.
352
+ if (d.reversePoolLedgerInitialized !== undefined && d.reversePoolLedgerInitialized !== true) return null;
353
+ if (d.reversePoolLedgerInitialized === true) out.reversePoolLedgerInitialized = true;
354
+ if (d.reversePoolAnchor !== undefined) {
355
+ if (d.reversePoolLedgerInitialized !== true) return null;
356
+ const anchor = parseReversePoolAnchor(d.reversePoolAnchor);
357
+ if (!anchor) return null;
358
+ out.reversePoolAnchor = anchor;
359
+ }
360
+ // The allocator and monotonic ledger live on the hub, which owns inbound
361
+ // peers. An outbound peer persists the pool negotiated by that hub but
362
+ // must not invent a second local allocation ledger for it.
363
+ if (nodes.some((node) => node.direction === 'inbound' && node.reversePool)
364
+ && (!out.reversePoolAnchor || d.reversePoolLedgerInitialized !== true)) return null;
365
+ }
255
366
  return out;
256
367
  } catch (_) {
257
368
  return null; // fail-closed: qualunque eccezione inattesa -> null, MAI throw
@@ -366,8 +477,12 @@ function getNode(store, name) {
366
477
  return store.nodes.find((n) => n.name === name) || null;
367
478
  }
368
479
 
480
+ function mutationSchemaVersion(store) {
481
+ return store && store.schemaVersion === SCHEMA_VERSION ? SCHEMA_VERSION : PREVIOUS_SCHEMA_VERSION;
482
+ }
483
+
369
484
  function addNode(store, entry) {
370
- const node = parseNode(entry, SCHEMA_VERSION);
485
+ const node = parseNode(entry, mutationSchemaVersion(store));
371
486
  if (!node) throw new Error('nodo non valido (schema strict): controlla name/ssh/remotePort/localPort');
372
487
  if (store.nodes.some((n) => n.name === node.name)) {
373
488
  throw new Error(`nodo duplicato: name "${node.name}" gia' presente`);
@@ -381,7 +496,7 @@ function addNode(store, entry) {
381
496
  throw new Error(`nodo duplicato: nodeId "${node.nodeId}" gia' presente`);
382
497
  }
383
498
  }
384
- return { ...store, schemaVersion: SCHEMA_VERSION, nodes: store.nodes.concat([node]) };
499
+ return { ...store, schemaVersion: mutationSchemaVersion(store), nodes: store.nodes.concat([node]) };
385
500
  }
386
501
 
387
502
  function removeNode(store, name) {
@@ -389,7 +504,7 @@ function removeNode(store, name) {
389
504
  if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
390
505
  const nodes = store.nodes.slice();
391
506
  nodes.splice(idx, 1);
392
- return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
507
+ return { ...store, schemaVersion: mutationSchemaVersion(store), nodes };
393
508
  }
394
509
 
395
510
  function setNodeToken(store, name, token) {
@@ -398,17 +513,47 @@ function setNodeToken(store, name, token) {
398
513
  if (!validToken(token)) throw new Error('token non valido (vuoto, multilinea o troppo lungo)');
399
514
  const nodes = store.nodes.slice();
400
515
  nodes[idx] = { ...nodes[idx], token };
401
- return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
516
+ return { ...store, schemaVersion: mutationSchemaVersion(store), nodes };
402
517
  }
403
518
 
404
519
  function updateNode(store, name, patch) {
405
520
  const idx = store.nodes.findIndex((n) => n.name === name);
406
521
  if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
407
- const parsed = parseNode({ ...store.nodes[idx], ...patch }, SCHEMA_VERSION);
522
+ const parsed = parseNode({ ...store.nodes[idx], ...patch }, mutationSchemaVersion(store));
408
523
  if (!parsed) throw new Error('aggiornamento nodo non valido');
409
524
  const nodes = store.nodes.slice();
410
525
  nodes[idx] = parsed;
411
- return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
526
+ return { ...store, schemaVersion: mutationSchemaVersion(store), nodes };
527
+ }
528
+
529
+ // The upgrade is explicit: normal legacy pairing remains schema v2 and keeps
530
+ // working with a previous NexusCrew binary. Enabling rotation is the only
531
+ // path that writes the v3 anchor and therefore requires both peers to support
532
+ // the capability.
533
+ function upgradeToReversePoolSchema(store, anchor = undefined) {
534
+ const current = parseStore(store);
535
+ if (!current) throw new Error('nodes store non valido');
536
+ const parsedAnchor = anchor === undefined ? null : parseReversePoolAnchor(anchor);
537
+ if (anchor !== undefined && !parsedAnchor) throw new Error('reverse pool anchor non valida');
538
+ const next = {
539
+ ...current,
540
+ schemaVersion: SCHEMA_VERSION,
541
+ ...(parsedAnchor ? { reversePoolLedgerInitialized: true, reversePoolAnchor: parsedAnchor } : {}),
542
+ };
543
+ const parsed = parseStore(next);
544
+ if (!parsed) throw new Error('upgrade reverse pool non valido');
545
+ return parsed;
546
+ }
547
+
548
+ function setNodeReversePool(store, name, value) {
549
+ const current = store && getNode(store, name);
550
+ if (!store || store.schemaVersion !== SCHEMA_VERSION || !current) {
551
+ throw new Error('reverse pool richiede schema v3');
552
+ }
553
+ if (current.direction === 'inbound' && !parseReversePoolAnchor(store.reversePoolAnchor)) {
554
+ throw new Error('reverse pool inbound richiede anchor valida');
555
+ }
556
+ return updateNode(store, name, { reversePool: value });
412
557
  }
413
558
 
414
559
  // --- Redazione (view sicura per status/list: MAI il token) ------------------
@@ -434,6 +579,18 @@ function redactNode(n) {
434
579
  if (n.visibility === 'selected') out.selected = [...(n.selected || [])];
435
580
  if (n.sshPort !== undefined) out.sshPort = n.sshPort;
436
581
  if (n.nodeId) out.nodeId = n.nodeId;
582
+ if (n.reversePool) {
583
+ out.reversePool = {
584
+ base: n.reversePool.base,
585
+ slots: n.reversePool.slots.map((slot) => ({ ...slot })),
586
+ activeSlot: n.reversePool.activeSlot,
587
+ activeGeneration: n.reversePool.activeGeneration,
588
+ verification: n.reversePool.verification,
589
+ verifiedSlots: [...n.reversePool.verifiedSlots],
590
+ rotation: { ...n.reversePool.rotation },
591
+ ...(n.reversePool.lastAutoRotationAt === undefined ? {} : { lastAutoRotationAt: n.reversePool.lastAutoRotationAt }),
592
+ };
593
+ }
437
594
  return out;
438
595
  }
439
596
 
@@ -577,10 +734,11 @@ function suggestNodeName(input, existing = []) {
577
734
  module.exports = {
578
735
  // parse/validate
579
736
  parseStore, parseNode, parseRendezvous, parseRoles, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
737
+ parseReversePool, parseReversePoolAnchor, reversePoolDefault,
580
738
  // I/O
581
739
  defaultNodesPath, loadStore, loadStoreStrict, initStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
582
740
  // mutazioni
583
- getNode, addNode, removeNode, setNodeToken, updateNode,
741
+ getNode, addNode, removeNode, setNodeToken, updateNode, upgradeToReversePoolSchema, setNodeReversePool,
584
742
  // redazione
585
743
  redactNode, redactStore, hasPairedPeers,
586
744
  // migrazione
@@ -588,5 +746,5 @@ module.exports = {
588
746
  // label / slug
589
747
  nodeLabel, validLabel, sanitizeLabel, toSlug, deriveNodeHandle, suggestNodeName, LABEL_MAX,
590
748
  // costanti
591
- SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
749
+ SCHEMA_VERSION, PREVIOUS_SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
592
750
  };
@@ -89,7 +89,14 @@ function clearForwardProbe() {
89
89
  function probeForward(expectedChild) {
90
90
  if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
91
91
  if (!forwardPort) {
92
- writeState('transport-probing', { sshPid: child.pid, detail: 'local forward unavailable' });
92
+ // A reverse-only sidecar has no local -L to probe. With
93
+ // ExitOnForwardFailure enabled, surviving the stability window proves that
94
+ // ssh accepted its -R request; the hub still performs the stronger MAC
95
+ // ownership probe before it publishes Share.
96
+ attempt = 0;
97
+ reverseFailures = 0;
98
+ logEvent(`reverse forward ready stableMs=${stableMs}`);
99
+ if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'reverse-forward' })) stop();
93
100
  return;
94
101
  }
95
102
  let settled = false;
@@ -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
  };