@mmmbuto/nexuscrew 0.8.19 → 0.8.21

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.
@@ -6,7 +6,7 @@ const fs = require('node:fs');
6
6
  const net = require('node:net');
7
7
  const path = require('node:path');
8
8
  const { spawn } = require('node:child_process');
9
- const { backoffDelay } = require('./tunnel.js');
9
+ const { backoffDelay, classifySshFailure } = require('./tunnel.js');
10
10
 
11
11
  const sshBin = process.argv[2];
12
12
  const sshArgs = process.argv.slice(3);
@@ -18,6 +18,9 @@ const stableMs = Number.isFinite(stableMsRaw) && stableMsRaw >= 100 ? Math.min(s
18
18
  const ownershipGraceRaw = Number(process.env.NEXUSCREW_TUNNEL_OWNERSHIP_GRACE_MS || 2000);
19
19
  const ownershipGraceMs = Number.isFinite(ownershipGraceRaw) && ownershipGraceRaw >= 100
20
20
  ? Math.min(ownershipGraceRaw, 10000) : 2000;
21
+ const reverseFailureMaxRaw = Number(process.env.NEXUSCREW_TUNNEL_REVERSE_FAILURE_MAX || 8);
22
+ const reverseFailureMax = Number.isInteger(reverseFailureMaxRaw) && reverseFailureMaxRaw >= 1
23
+ ? Math.min(reverseFailureMaxRaw, 32) : 8;
21
24
  if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
22
25
 
23
26
  let child = null;
@@ -29,6 +32,7 @@ let forwardProbeTimer = null;
29
32
  let forwardSocket = null;
30
33
  let ownershipWaitTimer = null;
31
34
  let ownershipTimer = null;
35
+ let reverseFailures = 0;
32
36
 
33
37
  function localForwardPort(args) {
34
38
  for (let i = 0; i < args.length - 1; i += 1) {
@@ -42,6 +46,18 @@ function localForwardPort(args) {
42
46
 
43
47
  const forwardPort = localForwardPort(sshArgs);
44
48
 
49
+ function reverseForwardPort(args) {
50
+ for (let i = 0; i < args.length - 1; i += 1) {
51
+ if (args[i] !== '-R') continue;
52
+ const match = String(args[i + 1] || '').match(/^127\.0\.0\.1:(\d+):127\.0\.0\.1:\d+$/);
53
+ const port = match ? Number(match[1]) : 0;
54
+ if (Number.isInteger(port) && port >= 1 && port <= 65535) return port;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ const reversePort = reverseForwardPort(sshArgs);
60
+
45
61
  function logEvent(message) {
46
62
  try { process.stderr.write(`[nexuscrew] ${String(message).replace(/[\r\n]+/g, ' ')}\n`); } catch (_) {}
47
63
  }
@@ -121,17 +137,34 @@ function scheduleRetry(detail) {
121
137
  retryTimer = setTimeout(run, delayMs);
122
138
  }
123
139
 
140
+ function terminalFailure(diagnosis) {
141
+ const safe = diagnosis || { code: 'reverse-forward-failed', detail: 'canale inverso non disponibile' };
142
+ logEvent(`ssh terminal failure code=${safe.code} attempts=${reverseFailures}`);
143
+ writeState('failed', {
144
+ code: safe.code, detail: safe.detail,
145
+ ...(safe.hint ? { hint: safe.hint } : {}), terminal: true,
146
+ });
147
+ process.exit(0);
148
+ }
149
+
124
150
  function run() {
125
151
  if (stopping) return finish();
126
152
  if (!writeState('starting')) return stop();
127
153
  logEvent(`ssh attempt=${attempt + 1} starting`);
154
+ let stderrTail = '';
128
155
  try {
129
- child = spawn(sshBin, sshArgs, { stdio: 'inherit' });
156
+ child = spawn(sshBin, sshArgs, { stdio: ['ignore', 'inherit', 'pipe'] });
130
157
  } catch (e) {
131
158
  child = null;
132
159
  return scheduleRetry(String(e && e.message || e));
133
160
  }
134
161
 
162
+ child.stderr?.on('data', (chunk) => {
163
+ const text = String(chunk || '');
164
+ stderrTail = `${stderrTail}${text}`.slice(-8192);
165
+ try { process.stderr.write(chunk); } catch (_) {}
166
+ });
167
+
135
168
  let failureHandled = false;
136
169
  const handleFailure = (detail) => {
137
170
  if (failureHandled) return;
@@ -139,7 +172,15 @@ function run() {
139
172
  clearTimeout(upTimer);
140
173
  clearForwardProbe();
141
174
  child = null;
142
- scheduleRetry(detail);
175
+ const diagnosis = reversePort ? classifySshFailure(stderrTail, reversePort) : null;
176
+ const reverseFailure = diagnosis && ['reverse-forward-bind', 'reverse-forward-failed'].includes(diagnosis.code);
177
+ if (reverseFailure) {
178
+ reverseFailures += 1;
179
+ if (reverseFailures >= reverseFailureMax) return terminalFailure(diagnosis);
180
+ } else {
181
+ reverseFailures = 0;
182
+ }
183
+ scheduleRetry((diagnosis && diagnosis.detail) || detail);
143
184
  };
144
185
  child.once('spawn', () => {
145
186
  logEvent(`ssh attempt=${attempt + 1} spawned`);
@@ -156,7 +197,9 @@ function run() {
156
197
  logEvent(`ssh child error code=${(e && e.code) || 'unknown'}`);
157
198
  handleFailure(String(e && e.message || e));
158
199
  });
159
- child.once('exit', (code, signal) => {
200
+ // `close` fires after stderr has drained, so classification sees the complete
201
+ // OpenSSH diagnostic instead of racing the child `exit` event.
202
+ child.once('close', (code, signal) => {
160
203
  if (stopping) return finish();
161
204
  logEvent(`ssh exited code=${code === null ? 'null' : code} signal=${signal || 'none'}`);
162
205
  handleFailure(`ssh exited code=${code} signal=${signal || ''}`);
@@ -186,6 +186,14 @@ function openTunnelLog(home, name) {
186
186
  function classifySshFailure(text, remotePort) {
187
187
  const s = String(text || '');
188
188
  const target = store.isPort(remotePort) ? `127.0.0.1:${remotePort}` : 'la porta NexusCrew richiesta';
189
+ const reverseMatch = s.match(/remote port forwarding failed for listen port\s+(\d+)/i)
190
+ || s.match(/(?:bind|listen)[^\r\n]*127\.0\.0\.1:(\d+)/i);
191
+ const reverseListenPort = reverseMatch ? Number(reverseMatch[1]) : null;
192
+ const reverseTarget = store.isPort(reverseListenPort)
193
+ ? `127.0.0.1:${reverseListenPort}` : 'la porta reverse negoziata';
194
+ const reversePolicy = store.isPort(reverseListenPort)
195
+ ? `che la chiave SSH autorizzi permitlisten="${reverseTarget}"`
196
+ : 'che la chiave SSH autorizzi il reverse listen negoziato';
189
197
  if (/administratively prohibited|request (?:was )?denied|open failed:.*prohibited|port forwarding.*(?:disabled|denied)/i.test(s)) {
190
198
  return {
191
199
  code: 'forward-denied',
@@ -209,7 +217,18 @@ function classifySshFailure(text, remotePort) {
209
217
  return { code: 'ssh-config', detail: 'configurazione SSH non utilizzabile', hint: 'verifica permessi e opzioni del file ~/.ssh/config' };
210
218
  }
211
219
  if (/remote port forwarding failed|Could not request remote forwarding/i.test(s)) {
212
- return { code: 'reverse-forward-denied', detail: 'il server SSH ha negato il canale inverso del nodo', hint: 'verifica permitlisten/AllowTcpForwarding sul nodo hub' };
220
+ if (/Address already in use|cannot listen|bind .*failed/i.test(s)) {
221
+ return {
222
+ code: 'reverse-forward-bind',
223
+ detail: 'il canale inverso non può aprire la porta sul nodo hub perché è già occupata',
224
+ hint: 'verifica un precedente listener SSH sulla stessa porta e l’eventuale doppio supervisor',
225
+ };
226
+ }
227
+ return {
228
+ code: 'reverse-forward-failed',
229
+ detail: 'il canale inverso non è stato aperto dal nodo hub',
230
+ hint: `verifica che ${reverseTarget} non sia occupata e ${reversePolicy}; controlla anche AllowTcpForwarding sul nodo hub`,
231
+ };
213
232
  }
214
233
  if (/Could not request local forwarding|Address already in use/i.test(s)) {
215
234
  return { code: 'local-forward-bind', detail: 'la porta locale scelta per il tunnel non è disponibile', hint: 'ferma il tunnel precedente e riprova' };
@@ -245,12 +264,27 @@ function tunnelStatePath(home, name) {
245
264
  function readTunnelState(home, name) {
246
265
  const p = tunnelPidPath(home, name);
247
266
  const meta = pidf.readPidfile(p);
267
+ const terminalState = () => {
268
+ try {
269
+ const state = JSON.parse(fs.readFileSync(tunnelStatePath(home, name), 'utf8'));
270
+ const owned = meta && state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
271
+ if (!owned || state.status !== 'failed' || state.terminal !== true) return null;
272
+ return {
273
+ status: 'down', phase: 'failed', reason: 'failed', pid: meta.pid, since: meta.startTs || null,
274
+ ...(typeof state.code === 'string' ? { code: state.code } : {}),
275
+ ...(typeof state.detail === 'string' ? { detail: state.detail } : {}),
276
+ ...(typeof state.hint === 'string' ? { hint: state.hint } : {}),
277
+ ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}),
278
+ };
279
+ } catch (_) { return null; }
280
+ };
248
281
  if (meta && pidf.isAlive(meta)) {
249
282
  try {
250
283
  const state = JSON.parse(fs.readFileSync(tunnelStatePath(home, name), 'utf8'));
251
284
  const transport = typeof state.transport === 'string' ? state.transport : undefined;
252
285
  const owned = state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
253
286
  if (!owned) return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
287
+ if (state.status === 'failed' && state.terminal === true) return terminalState();
254
288
  if (state.status === 'transport-ready' || state.status === 'up') {
255
289
  return { status: 'up', phase: 'transport-ready', pid: meta.pid, since: meta.startTs || null,
256
290
  ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}), ...(transport ? { transport } : {}) };
@@ -267,6 +301,10 @@ function readTunnelState(home, name) {
267
301
  return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
268
302
  }
269
303
  }
304
+ if (meta) {
305
+ const terminal = terminalState();
306
+ if (terminal) return terminal;
307
+ }
270
308
  return { status: 'down' };
271
309
  }
272
310
 
@@ -287,6 +325,13 @@ function diagnoseTunnel(home, node, state = null) {
287
325
  detail: `connessione SSH in retry${Number.isInteger(current.attempt) ? ` (tentativo ${current.attempt + 1})` : ''}`,
288
326
  hint: 'usa Test connessione per vedere la causa; verifica target SSH, chiave e porta' };
289
327
  }
328
+ if (current.reason === 'failed' || current.phase === 'failed') {
329
+ return {
330
+ stage: 'ssh', code: current.code || 'ssh-terminal-failure', status: 'down', phase: 'failed',
331
+ detail: current.detail || 'connessione SSH fermata dopo errori ripetuti',
332
+ hint: current.hint || 'correggi la causa e riavvia la connessione dalla PWA',
333
+ };
334
+ }
290
335
  return { stage: 'ssh', code: 'tunnel-stopped', status: 'down', phase: current.phase || null,
291
336
  detail: 'connessione SSH ferma', hint: 'avvia la connessione dalla PWA, Impostazioni > Nodi' };
292
337
  }
@@ -13,6 +13,7 @@ const {
13
13
 
14
14
  const MAX_HOPS = 4;
15
15
  const ROUTE_DELIMITER = '_';
16
+ const TOPOLOGY_PEER_TIMEOUT_MS = 1500;
16
17
 
17
18
  function peerFromToken(nodesPath, token) {
18
19
  const st = store.loadStore(nodesPath);
@@ -158,10 +159,16 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
158
159
  let timer;
159
160
  try {
160
161
  const ctrl = new AbortController();
161
- timer = setTimeout(() => ctrl.abort(), timeoutMs);
162
- r = await fetchImpl(`http://127.0.0.1:${port}/federation/health`, {
162
+ const request = fetchImpl(`http://127.0.0.1:${port}/federation/health`, {
163
163
  headers: { authorization: `Bearer ${token}` }, signal: ctrl.signal,
164
164
  });
165
+ const timeout = new Promise((_, reject) => {
166
+ timer = setTimeout(() => {
167
+ ctrl.abort();
168
+ const e = new Error(`health timeout (${timeoutMs}ms)`); e.name = 'AbortError'; reject(e);
169
+ }, timeoutMs);
170
+ });
171
+ r = await Promise.race([request, timeout]);
165
172
  } catch (e) {
166
173
  out.transport = 'down';
167
174
  out.status = 'down';
@@ -228,6 +235,71 @@ async function waitForHealthyPeer(opts = {}) {
228
235
  return last || { status: 'down', detail: 'peer non raggiungibile' };
229
236
  }
230
237
 
238
+ // Aggiorna lo stato Share sul hub attraverso il canale -L autenticato. Non
239
+ // legge mai il body remoto (potrebbe contenere diagnostica non sicura) e non
240
+ // include credenziali negli errori. Usato sia dal toggle interattivo sia dalla
241
+ // riconciliazione al boot.
242
+ async function notifyHubShare({ node, shared, fetchImpl = fetch, timeoutMs = 5000 }) {
243
+ if (!node || !store.isPort(node.localPort) || !store.validToken(node.token)
244
+ || typeof shared !== 'boolean') throw new Error('parametri riconciliazione Share non validi');
245
+ const ctrl = new AbortController();
246
+ const budget = Number.isInteger(timeoutMs) ? Math.max(100, Math.min(timeoutMs, 30000)) : 5000;
247
+ let timer;
248
+ try {
249
+ const request = fetchImpl(`http://127.0.0.1:${node.localPort}/federation/share`, {
250
+ method: 'POST', signal: ctrl.signal,
251
+ headers: { authorization: `Bearer ${node.token}`, 'content-type': 'application/json' },
252
+ body: JSON.stringify({ shared }),
253
+ });
254
+ const timeout = new Promise((_, reject) => {
255
+ timer = setTimeout(() => {
256
+ ctrl.abort();
257
+ const e = new Error(`hub Share timeout (${budget}ms)`); e.code = 'ETIMEDOUT'; reject(e);
258
+ }, budget);
259
+ });
260
+ const response = await Promise.race([request, timeout]);
261
+ if (!response || !response.ok) throw new Error(`hub Share HTTP ${response && response.status || 'unknown'}`);
262
+ return { shared };
263
+ } finally { clearTimeout(timer); }
264
+ }
265
+
266
+ // Il file locale contiene lo stato desiderato. Dopo un crash in qualunque
267
+ // punto del toggle, il boot ristabilisce il tunnel coerente e ripete l'update
268
+ // del hub: ON torna pubblicato, OFF revoca record stale. Tutto e' bounded.
269
+ async function reconcilePeerShare(opts = {}) {
270
+ const node = opts.node;
271
+ const shared = opts.shared === true;
272
+ if (!node || !store.validToken(node.token) || !store.NODE_ID_RE.test(String(node.nodeId || ''))) {
273
+ throw new Error('peer non associato: riconciliazione Share impossibile');
274
+ }
275
+ const fetchImpl = opts.fetchImpl || fetch;
276
+ const delay = typeof opts.delay === 'function'
277
+ ? opts.delay : (ms) => new Promise((resolve) => setTimeout(resolve, ms));
278
+ const health = await waitForHealthyPeer({
279
+ port: node.localPort, token: node.token, expectedInstanceId: node.nodeId,
280
+ fetchImpl,
281
+ attempts: Number.isInteger(opts.healthAttempts) ? opts.healthAttempts : 6,
282
+ delayMs: Number.isInteger(opts.delayMs) ? opts.delayMs : 200,
283
+ delay,
284
+ });
285
+ if (!health || health.status !== 'healthy') {
286
+ throw new Error((health && health.detail) || 'hub non raggiungibile per riconciliare Share');
287
+ }
288
+ const attempts = Number.isInteger(opts.notifyAttempts)
289
+ ? Math.max(1, Math.min(opts.notifyAttempts, 6)) : (shared ? 3 : 1);
290
+ let lastError = null;
291
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
292
+ try {
293
+ await notifyHubShare({ node, shared, fetchImpl, timeoutMs: opts.timeoutMs });
294
+ return { shared, health };
295
+ } catch (e) {
296
+ lastError = e;
297
+ if (attempt + 1 < attempts) await delay(Number.isInteger(opts.delayMs) ? opts.delayMs : 200);
298
+ }
299
+ }
300
+ throw lastError || new Error('riconciliazione Share fallita');
301
+ }
302
+
231
303
  function controlledVisited(req, ingress, instanceId) {
232
304
  const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
233
305
  const seen = raw ? raw.split(',').filter(Boolean) : [];
@@ -241,13 +313,41 @@ function controlledVisited(req, ingress, instanceId) {
241
313
  return [...seen, instanceId];
242
314
  }
243
315
 
244
- async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch }) {
316
+ async function fetchPeerTopology({ node, ttl, seen, fetchImpl, timeoutMs }) {
317
+ const ctrl = new AbortController();
318
+ const budget = Number.isFinite(timeoutMs)
319
+ ? Math.max(1, Math.min(30000, Math.floor(timeoutMs))) : TOPOLOGY_PEER_TIMEOUT_MS;
320
+ let timer;
321
+ const timeout = new Promise((_, reject) => {
322
+ timer = setTimeout(() => {
323
+ ctrl.abort();
324
+ const error = new Error(`topology peer timeout (${budget}ms)`);
325
+ error.code = 'ETIMEDOUT';
326
+ reject(error);
327
+ }, budget);
328
+ });
329
+ const request = (async () => {
330
+ const u = `http://127.0.0.1:${node.localPort}/federation/topology?ttl=${ttl - 1}&visited=${encodeURIComponent([...seen].join(','))}`;
331
+ const response = await fetchImpl(u, {
332
+ headers: { authorization: `Bearer ${node.token}` }, signal: ctrl.signal,
333
+ });
334
+ return { response, body: await response.json() };
335
+ })();
336
+ try { return await Promise.race([request, timeout]); }
337
+ finally { clearTimeout(timer); }
338
+ }
339
+
340
+ async function collectTopologyDetailed({
341
+ nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch,
342
+ timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS,
343
+ }) {
245
344
  const st = store.loadStore(nodesPath);
246
345
  if (!st) return { instanceId: null, nodes: [], authoritative: [] };
247
346
  const seen = new Set(visited.filter((x) => store.NODE_ID_RE.test(x)));
248
347
  seen.add(st.nodeId);
249
348
  const out = [];
250
349
  const authoritative = [];
350
+ const probes = [];
251
351
  for (const n of st.nodes) {
252
352
  // The local installation always keeps its outbound hub visible. Inbound
253
353
  // clients become part of Hydra only after their explicit Share toggle.
@@ -256,13 +356,15 @@ async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HO
256
356
  || (ingress && !canTransit(ingress, n))) continue;
257
357
  out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
258
358
  if (ttl <= 1 || !n.token) continue;
259
- try {
260
- const u = `http://127.0.0.1:${n.localPort}/federation/topology?ttl=${ttl - 1}&visited=${encodeURIComponent([...seen].join(','))}`;
261
- const r = await fetchImpl(u, { headers: { authorization: `Bearer ${n.token}` } });
262
- const j = await r.json();
263
- if (!r.ok || j.instanceId !== n.nodeId || !Array.isArray(j.nodes)) continue;
264
- authoritative.push(n.name);
265
- for (const child of j.nodes) {
359
+ probes.push({ n, pending: fetchPeerTopology({ node: n, ttl, seen, fetchImpl, timeoutMs }) });
360
+ }
361
+ const results = await Promise.all(probes.map(async ({ n, pending }) => {
362
+ try { return { n, ...(await pending) }; } catch (_) { return { n, response: null, body: null }; }
363
+ }));
364
+ for (const { n, response, body } of results) {
365
+ if (!response || !response.ok || body.instanceId !== n.nodeId || !Array.isArray(body.nodes)) continue;
366
+ authoritative.push(n.name);
367
+ for (const child of body.nodes) {
266
368
  if (!child || !store.NODE_ID_RE.test(child.instanceId) || child.instanceId === n.nodeId || seen.has(child.instanceId)
267
369
  || !store.NODE_NAME_RE.test(child.name)
268
370
  || !Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
@@ -271,8 +373,7 @@ async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HO
271
373
  || child.name !== child.route[child.route.length - 1]
272
374
  || child.route.includes(n.name)) continue;
273
375
  out.push({ instanceId: child.instanceId, name: child.name, route: [n.name, ...child.route], direct: false });
274
- }
275
- } catch (_) {}
376
+ }
276
377
  }
277
378
  const ids = new Set(); const routes = new Set(); const unique = [];
278
379
  for (const n of out.sort((a, b) => a.route.length - b.route.length)) {
@@ -290,8 +391,11 @@ async function collectTopology(opts) {
290
391
 
291
392
  // Local roster: live topology plus a credential-free cache of previously seen
292
393
  // transitive nodes. Stale entries are never returned by the peer endpoint.
293
- async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000) }) {
294
- const live = await collectTopologyDetailed({ nodesPath, fetchImpl });
394
+ async function collectLocalTopology({
395
+ nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000),
396
+ timeoutMs = TOPOLOGY_PEER_TIMEOUT_MS,
397
+ }) {
398
+ const live = await collectTopologyDetailed({ nodesPath, fetchImpl, timeoutMs });
295
399
  const st = store.loadStore(nodesPath);
296
400
  const directNames = new Set(((st && st.nodes) || [])
297
401
  .filter((n) => n.direction !== 'inbound' || n.shared === true)
@@ -373,7 +477,7 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
373
477
  });
374
478
  }
375
479
  }
376
- let st = store.loadOrInitStore(nodesPath);
480
+ let st = store.loadStoreStrict(nodesPath);
377
481
  const current = store.getNode(st, req.peer.name);
378
482
  if (!current) return res.status(404).json({ error: 'peer non trovato' });
379
483
  st = store.updateNode(st, current.name, {
@@ -384,7 +488,7 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
384
488
  store.atomicWriteStore(nodesPath, st);
385
489
  return res.json({ shared: body.shared });
386
490
  } catch (e) {
387
- return res.status(500).json({ error: String(e && e.message || e) });
491
+ return res.status(e.status || 500).json({ error: String(e && e.message || e), ...(e.code ? { code: e.code } : {}) });
388
492
  }
389
493
  });
390
494
  r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
@@ -434,7 +538,8 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
434
538
  function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
435
539
 
436
540
  module.exports = {
437
- MAX_HOPS, ROUTE_DELIMITER, peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
541
+ MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
542
+ peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
438
543
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
439
- probeHealth, waitForHealthyPeer,
544
+ probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare,
440
545
  };
package/lib/server.js CHANGED
@@ -156,6 +156,17 @@ function createServer(opts = {}) {
156
156
  });
157
157
  if (!tr.started && tr.reason !== 'already running') {
158
158
  process.stderr.write(`[nexuscrew] peer ${node.name} autostart failed: ${tr.reason || 'unknown'}\n`);
159
+ } else if (node.token && node.acceptToken && node.nodeId) {
160
+ // `shared` in nodes.json e' lo stato desiderato. Una riconciliazione
161
+ // asincrona chiude la finestra di crash tra write locale e ACK hub
162
+ // senza ritardare il listen del server.
163
+ const reconcileShare = cfg.reconcilePeerShareImpl || federation.reconcilePeerShare;
164
+ Promise.resolve(reconcileShare({
165
+ node, shared: node.shared === true, fetchImpl: healthFetch,
166
+ healthAttempts: 3, notifyAttempts: node.shared === true ? 3 : 1, delayMs: 200,
167
+ })).catch((e) => {
168
+ process.stderr.write(`[nexuscrew] peer ${node.name} Share reconcile pending: ${String(e && e.message || e).replace(/Bearer\s+\S+/gi, 'Bearer ***')}\n`);
169
+ });
159
170
  }
160
171
  }
161
172
  }
@@ -117,7 +117,7 @@ function createPairHandler(deps) {
117
117
 
118
118
  try {
119
119
  // --- conflict: il nome non deve gia' esistere --------------------------
120
- let st = nodesStore.loadOrInitStore(nodesPath);
120
+ let st = nodesStore.loadStoreStrict(nodesPath);
121
121
  if (st.nodes.some((n) => n.name === b.name)) {
122
122
  return fail(409, 'conflict', 'name-exists', `nodo "${b.name}" gia' presente`, {
123
123
  retryable: true, hint: 'scegli un altro nome nelle opzioni avanzate e riprova',
@@ -253,7 +253,7 @@ function createPairHandler(deps) {
253
253
  // --- tunnel-final: connessione privata, solo -L -------------------------
254
254
  // reversePort resta negoziata per un futuro Share opt-in, ma il builder
255
255
  // non emette -R finche' shared non diventa true.
256
- st = nodesStore.loadOrInitStore(nodesPath);
256
+ st = nodesStore.loadStoreStrict(nodesPath);
257
257
  st = nodesStore.updateNode(st, b.name, {
258
258
  token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
259
259
  shared: false,
@@ -317,7 +317,7 @@ function createPairHandler(deps) {
317
317
  send(res, 200, { paired: true, name: b.name, instanceId: joined.instanceId, transport: 'auto', health: { status: health.status } });
318
318
  } catch (e) {
319
319
  await rollback();
320
- fail(502, 'internal', 'unexpected', String((e && e.message) || e), {});
320
+ fail(e.status || 502, 'internal', e.code || 'unexpected', String((e && e.message) || e), {});
321
321
  }
322
322
  };
323
323
  }
@@ -46,7 +46,7 @@ function publicPeeringRoutes(deps = {}) {
46
46
  if (!st || !nodesStore.NODE_ID_RE.test(st.nodeId)) return res.status(503).json({ error: 'identita nodo non disponibile' });
47
47
  return res.json({ ok: true, instanceId: st.nodeId, proof });
48
48
  });
49
- r.post('/join', (req, res) => {
49
+ r.post('/join', async (req, res) => {
50
50
  const key = `join:${String(req.socket && req.socket.remoteAddress || 'local')}`;
51
51
  const now = Date.now();
52
52
  const recent = (attempts.get(key) || []).filter((x) => now - x < 60_000);
@@ -65,21 +65,42 @@ function publicPeeringRoutes(deps = {}) {
65
65
  if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
66
66
  return res.status(400).json({ error: 'label non valida' });
67
67
  }
68
- if (!peering.consumeInvite({ invitesPath, invite: b.invite })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
68
+ // Validate the one-time capability before conflict checks without burning
69
+ // it: a duplicate/stale peer must not destroy an otherwise reusable invite.
70
+ if (!peering.hasInvite({ invitesPath, invite: b.invite, now })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
71
+ let credential = null;
69
72
  try {
70
- const st = nodesStore.loadOrInitStore(nodesPath);
71
- if (st.nodeId === b.instanceId || st.nodes.some((n) => n.nodeId === b.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
72
- let name = b.name;
73
- for (let i = 2; nodesStore.getNode(st, name); i += 1) name = `${b.name.slice(0, 28)}-${i}`;
74
- const reversePort = peering.allocateReversePort(st.nodes);
75
- const credential = peering.createPending({ pendingPath, data: {
73
+ const st = nodesStore.loadStoreStrict(nodesPath);
74
+ const pending = peering.readInvites(pendingPath, now);
75
+ if (st.nodeId === b.instanceId || st.nodes.some((n) => n.nodeId === b.instanceId)
76
+ || pending.some((row) => row.instanceId === b.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
77
+ const reservedNames = new Set(pending.map((row) => row.name).filter(validPeerName));
78
+ if (nodesStore.getNode(st, b.name) || reservedNames.has(b.name)) {
79
+ return res.status(409).json({
80
+ error: `nome peer gia' in uso: ${b.name}`,
81
+ code: 'peer-name-conflict',
82
+ hint: 'rimuovi il record stale oppure scegli un nome univoco e riprova con lo stesso invito',
83
+ });
84
+ }
85
+ const name = b.name;
86
+ const reversePort = await peering.allocateAvailableReversePort(st.nodes, pending, {
87
+ ...(deps.createServerImpl ? { createServerImpl: deps.createServerImpl } : {}),
88
+ });
89
+ credential = peering.createPending({ pendingPath, data: {
76
90
  name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
77
91
  shared: false,
78
92
  label: nodesStore.sanitizeLabel(b.label, name),
79
93
  ...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
80
94
  } });
95
+ if (!peering.consumeInvite({ invitesPath, invite: b.invite, now })) {
96
+ peering.consumePending({ pendingPath, credential, now });
97
+ return res.status(410).json({ error: 'invito scaduto o gia usato' });
98
+ }
81
99
  res.json({ paired: true, instanceId: st.nodeId, reversePort, credential, roles: readRoles(configPath) });
82
- } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
100
+ } catch (e) {
101
+ if (credential) try { peering.consumePending({ pendingPath, credential, now }); } catch (_) {}
102
+ res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) });
103
+ }
83
104
  });
84
105
  r.post('/confirm', (req, res) => {
85
106
  const b = req.body || {};
@@ -91,8 +112,15 @@ function publicPeeringRoutes(deps = {}) {
91
112
  return res.status(410).json({ error: 'pairing pending scaduto o gia usato' });
92
113
  }
93
114
  try {
94
- let st = nodesStore.loadOrInitStore(nodesPath);
115
+ let st = nodesStore.loadStoreStrict(nodesPath);
95
116
  if (st.nodeId === pending.instanceId || st.nodes.some((n) => n.nodeId === pending.instanceId)) return res.status(409).json({ error: 'peer duplicato' });
117
+ if (st.nodes.some((n) => n.name === pending.name || n.localPort === pending.reversePort)) {
118
+ return res.status(409).json({
119
+ error: 'allocazione pairing non piu disponibile',
120
+ code: 'pairing-allocation-conflict',
121
+ hint: 'ripeti il pairing: verra negoziata una nuova porta reverse',
122
+ });
123
+ }
96
124
  st = nodesStore.addNode(st, {
97
125
  name: pending.name, remotePort: pending.remotePort, localPort: pending.reversePort,
98
126
  direction: 'inbound', transport: 'inbound', autostart: true,
@@ -104,7 +132,7 @@ function publicPeeringRoutes(deps = {}) {
104
132
  });
105
133
  nodesStore.atomicWriteStore(nodesPath, st);
106
134
  res.json({ confirmed: true });
107
- } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
135
+ } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) }); }
108
136
  });
109
137
  r.post('/cancel', (req, res) => {
110
138
  const b = req.body || {};