@mmmbuto/nexuscrew 0.8.15 → 0.8.16

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.
@@ -11,7 +11,7 @@
11
11
  <meta name="apple-mobile-web-app-title" content="NexusCrew" />
12
12
  <link rel="manifest" href="/manifest.json" />
13
13
  <title>NexusCrew</title>
14
- <script type="module" crossorigin src="/assets/index-BD-7qEsn.js"></script>
14
+ <script type="module" crossorigin src="/assets/index-B9RXe5E4.js"></script>
15
15
  <link rel="stylesheet" crossorigin href="/assets/index-C-JOkuIc.css">
16
16
  </head>
17
17
  <body>
@@ -1 +1 @@
1
- {"version":"0.8.15"}
1
+ {"version":"0.8.16"}
@@ -162,6 +162,12 @@ function stopManagedTunnels(opts = {}) {
162
162
  if (st && st.rendezvous) {
163
163
  try { nodesTunnel.stopTunnel({ home, name: nodesTunnel.REVERSE_NAME }); stopped.push(nodesTunnel.REVERSE_NAME); } catch (_) {}
164
164
  }
165
+ // Also recover supervisors whose node was already removed by an older or
166
+ // interrupted runtime. The pidfile verifier prevents broad process kills.
167
+ const recovered = nodesTunnel.reconcileTunnelSupervisors({ home, configuredNames: [] });
168
+ for (const name of [...recovered.stopped, ...recovered.cleaned]) {
169
+ if (!stopped.includes(name)) stopped.push(name);
170
+ }
165
171
  return stopped;
166
172
  }
167
173
 
@@ -3,6 +3,7 @@
3
3
  // Detached supervisor for one SSH tunnel. The parent NexusCrew process can exit;
4
4
  // this process keeps the tunnel alive and retries failures with bounded backoff.
5
5
  const fs = require('node:fs');
6
+ const net = require('node:net');
6
7
  const path = require('node:path');
7
8
  const { spawn } = require('node:child_process');
8
9
  const { backoffDelay } = require('./tunnel.js');
@@ -24,13 +25,70 @@ let stopping = false;
24
25
  let attempt = 0;
25
26
  let retryTimer = null;
26
27
  let upTimer = null;
28
+ let forwardProbeTimer = null;
29
+ let forwardSocket = null;
27
30
  let ownershipWaitTimer = null;
28
31
  let ownershipTimer = null;
29
32
 
