@mmmbuto/nexuscrew 0.9.4 → 0.9.6

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.
@@ -204,8 +204,14 @@ function nodesAdd(opts) {
204
204
 
205
205
  log(`nodes add: nodo "${name}" aggiunto (ssh ${ssh}${sshPort ? `:${sshPort}` : ''}, nexus remoto ${remotePort} -> locale ${localPort})`);
206
206
  log('Incolla nel ~/.ssh/authorized_keys del NODO (lato forward, chiave dedicata):');
207
- // permitopen vincola i -L alla SOLA porta nexus remota; command=/bin/false + restrict.
208
- if (pub) log(`restrict,port-forwarding,permitopen="127.0.0.1:${remotePort}",command="/bin/false" ${pub}`);
207
+ // R19: permitopen con le destinazioni EXPLICITE dei -L. Qui si conosce solo
208
+ // la porta nexus: la porta pannello del peer la annuncia il JOIN, non
209
+ // esiste ancora — quando apparirà, la riga completa (due destinazioni) la
210
+ // emette il pairing. Mai un permesso generico: è ciò che impedisce a questa
211
+ // chiave di aprire canali arbitrari. command=/bin/false + restrict.
212
+ const panelRemotePort = opts.panelRemotePort !== undefined ? Number(opts.panelRemotePort) : undefined;
213
+ const riga = tunnel.authorizedKeysLine({ remotePort, panelRemotePort, pub });
214
+ if (riga) log(riga);
209
215
  return { code: 0, name, sshPort, localPort, remotePort, transport: entry.transport };
210
216
  }
211
217
 
@@ -102,6 +102,7 @@ async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = fal
102
102
  transport: 'down', auth: 'unknown', reachability: 'unknown', status: 'down',
103
103
  detail: diagnostic.detail, code: diagnostic.code, stage: diagnostic.stage,
104
104
  ...(diagnostic.hint ? { hint: diagnostic.hint } : {}),
105
+ ...(diagnostic.authorizedKeys ? { authorizedKeys: diagnostic.authorizedKeys } : {}),
105
106
  transportEngine: ts.transport || 'ssh', managed: ts.managed !== false, at: now,
106
107
  };
107
108
  } else if (!node.token) {
@@ -65,10 +65,16 @@ function recordPeerTransition(nodeName, health, diagnostics) {
65
65
  `${nodeName}: tunnel ${previous.tunnel} -> ${tunnel}`,
66
66
  { node: nodeName, state: tunnel });
67
67
  }
