@mmmbuto/nexuscrew 0.9.4 → 0.9.5

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.
@@ -21,6 +21,24 @@ const ownershipGraceMs = Number.isFinite(ownershipGraceRaw) && ownershipGraceRaw
21
21
  const reverseFailureMaxRaw = Number(process.env.NEXUSCREW_TUNNEL_REVERSE_FAILURE_MAX || 8);
22
22
  const reverseFailureMax = Number.isInteger(reverseFailureMaxRaw) && reverseFailureMaxRaw >= 1
23
23
  ? Math.min(reverseFailureMaxRaw, 32) : 8;
24
+ // R19 seguito — contratto per la sonda del canale -L quando non conclude MAI:
25
+ // continuare a sondare ogni 250ms e' giusto per la finestra transitoria (il
26
+ // servizio remoto non e' ancora su dopo un restart/aggiornamento: la prossima
27
+ // sonda e' una connessione FRESCA, si qualifica da sola appena il servizio
28
+ // risponde) ma sbagliato oltre — un vero permitopen mancante o un servizio
29
+ // remoto permanentemente giu' non si risolvono mai da soli, e la sonda gira
30
+ // per sempre senza mai diventare osservabile ne' degradare, la stessa cosa
31
+ // che l'assenza di storia delle transizioni peer faceva prima di questo
32
+ // registro. 60 tentativi * 250ms = 15s, lo stesso budget di attesa gia' in
33
+ // uso altrove in questo prodotto per "il runtime e' tornato sano dopo un
34
+ // riavvio" (vedi healthAttempts/healthDelayMs in lib/update/runner.js): oltre
35
+ // quella soglia il canale entra in degraded, ESATTAMENTE come il fallimento
36
+ // del forward inverso — stesso stato, stessa auto-guarigione a cadenza fissa,
37
+ // mai un "pronto" dichiarato senza averlo verificato (sarebbe la stessa bugia
38
+ // che R19 ha tolto, solo con una bandiera "non verificato" appesa sopra).
39
+ const channelProbeMaxRaw = Number(process.env.NEXUSCREW_TUNNEL_CHANNEL_PROBE_MAX || 60);
40
+ const channelProbeMax = Number.isInteger(channelProbeMaxRaw) && channelProbeMaxRaw >= 1
41
+ ? Math.min(channelProbeMaxRaw, 240) : 60;
24
42
  // Once the initial reverse-forward budget is exhausted the supervisor does NOT
25
43
  // die: it stays "degraded" and retries at the fixed production cadence of 60s,
26
44
  // so a transient reverse listener on the hub (e.g. a mobile reconnect) heals on
