@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/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) => { handleLine(line); });
346
- return { close: () => { try { rl.close(); } catch (_) {} } };
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
- // Quando il client chiude stdin il processo esce pulito.
358
- (opts.input || process.stdin).on('end', () => process.exit(0));
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
 
@@ -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 = { name, ssh, remotePort, localPort, keyPath, roles: { client: true, node: false } };
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
- try { pub = ensureKey(keyPath, name, opts); }
120
- catch (e) { log(`nodes add: generazione chiave fallita (${e.message}) nodo NON salvato`); return { code: 1, reason: 'keygen fallita' }; }
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}, remote ${remotePort} -> local ${localPort})`);
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} -> remote:${n.remotePort}\ttunnel:${n.tunnel.status}\ttoken:${n.hasToken ? 'set' : 'unset'}`);
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
- const r = tunnel.startForward({ home: ctx.home, node: ctx.node, spawnImpl: opts.spawnImpl, spawnSyncImpl: opts.spawnSyncImpl, sshBin: opts.sshBin, logFd: opts.logFd });
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
+ };
@@ -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 = 1;
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(['name', 'ssh', 'remotePort', 'localPort', 'keyPath', 'roles', 'token', 'nodeId']);
87
- function parseNode(n) {
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 ssh = parseSsh(n.ssh);
92
- if (!ssh) 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;
93
113
  if (!isPort(n.remotePort)) return null;
94
114
  if (!isPort(n.localPort)) return null;
95
- if (!isAbsPath(n.keyPath)) 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;
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: SCHEMA_VERSION, nodeId: d.nodeId, nodes };
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/keyPath');
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
- keyPath: n.keyPath,
303
- roles: n.roles,
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
  };
@@ -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.isAbsPath(node.keyPath)) throw new Error('tunnel: keyPath non valido');
37
- if (!store.parseSsh(node.ssh)) throw new Error('tunnel: ssh (user@host) non valido');
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
- return SSH_BASE_OPTS.concat([
55
- '-i', node.keyPath,
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
- const args = buildForwardArgs(node);
261
- return startTunnel({ ...opts, name: node.name, args });
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.