@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.
- package/README.md +77 -20
- package/frontend/dist/assets/index-CbUkgtAz.js +91 -0
- package/frontend/dist/assets/index-ChGJawuv.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 +236 -141
- package/lib/cli/doctor.js +17 -14
- package/lib/cli/init.js +2 -2
- package/lib/cli/pidfile.js +3 -2
- package/lib/config.js +6 -0
- package/lib/decks/store.js +5 -2
- package/lib/fleet/builtin.js +69 -5
- package/lib/fleet/routes.js +13 -2
- package/lib/mcp/server.js +161 -0
- package/lib/nodes/commands.js +95 -123
- package/lib/nodes/health.js +33 -14
- package/lib/nodes/peering.js +75 -12
- package/lib/nodes/store.js +30 -20
- package/lib/nodes/tunnel-supervisor.js +29 -9
- package/lib/nodes/tunnel.js +217 -72
- package/lib/proxy/federation.js +81 -9
- package/lib/server.js +47 -32
- package/lib/settings/routes.js +247 -92
- package/lib/update/core.js +157 -0
- package/lib/update/manager.js +245 -0
- package/lib/update/runner.js +144 -0
- package/package.json +2 -2
- package/skills/nexuscrew-agent/SKILL.md +6 -2
- package/frontend/dist/assets/index-D2TrRtWQ.js +0 -90
- package/frontend/dist/assets/index-DrEuy6A6.css +0 -32
package/lib/nodes/store.js
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
};
|