@mmmbuto/nexuscrew 0.8.7 → 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.
@@ -103,8 +103,8 @@ const LABEL_MAX = 64;
103
103
  // tollerato oltre a quelli noti (schema chiuso: garbage -> null, non guess).
104
104
  const NODE_KEYS = new Set([
105
105
  'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
106
- 'roles', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
107
- 'direction', 'reversePort', 'label',
106
+ 'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
107
+ 'direction', 'reversePort', 'shared', 'label',
108
108
  ]);
109
109
  function parseNode(n, schemaVersion = SCHEMA_VERSION) {
110
110
  if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
@@ -123,6 +123,10 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
123
123
  if (schemaVersion === LEGACY_SCHEMA_VERSION && !identityFile) return null;
124
124
  const roles = parseRoles(n.roles);
125
125
  if (!roles) return null;
126
+ if (n.rolesKnown !== undefined && typeof n.rolesKnown !== 'boolean') return null;
127
+ // Before 0.8.9 `roles` was filled with a local default and did not describe
128
+ // the remote peer. Only the explicit marker makes role-based health safe.
129
+ const rolesKnown = n.rolesKnown === true;
126
130
  // label (display) opzionale ma, se presente, strict: stringa 1..LABEL_MAX, no
127
131
  // control char (newline/tab inclusi: una label su piu' righe non ha senso in
128
132
  // UI), no solo-spazi. Garbage -> null (schema chiuso), mai guess/truncate.
@@ -140,15 +144,22 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
140
144
  remotePort: n.remotePort,
141
145
  localPort: n.localPort,
142
146
  roles,
147
+ rolesKnown,
143
148
  direction,
144
149
  transport: n.transport || (direction === 'inbound' ? 'inbound' : (schemaVersion === LEGACY_SCHEMA_VERSION ? 'ssh' : 'auto')),
145
150
  autostart: n.autostart === undefined ? schemaVersion !== LEGACY_SCHEMA_VERSION : n.autostart,
151
+ // A paired device is private by default. `shared` only controls whether the
152
+ // hub may advertise/route this peer and whether the outbound SSH session
153
+ // requests the optional reverse (-R) channel. Old stores therefore migrate
154
+ // safely to private without a schema bump.
155
+ shared: n.shared === undefined ? false : n.shared,
146
156
  visibility: n.visibility || 'network',
147
157
  };
148
158
  if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
149
159
  if (direction === 'inbound' && out.transport !== 'inbound') return null;
150
160
  if (direction === 'outbound' && out.transport === 'inbound') return null;
151
161
  if (typeof out.autostart !== 'boolean') return null;
162
+ if (typeof out.shared !== 'boolean') return null;
152
163
  if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
153
164
  if (identityFile) out.identityFile = identityFile;
154
165
  // Keep the old public field while reading v1 so old callers/tests and a
@@ -183,7 +194,9 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
183
194
  return out;
184
195
  }
185
196
 
186
- // rendezvous (ruolo node/reverse): dove questa installazione si pubblica.
197
+ // Legacy rendezvous record: read-only migration data from pre-0.8.10. It is
198
+ // parsed so an existing store remains loadable, but no new runtime path writes
199
+ // or starts it.
187
200
  const RDV_KEYS = new Set(['ssh', 'publishedPort', 'localPort', 'keyPath']);
