@mmmbuto/nexuscrew 0.8.9 → 0.8.10

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.
@@ -1,12 +1,12 @@
1
1
  'use strict';
2
2
  // lib/nodes/tunnel.js — SSH tunnel manager (design §4b(1), §7).
3
3
  //
4
- // I processi ssh sono figli del lifecycle CLI/service, MAI di un handler HTTP
5
- // (nessun spawn dalla superficie web). Template SSH ESATTI dal design §4b(1);
6
- // argv puro (spawn con array), nessuna shell interpolation.
4
+ // I processi ssh sono gestiti da un solo supervisor detached, avviato dal
5
+ // lifecycle del servizio o dalle azioni PWA autenticate. argv puro (spawn con
6
+ // array), nessuna shell interpolation.
7
7
  //
8
8
  // Due meccaniche:
9
- // - builder puri (buildForwardArgs/buildReverseArgs) + backoff deterministico:
9
+ // - builder puro buildForwardArgs + backoff deterministico:
10
10
  // unit-testabili senza lanciare ssh.
11
11
  // - lifecycle per-tunnel via pidfile (start/stop/restart/state): il processo
12
12
  // ssh e' detached, la sua liveness/porta stabile e' interrogabile da
@@ -16,6 +16,7 @@
16
16
  const fs = require('node:fs');
17
17
  const path = require('node:path');
18
18
  const os = require('node:os');
19
+ const crypto = require('node:crypto');
19
20
  const { spawn, spawnSync } = require('node:child_process');
20
21
  const pidf = require('../cli/pidfile.js');
21
22
  const store = require('./store.js');
@@ -39,14 +40,6 @@ function assertForwardSpec(node) {
39
40
  if (!store.parseSshTarget(node.ssh)) throw new Error('tunnel: target ssh non valido');
40
41
  }
41
42
 
42
- function assertReverseSpec(rdv) {
43
- if (!rdv || typeof rdv !== 'object') throw new Error('tunnel: rendezvous mancante');
44
- if (!store.isPort(rdv.publishedPort)) throw new Error('tunnel: publishedPort non valida');
45
- if (!store.isPort(rdv.localPort)) throw new Error('tunnel: localPort non valida');
46
- if (!store.isAbsPath(rdv.keyPath)) throw new Error('tunnel: keyPath non valido');
47
- if (!store.parseSsh(rdv.ssh)) throw new Error('tunnel: ssh (user@rendezvous) non valido');
48
- }
49
-
50
43
  // Forward client->nodo (ruolo client). §4b(1):
51
44
  // ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=30
52
45
  // -o ServerAliveCountMax=3 -o BatchMode=yes -i <key>
