@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.
- package/README.md +24 -13
- package/frontend/dist/assets/{index-BD-7qEsn.js → index-B9RXe5E4.js} +20 -20
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +6 -0
- package/lib/nodes/tunnel-supervisor.js +65 -6
- package/lib/nodes/tunnel.js +49 -6
- package/lib/server.js +8 -0
- package/package.json +1 -1
package/frontend/dist/index.html
CHANGED
|
@@ -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-
|
|
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.
|
|
1
|
+
{"version":"0.8.16"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -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
|
|
88
|
-
//
|
|
89
|
-
//
|
|
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
|
-
|
|
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();
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -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 {
|
|
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.
|