@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.
- package/CHANGELOG.md +83 -0
- package/frontend/dist/assets/index-BUbgwJsU.js +93 -0
- package/frontend/dist/assets/index-BzmGsXBy.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/pidfile.js +389 -16
- package/lib/cli/runtime-lifecycle.js +11 -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/tunnel-supervisor.js +274 -30
- package/lib/nodes/tunnel.js +112 -0
- package/lib/proxy/panel-auth.js +45 -5
- package/lib/settings/pairing-coordinator.js +28 -1
- 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
|
@@ -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,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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
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 (
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 &&
|
|
225
|
-
probeForward(
|
|
463
|
+
if (!stopping && child === localChild && localChild.exitCode == null) {
|
|
464
|
+
probeForward(localChild);
|
|
226
465
|
}
|
|
227
466
|
}, stableMs);
|
|
228
467
|
});
|
|
229
|
-
|
|
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
|
-
|
|
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 };
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -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,
|
package/lib/proxy/panel-auth.js
CHANGED
|
@@ -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
|
|
20
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|