@mmmbuto/nexuscrew 0.8.9 → 0.8.11
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 +48 -20
- package/frontend/dist/assets/index-DoUIJ4GM.js +91 -0
- package/frontend/dist/assets/index-DrrRAIg6.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/auth/token.js +1 -1
- package/lib/cli/commands.js +287 -147
- package/lib/cli/doctor.js +37 -14
- package/lib/cli/init.js +2 -2
- package/lib/cli/pidfile.js +3 -2
- package/lib/cli/service.js +52 -0
- package/lib/decks/store.js +5 -2
- package/lib/fleet/builtin.js +12 -1
- package/lib/mcp/server.js +161 -0
- package/lib/nodes/commands.js +95 -123
- package/lib/nodes/health.js +22 -14
- package/lib/nodes/peering.js +72 -10
- package/lib/nodes/store.js +21 -18
- package/lib/nodes/tunnel-supervisor.js +29 -9
- package/lib/nodes/tunnel.js +217 -72
- package/lib/proxy/federation.js +68 -6
- package/lib/server.js +36 -31
- package/lib/settings/routes.js +202 -95
- package/lib/update/runner.js +4 -1
- package/package.json +2 -2
- package/skills/nexuscrew-agent/SKILL.md +6 -2
- package/frontend/dist/assets/index-9Drd8g0k.css +0 -32
- package/frontend/dist/assets/index-DTmT7yhV.js +0 -91
|
@@ -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
|
-
|
|
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
|
-
|
|
23
|
-
const
|
|
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.
|
|
62
|
-
//
|
|
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('
|
|
81
|
+
writeState('transport-ready', { sshPid: child.pid, stableMs });
|
|
67
82
|
}
|
|
68
|
-
},
|
|
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
|
-
|
|
83
|
-
|
|
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
|
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
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
|
-
|
|
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>"
|
|
102
|
-
//
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
|
255
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
287
|
-
// permitlisten
|
|
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,
|
|
451
|
+
buildForwardArgs, backoffDelay,
|
|
311
452
|
tunnelDir, tunnelPidPath, tunnelLogPath, tunnelStatePath, readTunnelState,
|
|
312
|
-
|
|
453
|
+
prepareTunnelDir, openTunnelLog,
|
|
454
|
+
classifySshFailure, readTunnelDiagnostic,
|
|
455
|
+
diagnoseTunnel,
|
|
456
|
+
removeStateIfOwned, supervisorExited,
|
|
457
|
+
startTunnel, stopTunnel, restartTunnel, startForward,
|
|
313
458
|
REVERSE_NAME,
|
|
314
|
-
readSshVersion,
|
|
459
|
+
readSshVersion, sshBinaryAvailable,
|
|
315
460
|
};
|
package/lib/proxy/federation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) || [])
|
|
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
|
-
|
|
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`;
|