@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.
- package/CHANGELOG.md +138 -0
- package/frontend/dist/assets/index-MdL-ZvnH.js +93 -0
- package/frontend/dist/assets/index-forYfgKZ.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/init.js +22 -2
- package/lib/cli/pidfile.js +389 -16
- package/lib/cli/runtime-lifecycle.js +11 -0
- package/lib/cli/service.js +11 -3
- package/lib/cli/stable-alias.js +117 -0
- package/lib/nodes/commands.js +8 -2
- package/lib/nodes/health.js +1 -0
- package/lib/nodes/peer-transitions.js +10 -4
- package/lib/nodes/pubkey-format.js +69 -0
- package/lib/nodes/tunnel-supervisor.js +271 -30
- package/lib/nodes/tunnel.js +166 -0
- package/lib/proxy/panel-auth.js +45 -5
- package/lib/settings/pairing-coordinator.js +34 -1
- package/lib/settings/routes.js +11 -0
- package/lib/update/runner.js +10 -3
- package/package.json +1 -1
- package/frontend/dist/assets/index-CbMytNmg.css +0 -32
- package/frontend/dist/assets/index-DFI1J-u5.js +0 -93
package/lib/nodes/commands.js
CHANGED
|
@@ -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
|
|
208
|
-
|
|
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
|
|
package/lib/nodes/health.js
CHANGED
|
@@ -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
|
|
69
|
-
//
|
|
70
|
-
//
|
|
71
|
-
|
|
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
|
|
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
|
|
77
|
+
if (Number.isInteger(port) && port >= 1 && port <= 65535 && !ports.includes(port)) ports.push(port);
|
|
54
78
|
}
|
|
55
|
-
return
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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 (
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
225
|
-
probeForward(
|
|
460
|
+
if (!stopping && child === localChild && localChild.exitCode == null) {
|
|
461
|
+
probeForward(localChild);
|
|
226
462
|
}
|
|
227
463
|
}, stableMs);
|
|
228
464
|
});
|
|
229
|
-
|
|
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
|
-
|
|
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 };
|