@mmmbuto/nexuscrew 0.8.44 → 0.8.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +7 -1
- package/frontend/dist/assets/{index-CNI9gSN5.js → index-DMG-rioF.js} +3 -3
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cells/routes.js +3 -2
- package/lib/cli/commands.js +5 -2
- package/lib/cli/pidfile.js +45 -2
- package/lib/fleet/builtin.js +21 -4
- package/lib/fleet/managed.js +89 -23
- package/lib/fleet/runtime.js +24 -11
- package/lib/mcp/cells.js +27 -4
- package/lib/mcp/server.js +16 -3
- package/lib/nodes/commands.js +17 -0
- package/lib/nodes/health.js +23 -2
- package/lib/nodes/reverse-pool.js +221 -0
- package/lib/nodes/reverse-rotation.js +78 -0
- package/lib/nodes/reverse-slot-listeners.js +80 -0
- package/lib/nodes/reverse-slot-proof.js +108 -0
- package/lib/nodes/store.js +169 -11
- package/lib/nodes/tunnel-supervisor.js +8 -1
- package/lib/nodes/tunnel.js +96 -11
- package/lib/proxy/federation.js +337 -9
- package/lib/server.js +247 -1
- package/lib/settings/pairing-coordinator.js +18 -0
- package/lib/settings/public-peering-routes.js +58 -4
- package/lib/settings/routes.js +31 -4
- package/package.json +1 -1
package/lib/nodes/store.js
CHANGED
|
@@ -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 =
|
|
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 (
|
|
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,
|
|
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:
|
|
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:
|
|
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:
|
|
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 },
|
|
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:
|
|
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
|
-
|
|
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;
|
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
|
};
|