33
+ function localForwardPort(args) {
34
+ for (let i = 0; i < args.length - 1; i += 1) {
35
+ if (args[i] !== '-L') continue;
36
+ const match = String(args[i + 1] || '').match(/^127\.0\.0\.1:(\d+):127\.0\.0\.1:\d+$/);
37
+ const port = match ? Number(match[1]) : 0;
38
+ if (Number.isInteger(port) && port >= 1 && port <= 65535) return port;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ const forwardPort = localForwardPort(sshArgs);
44
+
30
45
  function logEvent(message) {
31
46
  try { process.stderr.write(`[nexuscrew] ${String(message).replace(/[\r\n]+/g, ' ')}\n`); } catch (_) {}
32
47
  }
33
48
 
49
+ function clearForwardProbe() {
50
+ clearTimeout(forwardProbeTimer);
51
+ forwardProbeTimer = null;
52
+ if (forwardSocket) {
53
+ try { forwardSocket.destroy(); } catch (_) {}
54
+ forwardSocket = null;
55
+ }
56
+ }
57
+
58
+ // A live ssh PID is not proof of authentication: the process may still be
59
+ // blocked connecting to an unreachable endpoint. Opening the local -L socket
60
+ // forces OpenSSH to establish the real forward channel. Only that event may
61
+ // advertise transport-ready or reset retry backoff.
62
+ function probeForward(expectedChild) {
63
+ if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
64
+ if (!forwardPort) {
65
+ writeState('transport-probing', { sshPid: child.pid, detail: 'local forward unavailable' });
66
+ return;
67
+ }
68
+ let settled = false;
69
+ const socket = net.connect({ host: '127.0.0.1', port: forwardPort });
70
+ forwardSocket = socket;
71
+ const done = (ready) => {
72
+ if (settled) return;
73
+ settled = true;
74
+ try { socket.destroy(); } catch (_) {}
75
+ if (forwardSocket === socket) forwardSocket = null;
76
+ if (stopping || child !== expectedChild || !child || child.exitCode != null) return;
77
+ if (ready) {
78
+ attempt = 0;
79
+ logEvent(`forward ready stableMs=${stableMs}`);
80
+ if (!writeState('transport-ready', { sshPid: child.pid, stableMs, probe: 'tcp-forward' })) stop();
81
+ return;
82
+ }
83
+ if (!writeState('transport-probing', { sshPid: child.pid, stableMs })) return stop();
84
+ forwardProbeTimer = setTimeout(() => probeForward(expectedChild), 250);
85
+ };
86
+ socket.setTimeout(1000);
87
+ socket.once('connect', () => done(true));
88
+ socket.once('error', () => done(false));
89
+ socket.once('timeout', () => done(false));
90
+ }
91
+
34
92
  function ownsGeneration() {
35
93
  try {
36
94
  const meta = JSON.parse(fs.readFileSync(pidPath, 'utf8'));
@@ -79,19 +137,18 @@ function run() {
79
137
  if (failureHandled) return;
80
138
  failureHandled = true;
81
139
  clearTimeout(upTimer);
140
+ clearForwardProbe();
82
141
  child = null;
83
142
  scheduleRetry(detail);
84
143
  };
85
144
  child.once('spawn', () => {
86
145
  logEvent(`ssh attempt=${attempt + 1} spawned`);
87
- // ExitOnForwardFailure makes bind/auth failures exit. Do not reset backoff
88
- // or advertise readiness on the spawn event: only a stable transport window
89
- // qualifies as transport-ready; HTTP federation health is checked above us.
146
+ // ExitOnForwardFailure only proves that the local bind was accepted. It
147
+ // does not prove authentication or remote reachability, so after the
148
+ // stability window require a real TCP open through the -L channel.
90
149
  upTimer = setTimeout(() => {
91
150
  if (!stopping && child && child.exitCode == null) {
92
- attempt = 0;
93
- logEvent(`transport ready stableMs=${stableMs}`);
94
- if (!writeState('transport-ready', { sshPid: child.pid, stableMs })) stop();
151
+ probeForward(child);
95
152
  }
96
153
  }, stableMs);
97
154
  });
@@ -109,6 +166,7 @@ function run() {
109
166
  function finish() {
110
167
  clearTimeout(retryTimer);
111
168
  clearTimeout(upTimer);
169
+ clearForwardProbe();
112
170
  clearTimeout(ownershipWaitTimer);
113
171
  clearInterval(ownershipTimer);
114
172
  if (ownsGeneration()) {
@@ -126,6 +184,7 @@ function stop() {
126
184
  stopping = true;
127
185
  clearTimeout(retryTimer);
128
186
  clearTimeout(upTimer);
187
+ clearForwardProbe();
129
188
  if (child && child.exitCode == null) {
130
189
  try { child.kill('SIGTERM'); } catch (_) {}
131
190
  setTimeout(() => { try { if (child && child.exitCode == null) child.kill('SIGKILL'); } catch (_) {} finish(); }, 1500).unref();
@@ -28,6 +28,7 @@ const SSH_BASE_OPTS = Object.freeze([
28
28
  '-o', 'ServerAliveInterval=30',
29
29
  '-o', 'ServerAliveCountMax=3',
30
30
  '-o', 'BatchMode=yes',
31
+ '-o', 'ConnectTimeout=15',
31
32
  // A Host stanza may set LogLevel=QUIET, leaving the per-tunnel diagnostic
32
33
  // completely empty even for bind/auth/forward failures. ERROR is still
33
34
  // quiet on success and never logs key material, but preserves real failures.
@@ -101,6 +102,49 @@ function tunnelLogPath(home, name) {
101
102
  return path.join(tunnelDir(home), `${name}.log`);
102
103
  }
103
104
 
105
+ // Cleanup identifier for pre-0.8.10 standalone rendezvous supervisors. New
106
+ // runtimes never start this second connection; reconciliation can still stop a
107
+ // stale legacy process left by an older install.
108
+ const REVERSE_NAME = '__rendezvous__';
109
+
110
+ // Reconcile detached supervisors against the authoritative node store. A
111
+ // server crash or an older rollback could leave a valid supervisor pidfile
112
+ // after its node disappeared; startup and `nexuscrew stop` must recover it
113
+ // without broad process matching. Only strict NexusCrew pidfile names are
114
+ // considered and stopTunnel still performs pid+cmd verification.
115
+ function tunnelPidNames(home) {
116
+ const dir = tunnelDir(home);
117
+ try {
118
+ const root = fs.lstatSync(dir);
119
+ if (root.isSymbolicLink() || !root.isDirectory()) return [];
120
+ return fs.readdirSync(dir, { withFileTypes: true })
121
+ .filter((entry) => entry.isFile() && !entry.isSymbolicLink() && entry.name.endsWith('.pid'))
122
+ .map((entry) => entry.name.slice(0, -4))
123
+ .filter((name) => store.NODE_NAME_RE.test(name) || name === REVERSE_NAME)
124
+ .sort();
125
+ } catch (_) { return []; }
126
+ }
127
+
128
+ function reconcileTunnelSupervisors({ home = os.homedir(), configuredNames = [], stopImpl } = {}) {
129
+ const keep = new Set((Array.isArray(configuredNames) ? configuredNames : [])
130
+ .filter((name) => store.NODE_NAME_RE.test(String(name || ''))));
131
+ const stopOne = typeof stopImpl === 'function' ? stopImpl : stopTunnel;
132
+ const result = { kept: [], stopped: [], cleaned: [], failed: [] };
133
+ const safeAbsent = new Set(['no pidfile', 'stale (pid dead)', 'pid reuse (cmd mismatch)']);
134
+ for (const name of tunnelPidNames(home)) {
135
+ if (keep.has(name)) { result.kept.push(name); continue; }
136
+ try {
137
+ const out = stopOne({ home, name });
138
+ if (out && out.stopped) result.stopped.push(name);
139
+ else if (out && safeAbsent.has(out.reason)) result.cleaned.push(name);
140
+ else result.failed.push({ name, reason: (out && out.reason) || 'stop failed' });
141
+ } catch (error) {
142
+ result.failed.push({ name, reason: String((error && error.message) || error) });
143
+ }
144
+ }
145
+ return result;
146
+ }
147
+
104
148
  function prepareTunnelDir(home) {
105
149
  const dir = tunnelDir(home);
106
150
  try {
@@ -150,7 +194,10 @@ function classifySshFailure(text, remotePort) {
150
194
  };
151
195
  }
152
196
  if (/Permission denied \((?:publickey|password|keyboard-interactive)[^)]*\)|No supported authentication methods available/i.test(s)) {
153
- 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' };
197
+ return {
198
+ code: 'ssh-auth-failed', detail: 'autenticazione SSH rifiutata',
199
+ hint: 'usa lo stesso Host o alias che funziona con il client SSH di questo dispositivo; chiavi e alias restano locali e il link NON e\' stato consumato',
200
+ };
154
201
  }
155
202
  if (/REMOTE HOST IDENTIFICATION HAS CHANGED|Host key verification failed|authenticity of host .* can'?t be established/i.test(s)) {
156
203
  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' };
@@ -454,11 +501,6 @@ function startForward(opts) {
454
501
  return startTunnel({ ...opts, sshBin, name: node.name, args });
455
502
  }
456
503
 
457
- // Cleanup identifier for pre-0.8.10 standalone rendezvous supervisors. New
458
- // runtimes never start this second connection; stop/restart can still remove a
459
- // stale legacy process during migration.
460
- const REVERSE_NAME = '__rendezvous__';
461
-
462
504
  // Versione client OpenSSH, solo diagnostica. Non inferire mai da questa la
463
505
  // policy `permitlisten` del server remoto: quella si prova con il vero -R.
464
506
  function readSshVersion(spawnSyncImpl) {
@@ -476,6 +518,7 @@ module.exports = {
476
518
  buildForwardArgs, backoffDelay,
477
519
  tunnelDir, tunnelPidPath, tunnelLogPath, tunnelStatePath, readTunnelState,
478
520
  prepareTunnelDir, openTunnelLog,
521
+ tunnelPidNames, reconcileTunnelSupervisors,
479
522
  classifySshFailure, readTunnelDiagnostic,
480
523
  diagnoseTunnel,
481
524
  removeStateIfOwned, supervisorExited,
package/lib/server.js CHANGED
@@ -136,6 +136,14 @@ function createServer(opts = {}) {
136
136
  if (tunnelsStarted) return;
137
137
  tunnelsStarted = true;
138
138
  const st = nodesStore.loadStore(nodesPath);
139
+ const configured = ((st && st.nodes) || [])
140
+ .filter((node) => node.direction !== 'inbound')
141
+ .map((node) => node.name);
142
+ const reconcile = cfg.reconcileTunnelSupervisorsImpl || nodesTunnel.reconcileTunnelSupervisors;
143
+ const recovered = reconcile({ home: cfg.home || os.homedir(), configuredNames: configured });
144
+ for (const failure of recovered.failed || []) {
145
+ process.stderr.write(`[nexuscrew] orphan tunnel cleanup failed for ${failure.name}: ${failure.reason}\n`);
146
+ }
139
147
  // Exactly one connection per configured hub. Legacy `roles.node` /
140
148
  // rendezvous data is migration-only and never starts a second hidden SSH
141
149
  // process. Publishing this device is the optional -R on its outbound link.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.15",
3
+ "version": "0.8.16",
4
4
  "description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
5
5
  "main": "lib/server.js",
6
6
  "bin": {