@@ -32,7 +50,7 @@ const steadyRetryMsRaw = Number(steadyRetryTestMode ? (process.env.NEXUSCREW_TUN
32
50
  const steadyRetryMinMs = steadyRetryTestMode ? 1 : 60000;
33
51
  const steadyRetryMs = Number.isFinite(steadyRetryMsRaw) && steadyRetryMsRaw >= steadyRetryMinMs
34
52
  ? Math.min(steadyRetryMsRaw, 120000) : 60000;
35
- if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
53
+ if (require.main === module && (!sshBin || !statePath || !pidPath || !runId)) process.exit(2);
36
54
 
37
55
  let child = null;
38
56
  let stopping = false;
@@ -44,18 +62,25 @@ let forwardSocket = null;
44
62
  let ownershipWaitTimer = null;
45
63
  let ownershipTimer = null;
46
64
  let reverseFailures = 0;
65
+ let channelRefusedLogged = false;
66
+ let channelProbeFailures = 0;
47
67
 
48
68
  function localForwardPort(args) {
69
+ return localForwardPorts(args)[0] || null;
70
+ }
71
+ function localForwardPorts(args) {
72
+ const ports = [];
49
73
  for (let i = 0; i < args.length - 1; i += 1) {
50
74
  if (args[i] !== '-L') continue;
51
- const match = String(args[i + 1] || '').match(/^127\.0\.0\.1:(\d+):127\.0\.0\.1:\d+$/);
75
+ const match = String(args[i + 1] || '').match(/^127\.0\.0\.1:(\d+):127\.0\.0\.1:(\d+)$/);
52
76
  const port = match ? Number(match[1]) : 0;
53
- if (Number.isInteger(port) && port >= 1 && port <= 65535) return port;
77
+ if (Number.isInteger(port) && port >= 1 && port <= 65535 && !ports.includes(port)) ports.push(port);
54
78
  }
55
- return null;
79
+ return ports;
56
80
  }
57
81
 
58
82
  const forwardPort = localForwardPort(sshArgs);
83
+ const forwardPorts = localForwardPorts(sshArgs);
59
84
 
60
85
  function reverseForwardPort(args) {
61
86
  for (let i = 0; i < args.length - 1; i += 1) {
@@ -86,6 +111,109 @@ function clearForwardProbe() {
86
111
  // blocked connecting to an unreachable endpoint. Opening the local -L socket
87
112
  // forces OpenSSH to establish the real forward channel. Only that event may
88
113
  // advertise transport-ready or reset retry backoff.
114
+ //
115
+ // R19: connect NON è prova di canale. OpenSSH accetta la TCP sul listener
116
+ // locale SUBITO e chiede il canale al server DOPO: se il server lo nega
117
+ // (permitopen senza quella destinazione) il socket viene CHIUSO nel giro di
118
+ // millisecondi — bind locale riuscito, canale morto. Una finestra di grazia
119
+ // dopo connect distingue le due metà: «il canale è aperto» da «il server ha
120
+ // rifiutato». Ready senza questa prova misura la metà comoda.
121
+ const CHANNEL_GRACE_MS = 350;
122
+
123
+ // Pura (con net iniettabile): per ogni porta locale -L riporta
124
+ // 'channel-ok' | 'channel-refused' | 'bind-failed'.
125
+ function probeForwardChannels({ ports, graceMs = CHANNEL_GRACE_MS, connect = net.connect } = {}) {
126
+ return new Promise((resolveOuter) => {
127
+ const esiti = new Map();
128
+ const sockets = [];
129
+ let pending = ports.length;
130
+ if (!pending) return resolveOuter(esiti);
131
+ const settle = (port, esito, sock) => {
132
+ if (!esiti.has(port)) {
133
+ esiti.set(port, esito);
134
+ if (sock) { try { sock.destroy(); } catch (_) {} }
135
+ pending -= 1;
136
+ if (pending <= 0) resolveOuter(esiti);
137
+ }
138
+ };
139
+ for (const port of ports) {
140
+ const sock = connect({ host: '127.0.0.1', port });
141
+ sockets.push(sock);
142
+ let connected = false;
143
+ let graceTimer = null;
144
+ sock.setTimeout(1000);
145
+ sock.once('connect', () => {
146
+ connected = true;
147
+ // La finestra che decide: se entro graceMs il socket muore, il canale
148
+ // è stato negato dall'altro capo (o chiuso da lui): non è nostro.
149
+ graceTimer = setTimeout(() => settle(port, 'channel-ok', sock), graceMs);
150
+ });
151
+ const morte = () => {
152
+ if (graceTimer) clearTimeout(graceTimer);
153
+ settle(port, connected ? 'channel-refused' : 'bind-failed', sock);
154
+ };
155
+ sock.once('close', morte);
156
+ sock.once('error', morte);
157
+ sock.once('timeout', morte);
158
+ }
159
+ });
160
+ }
161
+
162
+ // R19 punto 3: chi ha installato la chiave quando il pannello non esisteva è
163
+ // rotto e NON lo sapeva. Il prodotto lo DICE, con la riga da sostituire: le
164
+ // destinazioni arrivano dagli `-L`, la pubblica si DERIVA dalla privata
165
+ // indicata da `-i`. Quando non si riesce a derivarla, dice comunque COSA
166
+ // aggiungere — mai una riga a metà.
167
+ // La riga e' un DATO, non una frase: chi la deve mostrare non deve ritagliarla
168
+ // da un testo costruito qui. `hint` resta per chi legge un log;
169
+ // `authorizedKeys` e' il campo che la UI consuma, e resta vuoto quando la riga
170
+ // completa non si puo' comporre.
171
+ // I DUE RAMI NON DICONO LA STESSA COSA, e prima la dicevano: senza la chiave
172
+ // pubblica si costruiva un FRAMMENTO ("aggiungi a permitopen: ...") e lo si
173
+ // infilava nella frase "Riga da usare (SOSTITUISCI quella esistente)". Chi
174
+ // avesse obbedito avrebbe sostituito una riga valida con mezza riga, rompendo
175
+ // l'accesso invece di ripararlo: una promessa falsa che peggiora il guasto.
176
+ // Con la chiave: riga completa, sostituzione, ed e' anche il campo copiabile.
177
+ // Senza: si dice che la chiave non e' identificabile e si chiede una modifica
178
+ // A MANO della riga esistente, mostrando solo le destinazioni da aggiungere.
179
+ function refusalDetails({ remoteDestinations, identityFile } = {}) {
180
+ const dests = (remoteDestinations || []).join('",permitopen="');
181
+ const pub = require('./tunnel.js').readPublicKey(identityFile);
182
+ const premessa = 'canale rifiutato dal NODO remoto: la chiave in ~/.ssh/authorized_keys non autorizza queste destinazioni.';
183
+ if (!pub) {
184
+ // Due situazioni diverse, e il messaggio non deve confonderle — ne'
185
+ // inventare una causa che non conosce. Senza `-i` la chiave non e'
186
+ // dichiarata affatto. Con `-i`, la derivazione dalla privata puo' fallire
187
+ // per piu' motivi (privata assente o illeggibile, protetta da passphrase,
188
+ // `ssh-keygen` non disponibile) e da qui non si distinguono: si elencano,
189
+ // invece di sceglierne uno a caso. Dire «la parte pubblica non e'
190
+ // leggibile» a chi ha una chiave cifrata col suo `.pub` accanto e' falso,
191
+ // e manda a cercare il problema dove non e'.
192
+ const causa = identityFile
193
+ ? `non riesco a derivare la chiave pubblica da ${path.basename(identityFile)}`
194
+ + ' (privata assente o illeggibile, protetta da passphrase, oppure ssh-keygen non disponibile)'
195
+ : 'nessun -i: ssh usa le chiavi di default, un agent o la config, e da qui non si sa quale';
196
+ return {
197
+ hint: `${premessa} NON posso identificare la chiave da correggere — ${causa}`
198
+ + `. Modifica A MANO la riga di quella chiave aggiungendo queste destinazioni: permitopen="${dests}"`,
199
+ authorizedKeys: '',
200
+ };
201
+ }
202
+ const riga = `restrict,port-forwarding,permitopen="${dests}",command="/bin/false" ${pub}`;
203
+ // NON diciamo «quella con cui il supervisor si autentica»: `-i` DICHIARA
204
+ // un'identita', non la impone — senza `IdentitiesOnly=yes` OpenSSH puo'
205
+ // comunque usare l'agent o la config, e il canale essere autenticato da
206
+ // un'altra chiave. Affermarlo sarebbe una promessa che il comando non
207
+ // mantiene, e manderebbe a sostituire la riga sbagliata.
208
+ const quale = identityFile ? ` (chiave DICHIARATA per questo nodo: ${path.basename(identityFile)}, non un'eventuale chiave "jump" dello stesso nodo; se ssh ne sceglie un'altra via agent o config, la riga da correggere e' quella)` : '';
209
+ return {
210
+ hint: `${premessa} Riga da usare (SOSTITUISCI quella esistente, non aggiungerne una seconda)${quale}: ${riga}`,
211
+ authorizedKeys: riga,
212
+ };
213
+ }
214
+
215
+ function refusalHint(opts) { return refusalDetails(opts).hint; }
216
+
89
217
  function probeForward(expectedChild) {
90
218
  if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
91
219
  if (!forwardPort) {
@@ -99,29 +227,72 @@ function probeForward(expectedChild) {
99
227
  if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'reverse-forward' })) stop();
100
228
  return;
101
229
  }
102
- let settled = false;
103
- const socket = net.connect({ host: '127.0.0.1', port: forwardPort });
104
- forwardSocket = socket;
105
- const done = (ready) => {
106
- if (settled) return;
107
- settled = true;
108
- try { socket.destroy(); } catch (_) {}
109
- if (forwardSocket === socket) forwardSocket = null;
230
+ const ports = forwardPorts.length ? forwardPorts : [forwardPort];
231
+ probeForwardChannels({ ports, graceMs: CHANNEL_GRACE_MS }).then((esiti) => {
110
232
  if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
111
- if (ready) {
233
+ const rifiutate = ports.filter((p) => esiti.get(p) === 'channel-refused');
234
+ const mancanti = ports.filter((p) => esiti.get(p) === 'bind-failed');
235
+ if (!rifiutate.length && !mancanti.length) {
112
236
  attempt = 0;
113
237
  reverseFailures = 0;
114
- logEvent(`forward ready stableMs=${stableMs}`);
115
- if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'tcp-forward' })) stop();
238
+ channelProbeFailures = 0;
239
+ logEvent(`forward ready stableMs=${stableMs} channel=verified`);
240
+ if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'tcp-forward-verified' })) stop();
116
241
  return;
117
242
  }
118
- if (!writeState('transport-probing', { sshPid: child.pid, stableMs })) return stop();
243
+ if (rifiutate.length && !channelRefusedLogged) {
244
+ // Una volta per generazione: il ciclo di probing continua (la
245
+ // concessione può essere aggiornata), ma chi guarda SA qual è la metà
246
+ // malata e COSA sostituire.
247
+ channelRefusedLogged = true;
248
+ logEvent(refusalHint({ remoteDestinations: remoteDestinationsOf(sshArgs), identityFile: identityFileOf(sshArgs) }));
249
+ }
250
+ channelProbeFailures += 1;
251
+ if (channelProbeFailures > channelProbeMax) {
252
+ // Budget esaurito: la finestra transitoria (servizio remoto non ancora
253
+ // su) aveva 15s per qualificarsi da sola con una sonda fresca ogni
254
+ // volta — non l'ha fatto. Da qui in poi non e' piu' "sta per succedere",
255
+ // e continuare a sondare ogni 250ms sarebbe la stessa sonda che gira
256
+ // per sempre senza mai diventare osservabile. Stesso stato del forward
257
+ // inverso: degraded, vivo, si riprova a cadenza fissa — mai "pronto"
258
+ // dichiarato senza averlo verificato.
259
+ return enterDegraded({
260
+ code: 'forward-channel-blocked',
261
+ detail: rifiutate.length
262
+ ? `canale rifiutato dal nodo remoto dopo ${channelProbeFailures} sonde`
263
+ : `nessun canale locale disponibile dopo ${channelProbeFailures} sonde`,
264
+ ...(rifiutate.length
265
+ ? (() => {
266
+ const d = refusalDetails({ remoteDestinations: remoteDestinationsOf(sshArgs), identityFile: identityFileOf(sshArgs) });
267
+ return { hint: d.hint, ...(d.authorizedKeys ? { authorizedKeys: d.authorizedKeys } : {}) };
268
+ })()
269
+ : {}),
270
+ });
271
+ }
272
+ if (!writeState('transport-probing', {
273
+ sshPid: child.pid, stableMs,
274
+ ...(rifiutate.length ? { probeDetail: 'channel-refused', refusedLocalPorts: rifiutate } : {}),
275
+ ...(mancanti.length ? { probeDetail: 'bind-failed', missingLocalPorts: mancanti } : {}),
276
+ })) return stop();
119
277
  forwardProbeTimer = setTimeout(() => probeForward(expectedChild), 250);
120
- };
121
- socket.setTimeout(1000);
122
- socket.once('connect', () => done(true));
123
- socket.once('error', () => done(false));
124
- socket.once('timeout', () => done(false));
278
+ });
279
+ }
280
+
281
+ // Le destinazioni REMOTE degli -L, nella forma che vive in permitopen.
282
+ function remoteDestinationsOf(args) {
283
+ const out = [];
284
+ for (let i = 0; i < args.length - 1; i += 1) {
285
+ if (args[i] !== '-L') continue;
286
+ const m = String(args[i + 1] || '').match(/^127\.0\.0\.1:\d+:(127\.0\.0\.1:\d+)$/);
287
+ if (m && !out.includes(m[1])) out.push(m[1]);
288
+ }
289
+ return out;
290
+ }
291
+ function identityFileOf(args) {
292
+ for (let i = 0; i < args.length - 1; i += 1) {
293
+ if (args[i] === '-i') return args[i + 1];
294
+ }
295
+ return null;
125
296
  }
