@mmmbuto/nexuscrew 0.8.18 → 0.8.20

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  const crypto = require('node:crypto');
4
4
  const fs = require('node:fs');
5
+ const net = require('node:net');
5
6
  const path = require('node:path');
6
7
  const store = require('./store.js');
7
8
 
@@ -132,9 +133,14 @@ function consumeInvite({ invitesPath, invite, now = Date.now() }) {
132
133
  return true;
133
134
  }
134
135
 
135
- function allocateReversePort(nodes) {
136
+ function hasInvite({ invitesPath, invite, now = Date.now() }) {
137
+ const hash = crypto.createHash('sha256').update(String(invite || '')).digest('hex');
138
+ return readInvites(invitesPath, now).some((row) => safeEqual(row.hash, hash));
139
+ }
140
+
141
+ function allocateReversePort(nodes, reservations = []) {
136
142
  const used = new Set();
137
- for (const n of nodes || []) {
143
+ for (const n of [...(nodes || []), ...(reservations || [])]) {
138
144
  if (store.isPort(n.localPort)) used.add(n.localPort);
139
145
  if (store.isPort(n.reversePort)) used.add(n.reversePort);
140
146
  }
@@ -144,6 +150,41 @@ function allocateReversePort(nodes) {
144
150
  return p;
145
151
  }
146
152
 
153
+ function canBindReversePort(port, createServerImpl = net.createServer) {
154
+ return new Promise((resolve, reject) => {
155
+ const server = createServerImpl();
156
+ let settled = false;
157
+ const finish = (value, error = null) => {
158
+ if (settled) return;
159
+ settled = true;
160
+ server.removeAllListeners?.();
161
+ if (error) reject(error); else resolve(value);
162
+ };
163
+ server.once('error', (error) => {
164
+ if (error && (error.code === 'EADDRINUSE' || error.code === 'EACCES')) finish(false);
165
+ else finish(false, error);
166
+ });
167
+ server.once('listening', () => {
168
+ if (typeof server.unref === 'function') server.unref();
169
+ try { server.close(() => finish(true)); } catch (error) { finish(false, error); }
170
+ });
171
+ server.listen({ host: '127.0.0.1', port, exclusive: true });
172
+ });
173
+ }
174
+
175
+ // The reverse listener will be owned by sshd, but allocation happens on the
176
+ // hub. Probe the real loopback bind before issuing a candidate so a stale or
177
+ // unrelated listener absent from nodes.json cannot be reallocated blindly.
178
+ async function allocateAvailableReversePort(nodes, reservations = [], opts = {}) {
179
+ const rejected = [];
180
+ while (rejected.length < (65535 - REVERSE_PORT_BASE)) {
181
+ const candidate = allocateReversePort(nodes, [...reservations, ...rejected]);
182
+ if (await canBindReversePort(candidate, opts.createServerImpl || net.createServer)) return candidate;
183
+ rejected.push({ localPort: candidate });
184
+ }
185
+ throw new Error('no reverse port available');
186
+ }
187
+
147
188
  function createPending({ pendingPath, data, now = Date.now() }) {
148
189
  const rows = readInvites(pendingPath, now);
149
190
  const credential = crypto.randomBytes(32).toString('base64url');
@@ -269,6 +310,7 @@ async function probeTransportReady({
269
310
  module.exports = {
270
311
  INVITE_TTL_MS, REVERSE_PORT_BASE, defaultInvitesPath, defaultPendingPath, safeEqual,
271
312
  readInvites, writeInvites, encodePairing, decodePairing, parsePairingUrl,
272
- createInvite, consumeInvite, allocateReversePort, createPending, consumePending,
313
+ createInvite, consumeInvite, hasInvite, allocateReversePort, canBindReversePort,
314
+ allocateAvailableReversePort, createPending, consumePending,
273
315
  capabilityIdentity, capabilityProbeMaterial, probeTransportReady,
274
316
  };
@@ -9,7 +9,8 @@
9
9
  // - nessun token in output redatto (redactStore/redactNode)
10
10
  //
11
11
  // Modulo PURO al parse: parseStore/parseNode non toccano il filesystem.
12
- // Tutto l'I/O vive in loadStore/atomicWriteStore/loadOrInitStore/migrateLegacyNodes.
12
+ // Tutto l'I/O vive in loadStore/loadStoreStrict/initStore/atomicWriteStore/
13
+ // migrateLegacyNodes.
13
14
  // Principio fail-closed: qualunque dato malformato -> null (parse) o throw
14
15
  // esplicito (mutazioni/write), mai un default silenzioso.
15
16
  const fs = require('node:fs');
@@ -227,12 +228,15 @@ function parseStore(raw) {
227
228
 
228
229
  const names = new Set();
229
230
  const ids = new Set();
231
+ const localPorts = new Set();
230
232
  const nodes = [];
231
233
  for (const raw2 of d.nodes) {
232
234
  const node = parseNode(raw2, d.schemaVersion);
233
235
  if (!node) return null;
234
236
  if (names.has(node.name)) return null; // name univoco
235
237
  names.add(node.name);
238
+ if (localPorts.has(node.localPort)) return null; // ogni listener locale ha un solo owner
239
+ localPorts.add(node.localPort);
236
240
  if (node.nodeId) {
237
241
  if (node.nodeId === d.nodeId) return null; // self-reference nei dati salvati
238
242
  if (ids.has(node.nodeId)) return null; // nodeId remoto univoco
@@ -306,22 +310,56 @@ function emptyStore(nodeId) {
306
310
  return { schemaVersion: SCHEMA_VERSION, nodeId: nodeId || newNodeId(), nodes: [] };
307
311
  }
308
312
 
309
- // loadOrInitStore(p): store esistente, oppure ne crea uno vuoto (nodeId fresco,
310
- // STABILE una volta scritto). Se il file esiste ma e' invalido -> throw esplicito
311
- // (mai sovrascrivere/mascherare corruzione o un file altrui).
312
- function loadOrInitStore(p) {
313
+ function storeUnavailable(code, message) {
314
+ const e = new Error(message);
315
+ e.status = 503;
316
+ e.code = code;
317
+ return e;
318
+ }
319
+
320
+ // Runtime strict: nodes.json e' identita' + credential store. Se sparisce non
321
+ // deve MAI essere rigenerato implicitamente da una route/command: un nuovo
322
+ // nodeId farebbe apparire l'installazione come un altro dispositivo e
323
+ // maschererebbe una perdita di stato. Solo `nexuscrew init` usa initStore().
324
+ function loadStoreStrict(p) {
313
325
  let st;
314
326
  try { st = fs.lstatSync(p); }
315
327
  catch (e) {
316
- if (e.code === 'ENOENT') return atomicWriteStore(p, emptyStore());
328
+ if (e.code === 'ENOENT') {
329
+ throw storeUnavailable('NODES_STORE_MISSING',
330
+ `nodes.json assente (${p}): esegui \`nexuscrew init\` per inizializzare esplicitamente lo store`);
331
+ }
317
332
  throw e;
318
333
  }
319
- if (st.isSymbolicLink()) throw new Error('nodes.json e\' un symlink (rifiutato)');
334
+ if (st.isSymbolicLink()) {
335
+ throw storeUnavailable('NODES_STORE_INVALID', 'nodes.json e\' un symlink (rifiutato)');
336
+ }
337
+ if (!st.isFile()) {
338
+ throw storeUnavailable('NODES_STORE_INVALID', 'nodes.json non e\' un file regolare');
339
+ }
320
340
  const parsed = loadStore(p);
321
- if (!parsed) throw new Error('nodes.json presente ma invalido (schema strict): correggi o rimuovi il file');
341
+ if (!parsed) {
342
+ throw storeUnavailable('NODES_STORE_INVALID',
343
+ 'nodes.json presente ma invalido (schema strict): ripristina un backup valido; non viene sovrascritto');
344
+ }
322
345
  return parsed;
323
346
  }
324
347
 
348
+ // Init esplicito e idempotente. E' l'unico percorso autorizzato a creare uno
349
+ // store mancante; un file presente ma invalido resta fail-closed.
350
+ function initStore(p) {
351
+ try { fs.lstatSync(p); }
352
+ catch (e) {
353
+ if (e.code === 'ENOENT') return atomicWriteStore(p, emptyStore());
354
+ throw e;
355
+ }
356
+ return loadStoreStrict(p);
357
+ }
358
+
359
+ // Compatibilita' API interna: il vecchio nome non inizializza piu' a runtime.
360
+ // Tenerlo strict impedisce che un call-site dimenticato reintroduca F1.
361
+ function loadOrInitStore(p) { return loadStoreStrict(p); }
362
+
325
363
  // --- Mutazioni pure (ritornano un nuovo store; il caller lo scrive) ---------
326
364
 
327
365
  function getNode(store, name) {
@@ -334,6 +372,9 @@ function addNode(store, entry) {
334
372
  if (store.nodes.some((n) => n.name === node.name)) {
335
373
  throw new Error(`nodo duplicato: name "${node.name}" gia' presente`);
336
374
  }
375
+ if (store.nodes.some((n) => n.localPort === node.localPort)) {
376
+ throw new Error(`nodo duplicato: localPort ${node.localPort} gia' assegnata`);
377
+ }
337
378
  if (node.nodeId) {
338
379
  if (node.nodeId === store.nodeId) throw new Error('self-reference: il nodeId coincide con questa installazione');
339
380
  if (store.nodes.some((n) => n.nodeId === node.nodeId)) {
@@ -425,7 +466,7 @@ function migrateLegacyNodes(configPath, nodesPath) {
425
466
  if (!cfg || typeof cfg !== 'object' || !Array.isArray(cfg.nodes) || cfg.nodes.length === 0) {
426
467
  return { migrated: false, count: 0, reason: 'nessun campo nodes legacy in config.json' };
427
468
  }
428
- const store = loadOrInitStore(nodesPath);
469
+ const store = initStore(nodesPath);
429
470
  if (store.nodes.length > 0) {
430
471
  return { migrated: false, count: 0, reason: 'nodes.json gia\' popolato (no overwrite)' };
431
472
  }
@@ -498,7 +539,7 @@ module.exports = {
498
539
  // parse/validate
499
540
  parseStore, parseNode, parseRendezvous, parseRoles, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
500
541
  // I/O
501
- defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
542
+ defaultNodesPath, loadStore, loadStoreStrict, initStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
502
543
  // mutazioni
503
544
  getNode, addNode, removeNode, setNodeToken, updateNode,
504
545
  // redazione
@@ -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 || ''}`);
@@ -209,7 +209,18 @@ function classifySshFailure(text, remotePort) {
209
209
  return { code: 'ssh-config', detail: 'configurazione SSH non utilizzabile', hint: 'verifica permessi e opzioni del file ~/.ssh/config' };
210
210
  }
211
211
  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' };
212
+ if (/Address already in use|cannot listen|bind .*failed/i.test(s)) {
213
+ return {
214
+ code: 'reverse-forward-bind',
215
+ detail: 'il canale inverso non può aprire la porta sul nodo hub perché è già occupata',
216
+ hint: 'verifica un precedente listener SSH sulla stessa porta e l’eventuale doppio supervisor',
217
+ };
218
+ }
219
+ return {
220
+ code: 'reverse-forward-failed',
221
+ detail: 'il canale inverso non è stato aperto dal nodo hub',
222
+ hint: 'la porta può essere già occupata oppure il server può vietare il forwarding; verifica listener e policy SSH sul nodo hub',
223
+ };
213
224
  }
214
225
  if (/Could not request local forwarding|Address already in use/i.test(s)) {
215
226
  return { code: 'local-forward-bind', detail: 'la porta locale scelta per il tunnel non è disponibile', hint: 'ferma il tunnel precedente e riprova' };
@@ -245,12 +256,27 @@ function tunnelStatePath(home, name) {
245
256
  function readTunnelState(home, name) {
246
257
  const p = tunnelPidPath(home, name);
247
258
  const meta = pidf.readPidfile(p);
259
+ const terminalState = () => {
260
+ try {
261
+ const state = JSON.parse(fs.readFileSync(tunnelStatePath(home, name), 'utf8'));
262
+ const owned = meta && state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
263
+ if (!owned || state.status !== 'failed' || state.terminal !== true) return null;
264
+ return {
265
+ status: 'down', phase: 'failed', reason: 'failed', pid: meta.pid, since: meta.startTs || null,
266
+ ...(typeof state.code === 'string' ? { code: state.code } : {}),
267
+ ...(typeof state.detail === 'string' ? { detail: state.detail } : {}),
268
+ ...(typeof state.hint === 'string' ? { hint: state.hint } : {}),
269
+ ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}),
270
+ };
271
+ } catch (_) { return null; }
272
+ };
248
273
  if (meta && pidf.isAlive(meta)) {
249
274
  try {
250
275
  const state = JSON.parse(fs.readFileSync(tunnelStatePath(home, name), 'utf8'));
251
276
  const transport = typeof state.transport === 'string' ? state.transport : undefined;
252
277
  const owned = state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
253
278
  if (!owned) return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
279
+ if (state.status === 'failed' && state.terminal === true) return terminalState();
254
280
  if (state.status === 'transport-ready' || state.status === 'up') {
255
281
  return { status: 'up', phase: 'transport-ready', pid: meta.pid, since: meta.startTs || null,
256
282
  ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}), ...(transport ? { transport } : {}) };
@@ -267,6 +293,10 @@ function readTunnelState(home, name) {
267
293
  return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
268
294
  }
269
295
  }
296
+ if (meta) {
297
+ const terminal = terminalState();
298
+ if (terminal) return terminal;
299
+ }
270
300
  return { status: 'down' };
271
301
  }
272
302
 
@@ -287,6 +317,13 @@ function diagnoseTunnel(home, node, state = null) {
287
317
  detail: `connessione SSH in retry${Number.isInteger(current.attempt) ? ` (tentativo ${current.attempt + 1})` : ''}`,
288
318
  hint: 'usa Test connessione per vedere la causa; verifica target SSH, chiave e porta' };
289
319
  }
320
+ if (current.reason === 'failed' || current.phase === 'failed') {
321
+ return {
322
+ stage: 'ssh', code: current.code || 'ssh-terminal-failure', status: 'down', phase: 'failed',
323
+ detail: current.detail || 'connessione SSH fermata dopo errori ripetuti',
324
+ hint: current.hint || 'correggi la causa e riavvia la connessione dalla PWA',
325
+ };
326
+ }
290
327
  return { stage: 'ssh', code: 'tunnel-stopped', status: 'down', phase: current.phase || null,
291
328
  detail: 'connessione SSH ferma', hint: 'avvia la connessione dalla PWA, Impostazioni > Nodi' };
292
329
  }
@@ -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
  }
@@ -175,11 +186,21 @@ function createServer(opts = {}) {
175
186
  try {
176
187
  const sessions = await listSessions(cfg.tmuxBin);
177
188
  const sum = watcher.getSummary();
178
- const enriched = await Promise.all(sessions.map(async (s) => ({
179
- ...s,
180
- outbox: sum[s.name] || { count: 0, latest: 0 },
181
- preview: await previews.get(s.name),
182
- })));
189
+ const enriched = await Promise.all(sessions.map(async (s) => {
190
+ const sample = await previews.getState(s.name);
191
+ // Pi keeps a static "π - ..." terminal title, so only that client uses
192
+ // the capture fallback. This prevents a quoted "• Working (...)" line
193
+ // inside another agent's transcript from becoming a false positive.
194
+ const piTitle = /^(?:π|pi)(?:\s+-|$)/iu.test(s.paneTitle);
195
+ const working = s.working === true || (piTitle && sample?.working === true);
196
+ return {
197
+ ...s,
198
+ working,
199
+ status: working ? (s.status || sample?.status || '') : '',
200
+ outbox: sum[s.name] || { count: 0, latest: 0 },
201
+ preview: sample?.preview ?? null,
202
+ };
203
+ }));
183
204
  res.json({ sessions: enriched });
184
205
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
185
206
  });
@@ -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
  }