@mmmbuto/nexuscrew 0.8.0 → 0.8.3
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 +66 -60
- package/bin/nexuscrew.js +10 -4
- package/frontend/dist/assets/index-BFyPeZsL.js +90 -0
- package/frontend/dist/assets/index-COsoJxXQ.css +32 -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 +314 -110
- package/lib/cli/doctor.js +1 -1
- package/lib/cli/init.js +40 -10
- package/lib/cli/pidfile.js +2 -2
- 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 +278 -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/topology-cache.js +75 -0
- package/lib/nodes/tunnel.js +22 -7
- package/lib/proxy/federation.js +263 -0
- package/lib/proxy/node-proxy.js +21 -8
- package/lib/server.js +91 -7
- package/lib/settings/routes.js +221 -5
- package/package.json +2 -2
- package/frontend/dist/assets/index-BBOgTCoO.css +0 -32
- package/frontend/dist/assets/index-DQrDBogT.js +0 -83
|
@@ -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
|
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const { NODE_ID_RE, NODE_NAME_RE } = require('./store.js');
|
|
8
|
+
|
|
9
|
+
const SCHEMA_VERSION = 1;
|
|
10
|
+
const MAX_ENTRIES = 256;
|
|
11
|
+
const MAX_HOPS = 4;
|
|
12
|
+
|
|
13
|
+
function defaultPath(home = os.homedir()) {
|
|
14
|
+
return path.join(home, '.nexuscrew', 'topology-cache.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseEntry(raw) {
|
|
18
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
19
|
+
const keys = Object.keys(raw).sort();
|
|
20
|
+
if (keys.some((k) => !['instanceId', 'lastSeen', 'name', 'route'].includes(k))) return null;
|
|
21
|
+
if (!NODE_ID_RE.test(raw.instanceId) || !NODE_NAME_RE.test(raw.name)) return null;
|
|
22
|
+
if (!Array.isArray(raw.route) || raw.route.length < 2 || raw.route.length > MAX_HOPS) return null;
|
|
23
|
+
if (raw.route.some((x) => !NODE_NAME_RE.test(x)) || new Set(raw.route).size !== raw.route.length) return null;
|
|
24
|
+
if (raw.name !== raw.route[raw.route.length - 1]) return null;
|
|
25
|
+
if (!Number.isInteger(raw.lastSeen) || raw.lastSeen < 0) return null;
|
|
26
|
+
return { instanceId: raw.instanceId, name: raw.name, route: [...raw.route], lastSeen: raw.lastSeen };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseCache(raw) {
|
|
30
|
+
let value = raw;
|
|
31
|
+
if (typeof value === 'string') {
|
|
32
|
+
try { value = JSON.parse(value); } catch (_) { return null; }
|
|
33
|
+
}
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
35
|
+
if (value.schemaVersion !== SCHEMA_VERSION || !Array.isArray(value.nodes) || value.nodes.length > MAX_ENTRIES) return null;
|
|
36
|
+
if (Object.keys(value).some((k) => !['schemaVersion', 'nodes'].includes(k))) return null;
|
|
37
|
+
const nodes = value.nodes.map(parseEntry);
|
|
38
|
+
if (nodes.some((x) => !x)) return null;
|
|
39
|
+
if (new Set(nodes.map((x) => x.instanceId)).size !== nodes.length) return null;
|
|
40
|
+
if (new Set(nodes.map((x) => x.route.join('/'))).size !== nodes.length) return null;
|
|
41
|
+
return { schemaVersion: SCHEMA_VERSION, nodes };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emptyCache() { return { schemaVersion: SCHEMA_VERSION, nodes: [] }; }
|
|
45
|
+
|
|
46
|
+
function loadCache(file = defaultPath()) {
|
|
47
|
+
try {
|
|
48
|
+
const st = fs.lstatSync(file);
|
|
49
|
+
if (!st.isFile() || st.isSymbolicLink()) return null;
|
|
50
|
+
return parseCache(fs.readFileSync(file, 'utf8'));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return e.code === 'ENOENT' ? emptyCache() : null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function atomicWriteCache(file, value) {
|
|
57
|
+
const parsed = parseCache(value);
|
|
58
|
+
if (!parsed) throw new Error('topology cache non valida');
|
|
59
|
+
try {
|
|
60
|
+
if (fs.lstatSync(file).isSymbolicLink()) throw new Error('refusing symlink topology cache target');
|
|
61
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
64
|
+
try {
|
|
65
|
+
fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
66
|
+
fs.chmodSync(tmp, 0o600);
|
|
67
|
+
fs.renameSync(tmp, file);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { SCHEMA_VERSION, MAX_ENTRIES, MAX_HOPS, defaultPath, parseEntry, parseCache, emptyCache, loadCache, atomicWriteCache };
|
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.
|