126
297
 
127
298
  function ownsGeneration() {
@@ -169,10 +340,21 @@ function enterDegraded(diagnosis) {
169
340
  // it cannot identify the process holding a listener on the hub. Never turn
170
341
  // local pidfile ownership into a false remote-listener attribution.
171
342
  const ownership = 'unknown';
172
- logEvent(`ssh degraded code=${safe.code} reversePort=${reversePort || 'none'} ownership=${ownership} failures=${reverseFailures} steadyRetryMs=${steadyRetryMs}`);
343
+ // Il contatore che si logga dipende da QUALE budget si e' esaurito: il
344
+ // canale -L (channelProbeFailures) e il forward inverso (reverseFailures)
345
+ // sono guasti indipendenti sullo stesso processo (vedi CHANNEL_GRACE_MS
346
+ // sopra) — loggare sempre reverseFailures direbbe "0" su un degraded di
347
+ // canale, come se non fosse mai stato provato.
348
+ const failures = safe.code === 'forward-channel-blocked' ? channelProbeFailures : reverseFailures;
349
+ logEvent(`ssh degraded code=${safe.code} reversePort=${reversePort || 'none'} ownership=${ownership} failures=${failures} steadyRetryMs=${steadyRetryMs}`);
173
350
  if (!writeState('degraded', {
174
351
  code: safe.code, detail: safe.detail,
175
352
  ...(safe.hint ? { hint: safe.hint } : {}),
353
+ // Il campo viaggia insieme alla frase, non al posto suo: chi legge un log
354
+ // vuole l'hint, la UI vuole la riga intera. Scriverne solo uno qui e' il
355
+ // modo in cui il campo si perde a meta' strada e tutto il resto della
356
+ // catena, gia' cablato, riceve per sempre il fallback testuale.
357
+ ...(safe.authorizedKeys ? { authorizedKeys: safe.authorizedKeys } : {}),
176
358
  reversePort, ownership, steadyRetryMs, terminal: false,
177
359
  })) return stop();
178
360
  // Keep the backoff counter honest for observers; the degraded retry is fixed.
@@ -180,19 +362,70 @@ function enterDegraded(diagnosis) {
180
362
  retryTimer = setTimeout(run, steadyRetryMs);
181
363
  }
182
364
 
365
+ // R19 seguito, secondo difetto (2026-08-17, audit su develop@437d29f):
366
+ // enterDegraded per il canale -L riusava la macchina del forward inverso
367
+ // senza la sua precondizione implicita. Nel reverse failure `child` e' GIA'
368
+ // null quando enterDegraded gira (handleFailure lo azzera PRIMA, perche' e'
369
+ // il crash del processo l'evento che ci porta li'). Nel canale -L rifiutato
370
+ // il processo NON e' morto — e' il canale a essere negato — quindi `child`
371
+ // e' ancora vivo quando enterDegraded schedula run() per la prossima
372
+ // generazione. run() faceva `child = spawn(...)` incondizionatamente: la
373
+ // vecchia generazione, ancora viva, restava senza piu' nessuna variabile che
374
+ // la referenzi — irraggiungibile da stop(), orfana, titolare dei suoi bind
375
+ // per sempre. Misurato dall'auditor: due fake-ssh vivi dopo un degraded,
376
+ // SIGTERM al supervisor ne ferma solo l'ultimo.
377
+ //
378
+ // La correzione non e' locale (un kill dentro enterDegraded): e' che
379
+ // run() — l'UNICO punto che assegna `child` a un nuovo spawn — non
380
+ // garantiva la precondizione da solo. replaceChild() e' ora quel punto
381
+ // unico: ferma SEMPRE il child uscente (se vivo) e ne attende l'uscita
382
+ // prima di procedere. Se non c'e' nulla da fermare (gia' null, o gia'
383
+ // uscito — il caso reverse, verificato invariato) procede subito: la stessa
384
+ // funzione copre correttamente entrambi i versi, nessun percorso bypassa la
385
+ // garanzia senza dichiararlo qui.
386
+ function replaceChild(spawnNext) {
387
+ const outgoing = child;
388
+ const proceed = () => {
389
+ clearTimeout(upTimer);
390
+ clearForwardProbe();
391
+ spawnNext();
392
+ };
393
+ if (!outgoing || outgoing.exitCode != null) return proceed();
394
+ // La generazione uscente muore per NOSTRA mano qui, non per un crash che
395
+ // handleFailure deve classificare: distacchiamo i suoi listener prima di
396
+ // ucciderla, altrimenti il kill sotto farebbe scattare la gestione
397
+ // fallimento della generazione vecchia in corsa con quella nuova (doppio
398
+ // scheduleRetry/enterDegraded, `child` azzerato da sotto i piedi).
399
+ outgoing.removeAllListeners('error');
400
+ outgoing.removeAllListeners('close');
401
+ outgoing.once('exit', proceed);
402
+ try { outgoing.kill('SIGTERM'); } catch (_) { return proceed(); }
403
+ const killTimer = setTimeout(() => {
404
+ try { if (outgoing.exitCode == null) outgoing.kill('SIGKILL'); } catch (_) {}
405
+ }, 1500);
406
+ if (typeof killTimer.unref === 'function') killTimer.unref();
407
+ }
408
+
183
409
  function run() {
410
+ if (stopping) return finish();
411
+ replaceChild(spawnGeneration);
412
+ }
413
+
414
+ function spawnGeneration() {
184
415
  if (stopping) return finish();
185
416
  if (!writeState('starting')) return stop();
186
417
  logEvent(`ssh attempt=${attempt + 1} starting`);
187
418
  let stderrTail = '';
419
+ let localChild;
188
420
  try {
189
- child = spawn(sshBin, sshArgs, { stdio: ['ignore', 'inherit', 'pipe'] });
421
+ localChild = spawn(sshBin, sshArgs, { stdio: ['ignore', 'inherit', 'pipe'] });
190
422
  } catch (e) {
191
423
  child = null;
192
424
  return scheduleRetry(String(e && e.message || e));
193
425
  }
426
+ child = localChild;
194
427
 
195
- child.stderr?.on('data', (chunk) => {
428
+ localChild.stderr?.on('data', (chunk) => {
196
429
  const text = String(chunk || '');
197
430
  stderrTail = `${stderrTail}${text}`.slice(-8192);
198
431
  try { process.stderr.write(chunk); } catch (_) {}
@@ -202,6 +435,12 @@ function run() {
202
435
  const handleFailure = (detail) => {
203
436
  if (failureHandled) return;
204
437
  failureHandled = true;
438
+ // Difesa in profondita', come probeForward: se replaceChild ha gia'
439
+ // sostituito questa generazione (i suoi listener sono stati distaccati,
440
+ // quindi in pratica questo ramo non dovrebbe piu' potersi attivare per
441
+ // una generazione rimpiazzata) non tocchiamo lo stato di una generazione
442
+ // che non e' piu' quella corrente.
443
+ if (child !== localChild) return;
205
444
  clearTimeout(upTimer);
206
445
  clearForwardProbe();
207
446
  child = null;
@@ -215,25 +454,26 @@ function run() {
215
454
  }
216
455
  scheduleRetry((diagnosis && diagnosis.detail) || detail);
217
456
  };
218
- child.once('spawn', () => {
457
+ localChild.once('spawn', () => {
219
458
  logEvent(`ssh attempt=${attempt + 1} spawned`);
220
459
  // ExitOnForwardFailure only proves that the local bind was accepted. It
221
460
  // does not prove authentication or remote reachability, so after the
222
461
  // stability window require a real TCP open through the -L channel.
223
462
  upTimer = setTimeout(() => {
224
- if (!stopping && child && child.exitCode == null) {
225
- probeForward(child);
463
+ if (!stopping && child === localChild && localChild.exitCode == null) {
464
+ probeForward(localChild);
226
465
  }
227
466
  }, stableMs);
228
467
  });
229
- child.once('error', (e) => {
468
+ localChild.once('error', (e) => {
230
469
  logEvent(`ssh child error code=${(e && e.code) || 'unknown'}`);
231
470
  handleFailure(String(e && e.message || e));
232
471
  });
233
472
  // `close` fires after stderr has drained, so classification sees the complete
234
473
  // OpenSSH diagnostic instead of racing the child `exit` event.
235
- child.once('close', (code, signal) => {
474
+ localChild.once('close', (code, signal) => {
236
475
  if (stopping) return finish();
476
+ if (child !== localChild) return; // sostituita da replaceChild: gestita li', non qui
237
477
  logEvent(`ssh exited code=${code === null ? 'null' : code} signal=${signal || 'none'}`);
238
478
  handleFailure(`ssh exited code=${code} signal=${signal || ''}`);
239
479
  });
@@ -286,4 +526,8 @@ function acquireGeneration() {
286
526
  if (Date.now() >= ownershipDeadline) return finish();
287
527
  ownershipWaitTimer = setTimeout(acquireGeneration, 20);
288
528
  }
289
- acquireGeneration();
529
+ if (require.main === module) acquireGeneration();
530
+
531
+ // Esportate per prova diretta (R19): il main resta argv/env-driven e NON parte
532
+ // al require.
533
+ module.exports = { probeForwardChannels, refusalHint, refusalDetails, CHANNEL_GRACE_MS };
@@ -409,6 +409,10 @@ function diagnoseTunnel(home, node, state = null) {
409
409
  stage: 'ssh', code: current.code || 'reverse-forward-failed', status: 'down', phase: 'degraded',
410
410
  detail: current.detail || 'canale inverso non disponibile, retry fisso in corso',
411
411
  hint: current.hint || 'il tunnel resta attivo e riprovera automaticamente; verifica il listener reverse sul hub',
412
+ // Campo strutturato: la riga authorized_keys viaggia INTERA fino alla UI,
413
+ // che non deve ritagliarla dall'hint.
414
+ ...(typeof current.authorizedKeys === 'string' && current.authorizedKeys
415
+ ? { authorizedKeys: current.authorizedKeys } : {}),
412
416
  ...(Number.isInteger(current.reversePort) ? { reversePort: current.reversePort } : {}),
413
417
  };
414
418
  }
@@ -690,8 +694,116 @@ function readSshVersion(spawnSyncImpl) {
690
694
  } catch (_) { return null; }
691
695
  }
692
696
 
697
+ // R19: la riga authorized_keys con le destinazioni EXPLICITE dei -L. Una sola
698
+ // porta quando il pannello non è (ancora) noto: nodesAdd non può conoscerla,
699
+ // la annuncia il peer nel join — e allora la riga la emette CHI ha
700
+ // l'informazione (pairing, supervisor). Due permitopen distinti, MAI un
701
+ // permesso generico: è il vincolo che impedisce a una chiave di tunnel di
702
+ // aprire canali arbitrari sulla macchina. Dedup: nexus e pannello sulla
703
+ // stessa porta si dichiarano una volta.
704
+ function authorizedKeysLine({ remotePort, panelRemotePort, pub } = {}) {
705
+ if (!pub || typeof pub !== 'string') return null;
706
+ const ports = [];
707
+ for (const p of [remotePort, panelRemotePort]) {
708
+ const n = Number(p);
709
+ if (Number.isInteger(n) && n >= 1 && n <= 65535 && !ports.includes(n)) ports.push(n);
710
+ }
711
+ if (!ports.length) return null;
712
+ const permitopen = ports.map((p) => `permitopen="127.0.0.1:${p}"`).join(',');
713
+ return `restrict,port-forwarding,${permitopen},command="/bin/false" ${pub}`;
714
+ }
715
+
716
+ // LA PUBBLICA SI DERIVA DALLA PRIVATA, non si legge dal file accanto.
717
+ //
718
+ // Tre giri fa validavo il nome dell'algoritmo, due giri fa la struttura del
719
+ // blob, un giro fa la chiedevo a `ssh-keygen -l` sul file `.pub`. Tutte e tre
720
+ // le volte un audit ha trovato il caso che restava: e l'ultimo lo chiude solo
721
+ // cambiando la domanda. Validare `A.pub` prova che quel file contiene UNA
722
+ // chiave valida — non che sia LA chiave di `A`. Se `A.pub` e' stale, ripristinato
723
+ // da un backup o sostituito, si pubblica la chiave sbagliata: l'utente
724
+ // sostituisce la riga di A con quella di B, e al reconnect successivo A perde
725
+ // l'accesso. Il prodotto avrebbe causato il guasto che prometteva di riparare.
726
+ //
727
+ // `ssh-keygen -y -f <privata>` deriva la pubblica DALLA privata: il legame e'
728
+ // garantito per costruzione, non verificato a posteriori. E siccome i byte
729
+ // arrivano da un solo comando, sparisce anche la finestra fra "leggo il file" e
730
+ // "lo faccio validare a qualcun altro", in cui il file poteva cambiare.
731
+ //
732
+ // Torna null se la privata non e' leggibile, e' protetta da passphrase (in
733
+ // batch non si puo' sbloccare) o `ssh-keygen` non c'e': senza poterla derivare
734
+ // non si compone nessuna riga.
735
+ function readPublicKey(identityFile, impl = {}) {
736
+ if (!identityFile || typeof identityFile !== 'string') return null;
737
+ const exec = impl.execImpl || require('node:child_process').execFileSync;
738
+ // Timeout iniettabile (test): il default 5000 ms e' la RETE, non il
739
+ // meccanismo — il meccanismo e' l'ambiente controllato qui sotto.
740
+ const timeoutMs = Number.isFinite(impl.timeoutMs) && impl.timeoutMs > 0 ? impl.timeoutMs : 5000;
741
+ let out;
742
+ try {
743
+ // AMBIENTE CONTROLLATO, e non e' pignoleria: con una privata cifrata
744
+ // `ssh-keygen` va a caccia della passphrase, e il timeout di execFileSync
745
+ // uccide solo il processo diretto — un helper askpass resterebbe orfano.
746
+ // Il supervisore chiama questa funzione a ogni rifiuto del canale, quindi
747
+ // i retry accumulerebbero prompt e processi che nessuno raccoglie: lo
748
+ // stesso difetto degli ssh orfani, per un'altra strada.
749
+ //
750
+ // La passphrase NON si legge da stdin (chiuderlo non basta): OpenSSH apre
751
+ // /dev/tty quando esiste un controlling terminal — il caso di `nexuscrew
752
+ // serve` foreground, che chiama questa via in modo sincrono — e li' resta
753
+ // ad aspettare input fino al timeout. La cura e' spostare la lettura via
754
+ // dal tty: `SSH_ASKPASS_REQUIRE=force` usa l'askpass ANCHE col tty
755
+ // presente, e SSH_ASKPASS punta a un path che NON ESISTE dentro una
756
+ // directory che controlliamo: l'askpass ineseguibile fa fallire SUBITO la
757
+ // chiave cifrata (misurato: 0.11 s contro gli 8 s dell'attesa su tty) e
758
+ // non puo' lasciare helper. Un path inesistente vince su /bin/false: su
759
+ // Termux i binari stanno sotto $PREFIX, e un path assoluto sbagliato e'
760
+ // una scommessa.
761
+ // LIMITE DA SAPERE: SSH_ASKPASS_REQUIRE esiste da OpenSSH 8.4. Le versioni
762
+ // piu' vecchie lo ignorano e tornano al tty: li' il timeout qui sopra
763
+ // resta come rete — il degrado e' «lento», non «appeso».
764
+ out = exec('ssh-keygen', ['-y', '-f', identityFile], {
765
+ encoding: 'utf8', timeout: timeoutMs, stdio: ['ignore', 'pipe', 'ignore'],
766
+ env: {
767
+ PATH: process.env.PATH || '/usr/bin:/bin',
768
+ HOME: process.env.HOME || '',
769
+ SSH_ASKPASS_REQUIRE: 'force',
770
+ SSH_ASKPASS: path.join(process.env.HOME || os.homedir(), '.nexuscrew', 'askpass-inesistente'),
771
+ },
772
+ });
773
+ } catch (_) { return null; } // assente, passphrase, privata illeggibile
774
+ const righe = String(out || '').split('\n').filter((r) => r.trim() !== '');
775
+ if (righe.length !== 1) return null;
776
+ const riga = righe[0].trim();
777
+ if (/[\u0000-\u0008\u000b-\u001f\u007f]/.test(riga)) return null;
778
+ const campi = riga.split(/[ \t]+/);
779
+ if (campi.length < 2 || !/^[A-Za-z0-9+/]+={0,2}$/.test(campi[1])) return null;
780
+ return riga;
781
+ }
782
+
783
+ // La riga per UN nodo, dal nodo. Sta qui e non nella route perche' la stessa
784
+ // domanda — "quale riga deve incollare il peer di questo nodo?" — se la pongono
785
+ // due punti lontani, e perche' la risposta dipende da un LIMITE che va detto.
786
+ //
787
+ // Senza `identityFile` il prodotto non sa quale chiave usera' ssh: usera' quelle
788
+ // di default dell'utente, un agent o la config, e da qui non si puo' sapere
789
+ // quale. Si torna `null` invece di inventarne una.
790
+ //
791
+ // E NON C'E' UN ALTRO CANALE CHE RIPARI, in quel caso: nemmeno la sonda del
792
+ // supervisore, che ricava la chiave solo da `-i` e senza quello puo' dire
793
+ // soltanto QUALI destinazioni aggiungere, non a quale riga. La riparazione
794
+ // automatica esiste solo per chi ha un'identita' dedicata; per gli altri il
795
+ // prodotto lo dichiara e chiede una modifica manuale. Il completamento vero e'
796
+ // provisionare un'identita' per peer — lavoro suo, non un ramo di questa
797
+ // funzione.
798
+ function authorizedKeysForNode(nodo, panelRemotePort) {
799
+ const pub = readPublicKey(nodo && nodo.identityFile);
800
+ if (!pub) return null;
801
+ return authorizedKeysLine({ remotePort: nodo.remotePort, panelRemotePort, pub });
802
+ }
803
+
693
804
  module.exports = {
694
805
  SSH_BASE_OPTS,
806
+ authorizedKeysLine, readPublicKey, authorizedKeysForNode,
695
807
  buildForwardArgs, buildReverseArgs, backoffDelay,
696
808
  tunnelDir, tunnelPidPath, tunnelLogPath, tunnelStatePath, readTunnelState,
697
809
  prepareTunnelDir, openTunnelLog,
@@ -16,8 +16,14 @@
16
16
  // UNA cella. Il token del nodo non deve finire nella cronologia del
17
17
  // browser, nei log del proxy o in un Referer.
18
18
  // 2. IL TICKET SI CONSUMA ALLA PRIMA RICHIESTA e la risposta imposta un cookie
19
- // HttpOnly SameSite Strict con `Path=/api/panel/<cella>` ESATTAMENTE quel
20
- // path, perche' le sotto-risorse relative passino e nient'altro.
19
+ // HttpOnly SameSite Strict con Path=ESATTAMENTE il mount da cui la
20
+ // richiesta e' entrata + la cella (R22, 2026-08-17): `/api/panel/<cella>`
21
+ // sul control plane, `/panel/<cella>` sulla porta pannello dedicata — MAI
22
+ // una costante unica, perche' i due mount servono path diversi e un cookie
23
+ // scritto per l'uno non verrebbe mai mandato dal browser sotto l'altro
24
+ // (misura originale: pannello bianco, ogni sotto-risorsa in 401). Lo scope
25
+ // resta stretto quanto prima — perche' le sotto-risorse relative passino
26
+ // e nient'altro — solo il prefisso non e' piu' assunto fisso.
21
27
  // 3. IL COOKIE NON E' UN'AUTENTICAZIONE DELL'ORIGINE. Il progetto non ne ha
22
28
  // una, e introdurla aprirebbe CSRF su tutte le altre route: qui si verifica
23
29
  // SEMPRE che il cookie sia stato emesso per la cella del path, e lo scope
@@ -153,10 +159,14 @@ function createPanelAuth({
153
159
  return !!rec && rec.exp > now() && rec.cell === cellId;
154
160
  }
155
161
 
156
- function cookieHeaderValue(cellId, value) {
162
+ // R22: il Path deve valere per il mount da cui la richiesta e' ENTRATA, non
163
+ // per una costante. Whitelist stretta invece di un default permissivo — un
164
+ // prefisso non riconosciuto rifiuta (vedi mountPrefixOf) invece di produrre
165
+ // un cookie con uno scope che nessuno ha verificato.
166
+ function cookieHeaderValue(mountPrefix, cellId, value) {
157
167
  const attrs = [
158
168
  `${COOKIE_NAME}=${value}`,
159
- `Path=/api/panel/${encodeURIComponent(cellId)}`,
169
+ `Path=${mountPrefix}/${encodeURIComponent(cellId)}`,
160
170
  'HttpOnly',
161
171
  'SameSite=Strict',
162
172
  `Max-Age=${Math.floor(cookieTtlMs / 1000)}`,
@@ -164,6 +174,25 @@ function createPanelAuth({
164
174
  return attrs.join('; ');
165
175
  }
166
176
 
177
+ // I DUE mount legittimi, misurati sul wire (non dedotti dalla struttura del
178
+ // codice — la stessa disciplina del commento a hopKind, righe 112-114):
179
+ // control plane (`api.use('/panel', …)` sotto `app.use('/api', api)`) e
180
+ // porta pannello dedicata (`panelApp.use('/panel', …)`, nessun livello
181
+ // sopra). `req.baseUrl` accumula i prefissi dei router express attraversati
182
+ // fino al middleware — misurato `/api/panel` nel primo caso, `/panel` nel
183
+ // secondo, per un mount annidato E per un mount diretto a due segmenti
184
+ // (il caso federato: `remoteApi.use('/api/panel', panelAuthMiddleware)`).
185
+ const PANEL_MOUNT_PREFIXES = new Set(['/api/panel', '/panel']);
186
+
187
+ // Un mount NON riconosciuto (baseUrl mancante, vuoto, o qualunque valore
188
+ // fuori dai due noti) non deve MAI produrre un Path per analogia o per
189
+ // default — significherebbe indovinare uno scope che nessuno ha verificato.
190
+ // Ritorna null per far fallire chiuso, mai una stringa "ragionevole".
191
+ function mountPrefixOf(req) {
192
+ const raw = String(req.baseUrl || '');
193
+ return PANEL_MOUNT_PREFIXES.has(raw) ? raw : null;
194
+ }
195
+
167
196
  // —— Emissione: POST /api/panel/<cella>/ticket, SOLO per la PWA autenticata.
168
197
  // La cella deve esistere ed avere un pannello valido: nessun ticket per
169
198
  // destinazioni che il proxy rifiuterebbe comunque.
@@ -208,9 +237,19 @@ function createPanelAuth({
208
237
  const qi = url.indexOf('?');
209
238
  if (qi !== -1) qTicket = new URLSearchParams(url.slice(qi + 1)).get('ticket');
210
239
  if (qTicket) {
240
+ // R22: il mount si verifica PRIMA di toccare il ticket — un mount non
241
+ // riconosciuto non deve bruciare un ticket monouso per un errore che
242
+ // non dipende da chi lo presenta.
243
+ const mountPrefix = mountPrefixOf(req);
244
+ if (!mountPrefix) {
245
+ log({ event: 'panel-auth', outcome: 'denied', reason: 'mount-non-riconosciuto', cell: cellId });
246
+ res.writeHead(500, { 'content-type': 'application/json' });
247
+ res.end(JSON.stringify({ error: 'mount non riconosciuto' }));
248
+ return;
249
+ }
211
250
  if (consumeTicket(qTicket, cellId)) {
212
251
  const value = issueCookie(cellId);
213
- res.setHeader('set-cookie', cookieHeaderValue(cellId, value));
252
+ res.setHeader('set-cookie', cookieHeaderValue(mountPrefix, cellId, value));
214
253
  log({ event: 'panel-auth', outcome: 'ticket-consumed', cell: cellId });
215
254
  return next();
216
255
  }
@@ -326,6 +365,7 @@ function createPanelAuth({
326
365
  verifyCookieForTest: verifyCookie,
327
366
  issueCookieForTest: issueCookie,
328
367
  cookieHeaderValueForTest: cookieHeaderValue,
368
+ mountPrefixOfForTest: mountPrefixOf,
329
369
  };
330
370
  }
331
371