@@ -55,9 +48,12 @@ function buildForwardArgs(node) {
55
48
  assertForwardSpec(node);
56
49
  const transport = node.sshPort === undefined ? [] : ['-p', String(node.sshPort)];
57
50
  const identity = node.identityFile || node.keyPath;
58
- const reverse = node.reversePort === undefined ? [] : [
51
+ // The forward channel is the connection to the hub and is always present.
52
+ // The reverse channel publishes this device back through the hub, so it is
53
+ // opt-in only. A negotiated reversePort alone must never imply consent.
54
+ const reverse = node.shared === true && node.reversePort !== undefined ? [
59
55
  '-R', `127.0.0.1:${node.reversePort}:127.0.0.1:${node.localAppPort || node.remotePort}`,
60
- ];
56
+ ] : [];
61
57
  return SSH_BASE_OPTS.concat(transport, identity ? ['-i', identity] : [], [
62
58
  '-L', `127.0.0.1:${node.localPort}:127.0.0.1:${node.remotePort}`,
63
59
  ...reverse,
@@ -65,18 +61,6 @@ function buildForwardArgs(node) {
65
61
  ]);
66
62
  }
67
63
 
68
- // Reverse nodo->rendezvous (ruolo node). §4b(1): bind loopback ESPLICITO lato
69
- // remoto (mai wildcard):
70
- // ssh -N ... -i <key> -R 127.0.0.1:<pubblicata>:127.0.0.1:<locale> user@rendezvous
71
- function buildReverseArgs(rdv) {
72
- assertReverseSpec(rdv);
73
- return SSH_BASE_OPTS.concat([
74
- '-i', rdv.keyPath,
75
- '-R', `127.0.0.1:${rdv.publishedPort}:127.0.0.1:${rdv.localPort}`,
76
- rdv.ssh,
77
- ]);
78
- }
79
-
80
64
  // Backoff esponenziale + jitter (design §7). Deterministico con rng iniettato.
81
65
  // delay = clamp(base * factor^attempt, 0..cap) * (1 +- jitter*(2*rand-1))
82
66
  // rng()=0.5 -> jitter nullo (ritorna il valore base clamperato); test deterministici.
@@ -98,8 +82,8 @@ function tunnelDir(home) {
98
82
  return path.join(home || os.homedir(), '.nexuscrew', 'tunnels');
99
83
  }
100
84
 
101
- // pidfile per-tunnel: forward "<name>", reverse "__rendezvous__". Il name e' gia'
102
- // una chiave strict (^[a-z0-9-]{1,32}$), safe come basename.
85
+ // pidfile per-tunnel: forward "<name>". `__rendezvous__` è riconosciuto soltanto
86
+ // per spegnere un supervisor legacy durante la migrazione.
103
87
  function tunnelPidPath(home, name) {
104
88
  return path.join(tunnelDir(home), `${name}.pid`);
105
89
  }
@@ -108,6 +92,95 @@ function tunnelLogPath(home, name) {
108
92
  return path.join(tunnelDir(home), `${name}.log`);
109
93
  }
110
94
 
95
+ function prepareTunnelDir(home) {
96
+ const dir = tunnelDir(home);
97
+ try {
98
+ const st = fs.lstatSync(dir);
99
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new Error('unsafe tunnel directory');
100
+ fs.chmodSync(dir, 0o700);
101
+ } catch (error) {
102
+ if (error.code !== 'ENOENT') throw error;
103
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
104
+ const st = fs.lstatSync(dir);
105
+ if (st.isSymbolicLink() || !st.isDirectory()) throw new Error('unsafe tunnel directory');
106
+ fs.chmodSync(dir, 0o700);
107
+ }
108
+ return dir;
109
+ }
110
+
111
+ function openTunnelLog(home, name) {
112
+ prepareTunnelDir(home);
113
+ const logPath = tunnelLogPath(home, name);
114
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC |
115
+ (fs.constants.O_NOFOLLOW || 0);
116
+ let fd;
117
+ try {
118
+ fd = fs.openSync(logPath, flags, 0o600);
119
+ const st = fs.fstatSync(fd);
120
+ if (!st.isFile()) throw new Error('unsafe tunnel log');
121
+ fs.fchmodSync(fd, 0o600);
122
+ return { fd, logPath };
123
+ } catch (error) {
124
+ if (fd !== undefined) try { fs.closeSync(fd); } catch (_) {}
125
+ throw error;
126
+ }
127
+ }
128
+
129
+ // Classifica soltanto firme SSH note e restituisce messaggi sicuri/azionabili:
130
+ // mai rimandare il log grezzo alla PWA (può contenere host/path locali). Serve
131
+ // soprattutto nel pairing: un normale `ssh hub-alias` può autenticarsi mentre permitopen nega
132
+ // il forward richiesto, caso che prima collassava nel generico "fetch failed".
133
+ function classifySshFailure(text, remotePort) {
134
+ const s = String(text || '');
135
+ const target = store.isPort(remotePort) ? `127.0.0.1:${remotePort}` : 'la porta NexusCrew richiesta';
136
+ if (/administratively prohibited|request (?:was )?denied|open failed:.*prohibited|port forwarding.*(?:disabled|denied)/i.test(s)) {
137
+ return {
138
+ code: 'forward-denied',
139
+ detail: `SSH autenticato, ma il server ha negato il port forwarding verso ${target}`,
140
+ hint: `verifica AllowTcpForwarding e l'eventuale permitopen per ${target}; il link NON e' stato consumato`,
141
+ };
142
+ }
143
+ if (/Permission denied \((?:publickey|password|keyboard-interactive)[^)]*\)|No supported authentication methods available/i.test(s)) {
144
+ return { code: 'ssh-auth-failed', detail: 'autenticazione SSH rifiutata', hint: 'verifica la chiave o l’Host SSH configurato su questo dispositivo; il link NON e\' stato consumato' };
145
+ }
146
+ if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|authenticity of host .* can'?t be established/i.test(s)) {
147
+ return { code: 'ssh-host-key', detail: 'verifica della host key SSH non completata', hint: 'verifica la host key con il normale client SSH; NexusCrew non la accetta automaticamente' };
148
+ }
149
+ if (/Could not resolve hostname|Name or service not known|nodename nor servname provided/i.test(s)) {
150
+ return { code: 'ssh-dns', detail: 'host SSH non risolvibile', hint: 'verifica hostname o alias nel file SSH di questo dispositivo' };
151
+ }
152
+ if (/Bad owner or permissions on .*ssh|Bad configuration option|Could not open user configuration file/i.test(s)) {
153
+ return { code: 'ssh-config', detail: 'configurazione SSH non utilizzabile', hint: 'verifica permessi e opzioni del file ~/.ssh/config' };
154
+ }
155
+ if (/remote port forwarding failed|Could not request remote forwarding/i.test(s)) {
156
+ return { code: 'reverse-forward-denied', detail: 'il server SSH ha negato il canale inverso del nodo', hint: 'verifica permitlisten/AllowTcpForwarding sul nodo hub' };
157
+ }
158
+ if (/Could not request local forwarding|Address already in use/i.test(s)) {
159
+ return { code: 'local-forward-bind', detail: 'la porta locale scelta per il tunnel non è disponibile', hint: 'ferma il tunnel precedente e riprova' };
160
+ }
161
+ if (/Connection refused|No route to host|Connection timed out|Operation timed out|Network is unreachable/i.test(s)) {
162
+ return { code: 'ssh-unreachable', detail: 'endpoint SSH non raggiungibile', hint: 'verifica rete, hostname e porta SSH' };
163
+ }
164
+ return null;
165
+ }
166
+
167
+ function readTunnelDiagnostic(home, name, remotePort) {
168
+ if (!store.NODE_NAME_RE.test(String(name || ''))) return null;
169
+ const p = tunnelLogPath(home, name);
170
+ let fd;
171
+ try {
172
+ fd = fs.openSync(p, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0));
173
+ const st = fs.fstatSync(fd);
174
+ if (!st.isFile()) return null;
175
+ const size = Math.min(st.size, 16 * 1024);
176
+ if (!size) return null;
177
+ const buf = Buffer.alloc(size);
178
+ fs.readSync(fd, buf, 0, size, Math.max(0, st.size - size));
179
+ return classifySshFailure(buf.toString('utf8'), remotePort);
180
+ } catch (_) { return null; }
181
+ finally { if (fd !== undefined) try { fs.closeSync(fd); } catch (_) {} }
182
+ }
183
+
111
184
  function tunnelStatePath(home, name) {
112
185
  return path.join(tunnelDir(home), `${name}.state.json`);
113
186
  }
