@mmmbuto/nexuscrew 0.7.7 → 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 +153 -25
- 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/sw.js +29 -0
- package/frontend/dist/version.json +1 -0
- package/lib/auth/middleware.js +7 -1
- package/lib/auth/token.js +31 -1
- package/lib/cli/commands.js +502 -43
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +16 -3
- package/lib/cli/init.js +69 -11
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -8
- package/lib/cli/url.js +63 -0
- package/lib/config.js +5 -0
- package/lib/decks/routes.js +81 -0
- package/lib/decks/store.js +123 -0
- package/lib/files/routes.js +57 -1
- package/lib/files/store.js +16 -1
- package/lib/fleet/builtin.js +151 -24
- package/lib/fleet/definitions.js +26 -0
- package/lib/fleet/managed.js +381 -0
- package/lib/fleet/routes.js +5 -4
- package/lib/mcp/server.js +381 -0
- package/lib/nodes/commands.js +411 -0
- package/lib/nodes/peering.js +116 -0
- package/lib/nodes/store.js +425 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +315 -0
- package/lib/notify/asks.js +150 -0
- package/lib/notify/events.js +59 -0
- package/lib/notify/notifier.js +37 -0
- package/lib/notify/persist.js +73 -0
- package/lib/notify/push.js +289 -0
- package/lib/notify/routes.js +260 -0
- package/lib/proxy/federation.js +217 -0
- package/lib/proxy/node-proxy.js +305 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +291 -14
- package/lib/settings/routes.js +685 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +8 -2
- package/skills/nexuscrew-agent/SKILL.md +83 -0
- package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
- package/skills/nexuscrew-agent/bin/nc-send +48 -0
- package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
- package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/nodes/store.js — nodes.json secret store (design §4, §4b(4)).
|
|
3
|
+
//
|
|
4
|
+
// nodes.json e' un SECRET STORE, non config qualunque: contiene i token dei nodi
|
|
5
|
+
// remoti (iniettati dal proxy in B1). Percio' riusa lo stesso hardening di
|
|
6
|
+
// lib/fleet/definitions.js e del token file:
|
|
7
|
+
// - permessi 0600, scrittura atomica (tmp stessa dir + rename), rifiuto symlink
|
|
8
|
+
// - schema STRICT validato al load: garbage -> errore esplicito, MAI guess
|
|
9
|
+
// - nessun token in output redatto (redactStore/redactNode)
|
|
10
|
+
//
|
|
11
|
+
// Modulo PURO al parse: parseStore/parseNode non toccano il filesystem.
|
|
12
|
+
// Tutto l'I/O vive in loadStore/atomicWriteStore/loadOrInitStore/migrateLegacyNodes.
|
|
13
|
+
// Principio fail-closed: qualunque dato malformato -> null (parse) o throw
|
|
14
|
+
// esplicito (mutazioni/write), mai un default silenzioso.
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
const crypto = require('node:crypto');
|
|
19
|
+
|
|
20
|
+
const SCHEMA_VERSION = 2;
|
|
21
|
+
const LEGACY_SCHEMA_VERSION = 1;
|
|
22
|
+
const MAX_NODES = 64;
|
|
23
|
+
const MAX_TOKEN_LEN = 4096;
|
|
24
|
+
const MAX_KEYPATH_LEN = 4096;
|
|
25
|
+
const MAX_SSH_LEN = 320; // user(<=32) + '@' + host(<=255)
|
|
26
|
+
|
|
27
|
+
// name: chiave strict usata anche come segmento path/route in B1 -> niente '.',
|
|
28
|
+
// '/', maiuscole. Allineato al contratto §4b(2) (^[a-z0-9-]{1,32}$).
|
|
29
|
+
const NODE_NAME_RE = /^[a-z0-9-]{1,32}$/;
|
|
30
|
+
// nodeId: id stabile per-installazione, hex casuale (crypto.randomBytes(16)).
|
|
31
|
+
const NODE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
32
|
+
// user@host: entrambe le parti argv-safe (no spazi, no leading '-' -> mai
|
|
33
|
+
// interpretabile come opzione ssh). Host: hostname/FQDN/IPv4 (niente ':' -> IPv6
|
|
34
|
+
// va usato via Host alias in ~/.ssh/config, fuori scope B0).
|
|
35
|
+
const SSH_USER_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
36
|
+
const SSH_HOST_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,254}$/;
|
|
37
|
+
|
|
38
|
+
const PORT_MIN = 1;
|
|
39
|
+
const PORT_MAX = 65535;
|
|
40
|
+
|
|
41
|
+
function isPort(n) {
|
|
42
|
+
return Number.isInteger(n) && n >= PORT_MIN && n <= PORT_MAX;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// user@host -> {user, host, value} | null. Strict: no whitespace/null, host non
|
|
46
|
+
// inizia con '-' (garanzia argv-safe, non diventa mai un flag ssh).
|
|
47
|
+
function parseSsh(s) {
|
|
48
|
+
if (typeof s !== 'string' || !s || s.length > MAX_SSH_LEN) return null;
|
|
49
|
+
if (s.includes('\0') || /\s/.test(s)) return null;
|
|
50
|
+
const at = s.indexOf('@');
|
|
51
|
+
if (at <= 0 || at !== s.lastIndexOf('@')) return null; // esattamente un '@'
|
|
52
|
+
const user = s.slice(0, at);
|
|
53
|
+
const host = s.slice(at + 1);
|
|
54
|
+
if (!SSH_USER_RE.test(user) || !SSH_HOST_RE.test(host)) return null;
|
|
55
|
+
return { user, host, value: `${user}@${host}` };
|
|
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
|
+
|
|
69
|
+
// keyPath: path assoluto, no null/newline (la chiave potrebbe non esistere ancora
|
|
70
|
+
// al momento dell'add: viene generata dopo). L'esistenza si verifica al lancio ssh.
|
|
71
|
+
function isAbsPath(p) {
|
|
72
|
+
return typeof p === 'string' && p.length > 0 && p.length <= MAX_KEYPATH_LEN
|
|
73
|
+
&& !p.includes('\0') && !/[\n\r]/.test(p) && path.isAbsolute(p);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// token remoto: segreto opaco, single-line, cap. Vuoto -> assente (non salvato).
|
|
77
|
+
// Charset ristretto a header-safe (VCHAR + spazio/tab): il token viene iniettato
|
|
78
|
+
// in `Authorization: Bearer <t>` verso l'upstream; un char fuori range farebbe
|
|
79
|
+
// lanciare setHeader in modo sincrono (ERR_INVALID_CHAR). (hardening audit).
|
|
80
|
+
function validToken(t) {
|
|
81
|
+
return typeof t === 'string' && t.length > 0 && t.length <= MAX_TOKEN_LEN
|
|
82
|
+
&& /^[\x20-\x7e\t]+$/.test(t);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// roles per-nodo (default {client:true, node:false}): STRICT, nessuna chiave extra.
|
|
86
|
+
function parseRoles(r) {
|
|
87
|
+
if (r === undefined) return { client: true, node: false };
|
|
88
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
|
|
89
|
+
for (const k of Object.keys(r)) { if (k !== 'client' && k !== 'node') return null; }
|
|
90
|
+
const client = r.client === undefined ? true : r.client;
|
|
91
|
+
const node = r.node === undefined ? false : r.node;
|
|
92
|
+
if (typeof client !== 'boolean' || typeof node !== 'boolean') return null;
|
|
93
|
+
return { client, node };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// parseNode(n) -> nodo normalizzato | null (fail-closed). Nessun campo extra
|
|
97
|
+
// tollerato oltre a quelli noti (schema chiuso: garbage -> null, non guess).
|
|
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) {
|
|
104
|
+
if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
|
|
105
|
+
for (const k of Object.keys(n)) { if (!NODE_KEYS.has(k)) return null; } // schema chiuso
|
|
106
|
+
if (typeof n.name !== 'string' || !NODE_NAME_RE.test(n.name)) return null;
|
|
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;
|
|
113
|
+
if (!isPort(n.remotePort)) return null;
|
|
114
|
+
if (!isPort(n.localPort)) return null;
|
|
115
|
+
const identityFile = n.identityFile || n.keyPath;
|
|
116
|
+
if (identityFile !== undefined && !isAbsPath(identityFile)) return null;
|
|
117
|
+
if (schemaVersion === LEGACY_SCHEMA_VERSION && !identityFile) return null;
|
|
118
|
+
const roles = parseRoles(n.roles);
|
|
119
|
+
if (!roles) return null;
|
|
120
|
+
|
|
121
|
+
const out = {
|
|
122
|
+
name: n.name,
|
|
123
|
+
...(ssh ? { ssh: ssh.value } : {}),
|
|
124
|
+
remotePort: n.remotePort,
|
|
125
|
+
localPort: n.localPort,
|
|
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',
|
|
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;
|
|
154
|
+
if (n.token !== undefined) {
|
|
155
|
+
if (!validToken(n.token)) return null;
|
|
156
|
+
out.token = n.token;
|
|
157
|
+
}
|
|
158
|
+
if (n.acceptToken !== undefined) {
|
|
159
|
+
if (!validToken(n.acceptToken)) return null;
|
|
160
|
+
out.acceptToken = n.acceptToken;
|
|
161
|
+
}
|
|
162
|
+
if (n.nodeId !== undefined) {
|
|
163
|
+
if (typeof n.nodeId !== 'string' || !NODE_ID_RE.test(n.nodeId)) return null;
|
|
164
|
+
out.nodeId = n.nodeId;
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// rendezvous (ruolo node/reverse): dove questa installazione si pubblica.
|
|
170
|
+
const RDV_KEYS = new Set(['ssh', 'publishedPort', 'localPort', 'keyPath']);
|
|
171
|
+
function parseRendezvous(r) {
|
|
172
|
+
if (!r || typeof r !== 'object' || Array.isArray(r)) return null;
|
|
173
|
+
for (const k of Object.keys(r)) { if (!RDV_KEYS.has(k)) return null; }
|
|
174
|
+
const ssh = parseSsh(r.ssh);
|
|
175
|
+
if (!ssh) return null;
|
|
176
|
+
if (!isPort(r.publishedPort)) return null;
|
|
177
|
+
if (!isPort(r.localPort)) return null;
|
|
178
|
+
if (!isAbsPath(r.keyPath)) return null;
|
|
179
|
+
return { ssh: ssh.value, publishedPort: r.publishedPort, localPort: r.localPort, keyPath: r.keyPath };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// parseStore(raw) -> store normalizzato | null. Accetta stringa JSON o oggetto.
|
|
183
|
+
function parseStore(raw) {
|
|
184
|
+
try {
|
|
185
|
+
let d;
|
|
186
|
+
if (typeof raw === 'string') {
|
|
187
|
+
try { d = JSON.parse(raw); } catch (_) { return null; }
|
|
188
|
+
} else if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
|
|
189
|
+
d = raw;
|
|
190
|
+
} else {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (d.schemaVersion !== SCHEMA_VERSION && d.schemaVersion !== LEGACY_SCHEMA_VERSION) return null;
|
|
195
|
+
if (typeof d.nodeId !== 'string' || !NODE_ID_RE.test(d.nodeId)) return null;
|
|
196
|
+
if (!Array.isArray(d.nodes) || d.nodes.length > MAX_NODES) return null;
|
|
197
|
+
|
|
198
|
+
const names = new Set();
|
|
199
|
+
const ids = new Set();
|
|
200
|
+
const nodes = [];
|
|
201
|
+
for (const raw2 of d.nodes) {
|
|
202
|
+
const node = parseNode(raw2, d.schemaVersion);
|
|
203
|
+
if (!node) return null;
|
|
204
|
+
if (names.has(node.name)) return null; // name univoco
|
|
205
|
+
names.add(node.name);
|
|
206
|
+
if (node.nodeId) {
|
|
207
|
+
if (node.nodeId === d.nodeId) return null; // self-reference nei dati salvati
|
|
208
|
+
if (ids.has(node.nodeId)) return null; // nodeId remoto univoco
|
|
209
|
+
ids.add(node.nodeId);
|
|
210
|
+
}
|
|
211
|
+
nodes.push(node);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const out = { schemaVersion: d.schemaVersion, nodeId: d.nodeId, nodes };
|
|
215
|
+
|
|
216
|
+
if (d.rendezvous !== undefined && d.rendezvous !== null) {
|
|
217
|
+
const rdv = parseRendezvous(d.rendezvous);
|
|
218
|
+
if (!rdv) return null;
|
|
219
|
+
out.rendezvous = rdv;
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
} catch (_) {
|
|
223
|
+
return null; // fail-closed: qualunque eccezione inattesa -> null, MAI throw
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- I/O -------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
function defaultNodesPath(home) {
|
|
230
|
+
return path.join(home || os.homedir(), '.nexuscrew', 'nodes.json');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// loadStore(p): legge rifiutando i symlink; parse strict. null se assente/invalido.
|
|
234
|
+
function loadStore(p) {
|
|
235
|
+
try {
|
|
236
|
+
let st;
|
|
237
|
+
try { st = fs.lstatSync(p); } catch (_) { return null; } // missing -> null
|
|
238
|
+
if (st.isSymbolicLink()) return null; // no symlink
|
|
239
|
+
if (!st.isFile()) return null;
|
|
240
|
+
return parseStore(fs.readFileSync(p, 'utf8'));
|
|
241
|
+
} catch (_) { return null; }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// atomicWriteStore(p, data): valida PRIMA di scrivere (fail-closed). Rifiuta di
|
|
245
|
+
// scrivere attraverso un symlink. tmp stessa dir -> chmod 0600 -> rename atomico.
|
|
246
|
+
function atomicWriteStore(p, data) {
|
|
247
|
+
try {
|
|
248
|
+
if (fs.lstatSync(p).isSymbolicLink()) {
|
|
249
|
+
throw new Error('refuse to write: nodes.json target e\' un symlink');
|
|
250
|
+
}
|
|
251
|
+
} catch (e) {
|
|
252
|
+
if (e.code === 'ENOENT') { /* nuovo file, ok */ }
|
|
253
|
+
else throw e; // inclusi i nostri 'refuse to write'
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const parsed = parseStore(data);
|
|
257
|
+
if (!parsed) throw new Error('nodes.json non valido: validazione fallita (schema strict)');
|
|
258
|
+
|
|
259
|
+
const dir = path.dirname(p);
|
|
260
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
261
|
+
const tmp = path.join(dir, `.${path.basename(p)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
262
|
+
try {
|
|
263
|
+
fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
264
|
+
fs.chmodSync(tmp, 0o600); // forza 0600 a prescindere da umask
|
|
265
|
+
fs.renameSync(tmp, p); // atomico sullo stesso filesystem
|
|
266
|
+
} catch (e) {
|
|
267
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* cleanup best-effort */ }
|
|
268
|
+
throw e;
|
|
269
|
+
}
|
|
270
|
+
return parsed;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function newNodeId() { return crypto.randomBytes(16).toString('hex'); }
|
|
274
|
+
|
|
275
|
+
function emptyStore(nodeId) {
|
|
276
|
+
return { schemaVersion: SCHEMA_VERSION, nodeId: nodeId || newNodeId(), nodes: [] };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// loadOrInitStore(p): store esistente, oppure ne crea uno vuoto (nodeId fresco,
|
|
280
|
+
// STABILE una volta scritto). Se il file esiste ma e' invalido -> throw esplicito
|
|
281
|
+
// (mai sovrascrivere/mascherare corruzione o un file altrui).
|
|
282
|
+
function loadOrInitStore(p) {
|
|
283
|
+
let st;
|
|
284
|
+
try { st = fs.lstatSync(p); }
|
|
285
|
+
catch (e) {
|
|
286
|
+
if (e.code === 'ENOENT') return atomicWriteStore(p, emptyStore());
|
|
287
|
+
throw e;
|
|
288
|
+
}
|
|
289
|
+
if (st.isSymbolicLink()) throw new Error('nodes.json e\' un symlink (rifiutato)');
|
|
290
|
+
const parsed = loadStore(p);
|
|
291
|
+
if (!parsed) throw new Error('nodes.json presente ma invalido (schema strict): correggi o rimuovi il file');
|
|
292
|
+
return parsed;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// --- Mutazioni pure (ritornano un nuovo store; il caller lo scrive) ---------
|
|
296
|
+
|
|
297
|
+
function getNode(store, name) {
|
|
298
|
+
return store.nodes.find((n) => n.name === name) || null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function addNode(store, entry) {
|
|
302
|
+
const node = parseNode(entry, SCHEMA_VERSION);
|
|
303
|
+
if (!node) throw new Error('nodo non valido (schema strict): controlla name/ssh/remotePort/localPort');
|
|
304
|
+
if (store.nodes.some((n) => n.name === node.name)) {
|
|
305
|
+
throw new Error(`nodo duplicato: name "${node.name}" gia' presente`);
|
|
306
|
+
}
|
|
307
|
+
if (node.nodeId) {
|
|
308
|
+
if (node.nodeId === store.nodeId) throw new Error('self-reference: il nodeId coincide con questa installazione');
|
|
309
|
+
if (store.nodes.some((n) => n.nodeId === node.nodeId)) {
|
|
310
|
+
throw new Error(`nodo duplicato: nodeId "${node.nodeId}" gia' presente`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { ...store, schemaVersion: SCHEMA_VERSION, nodes: store.nodes.concat([node]) };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function removeNode(store, name) {
|
|
317
|
+
const idx = store.nodes.findIndex((n) => n.name === name);
|
|
318
|
+
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
319
|
+
const nodes = store.nodes.slice();
|
|
320
|
+
nodes.splice(idx, 1);
|
|
321
|
+
return { ...store, schemaVersion: SCHEMA_VERSION, nodes };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function setNodeToken(store, name, token) {
|
|
325
|
+
const idx = store.nodes.findIndex((n) => n.name === name);
|
|
326
|
+
if (idx < 0) throw new Error(`nodo sconosciuto: "${name}"`);
|
|
327
|
+
if (!validToken(token)) throw new Error('token non valido (vuoto, multilinea o troppo lungo)');
|
|
328
|
+
const nodes = store.nodes.slice();
|
|
329
|
+
nodes[idx] = { ...nodes[idx], token };
|
|
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 };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function setRendezvous(store, rdv) {
|
|
344
|
+
const parsed = parseRendezvous(rdv);
|
|
345
|
+
if (!parsed) throw new Error('rendezvous non valido: controlla ssh/publishedPort/localPort/keyPath');
|
|
346
|
+
return { ...store, schemaVersion: SCHEMA_VERSION, rendezvous: parsed };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function clearRendezvous(store) {
|
|
350
|
+
const out = { ...store, schemaVersion: SCHEMA_VERSION };
|
|
351
|
+
delete out.rendezvous;
|
|
352
|
+
return out;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --- Redazione (view sicura per status/list: MAI il token) ------------------
|
|
356
|
+
|
|
357
|
+
function redactNode(n) {
|
|
358
|
+
const out = {
|
|
359
|
+
name: n.name,
|
|
360
|
+
...(n.ssh ? { ssh: n.ssh } : {}),
|
|
361
|
+
remotePort: n.remotePort,
|
|
362
|
+
localPort: n.localPort,
|
|
363
|
+
direction: n.direction || 'outbound',
|
|
364
|
+
transport: n.transport || 'ssh',
|
|
365
|
+
autostart: !!n.autostart,
|
|
366
|
+
visibility: n.visibility || 'network',
|
|
367
|
+
hasToken: !!n.token, // presenza, non il valore
|
|
368
|
+
paired: !!(n.token && n.acceptToken),
|
|
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;
|
|
373
|
+
if (n.nodeId) out.nodeId = n.nodeId;
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function redactStore(store) {
|
|
378
|
+
const out = {
|
|
379
|
+
schemaVersion: store.schemaVersion,
|
|
380
|
+
nodeId: store.nodeId,
|
|
381
|
+
nodes: store.nodes.map(redactNode),
|
|
382
|
+
};
|
|
383
|
+
// rendezvous non contiene token: sicuro da esporre integralmente.
|
|
384
|
+
if (store.rendezvous) out.rendezvous = { ...store.rendezvous };
|
|
385
|
+
return out;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// --- Migrazione esplicita da config.json (guarded, no-op se assente) ---------
|
|
389
|
+
// Se config.json contiene un array `nodes` legacy (vecchio formato pre-B0),
|
|
390
|
+
// lo importa in nodes.json. Guarded: no-op se config.json non ha `nodes`, o se
|
|
391
|
+
// nodes.json ha gia' dei nodi (mai overwrite). Strict: un nodo legacy malformato
|
|
392
|
+
// -> throw esplicito (non importa silenziosamente spazzatura).
|
|
393
|
+
function migrateLegacyNodes(configPath, nodesPath) {
|
|
394
|
+
let cfg;
|
|
395
|
+
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
|
|
396
|
+
catch (_) { return { migrated: false, count: 0, reason: 'config.json assente/illeggibile' }; }
|
|
397
|
+
if (!cfg || typeof cfg !== 'object' || !Array.isArray(cfg.nodes) || cfg.nodes.length === 0) {
|
|
398
|
+
return { migrated: false, count: 0, reason: 'nessun campo nodes legacy in config.json' };
|
|
399
|
+
}
|
|
400
|
+
const store = loadOrInitStore(nodesPath);
|
|
401
|
+
if (store.nodes.length > 0) {
|
|
402
|
+
return { migrated: false, count: 0, reason: 'nodes.json gia\' popolato (no overwrite)' };
|
|
403
|
+
}
|
|
404
|
+
let next = store;
|
|
405
|
+
for (const legacy of cfg.nodes) {
|
|
406
|
+
next = addNode(next, legacy); // strict: garbage legacy -> throw esplicito
|
|
407
|
+
}
|
|
408
|
+
const written = atomicWriteStore(nodesPath, next);
|
|
409
|
+
return { migrated: true, count: written.nodes.length, reason: 'migrato da config.json' };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
module.exports = {
|
|
413
|
+
// parse/validate
|
|
414
|
+
parseStore, parseNode, parseRendezvous, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
|
|
415
|
+
// I/O
|
|
416
|
+
defaultNodesPath, loadStore, atomicWriteStore, loadOrInitStore, emptyStore, newNodeId,
|
|
417
|
+
// mutazioni
|
|
418
|
+
getNode, addNode, removeNode, setNodeToken, updateNode, setRendezvous, clearRendezvous,
|
|
419
|
+
// redazione
|
|
420
|
+
redactNode, redactStore,
|
|
421
|
+
// migrazione
|
|
422
|
+
migrateLegacyNodes,
|
|
423
|
+
// costanti
|
|
424
|
+
SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
425
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Detached supervisor for one SSH tunnel. The parent NexusCrew process can exit;
|
|
4
|
+
// this process keeps the tunnel alive and retries failures with bounded backoff.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const path = require('node:path');
|
|
7
|
+
const { spawn } = require('node:child_process');
|
|
8
|
+
const { backoffDelay } = require('./tunnel.js');
|
|
9
|
+
|
|
10
|
+
const sshBin = process.argv[2];
|
|
11
|
+
const sshArgs = process.argv.slice(3);
|
|
12
|
+
const statePath = process.env.NEXUSCREW_TUNNEL_STATE;
|
|
13
|
+
if (!sshBin || !statePath) process.exit(2);
|
|
14
|
+
|
|
15
|
+
let child = null;
|
|
16
|
+
let stopping = false;
|
|
17
|
+
let attempt = 0;
|
|
18
|
+
let retryTimer = null;
|
|
19
|
+
let upTimer = null;
|
|
20
|
+
|
|
21
|
+
function writeState(status, extra = {}) {
|
|
22
|
+
const tmp = `${statePath}.tmp.${process.pid}`;
|
|
23
|
+
const data = { status, supervisorPid: process.pid, attempt, updatedAt: Date.now(), ...extra };
|
|
24
|
+
try {
|
|
25
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true });
|
|
26
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data)}\n`, { mode: 0o600 });
|
|
27
|
+
fs.chmodSync(tmp, 0o600);
|
|
28
|
+
fs.renameSync(tmp, statePath);
|
|
29
|
+
} catch (_) {
|
|
30
|
+
try { fs.unlinkSync(tmp); } catch (_e) {}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function scheduleRetry(detail) {
|
|
35
|
+
if (stopping) return finish();
|
|
36
|
+
const delayMs = backoffDelay(attempt, { baseMs: 1000, capMs: 60000 });
|
|
37
|
+
writeState('retrying', { delayMs, detail });
|
|
38
|
+
attempt += 1;
|
|
39
|
+
retryTimer = setTimeout(run, delayMs);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function run() {
|
|
43
|
+
if (stopping) return finish();
|
|
44
|
+
writeState('starting');
|
|
45
|
+
try {
|
|
46
|
+
child = spawn(sshBin, sshArgs, { stdio: 'inherit' });
|
|
47
|
+
} catch (e) {
|
|
48
|
+
child = null;
|
|
49
|
+
return scheduleRetry(String(e && e.message || e));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let failureHandled = false;
|
|
53
|
+
const handleFailure = (detail) => {
|
|
54
|
+
if (failureHandled) return;
|
|
55
|
+
failureHandled = true;
|
|
56
|
+
clearTimeout(upTimer);
|
|
57
|
+
child = null;
|
|
58
|
+
scheduleRetry(detail);
|
|
59
|
+
};
|
|
60
|
+
child.once('spawn', () => {
|
|
61
|
+
// ExitOnForwardFailure makes bind/auth failures exit. Surviving this short
|
|
62
|
+
// grace window is the best portable readiness signal available for ssh -N.
|
|
63
|
+
upTimer = setTimeout(() => {
|
|
64
|
+
if (!stopping && child && child.exitCode == null) {
|
|
65
|
+
attempt = 0;
|
|
66
|
+
writeState('up', { sshPid: child.pid });
|
|
67
|
+
}
|
|
68
|
+
}, 750);
|
|
69
|
+
});
|
|
70
|
+
child.once('error', (e) => {
|
|
71
|
+
handleFailure(String(e && e.message || e));
|
|
72
|
+
});
|
|
73
|
+
child.once('exit', (code, signal) => {
|
|
74
|
+
if (stopping) return finish();
|
|
75
|
+
handleFailure(`ssh exited code=${code} signal=${signal || ''}`);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function finish() {
|
|
80
|
+
clearTimeout(retryTimer);
|
|
81
|
+
clearTimeout(upTimer);
|
|
82
|
+
writeState('down');
|
|
83
|
+
try { fs.unlinkSync(statePath); } catch (_) {}
|
|
84
|
+
process.exit(0);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function stop() {
|
|
88
|
+
if (stopping) return;
|
|
89
|
+
stopping = true;
|
|
90
|
+
clearTimeout(retryTimer);
|
|
91
|
+
clearTimeout(upTimer);
|
|
92
|
+
if (child && child.exitCode == null) {
|
|
93
|
+
try { child.kill('SIGTERM'); } catch (_) {}
|
|
94
|
+
setTimeout(() => { try { if (child && child.exitCode == null) child.kill('SIGKILL'); } catch (_) {} finish(); }, 1500).unref();
|
|
95
|
+
} else {
|
|
96
|
+
finish();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
process.on('SIGTERM', stop);
|
|
101
|
+
process.on('SIGINT', stop);
|
|
102
|
+
run();
|