188
201
  function parseRendezvous(r) {
189
202
  if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
@@ -357,18 +370,6 @@ function updateNode(store, name, patch) {
357
370
  return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
358
371
  }
359
372
 
360
- function setRendezvous(store, rdv) {
361
- const parsed = parseRendezvous(rdv);
362
- if (!parsed) throw new Error('rendezvous non valido: controlla ssh/publishedPort/localPort/keyPath');
363
- return { ...store, schemaVersion: SCHEMA_VERSION, rendezvous: parsed };
364
- }
365
-
366
- function clearRendezvous(store) {
367
- const out = { ...store, schemaVersion: SCHEMA_VERSION };
368
- delete out.rendezvous;
369
- return out;
370
- }
371
-
372
373
  // --- Redazione (view sicura per status/list: MAI il token) ------------------
373
374
 
374
375
  function redactNode(n) {
@@ -379,8 +380,11 @@ function redactNode(n) {
379
380
  remotePort: n.remotePort,
380
381
  localPort: n.localPort,
381
382
  direction: n.direction || 'outbound',
383
+ roles: { ...n.roles },
384
+ rolesKnown: n.rolesKnown === true,
382
385
  transport: n.transport || 'ssh',
383
386
  autostart: !!n.autostart,
387
+ shared: n.shared === true,
384
388
  visibility: n.visibility || 'network',
385
389
  hasToken: !!n.token, // presenza, non il valore
386
390
  paired: !!(n.token && n.acceptToken),
@@ -398,11 +402,17 @@ function redactStore(store) {
398
402
  nodeId: store.nodeId,
399
403
  nodes: store.nodes.map(redactNode),
400
404
  };
401
- // rendezvous non contiene token: sicuro da esporre integralmente.
402
- if (store.rendezvous) out.rendezvous = { ...store.rendezvous };
403
405
  return out;
404
406
  }
405
407
 
408
+ // A peer with both scoped credentials has completed pairing. Its advertised
409
+ // HTTP port is part of the established SSH/federation contract; silently
410
+ // moving that port would strand the peer even though the local PWA still works.
411
+ function hasPairedPeers(store) {
412
+ return !!(store && Array.isArray(store.nodes)
413
+ && store.nodes.some((node) => validToken(node.token) && validToken(node.acceptToken)));
414
+ }
415
+
406
416
  // --- Migrazione esplicita da config.json (guarded, no-op se assente) ---------
407
417
  // Se config.json contiene un array `nodes` legacy (vecchio formato pre-B0),
408
418
  // lo importa in nodes.json. Guarded: no-op se config.json non ha `nodes`, o se
@@ -486,13 +496,13 @@ function suggestNodeName(input, existing = []) {
486
496
 
487
497
  module.exports = {
488
498
  // parse/validate
489
- parseStore, parseNode, parseRendezvous, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
499
+ parseStore, parseNode, parseRendezvous, parseRoles, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
490
500
  // I/O
491
501
  defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
492
502
  // mutazioni
493
- getNode, addNode, removeNode, setNodeToken, updateNode, setRendezvous, clearRendezvous,
503
+ getNode, addNode, removeNode, setNodeToken, updateNode,
494
504
  // redazione
495
- redactNode, redactStore,
505
+ redactNode, redactStore, hasPairedPeers,
496
506
  // migrazione
497
507
  migrateLegacyNodes,
498
508
  // label / slug
@@ -10,7 +10,11 @@ const { backoffDelay } = require('./tunnel.js');
10
10
  const sshBin = process.argv[2];
11
11
  const sshArgs = process.argv.slice(3);
12
12
  const statePath = process.env.NEXUSCREW_TUNNEL_STATE;
13
- if (!sshBin || !statePath) process.exit(2);
13
+ const pidPath = process.env.NEXUSCREW_TUNNEL_PIDFILE;
14
+ const runId = process.env.NEXUSCREW_TUNNEL_RUN_ID;
15
+ const stableMsRaw = Number(process.env.NEXUSCREW_TUNNEL_STABLE_MS || 3000);
16
+ const stableMs = Number.isFinite(stableMsRaw) && stableMsRaw >= 100 ? Math.min(stableMsRaw, 30000) : 3000;
17
+ if (!sshBin || !statePath || !pidPath || !runId) process.exit(2);
14
18
 
15
19
  let child = null;
16
20
  let stopping = false;
@@ -18,16 +22,26 @@ let attempt = 0;
18
22
  let retryTimer = null;
19
23
  let upTimer = null;
20
24
 
25
+ function ownsGeneration() {
26
+ try {
27
+ const meta = JSON.parse(fs.readFileSync(pidPath, 'utf8'));
28
+ return meta && meta.pid === process.pid && meta.runId === runId;
29
+ } catch (_) { return false; }
30
+ }
31
+
21
32
  function writeState(status, extra = {}) {
22
- const tmp = `${statePath}.tmp.${process.pid}`;
23
- const data = { status, supervisorPid: process.pid, attempt, updatedAt: Date.now(), ...extra };
33
+ if (!ownsGeneration()) return false;
34
+ const tmp = `${statePath}.tmp.${process.pid}.${runId}`;
35
+ const data = { status, runId, transport: path.basename(sshBin), supervisorPid: process.pid, attempt, updatedAt: Date.now(), ...extra };
24
36
  try {
25
37
  fs.mkdirSync(path.dirname(statePath), { recursive: true });
26
38
  fs.writeFileSync(tmp, `${JSON.stringify(data)}\n`, { mode: 0o600 });
27
39
  fs.chmodSync(tmp, 0o600);
28
40
  fs.renameSync(tmp, statePath);
41
+ return true;
29
42
  } catch (_) {
30
43
  try { fs.unlinkSync(tmp); } catch (_e) {}
44
+ return false;
31
45
  }
32
46
  }
33
47
 
@@ -58,14 +72,15 @@ function run() {
58
72
  scheduleRetry(detail);
59
73
  };
60
74
  child.once('spawn', () => {
61
- // ExitOnForwardFailure makes bind/auth failures exit. Surviving this short
62
- // grace window is the best portable readiness signal available for ssh -N.
75
+ // ExitOnForwardFailure makes bind/auth failures exit. Do not reset backoff
76
+ // or advertise readiness on the spawn event: only a stable transport window
77
+ // qualifies as transport-ready; HTTP federation health is checked above us.
63
78
  upTimer = setTimeout(() => {
64
79
  if (!stopping && child && child.exitCode == null) {
65
80
  attempt = 0;
66
- writeState('up', { sshPid: child.pid });
81
+ writeState('transport-ready', { sshPid: child.pid, stableMs });
67
82
  }
68
- }, 750);
83
+ }, stableMs);
69
84
  });
70
85
  child.once('error', (e) => {
71
86
  handleFailure(String(e && e.message || e));
@@ -79,8 +94,13 @@ function run() {
79
94
  function finish() {
80
95
  clearTimeout(retryTimer);
81
96
  clearTimeout(upTimer);
82
- writeState('down');
83
- try { fs.unlinkSync(statePath); } catch (_) {}
97
+ if (ownsGeneration()) {
98
+ writeState('down');
99
+ try {
100
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
101
+ if (state.runId === runId && state.supervisorPid === process.pid) fs.unlinkSync(statePath);
102
+ } catch (_) {}
103
+ }
84
104
  process.exit(0);
85
105
  }
86
106
 
@@ -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
  };