68
- // Testabile solo quando il tunnel e' su ORA: se e' appena caduto, il
69
- // servizio e' passato a 'unknown' come artefatto del tunnel (gia' segnalato
70
- // sopra), non come un fatto nuovo quindi qui non si scrive nulla.
71
- if (tunnel === 'up' && previous.service !== service) {
68
+ // Testabile solo quando il tunnel era SU e resta SU: solo allora entrambi
69
+ // gli estremi del confronto sono misure vere. Se e' appena caduto, il
70
+ // servizio passa a 'unknown' come artefatto del tunnel (gia' segnalato
71
+ // sopra), non come un fatto nuovo. Per SIMMETRIA vale anche al contrario:
72
+ // se e' appena tornato, il servizio non e' "tornato" — e' semplicemente di
73
+ // nuovo osservabile, e 'unknown' non era una misura precedente da cui
74
+ // partire. Scrivere "unknown -> ok" alla ripresa sarebbe un fatto inventato
75
+ // tanto quanto lo sarebbe "ok -> unknown" alla caduta: un record diagnostico
76
+ // che dichiara misurato cio' che non lo e' stato e' peggio del silenzio.
77
+ if (previous.tunnel === 'up' && tunnel === 'up' && previous.service !== service) {
72
78
  diagnostics.record('warn', 'peer-health', 'SERVICE_TRANSITION',
73
79
  `${nodeName}: service ${previous.service} -> ${service}`,
74
80
  { node: nodeName, state: service });
@@ -0,0 +1,69 @@
1
+ 'use strict';
2
+ // Un SOLO formatter al confine UI/log per gli esiti del resolver della
3
+ // pubblica (job 5, strutturale). Prima le frasi si componevano in basso —
4
+ // nel supervisor e nella route — dove non si sa abbastanza per scriverle:
5
+ // meta' della 0.9.5 e' nata cosi' (promesse false, cause enumerate «a caso»,
6
+ // silenzi). Ora il resolver produce DATI enumerati (resolvePublicKey in
7
+ // tunnel.js) e QUESTO punto unico li trasforma in frasi. Un test per ogni
8
+ // causa.
9
+
10
+ const path = require('node:path');
11
+ const {
12
+ PUBKEY_DERIVED, PUBKEY_NO_IDENTITY, PUBKEY_ACTUAL_KEY_UNKNOWN,
13
+ PUBKEY_TOOL_UNAVAILABLE, PUBKEY_ENCRYPTED_OR_UNREADABLE,
14
+ } = require('./tunnel.js');
15
+
16
+ // La frase della CAUSA per ogni esito — il «perche'» che chi guarda deve
17
+ // leggere. `resolution` e' il dato del risolutore; `identityFile` il path
18
+ // dichiarato, per nominare l'oggetto giusto. Non inventare mai una causa che
19
+ // il dato non porta: l'ignosciuto si dice sconosciuto, non si indovina.
20
+ function pubkeyCauseText(resolution, { identityFile } = {}) {
21
+ const nome = identityFile ? path.basename(identityFile) : null;
22
+ switch (resolution && resolution.outcome) {
23
+ case PUBKEY_DERIVED:
24
+ // Nessuna causa da dichiarare: la riga c'e'.
25
+ return null;
26
+ case PUBKEY_NO_IDENTITY:
27
+ return `la chiave dichiarata${nome ? ` (${nome})` : ''} non esiste dove dovrebbe`;
28
+ case PUBKEY_ACTUAL_KEY_UNKNOWN:
29
+ return 'nessun -i: ssh usa le chiavi di default, un agent o la config, e da qui non si sa quale';
30
+ case PUBKEY_TOOL_UNAVAILABLE:
31
+ return 'ssh-keygen non e\' disponibile su questa macchina: la pubblica non si puo\' derivare';
32
+ case PUBKEY_ENCRYPTED_OR_UNREADABLE:
33
+ return `non riesco a derivare la pubblica da ${nome || 'quella chiave'}: e' cifrata o illeggibile`;
34
+ default:
35
+ // Al confine, l'ignosciuto si NOMINA: un silenzio qui sarebbe lo stesso
36
+ // difetto che questo modulo chiude.
37
+ return `esito non riconosciuto: ${resolution && resolution.outcome}`;
38
+ }
39
+ }
40
+
41
+ // L'AZIONE suggerita per ogni esito — il «cosa fare» accanto al perche'.
42
+ // Aspettare non serve dove bisogna concedere; riprovare non serve dove la
43
+ // condizione dipende da una decisione altrove.
44
+ function pubkeyActionText(resolution) {
45
+ switch (resolution && resolution.outcome) {
46
+ case PUBKEY_DERIVED:
47
+ return 'sostituisci la riga in ~/.ssh/authorized_keys del nodo con questa';
48
+ case PUBKEY_NO_IDENTITY:
49
+ return 'verifica il path della chiave dichiarata per questo nodo';
50
+ case PUBKEY_ACTUAL_KEY_UNKNOWN:
51
+ return 'individua la chiave che ssh usa davvero e modifica A MANO la sua riga';
52
+ case PUBKEY_TOOL_UNAVAILABLE:
53
+ return 'installa ssh-keygen (openssh-client), poi riprova';
54
+ case PUBKEY_ENCRYPTED_OR_UNREADABLE:
55
+ return 'usa una chiave dedicata non cifrata per questo nodo, o modifica A MANO la riga';
56
+ default:
57
+ return 'verifica la configurazione del nodo';
58
+ }
59
+ }
60
+
61
+ // La nota del pairing quando la riga c'e': il testo che accompagnava la
62
+ // risposta, ora composto qui (l'unico punto) e non nella route.
63
+ function pubkeyPairingNote(resolution, { panelPort } = {}) {
64
+ if (!resolution || resolution.outcome !== PUBKEY_DERIVED) return null;
65
+ return 'il peer ha un pannello sulla propria porta ' + panelPort
66
+ + ': SOSTITUISCI la riga già installata in ~/.ssh/authorized_keys del NODO con questa (due destinazioni), altrimenti il canale del pannello sarà rifiutato. È la riga della chiave DICHIARATA per questo nodo (-i), non un\'eventuale chiave "jump" dello stesso nodo. Se ssh sceglie un\'altra identità (agent o config), la riga giusta è quella della chiave che usa davvero.';
67
+ }
68
+
69
+ module.exports = { pubkeyCauseText, pubkeyActionText, pubkeyPairingNote };
@@ -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,106 @@ 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
+ // Esito enumerato (resolver) + frase composta al confine UI/log — il punto
182
+ // unico pubkey-format. Prima qui si sceglieva fra cause che non si potevano
183
+ // distinguere e le si enumerava «a caso»; ora il dato distingue, e il
184
+ // testo dice la causa ESATTA: mai inventarne una che il dato non porta.
185
+ const resolution = require('./tunnel.js').resolvePublicKey(identityFile);
186
+ const { pubkeyCauseText, pubkeyActionText } = require('./pubkey-format.js');
187
+ const premessa = 'canale rifiutato dal NODO remoto: la chiave in ~/.ssh/authorized_keys non autorizza queste destinazioni.';
188
+ if (resolution.outcome !== require('./tunnel.js').PUBKEY_DERIVED) {
189
+ const causa = pubkeyCauseText(resolution, { identityFile });
190
+ const azione = pubkeyActionText(resolution);
191
+ return {
192
+ hint: `${premessa} NON posso identificare la chiave da correggere — ${causa}. ${azione}`
193
+ + `: aggiungi queste destinazioni: permitopen="${dests}"`,
194
+ authorizedKeys: '',
195
+ outcome: resolution.outcome,
196
+ };
197
+ }
198
+ const riga = `restrict,port-forwarding,permitopen="${dests}",command="/bin/false" ${resolution.line}`;
199
+ // NON diciamo «quella con cui il supervisor si autentica»: `-i` DICHIARA
200
+ // un'identita', non la impone — senza `IdentitiesOnly=yes` OpenSSH puo'
201
+ // comunque usare l'agent o la config, e il canale essere autenticato da
202
+ // un'altra chiave. Affermarlo sarebbe una promessa che il comando non
203
+ // mantiene, e manderebbe a sostituire la riga sbagliata.
204
+ 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)` : '';
205
+ return {
206
+ hint: `${premessa} Riga da usare (SOSTITUISCI quella esistente, non aggiungerne una seconda)${quale}: ${riga}`,
207
+ authorizedKeys: riga,
208
+ outcome: resolution.outcome,
209
+ };
210
+ }
211
+
212
+ function refusalHint(opts) { return refusalDetails(opts).hint; }
213
+
89
214
  function probeForward(expectedChild) {
90
215
  if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
91
216
  if (!forwardPort) {
@@ -99,29 +224,72 @@ function probeForward(expectedChild) {
99
224
  if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'reverse-forward' })) stop();
100
225
  return;
101
226
  }
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;
227
+ const ports = forwardPorts.length ? forwardPorts : [forwardPort];
228
+ probeForwardChannels({ ports, graceMs: CHANNEL_GRACE_MS }).then((esiti) => {
110
229
  if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
111
- if (ready) {
230
+ const rifiutate = ports.filter((p) => esiti.get(p) === 'channel-refused');
231
+ const mancanti = ports.filter((p) => esiti.get(p) === 'bind-failed');
232
+ if (!rifiutate.length && !mancanti.length) {
112
233
  attempt = 0;
113
234
  reverseFailures = 0;
114
- logEvent(`forward ready stableMs=${stableMs}`);
115
- if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'tcp-forward' })) stop();
235
+ channelProbeFailures = 0;
236
+ logEvent(`forward ready stableMs=${stableMs} channel=verified`);
237
+ if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'tcp-forward-verified' })) stop();
116
238
  return;
117
239
  }
118
- if (!writeState('transport-probing', { sshPid: child.pid, stableMs })) return stop();
240
+ if (rifiutate.length && !channelRefusedLogged) {
241
+ // Una volta per generazione: il ciclo di probing continua (la
242
+ // concessione può essere aggiornata), ma chi guarda SA qual è la metà
243
+ // malata e COSA sostituire.
244
+ channelRefusedLogged = true;
245
+ logEvent(refusalHint({ remoteDestinations: remoteDestinationsOf(sshArgs), identityFile: identityFileOf(sshArgs) }));
246
+ }
247
+ channelProbeFailures += 1;
248
+ if (channelProbeFailures > channelProbeMax) {
249
+ // Budget esaurito: la finestra transitoria (servizio remoto non ancora
250
+ // su) aveva 15s per qualificarsi da sola con una sonda fresca ogni
251
+ // volta — non l'ha fatto. Da qui in poi non e' piu' "sta per succedere",
252
+ // e continuare a sondare ogni 250ms sarebbe la stessa sonda che gira
253
+ // per sempre senza mai diventare osservabile. Stesso stato del forward
254
+ // inverso: degraded, vivo, si riprova a cadenza fissa — mai "pronto"
255
+ // dichiarato senza averlo verificato.
256
+ return enterDegraded({
257
+ code: 'forward-channel-blocked',
258
+ detail: rifiutate.length
259
+ ? `canale rifiutato dal nodo remoto dopo ${channelProbeFailures} sonde`
260
+ : `nessun canale locale disponibile dopo ${channelProbeFailures} sonde`,
261
+ ...(rifiutate.length
262
+ ? (() => {
263
+ const d = refusalDetails({ remoteDestinations: remoteDestinationsOf(sshArgs), identityFile: identityFileOf(sshArgs) });
264
+ return { hint: d.hint, ...(d.authorizedKeys ? { authorizedKeys: d.authorizedKeys } : {}) };
265
+ })()
266
+ : {}),
267
+ });
268
+ }
269
+ if (!writeState('transport-probing', {
270
+ sshPid: child.pid, stableMs,
271
+ ...(rifiutate.length ? { probeDetail: 'channel-refused', refusedLocalPorts: rifiutate } : {}),
272
+ ...(mancanti.length ? { probeDetail: 'bind-failed', missingLocalPorts: mancanti } : {}),
273
+ })) return stop();
119
274
  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));
275
+ });
276
+ }
277
+
278
+ // Le destinazioni REMOTE degli -L, nella forma che vive in permitopen.
279
+ function remoteDestinationsOf(args) {
280
+ const out = [];
281
+ for (let i = 0; i < args.length - 1; i += 1) {
282
+ if (args[i] !== '-L') continue;
283
+ const m = String(args[i + 1] || '').match(/^127\.0\.0\.1:\d+:(127\.0\.0\.1:\d+)$/);
284
+ if (m && !out.includes(m[1])) out.push(m[1]);
285
+ }
286
+ return out;
287
+ }
288
+ function identityFileOf(args) {
289
+ for (let i = 0; i < args.length - 1; i += 1) {
290
+ if (args[i] === '-i') return args[i + 1];
291
+ }
292
+ return null;
125
293
  }
126
294
 
127
295
  function ownsGeneration() {
@@ -169,10 +337,21 @@ function enterDegraded(diagnosis) {
169
337
  // it cannot identify the process holding a listener on the hub. Never turn
170
338
  // local pidfile ownership into a false remote-listener attribution.
171
339
  const ownership = 'unknown';
172
- logEvent(`ssh degraded code=${safe.code} reversePort=${reversePort || 'none'} ownership=${ownership} failures=${reverseFailures} steadyRetryMs=${steadyRetryMs}`);
340
+ // Il contatore che si logga dipende da QUALE budget si e' esaurito: il
341
+ // canale -L (channelProbeFailures) e il forward inverso (reverseFailures)
342
+ // sono guasti indipendenti sullo stesso processo (vedi CHANNEL_GRACE_MS
343
+ // sopra) — loggare sempre reverseFailures direbbe "0" su un degraded di
344
+ // canale, come se non fosse mai stato provato.
345
+ const failures = safe.code === 'forward-channel-blocked' ? channelProbeFailures : reverseFailures;
346
+ logEvent(`ssh degraded code=${safe.code} reversePort=${reversePort || 'none'} ownership=${ownership} failures=${failures} steadyRetryMs=${steadyRetryMs}`);
173
347
  if (!writeState('degraded', {
174
348
  code: safe.code, detail: safe.detail,
175
349
  ...(safe.hint ? { hint: safe.hint } : {}),
350
+ // Il campo viaggia insieme alla frase, non al posto suo: chi legge un log
351
+ // vuole l'hint, la UI vuole la riga intera. Scriverne solo uno qui e' il
352
+ // modo in cui il campo si perde a meta' strada e tutto il resto della
353
+ // catena, gia' cablato, riceve per sempre il fallback testuale.
354
+ ...(safe.authorizedKeys ? { authorizedKeys: safe.authorizedKeys } : {}),
176
355
  reversePort, ownership, steadyRetryMs, terminal: false,
177
356
  })) return stop();
178
357
  // Keep the backoff counter honest for observers; the degraded retry is fixed.
@@ -180,19 +359,70 @@ function enterDegraded(diagnosis) {
180
359
  retryTimer = setTimeout(run, steadyRetryMs);
181
360
  }
182
361
 
362
+ // R19 seguito, secondo difetto (2026-08-17, audit su develop@437d29f):
363
+ // enterDegraded per il canale -L riusava la macchina del forward inverso
364
+ // senza la sua precondizione implicita. Nel reverse failure `child` e' GIA'
365
+ // null quando enterDegraded gira (handleFailure lo azzera PRIMA, perche' e'
366
+ // il crash del processo l'evento che ci porta li'). Nel canale -L rifiutato
367
+ // il processo NON e' morto — e' il canale a essere negato — quindi `child`
368
+ // e' ancora vivo quando enterDegraded schedula run() per la prossima
369
+ // generazione. run() faceva `child = spawn(...)` incondizionatamente: la
370
+ // vecchia generazione, ancora viva, restava senza piu' nessuna variabile che
371
+ // la referenzi — irraggiungibile da stop(), orfana, titolare dei suoi bind
372
+ // per sempre. Misurato dall'auditor: due fake-ssh vivi dopo un degraded,
373
+ // SIGTERM al supervisor ne ferma solo l'ultimo.
374
+ //
375
+ // La correzione non e' locale (un kill dentro enterDegraded): e' che
376
+ // run() — l'UNICO punto che assegna `child` a un nuovo spawn — non
377
+ // garantiva la precondizione da solo. replaceChild() e' ora quel punto
378
+ // unico: ferma SEMPRE il child uscente (se vivo) e ne attende l'uscita
379
+ // prima di procedere. Se non c'e' nulla da fermare (gia' null, o gia'
380
+ // uscito — il caso reverse, verificato invariato) procede subito: la stessa
381
+ // funzione copre correttamente entrambi i versi, nessun percorso bypassa la
382
+ // garanzia senza dichiararlo qui.
383
+ function replaceChild(spawnNext) {
384
+ const outgoing = child;
385
+ const proceed = () => {
386
+ clearTimeout(upTimer);
387
+ clearForwardProbe();
388
+ spawnNext();
389
+ };
390
+ if (!outgoing || outgoing.exitCode != null) return proceed();
391
+ // La generazione uscente muore per NOSTRA mano qui, non per un crash che
392
+ // handleFailure deve classificare: distacchiamo i suoi listener prima di
393
+ // ucciderla, altrimenti il kill sotto farebbe scattare la gestione
394
+ // fallimento della generazione vecchia in corsa con quella nuova (doppio
395
+ // scheduleRetry/enterDegraded, `child` azzerato da sotto i piedi).
396
+ outgoing.removeAllListeners('error');
397
+ outgoing.removeAllListeners('close');
398
+ outgoing.once('exit', proceed);
399
+ try { outgoing.kill('SIGTERM'); } catch (_) { return proceed(); }
400
+ const killTimer = setTimeout(() => {
401
+ try { if (outgoing.exitCode == null) outgoing.kill('SIGKILL'); } catch (_) {}
402
+ }, 1500);
403
+ if (typeof killTimer.unref === 'function') killTimer.unref();
404
+ }
405
+
183
406
  function run() {
407
+ if (stopping) return finish();
408
+ replaceChild(spawnGeneration);
409
+ }
410
+
411
+ function spawnGeneration() {
184
412
  if (stopping) return finish();
185
413
  if (!writeState('starting')) return stop();
186
414
  logEvent(`ssh attempt=${attempt + 1} starting`);
187
415
  let stderrTail = '';
416
+ let localChild;
188
417
  try {
189
- child = spawn(sshBin, sshArgs, { stdio: ['ignore', 'inherit', 'pipe'] });
418
+ localChild = spawn(sshBin, sshArgs, { stdio: ['ignore', 'inherit', 'pipe'] });
190
419
  } catch (e) {
191
420
  child = null;
192
421
  return scheduleRetry(String(e && e.message || e));
193
422
  }
423
+ child = localChild;
194
424
 
195
- child.stderr?.on('data', (chunk) => {
425
+ localChild.stderr?.on('data', (chunk) => {
196
426
  const text = String(chunk || '');
197
427
  stderrTail = `${stderrTail}${text}`.slice(-8192);
198
428
  try { process.stderr.write(chunk); } catch (_) {}
@@ -202,6 +432,12 @@ function run() {
202
432
  const handleFailure = (detail) => {
203
433
  if (failureHandled) return;
204
434
  failureHandled = true;
435
+ // Difesa in profondita', come probeForward: se replaceChild ha gia'
436
+ // sostituito questa generazione (i suoi listener sono stati distaccati,
437
+ // quindi in pratica questo ramo non dovrebbe piu' potersi attivare per
438
+ // una generazione rimpiazzata) non tocchiamo lo stato di una generazione
439
+ // che non e' piu' quella corrente.
440
+ if (child !== localChild) return;
205
441
  clearTimeout(upTimer);
206
442
  clearForwardProbe();
207
443
  child = null;
@@ -215,25 +451,26 @@ function run() {
215
451
  }
216
452
  scheduleRetry((diagnosis && diagnosis.detail) || detail);
217
453
  };
218
- child.once('spawn', () => {
454
+ localChild.once('spawn', () => {
219
455
  logEvent(`ssh attempt=${attempt + 1} spawned`);
220
456
  // ExitOnForwardFailure only proves that the local bind was accepted. It
221
457
  // does not prove authentication or remote reachability, so after the
222
458
  // stability window require a real TCP open through the -L channel.
223
459
  upTimer = setTimeout(() => {
224
- if (!stopping && child && child.exitCode == null) {
225
- probeForward(child);
460
+ if (!stopping && child === localChild && localChild.exitCode == null) {
461
+ probeForward(localChild);
226
462
  }
227
463
  }, stableMs);
228
464
  });
229
- child.once('error', (e) => {
465
+ localChild.once('error', (e) => {
230
466
  logEvent(`ssh child error code=${(e && e.code) || 'unknown'}`);
231
467
  handleFailure(String(e && e.message || e));
232
468
  });
233
469
  // `close` fires after stderr has drained, so classification sees the complete
234
470
  // OpenSSH diagnostic instead of racing the child `exit` event.
235
- child.once('close', (code, signal) => {
471
+ localChild.once('close', (code, signal) => {
236
472
  if (stopping) return finish();
473
+ if (child !== localChild) return; // sostituita da replaceChild: gestita li', non qui
237
474
  logEvent(`ssh exited code=${code === null ? 'null' : code} signal=${signal || 'none'}`);
238
475
  handleFailure(`ssh exited code=${code} signal=${signal || ''}`);
239
476
  });
@@ -286,4 +523,8 @@ function acquireGeneration() {
286
523
  if (Date.now() >= ownershipDeadline) return finish();
287
524
  ownershipWaitTimer = setTimeout(acquireGeneration, 20);
288
525
  }
289
- acquireGeneration();
526
+ if (require.main === module) acquireGeneration();
527
+
528
+ // Esportate per prova diretta (R19): il main resta argv/env-driven e NON parte
529
+ // al require.
530
+ module.exports = { probeForwardChannels, refusalHint, refusalDetails, CHANNEL_GRACE_MS };