@@ -119,8 +192,16 @@ function readTunnelState(home, name) {
119
192
  if (meta && pidf.isAlive(meta)) {
120
193
  try {
121
194
  const state = JSON.parse(fs.readFileSync(tunnelStatePath(home, name), 'utf8'));
122
- if (state.status === 'up') return { status: 'up', pid: meta.pid, since: meta.startTs || null };
123
- return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: state.status || 'starting' };
195
+ const transport = typeof state.transport === 'string' ? state.transport : undefined;
196
+ const owned = state.supervisorPid === meta.pid && (!meta.runId || state.runId === meta.runId);
197
+ if (!owned) return { status: 'down', pid: meta.pid, since: meta.startTs || null, reason: 'starting' };
198
+ if (state.status === 'transport-ready' || state.status === 'up') {
199
+ return { status: 'up', phase: 'transport-ready', pid: meta.pid, since: meta.startTs || null,
200
+ ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}), ...(transport ? { transport } : {}) };
201
+ }
202
+ return { status: 'down', phase: state.status || 'starting', pid: meta.pid, since: meta.startTs || null,
203
+ reason: state.status || 'starting', ...(Number.isInteger(state.attempt) ? { attempt: state.attempt } : {}),
204
+ ...(Number.isFinite(state.delayMs) ? { retryInMs: state.delayMs } : {}), ...(transport ? { transport } : {}) };
124
205
  } catch (_) {
125
206
  // Backward compatibility for pre-supervisor pidfiles: a live direct ssh
126
207
  // process had no state sidecar and was considered up.
@@ -133,6 +214,50 @@ function readTunnelState(home, name) {
133
214
  return { status: 'down' };
134
215
  }
135
216
 
217
+ function diagnoseTunnel(home, node, state = null) {
218
+ const current = state || readTunnelState(home, node && node.name);
219
+ const ssh = node && readTunnelDiagnostic(home, node.name, node.remotePort);
220
+ if (ssh) return { stage: 'ssh', ...ssh, status: current.status, phase: current.phase || current.reason || null };
221
+ if (current.status === 'up') {
222
+ return { stage: 'transport', code: 'transport-ready', status: 'up', phase: current.phase || 'transport-ready',
223
+ detail: 'trasporto SSH stabile; verifica federation in corso' };
224
+ }
225
+ if (current.reason === 'starting' || current.phase === 'starting') {
226
+ return { stage: 'ssh', code: 'ssh-starting', status: 'starting', phase: 'starting',
227
+ detail: 'connessione SSH in avvio', hint: 'attendi qualche secondo e usa Test connessione' };
228
+ }
229
+ if (current.reason === 'retrying' || current.phase === 'retrying') {
230
+ return { stage: 'ssh', code: 'ssh-retrying', status: 'retrying', phase: 'retrying',
231
+ detail: `connessione SSH in retry${Number.isInteger(current.attempt) ? ` (tentativo ${current.attempt + 1})` : ''}`,
232
+ hint: 'usa Test connessione per vedere la causa; verifica target SSH, chiave e porta' };
233
+ }
234
+ return { stage: 'ssh', code: 'tunnel-stopped', status: 'down', phase: current.phase || null,
235
+ detail: 'connessione SSH ferma', hint: 'avvia la connessione dalla PWA, Impostazioni > Nodi' };
236
+ }
237
+
238
+ function removeStateIfOwned(home, name, meta) {
239
+ const statePath = tunnelStatePath(home, name);
240
+ try {
241
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
242
+ const samePid = !meta || !meta.pid || state.supervisorPid === meta.pid;
243
+ const sameRun = !meta || !meta.runId || state.runId === meta.runId;
244
+ if (samePid && sameRun) { fs.unlinkSync(statePath); return true; }
245
+ } catch (_) {}
246
+ return false;
247
+ }
248
+
249
+ function supervisorExited(pid, timeoutMs = 2500) {
250
+ const deadline = Date.now() + timeoutMs;
251
+ const sleeper = new Int32Array(new SharedArrayBuffer(4));
252
+ const exited = () => {
253
+ if (!pidf.pidExists(pid)) return true;
254
+ try { return fs.readFileSync(`/proc/${pid}/stat`, 'utf8').split(' ')[2] === 'Z'; }
255
+ catch (_) { return false; }
256
+ };
257
+ while (!exited() && Date.now() < deadline) Atomics.wait(sleeper, 0, 0, 25);
258
+ return exited();
259
+ }
260
+
136
261
  // Pre-flight sincrono: l'unico modo di "surfaccare" un binario ssh assente come
137
262
  // failure ESPLICITA nel valore di ritorno (lo spawn emette 'error' asincrono, non
138
263
  // catturabile sync). spawnSyncImpl iniettabile per test deterministici.
@@ -167,19 +292,40 @@ function startTunnel(opts) {
167
292
  if (!Array.isArray(args)) throw new Error('startTunnel: args mancanti');
168
293
 
169
294
  const pidPath = tunnelPidPath(home, name);
295
+ const supervisor = path.join(__dirname, 'tunnel-supervisor.js');
296
+ const runId = crypto.randomBytes(16).toString('hex');
297
+ const supervisorArgs = [supervisor, sshBin, ...args];
298
+ const cmd = `${process.execPath} ${supervisorArgs.join(' ')}`;
170
299
  const existing = pidf.readPidfile(pidPath);
171
300
  if (existing && pidf.isAlive(existing)) {
172
- return { started: false, reason: 'already running', pid: existing.pid };
301
+ // An update or automatic HTTP-port fallback can change -L/-R while the
302
+ // detached supervisor is still alive. Keep exact matches idempotent, but
303
+ // replace a supervisor whose saved argv no longer matches the desired one.
304
+ if (!existing.cmd || existing.cmd === cmd) {
305
+ return { started: false, reason: 'already running', pid: existing.pid, transport: sshBin };
306
+ }
307
+ const oldMeta = existing;
308
+ const stopped = pidf.killPidfile(pidPath);
309
+ if (!stopped.killed) return { started: false, reason: `running spec mismatch: ${stopped.reason || 'stop failed'}`, pid: existing.pid };
310
+ if (!(opts.supervisorExitedImpl || supervisorExited)(oldMeta.pid, opts.stopWaitMs || 2500)) {
311
+ return { started: false, reason: `running spec mismatch: old supervisor ${oldMeta.pid} did not exit`, pid: oldMeta.pid };
312
+ }
313
+ removeStateIfOwned(home, name, oldMeta);
173
314
  }
174
315
  pidf.cleanStale(pidPath);
175
316
 
176
- fs.mkdirSync(tunnelDir(home), { recursive: true });
177
317
  const logPath = tunnelLogPath(home, name);
178
318
  const statePath = tunnelStatePath(home, name);
179
319
  // fdProvided: il caller ci passa un fd (es. test con logFd:null) e ne rimane
180
320
  // proprietario; noi chiudiamo solo la fd che apriamo noi (no double-close).
181
321
  const fdProvided = opts.logFd !== undefined;
182
- const logFd = fdProvided ? opts.logFd : fs.openSync(logPath, 'a');
322
+ let logFd;
323
+ try {
324
+ prepareTunnelDir(home);
325
+ logFd = fdProvided ? opts.logFd : openTunnelLog(home, name).fd;
326
+ } catch (_) {
327
+ return { started: false, reason: 'unsafe tunnel log path' };
328
+ }
183
329
  let fdClosed = false;
184
330
  const closeOwnedFd = () => {
185
331
  if (fdClosed) return; fdClosed = true;
@@ -196,13 +342,17 @@ function startTunnel(opts) {
196
342
  // Spawn a detached Node supervisor. It owns ssh and retries failures with the
197
343
  // bounded backoff defined above, so CLI/server lifetime does not own liveness.
198
344
  let child;
199
- const supervisor = path.join(__dirname, 'tunnel-supervisor.js');
200
- const supervisorArgs = [supervisor, sshBin, ...args];
201
345
  try {
202
346
  child = spawnImpl(process.execPath, supervisorArgs, {
203
347
  detached: true,
204
348
  stdio: ['ignore', logFd, logFd],
205
- env: { ...process.env, AUTOSSH_GATETIME: '0', NEXUSCREW_TUNNEL_STATE: statePath },
349
+ env: {
350
+ ...process.env,
351
+ NEXUSCREW_TUNNEL_STATE: statePath,
352
+ NEXUSCREW_TUNNEL_PIDFILE: pidPath,
353
+ NEXUSCREW_TUNNEL_RUN_ID: runId,
354
+ ...(opts.stableMs === undefined ? {} : { NEXUSCREW_TUNNEL_STABLE_MS: String(opts.stableMs) }),
355
+ },
206
356
  });
207
357
  } catch (e) {
208
358
  closeOwnedFd();
@@ -216,8 +366,8 @@ function startTunnel(opts) {
216
366
  let pid = child && child.pid;
217
367
  const cleanupIfOwned = () => {
218
368
  const current = pidf.readPidfile(pidPath);
219
- if (current && current.pid === pid) pidf.removePidfile(pidPath);
220
- try { fs.unlinkSync(statePath); } catch (_) {}
369
+ if (current && current.pid === pid && current.runId === runId) pidf.removePidfile(pidPath);
370
+ removeStateIfOwned(home, name, { pid, runId });
221
371
  };
222
372
  if (child && typeof child.on === 'function') {
223
373
  child.on('error', () => {
@@ -232,10 +382,9 @@ function startTunnel(opts) {
232
382
  return { started: false, reason: 'spawn error (no pid)' };
233
383
  }
234
384
 
235
- const cmd = `${process.execPath} ${supervisorArgs.join(' ')}`;
236
385
  // writePidfile e' exclusive (wx): cleanStale sopra ha gia' tolto uno stale.
237
386
  try {
238
- pidf.writePidfile(pidPath, pid, cmd);
387
+ pidf.writePidfile(pidPath, pid, cmd, { runId });
239
388
  } catch (e) {
240
389
  try { process.kill(pid, 'SIGTERM'); } catch (_) {}
241
390
  cleanupIfOwned();
@@ -243,7 +392,7 @@ function startTunnel(opts) {
243
392
  return { started: false, reason: 'pidfile error', error: String(e && e.message || e) };
244
393
  }
245
394
  closeOwnedFd(); // copia del padre: il figlio ha la sua dup; nessun leak (audit F3)
246
- return { started: true, pid, logPath };
395
+ return { started: true, pid, logPath, transport: sshBin };
247
396
  }
248
397
 
249
398
  // Ferma il tunnel: kill verificato via pidfile (mai broad match by name).
@@ -251,8 +400,13 @@ function stopTunnel(opts) {
251
400
  const home = opts.home || os.homedir();
252
401
  const name = opts.name;
253
402
  if (!name) throw new Error('stopTunnel: name mancante');
254
- const r = pidf.killPidfile(tunnelPidPath(home, name));
255
- try { fs.unlinkSync(tunnelStatePath(home, name)); } catch (_) {}
403
+ const pidPath = tunnelPidPath(home, name);
404
+ const meta = pidf.readPidfile(pidPath);
405
+ const r = pidf.killPidfile(pidPath);
406
+ if (r.killed && !(opts.supervisorExitedImpl || supervisorExited)(r.pid, opts.stopWaitMs || 2500)) {
407
+ return { stopped: false, pid: r.pid, reason: `supervisor ${r.pid} did not exit after SIGTERM` };
408
+ }
409
+ removeStateIfOwned(home, name, meta);
256
410
  return { stopped: r.killed, pid: r.pid, reason: r.reason };
257
411
  }
258
412
 
@@ -264,29 +418,24 @@ function restartTunnel(opts) {
264
418
  // Helper di alto livello: costruisce args dal nodo e avvia il forward.
265
419
  function startForward(opts) {
266
420
  const node = opts.node;
267
- let sshBin = opts.sshBin;
268
- let args = buildForwardArgs({ ...node, localAppPort: opts.localAppPort });
269
- const wanted = node.transport || (node.keyPath ? 'ssh' : 'auto');
270
- if (!sshBin && (wanted === 'auto' || wanted === 'autossh') && sshBinaryAvailable('autossh', opts.spawnSyncImpl)) {
271
- sshBin = 'autossh';
272
- args = ['-M', '0', ...args];
273
- } else if (!sshBin) {
274
- sshBin = 'ssh';
275
- }
421
+ const args = buildForwardArgs({ ...node, localAppPort: opts.localAppPort });
422
+ // NexusCrew already owns retry/backoff in tunnel-supervisor.js. Nesting
423
+ // autossh inside that supervisor made liveness dishonest: autossh could stay
424
+ // alive while every SSH child exited 255, so the UI reported "tunnel up".
425
+ // Use one portable supervisor around OpenSSH on Linux, macOS and Termux.
426
+ // `transport: autossh` remains readable for old stores but is normalized at
427
+ // runtime to supervised ssh.
428
+ const sshBin = opts.sshBin || 'ssh';
276
429
  return startTunnel({ ...opts, sshBin, name: node.name, args });
277
430
  }
278
431
 
279
- // Helper di alto livello: reverse verso il rendezvous.
432
+ // Cleanup identifier for pre-0.8.10 standalone rendezvous supervisors. New
433
+ // runtimes never start this second connection; stop/restart can still remove a
434
+ // stale legacy process during migration.
280
435
  const REVERSE_NAME = '__rendezvous__';
281
- function startReverse(opts) {
282
- const args = buildReverseArgs(opts.rendezvous);
283
- return startTunnel({ ...opts, name: REVERSE_NAME, args });
284
- }
285
436
 
286
- // --- Capability OpenSSH (permitlisten, design §7 advisory (a)) --------------
287
- // permitlisten (vincola i -R) esiste da OpenSSH 7.8. Serve sul rendezvous per il
288
- // ruolo node; il check locale su `ssh -V` e' l'unico verificabile in autonomia.
289
- // null = versione non determinabile (non un fail: warn + verifica manuale).
437
+ // Versione client OpenSSH, solo diagnostica. Non inferire mai da questa la
438
+ // policy `permitlisten` del server remoto: quella si prova con il vero -R.
290
439
  function readSshVersion(spawnSyncImpl) {
291
440
  try {
292
441
  const r = (spawnSyncImpl || spawnSync)('ssh', ['-V'], { encoding: 'utf8' });
@@ -297,19 +446,15 @@ function readSshVersion(spawnSyncImpl) {
297
446
  } catch (_) { return null; }
298
447
  }
299
448
 
300
- // true/false se determinabile, null se versione ignota.
301
- function sshSupportsPermitlisten(v) {
302
- if (!v) return null;
303
- if (v.major > 7) return true;
304
- if (v.major === 7 && v.minor >= 8) return true;
305
- return false;
306
- }
307
-
308
449
  module.exports = {
309
450
  SSH_BASE_OPTS,
310
- buildForwardArgs, buildReverseArgs, backoffDelay,
451
+ buildForwardArgs, backoffDelay,
311
452
  tunnelDir, tunnelPidPath, tunnelLogPath, tunnelStatePath, readTunnelState,
312
- startTunnel, stopTunnel, restartTunnel, startForward, startReverse,
453
+ prepareTunnelDir, openTunnelLog,
454
+ classifySshFailure, readTunnelDiagnostic,
455
+ diagnoseTunnel,
456
+ removeStateIfOwned, supervisorExited,
457
+ startTunnel, stopTunnel, restartTunnel, startForward,
313
458
  REVERSE_NAME,
314
- readSshVersion, sshSupportsPermitlisten, sshBinaryAvailable,
459
+ readSshVersion, sshBinaryAvailable,
315
460
  };
@@ -29,7 +29,10 @@ function peerAllows(peer, otherId) {
29
29
 
30
30
  function canTransit(ingress, egress) {
31
31
  if (!ingress || !egress || ingress.name === egress.name) return !ingress;
32
- return peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
32
+ // `shared` is the explicit publication gate. Visibility remains the hub ACL,
33
+ // but it cannot make a private peer routable on its own.
34
+ return egress.shared === true
35
+ && peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
33
36
  }
34
37
 
35
38
  function parseRoute(raw) {
@@ -54,6 +57,13 @@ function knownResource(resource) {
54
57
  || resource === '/files'
55
58
  || resource === '/files/download'
56
59
  || resource === '/files/upload'
60
+ || resource === '/decks'
61
+ || /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
62
+ || resource === '/topology'
63
+ // A connected client may ask its hub to mint a hub-owned, one-time
64
+ // pairing invite. This is the only settings mutation exposed through
65
+ // Hydra: the rest of /settings stays unreachable.
66
+ || resource === '/settings/peering/invite'
57
67
  || resource === '/ws'
58
68
  || /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource);
59
69
  }
@@ -66,6 +76,12 @@ function allowedResource(resource, method = 'GET') {
66
76
  if (resource === '/files') return method === 'GET' || method === 'DELETE';
67
77
  if (resource === '/files/download') return method === 'GET';
68
78
  if (resource === '/files/upload') return method === 'POST';
79
+ if (resource === '/decks') return method === 'GET' || method === 'POST';
80
+ if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
81
+ return method === 'PUT' || method === 'PATCH' || method === 'DELETE';
82
+ }
83
+ if (resource === '/topology') return method === 'GET';
84
+ if (resource === '/settings/peering/invite') return method === 'POST';
69
85
  if (resource === '/ws') return method === 'GET';
70
86
  if (/^\/fleet\/(status|schema|definitions)$/.test(resource)) return method === 'GET';
71
87
  if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource)) return method === 'POST';
@@ -103,7 +119,8 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
103
119
  return proxyHttp(req, res, { port: typeof localPort === 'function' ? localPort() : localPort, path: `/api${parsed.resource}${queryOf(req.url)}`, credential: localCredential() });
104
120
  }
105
121
  const next = st && store.getNode(st, parsed.route[0]);
106
- if (!next || !next.token || (ingress && !canTransit(ingress, next))) return res.status(403).json({ error: 'route non consentita' });
122
+ const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
123
+ if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return res.status(403).json({ error: 'route non consentita' });
107
124
  const rest = parsed.route.slice(1);
108
125
  const path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}${queryOf(req.url)}`;
109
126
  proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited });
@@ -194,7 +211,11 @@ async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HO
194
211
  const out = [];
195
212
  const authoritative = [];
196
213
  for (const n of st.nodes) {
197
- if (!n.nodeId || seen.has(n.nodeId) || (ingress && !canTransit(ingress, n))) continue;
214
+ // The local installation always keeps its outbound hub visible. Inbound
215
+ // clients become part of Hydra only after their explicit Share toggle.
216
+ if (!n.nodeId || seen.has(n.nodeId)
217
+ || (!ingress && n.direction === 'inbound' && n.shared !== true)
218
+ || (ingress && !canTransit(ingress, n))) continue;
198
219
  out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
199
220
  if (ttl <= 1 || !n.token) continue;
200
221
  try {
@@ -234,7 +255,9 @@ async function collectTopology(opts) {
234
255
  async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000) }) {
235
256
  const live = await collectTopologyDetailed({ nodesPath, fetchImpl });
236
257
  const st = store.loadStore(nodesPath);
237
- const directNames = new Set(((st && st.nodes) || []).map((n) => n.name));
258
+ const directNames = new Set(((st && st.nodes) || [])
259
+ .filter((n) => n.direction !== 'inbound' || n.shared === true)
260
+ .map((n) => n.name));
238
261
  const authoritative = new Set(live.authoritative);
239
262
  const liveIds = new Set(live.nodes.map((n) => n.instanceId));
240
263
  const liveRoutes = new Set(live.nodes.map((n) => n.route.join('/')));
@@ -264,7 +287,7 @@ async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, n
264
287
  return { instanceId: live.instanceId, nodes };
265
288
  }
266
289
 
267
- function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly, version = null, roles = null }) {
290
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null }) {
268
291
  const r = express.Router();
269
292
  r.use((req, res, next) => {
270
293
  const peer = peerFromToken(nodesPath, bearerFrom(req));
@@ -286,6 +309,44 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
286
309
  const visited = String(req.query.visited || '').split(',');
287
310
  res.json(await collectTopology({ nodesPath, ingress: req.peer, ttl, visited, fetchImpl }));
288
311
  });
312
+ // A connected client publishes itself through the SAME SSH connection by
313
+ // toggling its optional -R channel. The hub records that intent only after a
314
+ // real authenticated health probe succeeds; Share off is immediate/fail-safe.
315
+ r.post('/share', express.json({ limit: '1kb' }), async (req, res) => {
316
+ if (readonly()) return res.status(403).json({ error: 'READONLY: share bloccato' });
317
+ const body = req.body || {};
318
+ if (Object.keys(body).some((k) => k !== 'shared') || typeof body.shared !== 'boolean') {
319
+ return res.status(400).json({ error: 'body non valido: atteso {shared:boolean}' });
320
+ }
321
+ try {
322
+ if (body.shared) {
323
+ const health = await probeHealth({
324
+ port: req.peer.localPort,
325
+ token: req.peer.token,
326
+ expectedInstanceId: req.peer.nodeId || null,
327
+ fetchImpl: fetchImpl || fetch,
328
+ });
329
+ if (health.status !== 'healthy') {
330
+ return res.status(409).json({
331
+ error: 'canale share non raggiungibile',
332
+ detail: health.detail || 'reverse SSH non pronto',
333
+ });
334
+ }
335
+ }
336
+ let st = store.loadOrInitStore(nodesPath);
337
+ const current = store.getNode(st, req.peer.name);
338
+ if (!current) return res.status(404).json({ error: 'peer non trovato' });
339
+ st = store.updateNode(st, current.name, {
340
+ shared: body.shared,
341
+ roles: { ...current.roles, node: body.shared },
342
+ rolesKnown: true,
343
+ });
344
+ store.atomicWriteStore(nodesPath, st);
345
+ return res.json({ shared: body.shared });
346
+ } catch (e) {
347
+ return res.status(500).json({ error: String(e && e.message || e) });
348
+ }
349
+ });
289
350
  r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
290
351
  return r;
291
352
  }
@@ -307,7 +368,8 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
307
368
  let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential(); let path = '/ws';
308
369
  if (parsed.route.length) {
309
370
  const next = store.getNode(st, parsed.route[0]);
310
- if (!next || !next.token || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
371
+ const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
372
+ if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
311
373
  port = next.localPort; credential = next.token;
312
374
  const rest = parsed.route.slice(1);
313
375
  path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}/ws`;