@mmmbuto/nexuscrew 0.8.0 → 0.8.2
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 +43 -51
- package/bin/nexuscrew.js +10 -4
- package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
- package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
- package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +175 -100
- package/lib/cli/init.js +32 -7
- package/lib/cli/service.js +0 -5
- package/lib/decks/store.js +8 -2
- package/lib/files/routes.js +3 -1
- package/lib/fleet/builtin.js +15 -5
- package/lib/fleet/managed.js +277 -107
- package/lib/mcp/server.js +24 -5
- package/lib/nodes/commands.js +27 -12
- package/lib/nodes/peering.js +116 -0
- package/lib/nodes/store.js +91 -24
- package/lib/nodes/tunnel.js +22 -7
- package/lib/proxy/federation.js +217 -0
- package/lib/proxy/node-proxy.js +21 -8
- package/lib/server.js +90 -7
- package/lib/settings/routes.js +216 -4
- package/package.json +2 -2
- package/frontend/dist/assets/index-BBOgTCoO.css +0 -32
- package/frontend/dist/assets/index-DQrDBogT.js +0 -83
package/lib/mcp/server.js
CHANGED
|
@@ -340,10 +340,28 @@ function createMcpServer(opts = {}) {
|
|
|
340
340
|
}
|
|
341
341
|
|
|
342
342
|
let rl = null;
|
|
343
|
+
const inFlight = new Set();
|
|
344
|
+
let inputClosed = false;
|
|
345
|
+
let drainResolve;
|
|
346
|
+
const drained = new Promise((resolve) => { drainResolve = resolve; });
|
|
347
|
+
const maybeDrained = () => {
|
|
348
|
+
if (inputClosed && inFlight.size === 0) drainResolve();
|
|
349
|
+
};
|
|
343
350
|
function start() {
|
|
344
351
|
rl = readline.createInterface({ input, crlfDelay: Infinity });
|
|
345
|
-
rl.on('line', (line) => {
|
|
346
|
-
|
|
352
|
+
rl.on('line', (line) => {
|
|
353
|
+
const pending = handleLine(line);
|
|
354
|
+
inFlight.add(pending);
|
|
355
|
+
pending.finally(() => {
|
|
356
|
+
inFlight.delete(pending);
|
|
357
|
+
maybeDrained();
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
rl.on('close', () => {
|
|
361
|
+
inputClosed = true;
|
|
362
|
+
maybeDrained();
|
|
363
|
+
});
|
|
364
|
+
return { close: () => { try { rl.close(); } catch (_) {} }, drained };
|
|
347
365
|
}
|
|
348
366
|
|
|
349
367
|
return { start, handleLine, toolsList, ctx, cfg: { port: cfg.port, tmuxBin: cfg.tmuxBin } };
|
|
@@ -353,9 +371,10 @@ function createMcpServer(opts = {}) {
|
|
|
353
371
|
// e' aperto (il client MCP chiude la pipe per terminare il server).
|
|
354
372
|
function startMcp(opts = {}) {
|
|
355
373
|
const srv = createMcpServer(opts);
|
|
356
|
-
srv.start();
|
|
357
|
-
//
|
|
358
|
-
|
|
374
|
+
const lifecycle = srv.start();
|
|
375
|
+
// Non forzare process.exit su EOF: una tools/call asincrona puo' essere
|
|
376
|
+
// ancora in volo. Una volta drenate le richieste, Node termina naturalmente.
|
|
377
|
+
srv.drained = lifecycle.drained;
|
|
359
378
|
return srv;
|
|
360
379
|
}
|
|
361
380
|
|
package/lib/nodes/commands.js
CHANGED
|
@@ -93,19 +93,31 @@ function nodesAdd(opts) {
|
|
|
93
93
|
const { home, nodesPath } = resolveNodePaths(opts);
|
|
94
94
|
const name = opts.name;
|
|
95
95
|
const ssh = opts.ssh;
|
|
96
|
-
if (!name) { log('usage: nexuscrew nodes add <name> --ssh user@host [--remote-port N] [--key path] [--local-port N]'); return { code: 1, reason: 'name mancante' }; }
|
|
96
|
+
if (!name) { log('usage: nexuscrew nodes add <name> --ssh user@host [--ssh-port N] [--remote-port N] [--key path] [--local-port N]'); return { code: 1, reason: 'name mancante' }; }
|
|
97
97
|
if (!ssh) { log('nodes add: --ssh user@host obbligatorio'); return { code: 1, reason: 'ssh mancante' }; }
|
|
98
98
|
|
|
99
99
|
const remotePort = opts.remotePort ? Number(opts.remotePort) : DEFAULT_PORT;
|
|
100
|
+
const sshPort = opts.sshPort === undefined ? undefined : Number(opts.sshPort);
|
|
100
101
|
|
|
101
102
|
let st;
|
|
102
103
|
try { st = store.loadOrInitStore(nodesPath); }
|
|
103
104
|
catch (e) { log(`nodes add: ${e.message}`); return { code: 1, reason: 'store invalido' }; }
|
|
104
105
|
|
|
105
106
|
const localPort = opts.localPort ? Number(opts.localPort) : assignLocalPort(st);
|
|
106
|
-
const keyPath = opts.key || defaultKeyPath(home, name);
|
|
107
|
-
|
|
108
|
-
const entry = {
|
|
107
|
+
const keyPath = opts.identityFile || opts.key || (typeof opts.keygen === 'function' ? defaultKeyPath(home, name) : undefined);
|
|
108
|
+
|
|
109
|
+
const entry = {
|
|
110
|
+
name, ssh, remotePort, localPort,
|
|
111
|
+
direction: opts.direction || 'outbound',
|
|
112
|
+
transport: opts.transport || 'auto',
|
|
113
|
+
autostart: opts.autostart !== false,
|
|
114
|
+
visibility: opts.visibility || 'network',
|
|
115
|
+
roles: { client: true, node: false },
|
|
116
|
+
};
|
|
117
|
+
if (keyPath) entry.identityFile = keyPath;
|
|
118
|
+
if (opts.token) entry.token = opts.token;
|
|
119
|
+
if (opts.reversePort) entry.reversePort = Number(opts.reversePort);
|
|
120
|
+
if (sshPort !== undefined) entry.sshPort = sshPort;
|
|
109
121
|
if (opts.nodeId) entry.nodeId = opts.nodeId;
|
|
110
122
|
|
|
111
123
|
// Valida + inserisci in memoria (dup name/id, self-reference, schema) PRIMA di
|
|
@@ -115,18 +127,20 @@ function nodesAdd(opts) {
|
|
|
115
127
|
catch (e) { log(`nodes add: ${e.message}`); return { code: 1, reason: 'add rifiutato' }; }
|
|
116
128
|
|
|
117
129
|
// chiave dedicata per-tunnel (genera o riusa)
|
|
118
|
-
let pub;
|
|
119
|
-
|
|
120
|
-
|
|
130
|
+
let pub = null;
|
|
131
|
+
if (keyPath && typeof opts.keygen === 'function') {
|
|
132
|
+
try { pub = ensureKey(keyPath, name, opts); }
|
|
133
|
+
catch (e) { log(`nodes add: generazione chiave fallita (${e.message}) — nodo NON salvato`); return { code: 1, reason: 'keygen fallita' }; }
|
|
134
|
+
}
|
|
121
135
|
|
|
122
136
|
try { store.atomicWriteStore(nodesPath, next); }
|
|
123
137
|
catch (e) { log(`nodes add: scrittura nodes.json fallita: ${e.message}`); return { code: 1, reason: 'write fallita' }; }
|
|
124
138
|
|
|
125
|
-
log(`nodes add: nodo "${name}" aggiunto (ssh ${ssh},
|
|
139
|
+
log(`nodes add: nodo "${name}" aggiunto (ssh ${ssh}${sshPort ? `:${sshPort}` : ''}, nexus remoto ${remotePort} -> locale ${localPort})`);
|
|
126
140
|
log('Incolla nel ~/.ssh/authorized_keys del NODO (lato forward, chiave dedicata):');
|
|
127
141
|
// permitopen vincola i -L alla SOLA porta nexus remota; command=/bin/false + restrict.
|
|
128
|
-
log(`restrict,port-forwarding,permitopen="127.0.0.1:${remotePort}",command="/bin/false" ${pub}`);
|
|
129
|
-
return { code: 0, name, localPort, remotePort };
|
|
142
|
+
if (pub) log(`restrict,port-forwarding,permitopen="127.0.0.1:${remotePort}",command="/bin/false" ${pub}`);
|
|
143
|
+
return { code: 0, name, sshPort, localPort, remotePort, transport: entry.transport };
|
|
130
144
|
}
|
|
131
145
|
|
|
132
146
|
// --- nodes list ------------------------------------------------------------
|
|
@@ -150,7 +164,7 @@ function nodesList(opts) {
|
|
|
150
164
|
if (opts.json) { log(JSON.stringify(out, null, 2)); return { code: 0, nodes }; }
|
|
151
165
|
if (nodes.length === 0) { log('nodes: (nessun nodo)'); return { code: 0, nodes }; }
|
|
152
166
|
for (const n of nodes) {
|
|
153
|
-
log(`${n.name}\t${n.ssh}\tlocal:${n.localPort} ->
|
|
167
|
+
log(`${n.name}\t${n.ssh}${n.sshPort ? `:${n.sshPort}` : ''}\tlocal:${n.localPort} -> nexus:${n.remotePort}\ttunnel:${n.tunnel.status}\ttoken:${n.hasToken ? 'set' : 'unset'}`);
|
|
154
168
|
}
|
|
155
169
|
return { code: 0, nodes };
|
|
156
170
|
}
|
|
@@ -253,7 +267,8 @@ function nodesUp(opts) {
|
|
|
253
267
|
const log = opts.log || console.log;
|
|
254
268
|
const ctx = loadNodeOrFail(opts, log);
|
|
255
269
|
if (!ctx) return { code: 1 };
|
|
256
|
-
|
|
270
|
+
if (ctx.node.direction === 'inbound') return { code: 0, started: false, inbound: true };
|
|
271
|
+
const r = tunnel.startForward({ home: ctx.home, node: ctx.node, localAppPort: opts.localAppPort, spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd });
|
|
257
272
|
if (r.started) {
|
|
258
273
|
log(`nodes up [${ctx.node.name}]: tunnel avviato (pid ${r.pid}, local ${ctx.node.localPort})`);
|
|
259
274
|
return { code: 0, started: true, pid: r.pid };
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const store = require('./store.js');
|
|
7
|
+
|
|
8
|
+
const INVITE_TTL_MS = 10 * 60 * 1000;
|
|
9
|
+
const REVERSE_PORT_BASE = 44001;
|
|
10
|
+
|
|
11
|
+
function defaultInvitesPath(home) { return path.join(home, '.nexuscrew', 'invites.json'); }
|
|
12
|
+
function defaultPendingPath(home) { return path.join(home, '.nexuscrew', 'pairing-pending.json'); }
|
|
13
|
+
|
|
14
|
+
function safeEqual(a, b) {
|
|
15
|
+
const aa = Buffer.from(String(a || ''));
|
|
16
|
+
const bb = Buffer.from(String(b || ''));
|
|
17
|
+
return aa.length === bb.length && crypto.timingSafeEqual(aa, bb);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function readInvites(p, now = Date.now()) {
|
|
21
|
+
try {
|
|
22
|
+
const st = fs.lstatSync(p);
|
|
23
|
+
if (!st.isFile() || st.isSymbolicLink()) return [];
|
|
24
|
+
const rows = JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
25
|
+
return Array.isArray(rows) ? rows.filter((x) => x && x.expiresAt > now && !x.usedAt) : [];
|
|
26
|
+
} catch (_) { return []; }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function writeInvites(p, rows) {
|
|
30
|
+
try { if (fs.lstatSync(p).isSymbolicLink()) throw new Error('invites target is symlink'); }
|
|
31
|
+
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
32
|
+
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
|
|
33
|
+
const tmp = `${p}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
34
|
+
fs.writeFileSync(tmp, `${JSON.stringify(rows)}\n`, { mode: 0o600 });
|
|
35
|
+
fs.chmodSync(tmp, 0o600);
|
|
36
|
+
fs.renameSync(tmp, p);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function encodePairing(data) {
|
|
40
|
+
return Buffer.from(JSON.stringify(data)).toString('base64url');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function decodePairing(value) {
|
|
44
|
+
try {
|
|
45
|
+
const x = JSON.parse(Buffer.from(String(value || ''), 'base64url').toString('utf8'));
|
|
46
|
+
if (!x || x.v !== 1 || !store.NODE_ID_RE.test(x.instanceId) || !store.isPort(x.port)
|
|
47
|
+
|| !store.validToken(x.invite) || typeof x.label !== 'string') return null;
|
|
48
|
+
return x;
|
|
49
|
+
} catch (_) { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parsePairingUrl(value) {
|
|
53
|
+
try {
|
|
54
|
+
const u = new URL(String(value));
|
|
55
|
+
const payload = new URLSearchParams(u.hash.replace(/^#/, '')).get('pair');
|
|
56
|
+
return decodePairing(payload);
|
|
57
|
+
} catch (_) { return null; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function createInvite({ invitesPath, instanceId, port, label = 'NexusCrew', now = Date.now(), randomBytes = crypto.randomBytes }) {
|
|
61
|
+
const invite = randomBytes(32).toString('base64url');
|
|
62
|
+
const expiresAt = now + INVITE_TTL_MS;
|
|
63
|
+
const rows = readInvites(invitesPath, now);
|
|
64
|
+
rows.push({ hash: crypto.createHash('sha256').update(invite).digest('hex'), expiresAt, usedAt: null });
|
|
65
|
+
writeInvites(invitesPath, rows.slice(-32));
|
|
66
|
+
const pair = encodePairing({ v: 1, instanceId, port, label: String(label).slice(0, 64), invite });
|
|
67
|
+
return { pairingUrl: `http://127.0.0.1:${port}/#pair=${pair}`, expiresAt };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function consumeInvite({ invitesPath, invite, now = Date.now() }) {
|
|
71
|
+
const all = readInvites(invitesPath, 0);
|
|
72
|
+
const hash = crypto.createHash('sha256').update(String(invite || '')).digest('hex');
|
|
73
|
+
const idx = all.findIndex((x) => !x.usedAt && x.expiresAt > now && safeEqual(x.hash, hash));
|
|
74
|
+
if (idx < 0) return false;
|
|
75
|
+
all[idx] = { ...all[idx], usedAt: now };
|
|
76
|
+
writeInvites(invitesPath, all.slice(-32));
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function allocateReversePort(nodes) {
|
|
81
|
+
const used = new Set();
|
|
82
|
+
for (const n of nodes || []) {
|
|
83
|
+
if (store.isPort(n.localPort)) used.add(n.localPort);
|
|
84
|
+
if (store.isPort(n.reversePort)) used.add(n.reversePort);
|
|
85
|
+
}
|
|
86
|
+
let p = REVERSE_PORT_BASE;
|
|
87
|
+
while (used.has(p) && p < 65535) p += 1;
|
|
88
|
+
if (p >= 65535) throw new Error('no reverse port available');
|
|
89
|
+
return p;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function createPending({ pendingPath, data, now = Date.now() }) {
|
|
93
|
+
const rows = readInvites(pendingPath, now);
|
|
94
|
+
const credential = crypto.randomBytes(32).toString('base64url');
|
|
95
|
+
rows.push({ ...data, hash: crypto.createHash('sha256').update(credential).digest('hex'), expiresAt: now + INVITE_TTL_MS, usedAt: null });
|
|
96
|
+
writeInvites(pendingPath, rows.slice(-32));
|
|
97
|
+
return credential;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function consumePending({ pendingPath, credential, now = Date.now() }) {
|
|
101
|
+
const rows = readInvites(pendingPath, 0);
|
|
102
|
+
const hash = crypto.createHash('sha256').update(String(credential || '')).digest('hex');
|
|
103
|
+
const idx = rows.findIndex((x) => !x.usedAt && x.expiresAt > now && safeEqual(x.hash, hash));
|
|
104
|
+
if (idx < 0) return null;
|
|
105
|
+
const found = { ...rows[idx] };
|
|
106
|
+
rows.splice(idx, 1);
|
|
107
|
+
writeInvites(pendingPath, rows.slice(-32));
|
|
108
|
+
delete found.hash; delete found.expiresAt; delete found.usedAt;
|
|
109
|
+
return found;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
module.exports = {
|
|
113
|
+
INVITE_TTL_MS, REVERSE_PORT_BASE, defaultInvitesPath, defaultPendingPath, safeEqual,
|
|
114
|
+
readInvites, writeInvites, encodePairing, decodePairing, parsePairingUrl,
|
|
115
|
+
createInvite, consumeInvite, allocateReversePort, createPending, consumePending,
|
|
116
|
+
};
|
package/lib/nodes/store.js
CHANGED
|
@@ -17,7 +17,8 @@ const os = require('node:os');
|
|
|
17
17
|
const path = require('node:path');
|
|
18
18
|
const crypto = require('node:crypto');
|
|
19
19
|
|
|
20
|
-
const SCHEMA_VERSION =
|
|
20
|
+
const SCHEMA_VERSION = 2;
|
|
21
|
+
const LEGACY_SCHEMA_VERSION = 1;
|
|
21
22
|
const MAX_NODES = 64;
|
|
22
23
|
const MAX_TOKEN_LEN = 4096;
|
|
23
24
|
const MAX_KEYPATH_LEN = 4096;
|
|
@@ -54,6 +55,17 @@ function parseSsh(s) {
|
|
|
54
55
|
return { user, host, value: `${user}@${host}` };
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
// OpenSSH target/Host alias. It is passed as one argv item (never through a
|
|
59
|
+
// shell), therefore the safety boundary is: non-empty, no whitespace/control
|
|
60
|
+
// characters and no leading '-'. user@host remains accepted as a subset.
|
|
61
|
+
function parseSshTarget(s) {
|
|
62
|
+
if (typeof s !== 'string' || !s || s.length > MAX_SSH_LEN) return null;
|
|
63
|
+
if (s.startsWith('-') || /[\0-\x20\x7f]/.test(s)) return null;
|
|
64
|
+
if (s.includes('@')) return parseSsh(s);
|
|
65
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,254}$/.test(s)) return null;
|
|
66
|
+
return { value: s };
|
|
67
|
+
}
|
|
68
|
+
|
|
57
69
|
// keyPath: path assoluto, no null/newline (la chiave potrebbe non esistere ancora
|
|
58
70
|
// al momento dell'add: viene generata dopo). L'esistenza si verifica al lancio ssh.
|
|
59
71
|
function isAbsPath(p) {
|
|
@@ -83,31 +95,70 @@ function parseRoles(r) {
|
|
|
83
95
|
|
|
84
96
|
// parseNode(n) -> nodo normalizzato | null (fail-closed). Nessun campo extra
|
|
85
97
|
// tollerato oltre a quelli noti (schema chiuso: garbage -> null, non guess).
|
|
86
|
-
const NODE_KEYS = new Set([
|
|
87
|
-
|
|
98
|
+
const NODE_KEYS = new Set([
|
|
99
|
+
'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
|
|
100
|
+
'roles', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
|
|
101
|
+
'direction', 'reversePort',
|
|
102
|
+
]);
|
|
103
|
+
function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
88
104
|
if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
|
|
89
105
|
for (const k of Object.keys(n)) { if (!NODE_KEYS.has(k)) return null; } // schema chiuso
|
|
90
106
|
if (typeof n.name !== 'string' || !NODE_NAME_RE.test(n.name)) return null;
|
|
91
|
-
const
|
|
92
|
-
if (!
|
|
107
|
+
const direction = n.direction || 'outbound';
|
|
108
|
+
if (!['outbound', 'inbound'].includes(direction)) return null;
|
|
109
|
+
const ssh = n.ssh === undefined && direction === 'inbound' ? null
|
|
110
|
+
: (schemaVersion === LEGACY_SCHEMA_VERSION ? parseSsh(n.ssh) : parseSshTarget(n.ssh));
|
|
111
|
+
if (direction === 'outbound' && !ssh) return null;
|
|
112
|
+
if (n.sshPort !== undefined && !isPort(n.sshPort)) return null;
|
|
93
113
|
if (!isPort(n.remotePort)) return null;
|
|
94
114
|
if (!isPort(n.localPort)) return null;
|
|
95
|
-
|
|
115
|
+
const identityFile = n.identityFile || n.keyPath;
|
|
116
|
+
if (identityFile !== undefined && !isAbsPath(identityFile)) return null;
|
|
117
|
+
if (schemaVersion === LEGACY_SCHEMA_VERSION && !identityFile) return null;
|
|
96
118
|
const roles = parseRoles(n.roles);
|
|
97
119
|
if (!roles) return null;
|
|
98
120
|
|
|
99
121
|
const out = {
|
|
100
122
|
name: n.name,
|
|
101
|
-
ssh: ssh.value,
|
|
123
|
+
...(ssh ? { ssh: ssh.value } : {}),
|
|
102
124
|
remotePort: n.remotePort,
|
|
103
125
|
localPort: n.localPort,
|
|
104
|
-
keyPath: n.keyPath,
|
|
105
126
|
roles,
|
|
127
|
+
direction,
|
|
128
|
+
transport: n.transport || (direction === 'inbound' ? 'inbound' : (schemaVersion === LEGACY_SCHEMA_VERSION ? 'ssh' : 'auto')),
|
|
129
|
+
autostart: n.autostart === undefined ? schemaVersion !== LEGACY_SCHEMA_VERSION : n.autostart,
|
|
130
|
+
visibility: n.visibility || 'network',
|
|
106
131
|
};
|
|
132
|
+
if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
|
|
133
|
+
if (direction === 'inbound' && out.transport !== 'inbound') return null;
|
|
134
|
+
if (direction === 'outbound' && out.transport === 'inbound') return null;
|
|
135
|
+
if (typeof out.autostart !== 'boolean') return null;
|
|
136
|
+
if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
|
|
137
|
+
if (identityFile) out.identityFile = identityFile;
|
|
138
|
+
// Keep the old public field while reading v1 so old callers/tests and a
|
|
139
|
+
// running 0.8.1 service can coexist during the atomic upgrade.
|
|
140
|
+
if (n.keyPath) out.keyPath = n.keyPath;
|
|
141
|
+
if (n.reversePort !== undefined) {
|
|
142
|
+
if (!isPort(n.reversePort)) return null;
|
|
143
|
+
out.reversePort = n.reversePort;
|
|
144
|
+
}
|
|
145
|
+
if (n.selected !== undefined) {
|
|
146
|
+
if (!Array.isArray(n.selected) || n.selected.length > MAX_NODES) return null;
|
|
147
|
+
const selected = [...new Set(n.selected)];
|
|
148
|
+
if (selected.some((id) => typeof id !== 'string' || !NODE_ID_RE.test(id))) return null;
|
|
149
|
+
out.selected = selected;
|
|
150
|
+
}
|
|
151
|
+
// Campo opzionale per compatibilita' con gli store 0.8.0: se assente, ssh
|
|
152
|
+
// continua a usare la porta risolta da ~/.ssh/config o il default OpenSSH.
|
|
153
|
+
if (n.sshPort !== undefined) out.sshPort = n.sshPort;
|
|
107
154
|
if (n.token !== undefined) {
|
|
108
155
|
if (!validToken(n.token)) return null;
|
|
109
156
|
out.token = n.token;
|
|
110
157
|
}
|
|
158
|
+
if (n.acceptToken !== undefined) {
|
|
159
|
+
if (!validToken(n.acceptToken)) return null;
|
|
160
|
+
out.acceptToken = n.acceptToken;
|
|
161
|
+
}
|
|
111
162
|
if (n.nodeId !== undefined) {
|
|
112
163
|
if (typeof n.nodeId !== 'string' || !NODE_ID_RE.test(n.nodeId)) return null;
|
|
113
164
|
out.nodeId = n.nodeId;
|
|
@@ -140,7 +191,7 @@ function parseStore(raw) {
|
|
|
140
191
|
return null;
|
|
141
192
|
}
|
|
142
193
|
|
|
143
|
-
if (d.schemaVersion !== SCHEMA_VERSION) return null;
|
|
194
|
+
if (d.schemaVersion !== SCHEMA_VERSION && d.schemaVersion !== LEGACY_SCHEMA_VERSION) return null;
|
|
144
195
|
if (typeof d.nodeId !== 'string' || !NODE_ID_RE.test(d.nodeId)) return null;
|
|
145
196
|
if (!Array.isArray(d.nodes) || d.nodes.length > MAX_NODES) return null;
|
|
146
197
|
|
|
@@ -148,7 +199,7 @@ function parseStore(raw) {
|
|
|
148
199
|
const ids = new Set();
|
|
149
200
|
const nodes = [];
|
|
150
201
|
for (const raw2 of d.nodes) {
|
|
151
|
-
const node = parseNode(raw2);
|
|
202
|
+
const node = parseNode(raw2, d.schemaVersion);
|
|
152
203
|
if (!node) return null;
|
|
153
204
|
if (names.has(node.name)) return null; // name univoco
|
|
154
205
|
names.add(node.name);
|
|
@@ -160,7 +211,7 @@ function parseStore(raw) {
|
|
|
160
211
|
nodes.push(node);
|
|
161
212
|
}
|
|
162
213
|
|
|
163
|
-
const out = { schemaVersion:
|
|
214
|
+
const out = { schemaVersion: d.schemaVersion, nodeId: d.nodeId, nodes };
|
|
164
215
|
|
|
165
216
|
if (d.rendezvous !== undefined && d.rendezvous !== null) {
|
|
166
217
|
const rdv = parseRendezvous(d.rendezvous);
|
|
@@ -248,8 +299,8 @@ function getNode(store, name) {
|
|
|
248
299
|
}
|
|
249
300
|
|
|
250
301
|
function addNode(store, entry) {
|
|
251
|
-
const node = parseNode(entry);
|
|
252
|
-
if (!node) throw new Error('nodo non valido (schema strict): controlla name/ssh/remotePort/localPort
|
|
302
|
+
const node = parseNode(entry, SCHEMA_VERSION);
|
|
303
|
+
if (!node) throw new Error('nodo non valido (schema strict): controlla name/ssh/remotePort/localPort');
|
|
253
304
|
if (store.nodes.some((n) => n.name === node.name)) {
|
|
254
305
|
throw new Error(`nodo duplicato: name "${node.name}" gia' presente`);
|
|
255
306
|
}
|
|
@@ -259,7 +310,7 @@ function addNode(store, entry) {
|
|
|
259
310
|
throw new Error(`nodo duplicato: nodeId "${node.nodeId}" gia' presente`);
|
|
260
311
|
}
|
|
261
312
|
}
|
|
262
|
-
return { ...store, nodes: store.nodes.concat([node]) };
|
|
313
|
+
return { ...store, schemaVersion: SCHEMA_VERSION, nodes: store.nodes.concat([node]) };
|
|
263
314
|
}
|
|
264
315
|
|
|
265
316
|
function removeNode(store, name) {
|
|
@@ -267,7 +318,7 @@ function removeNode(store, name) {
|
|
|
267
318
|
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
268
319
|
const nodes = store.nodes.slice();
|
|
269
320
|
nodes.splice(idx, 1);
|
|
270
|
-
return { ...store, nodes };
|
|
321
|
+
return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
|
|
271
322
|
}
|
|
272
323
|
|
|
273
324
|
function setNodeToken(store, name, token) {
|
|
@@ -276,17 +327,27 @@ function setNodeToken(store, name, token) {
|
|
|
276
327
|
if (!validToken(token)) throw new Error('token non valido (vuoto, multilinea o troppo lungo)');
|
|
277
328
|
const nodes = store.nodes.slice();
|
|
278
329
|
nodes[idx] = { ...nodes[idx], token };
|
|
279
|
-
return { ...store, nodes };
|
|
330
|
+
return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function updateNode(store, name, patch) {
|
|
334
|
+
const idx = store.nodes.findIndex((n) => n.name === name);
|
|
335
|
+
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
336
|
+
const parsed = parseNode({ ...store.nodes[idx], ...patch }, SCHEMA_VERSION);
|
|
337
|
+
if (!parsed) throw new Error('aggiornamento nodo non valido');
|
|
338
|
+
const nodes = store.nodes.slice();
|
|
339
|
+
nodes[idx] = parsed;
|
|
340
|
+
return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
|
|
280
341
|
}
|
|
281
342
|
|
|
282
343
|
function setRendezvous(store, rdv) {
|
|
283
344
|
const parsed = parseRendezvous(rdv);
|
|
284
345
|
if (!parsed) throw new Error('rendezvous non valido: controlla ssh/publishedPort/localPort/keyPath');
|
|
285
|
-
return { ...store, rendezvous: parsed };
|
|
346
|
+
return { ...store, schemaVersion: SCHEMA_VERSION, rendezvous: parsed };
|
|
286
347
|
}
|
|
287
348
|
|
|
288
349
|
function clearRendezvous(store) {
|
|
289
|
-
const out = { ...store };
|
|
350
|
+
const out = { ...store, schemaVersion: SCHEMA_VERSION };
|
|
290
351
|
delete out.rendezvous;
|
|
291
352
|
return out;
|
|
292
353
|
}
|
|
@@ -296,13 +357,19 @@ function clearRendezvous(store) {
|
|
|
296
357
|
function redactNode(n) {
|
|
297
358
|
const out = {
|
|
298
359
|
name: n.name,
|
|
299
|
-
ssh: n.ssh,
|
|
360
|
+
...(n.ssh ? { ssh: n.ssh } : {}),
|
|
300
361
|
remotePort: n.remotePort,
|
|
301
362
|
localPort: n.localPort,
|
|
302
|
-
|
|
303
|
-
|
|
363
|
+
direction: n.direction || 'outbound',
|
|
364
|
+
transport: n.transport || 'ssh',
|
|
365
|
+
autostart: !!n.autostart,
|
|
366
|
+
visibility: n.visibility || 'network',
|
|
304
367
|
hasToken: !!n.token, // presenza, non il valore
|
|
368
|
+
paired: !!(n.token && n.acceptToken),
|
|
305
369
|
};
|
|
370
|
+
if (n.identityFile || n.keyPath) out.hasIdentity = true;
|
|
371
|
+
if (n.visibility === 'selected') out.selected = [...(n.selected || [])];
|
|
372
|
+
if (n.sshPort !== undefined) out.sshPort = n.sshPort;
|
|
306
373
|
if (n.nodeId) out.nodeId = n.nodeId;
|
|
307
374
|
return out;
|
|
308
375
|
}
|
|
@@ -344,15 +411,15 @@ function migrateLegacyNodes(configPath, nodesPath) {
|
|
|
344
411
|
|
|
345
412
|
module.exports = {
|
|
346
413
|
// parse/validate
|
|
347
|
-
parseStore, parseNode, parseRendezvous, parseSsh, isPort, isAbsPath, validToken,
|
|
414
|
+
parseStore, parseNode, parseRendezvous, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
|
|
348
415
|
// I/O
|
|
349
416
|
defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
|
|
350
417
|
// mutazioni
|
|
351
|
-
getNode, addNode, removeNode, setNodeToken, setRendezvous, clearRendezvous,
|
|
418
|
+
getNode, addNode, removeNode, setNodeToken, updateNode, setRendezvous, clearRendezvous,
|
|
352
419
|
// redazione
|
|
353
420
|
redactNode, redactStore,
|
|
354
421
|
// migrazione
|
|
355
422
|
migrateLegacyNodes,
|
|
356
423
|
// costanti
|
|
357
|
-
SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
424
|
+
SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
358
425
|
};
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -33,8 +33,10 @@ function assertForwardSpec(node) {
|
|
|
33
33
|
if (!node || typeof node !== 'object') throw new Error('tunnel: spec mancante');
|
|
34
34
|
if (!store.isPort(node.localPort)) throw new Error('tunnel: localPort non valida');
|
|
35
35
|
if (!store.isPort(node.remotePort)) throw new Error('tunnel: remotePort non valida');
|
|
36
|
-
if (!store.
|
|
37
|
-
if (!store.
|
|
36
|
+
if (node.sshPort !== undefined && !store.isPort(node.sshPort)) throw new Error('tunnel: sshPort non valida');
|
|
37
|
+
if (node.identityFile !== undefined && !store.isAbsPath(node.identityFile)) throw new Error('tunnel: identityFile non valido');
|
|
38
|
+
if (node.keyPath !== undefined && !store.isAbsPath(node.keyPath)) throw new Error('tunnel: keyPath non valido');
|
|
39
|
+
if (!store.parseSshTarget(node.ssh)) throw new Error('tunnel: target ssh non valido');
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
function assertReverseSpec(rdv) {
|
|
@@ -51,9 +53,14 @@ function assertReverseSpec(rdv) {
|
|
|
51
53
|
// -L 127.0.0.1:<locale>:127.0.0.1:<remota> user@host
|
|
52
54
|
function buildForwardArgs(node) {
|
|
53
55
|
assertForwardSpec(node);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
+
const transport = node.sshPort === undefined ? [] : ['-p', String(node.sshPort)];
|
|
57
|
+
const identity = node.identityFile || node.keyPath;
|
|
58
|
+
const reverse = node.reversePort === undefined ? [] : [
|
|
59
|
+
'-R', `127.0.0.1:${node.reversePort}:127.0.0.1:${node.localAppPort || node.remotePort}`,
|
|
60
|
+
];
|
|
61
|
+
return SSH_BASE_OPTS.concat(transport, identity ? ['-i', identity] : [], [
|
|
56
62
|
'-L', `127.0.0.1:${node.localPort}:127.0.0.1:${node.remotePort}`,
|
|
63
|
+
...reverse,
|
|
57
64
|
node.ssh,
|
|
58
65
|
]);
|
|
59
66
|
}
|
|
@@ -195,7 +202,7 @@ function startTunnel(opts) {
|
|
|
195
202
|
child = spawnImpl(process.execPath, supervisorArgs, {
|
|
196
203
|
detached: true,
|
|
197
204
|
stdio: ['ignore', logFd, logFd],
|
|
198
|
-
env: { ...process.env, NEXUSCREW_TUNNEL_STATE: statePath },
|
|
205
|
+
env: { ...process.env, AUTOSSH_GATETIME: '0', NEXUSCREW_TUNNEL_STATE: statePath },
|
|
199
206
|
});
|
|
200
207
|
} catch (e) {
|
|
201
208
|
closeOwnedFd();
|
|
@@ -257,8 +264,16 @@ function restartTunnel(opts) {
|
|
|
257
264
|
// Helper di alto livello: costruisce args dal nodo e avvia il forward.
|
|
258
265
|
function startForward(opts) {
|
|
259
266
|
const node = opts.node;
|
|
260
|
-
|
|
261
|
-
|
|
267
|
+
let sshBin = opts.sshBin;
|
|
268
|
+
let args = buildForwardArgs({ ...node, localAppPort: opts.localAppPort });
|
|
269
|
+
const wanted = node.transport || (node.keyPath ? 'ssh' : 'auto');
|
|
270
|
+
if (!sshBin && (wanted === 'auto' || wanted === 'autossh') && sshBinaryAvailable('autossh', opts.spawnSyncImpl)) {
|
|
271
|
+
sshBin = 'autossh';
|
|
272
|
+
args = ['-M', '0', ...args];
|
|
273
|
+
} else if (!sshBin) {
|
|
274
|
+
sshBin = 'ssh';
|
|
275
|
+
}
|
|
276
|
+
return startTunnel({ ...opts, sshBin, name: node.name, args });
|
|
262
277
|
}
|
|
263
278
|
|
|
264
279
|
// Helper di alto livello: reverse verso il rendezvous.
|