@mmmbuto/nexuscrew 0.8.52-rc.9 → 0.8.53

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/tools.js CHANGED
@@ -48,6 +48,52 @@ const IDENTITY_REMEDIATION =
48
48
  + 'oppure assicurati che il client MCP inoltri il contesto tmux al processo child: nessun valore '
49
49
  + 'viene copiato nella CLI o nel file di configurazione.';
50
50
 
51
+ // Perche' una ricerca fallita non e' una sola cosa.
52
+ //
53
+ // Il messaggio storico era «cella destinataria non trovata nella rete
54
+ // autorizzata» per OGNI fallimento. Chi lo leggeva andava a controllare la
55
+ // directory e le autorizzazioni — cioe' i due posti dove il difetto non era.
56
+ // Il caso reale: un instanceId RIBATTUTO invece che copiato da `nc_cells`, con
57
+ // un carattere perso. `NODE_ID_RE` accetta 16-64 esadecimali, quindi un id
58
+ // troncato passa la validazione, e da li' in poi ogni indizio puntava altrove.
59
+ // Il costo e' stato un'indagine su un canale che funzionava.
60
+ //
61
+ // Le tre risposte sono distinte perche' portano a tre azioni distinte:
62
+ // correggere l'id, accendere il nodo, correggere il nome della cella.
63
+ function cellLookupFailure(directory, targetRef) {
64
+ const cells = Array.isArray(directory.cells) ? directory.cells : [];
65
+ if (cells.some((cell) => cell.instanceId === targetRef.instanceId)) {
66
+ return `cella "${targetRef.cell}" non trovata sul nodo ${targetRef.instanceId}`;
67
+ }
68
+ const down = (Array.isArray(directory.unavailable) ? directory.unavailable : [])
69
+ .find((owner) => owner.instanceId === targetRef.instanceId);
70
+ // Il nodo esiste ed e' autorizzato: e' irraggiungibile adesso. Dirlo evita di
71
+ // cercare un errore di configurazione al posto di un dispositivo spento.
72
+ if (down) return `nodo ${targetRef.instanceId} non raggiungibile (${down.failure}): le sue celle non sono in elenco`;
73
+ // «rete autorizzata» resta fuori di proposito: e' la formula che faceva
74
+ // leggere un id sbagliato come un problema di permessi.
75
+ return `nessun nodo con instanceId ${targetRef.instanceId} fra quelli noti a questo hub`
76
+ + ' — copia l\'id esatto da nc_cells invece di ribatterlo';
77
+ }
78
+
79
+ // La stessa lezione sull'altra directory. `VL_TARGET_RE` accetta anch'esso
80
+ // 16-64 esadecimali per l'owner, quindi un id ribattuto e troncato arriva fino
81
+ // alla ricerca esattamente come per le celle — e il messaggio unico nominava il
82
+ // micro-device e l'autorizzazione, tacendo sull'owner.
83
+ function vlLookupFailure(directory, { instanceId, nodeId = null }) {
84
+ const owners = Array.isArray(directory.owners) ? directory.owners : [];
85
+ // Se l'owner c'e', allora manca davvero il device: e' l'unico caso in cui il
86
+ // soggetto giusto e' il micro-device.
87
+ if (nodeId && owners.some((owner) => owner.instanceId === instanceId)) {
88
+ return `micro-device VL-${nodeId} non trovato sull'owner ${instanceId}`;
89
+ }
90
+ const down = (Array.isArray(directory.unavailable) ? directory.unavailable : [])
91
+ .find((owner) => owner.instanceId === instanceId);
92
+ if (down) return `owner ${instanceId} non raggiungibile (${down.failure}): la sua directory VL non e' in elenco`;
93
+ return `nessun owner con instanceId ${instanceId} fra quelli noti a questo hub`
94
+ + ' — copia l\'id esatto da nc_vl_nodes invece di ribatterlo';
95
+ }
96
+
51
97
  function requireSession(session, tool, code = IDENTITY_CODE.MISSING) {
52
98
  if (session) return session;
53
99
  const stableCode = code === IDENTITY_CODE.INVALID ? IDENTITY_CODE.INVALID : IDENTITY_CODE.MISSING;
@@ -237,6 +283,10 @@ const TOOLS = [
237
283
  type: 'string',
238
284
  description: 'lingua opzionale del testo: it, en, es o locale BCP-47 equivalente (es. it-IT)',
239
285
  },
286
+ target: {
287
+ type: 'string',
288
+ description: 'instanceId ESATTO del nodo su cui sta l\'operatore, per avvisarlo quando non e\' su questo nodo. Ottienilo da nc_cells/nc_status; niente wildcard. Omesso = questo nodo.',
289
+ },
240
290
  },
241
291
  required: ['title'],
242
292
  },
@@ -245,6 +295,10 @@ const TOOLS = [
245
295
  const body = argString(args, 'body', { max: 2000 });
246
296
  const urgency = argString(args, 'urgency', { max: 16 });
247
297
  const rawLang = argString(args, 'lang', { max: 35 });
298
+ const target = argString(args, 'target', { max: 64 });
299
+ if (target !== undefined && !/^[a-f0-9]{32}$/i.test(target)) {
300
+ throw new Error('target deve essere un instanceId di nodo (32 hex)');
301
+ }
248
302
  if (urgency !== undefined && urgency !== 'normal' && urgency !== 'high') {
249
303
  throw new Error('urgency deve essere "normal" o "high"');
250
304
  }
@@ -258,9 +312,18 @@ const TOOLS = [
258
312
  ...(body ? { body } : {}),
259
313
  ...(urgency ? { urgency } : {}),
260
314
  ...(lang ? { lang } : {}),
315
+ ...(target ? { target } : {}),
261
316
  };
262
317
  if (session) payload.session = session;
263
318
  const j = await ctx.api('POST', '/api/notify', payload);
319
+ // Con un target remoto l'esito e' quello del nodo che ha consegnato:
320
+ // `delivered` da solo direbbe "0 tentativi qui", che e' vero e inutile.
321
+ // Restituirlo cosi' evita di far leggere un successo dove c'e' un rifiuto.
322
+ // Nessun conteggio: il dispatcher non lo propaga, e un ramo che non puo'
323
+ // mai essere vero e' codice morto travestito da informazione
324
+ // (rilievo R1 di DevAuditor su rc.14). Se un giorno servira' il dettaglio,
325
+ // va fatto propagare da forward(), non dedotto qui.
326
+ if (target) return { status: j.status, ...(j.reason ? { reason: j.reason } : {}) };
264
327
  return { delivered: j.delivered };
265
328
  },
266
329
  },
@@ -472,7 +535,7 @@ const TOOLS = [
472
535
  || ttlSeconds < 30 || ttlSeconds > 3600) throw new Error('ownerId o ttlSeconds non valido');
473
536
  const directory = await readVlDirectory(ctx);
474
537
  const owner = directory.owners.find((item) => item.instanceId === ownerId);
475
- if (!owner) throw new Error('owner VL non disponibile nella rete autorizzata');
538
+ if (!owner) throw new Error(vlLookupFailure(directory, { instanceId: ownerId }));
476
539
  const apiPath = vlOwnerPath(owner, 'invite');
477
540
  if (!apiPath) throw new Error('route owner VL non valida');
478
541
  const result = await ctx.api('POST', apiPath, { label, ttlSeconds });
@@ -500,7 +563,7 @@ const TOOLS = [
500
563
  || JSON.stringify(commandArgs).length > 4096) throw new Error('target o args VL non validi');
501
564
  const directory = await readVlDirectory(ctx);
502
565
  const node = directory.nodes.find((item) => item.id === target.id);
503
- if (!node) throw new Error('micro-device VL non trovato nella rete autorizzata');
566
+ if (!node) throw new Error(vlLookupFailure(directory, target));
504
567
  if (!node.canManage || !node.online) throw new Error('micro-device VL offline; nessun comando accodato');
505
568
  const apiPath = vlOwnerPath(node, `${target.nodeId}/commands`);
506
569
  if (!apiPath) throw new Error('route micro-device VL non valida');
@@ -523,7 +586,7 @@ const TOOLS = [
523
586
  if (!target) throw new Error('target VL non valido');
524
587
  const directory = await readVlDirectory(ctx);
525
588
  const node = directory.nodes.find((item) => item.id === target.id);
526
- if (!node) throw new Error('micro-device VL non trovato nella rete autorizzata');
589
+ if (!node) throw new Error(vlLookupFailure(directory, target));
527
590
  const apiPath = vlOwnerPath(node, target.nodeId);
528
591
  if (!apiPath) throw new Error('route micro-device VL non valida');
529
592
  await ctx.api('DELETE', apiPath);
@@ -604,7 +667,7 @@ const TOOLS = [
604
667
  if (!sender) throw new Error('nc_send_cell: la sessione chiamante non e\' una cella Fleet attiva locale');
605
668
  const target = directory.cells.find((cell) => cell.instanceId === targetRef.instanceId
606
669
  && cell.cell === targetRef.cell);
607
- if (!target) throw new Error('cella destinataria non trovata nella rete autorizzata');
670
+ if (!target) throw new Error(cellLookupFailure(directory, targetRef));
608
671
  if (!target.canReceive) throw new Error('cella destinataria non attiva; nessun messaggio accodato');
609
672
  const apiPath = target.route === 'local'
610
673
  ? '/api/cells/send' : routePath(target.route.split('/'), 'cells/send');
@@ -252,6 +252,15 @@ function nodesInspect(opts) {
252
252
  log(`tipo: ${peer.kind} · ${peer.relation}`);
253
253
  log(`route: ${peer.route.join(' -> ')}`);
254
254
  if (peer.tunnel) log(`status: ${peer.tunnel.status}`);
255
+ // Uno scope celle ristretto e' una limitazione che l'operatore ha imposto
256
+ // e che poi non vede piu' da nessuna parte: si dimentica, e il giorno che
257
+ // un peer "non trova" una cella la causa e' invisibile. Il caso `all` non
258
+ // si stampa: e' il default, e una riga per dire "nessun limite" e' rumore.
259
+ if (peer.cellVisibility === 'none') log('celle: nessuna (scope: none)');
260
+ else if (peer.cellVisibility === 'selected') {
261
+ const cells = Array.isArray(peer.cells) ? peer.cells : [];
262
+ log(`celle: ${cells.length ? cells.join(', ') : 'nessuna (elenco vuoto)'} (scope: selected)`);
263
+ }
255
264
  log(`azioni: ${Object.keys(peer.actions || {}).filter((key) => peer.actions[key]).join(', ')}`);
256
265
  }
257
266
  return { code: 0, peer: found.peer };
@@ -267,9 +276,12 @@ function nodesEdit(opts) {
267
276
  const node = resolveStoredNode(st, opts.ref || opts.name);
268
277
  if (!node) { log(`nodes edit: nodo sconosciuto "${opts.ref || opts.name || ''}"`); return { code: 1, reason: 'unknown-node' }; }
269
278
  const supplied = opts.patch && typeof opts.patch === 'object' ? opts.patch : {};
279
+ // Lo scope celle vale per QUALUNQUE peer accoppiato, in entrambe le
280
+ // direzioni: riguarda chi accede alle celle di questo nodo, non chi ha
281
+ // aperto il tunnel.
270
282
  const allowed = node.direction === 'inbound'
271
- ? new Set(['label', 'visibility', 'selected'])
272
- : new Set(['label', 'ssh', 'sshPort', 'autostart']);
283
+ ? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells'])
284
+ : new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells']);
273
285
  const keys = Object.keys(supplied);
274
286
  const invalid = keys.find((key) => !allowed.has(key));
275
287
  if (invalid) {
@@ -298,6 +310,13 @@ function nodesEdit(opts) {
298
310
  log('nodes edit: visibility non valida'); return { code: 1, reason: 'invalid-visibility' };
299
311
  }
300
312
  if (patch.visibility !== 'selected') delete patch.selected;
313
+ // Stessa simmetria per lo scope celle. `delete` non basterebbe: updateNode
314
+ // fonde patch e nodo esistente, quindi le celle concesse prima
315
+ // sopravviverebbero alla modalita' che le revoca — e tornerebbero buone al
316
+ // primo ritorno a `selected`, concedendo in silenzio cio' che l'operatore
317
+ // credeva di aver tolto. `undefined` invece le fa sparire, perche' parseNode
318
+ // ricostruisce il nodo da zero.
319
+ if (Object.hasOwn(patch, 'cellVisibility') && patch.cellVisibility !== 'selected') patch.cells = undefined;
301
320
 
302
321
  let next;
303
322
  try { next = store.updateNode(st, node.name, patch); }
@@ -475,6 +494,70 @@ async function nodesTest(opts) {
475
494
  };
476
495
  }
477
496
 
497
+ // `nodes test` senza riferimento: prova TUTTI i peer diretti e chiude con le
498
+ // incoerenze.
499
+ //
500
+ // Nasce da un caso reale: tre peer su quattro risultavano "Share enabled" e uno
501
+ // solo aveva davvero il canale inverso attivo. La diagnosi c'era gia' —
502
+ // `nodes test <nodo>` distingue OK, KO e passivo con precisione — ma andava
503
+ // lanciata un nodo alla volta, sapendo gia' di doverlo fare. Uno stato che
504
+ // mostra il DESIDERIO ("Share enabled") accanto a nessuna verifica non e' una
505
+ // bugia del codice: e' una domanda che nessuno pensa di fare.
506
+ //
507
+ // Il valore non e' l'elenco, che si otteneva anche prima: e' l'ultima riga.
508
+ async function nodesTestAll(opts) {
509
+ const log = opts.log || console.log;
510
+ const { nodesPath } = resolveNodePaths(opts);
511
+ const st = store.loadStore(nodesPath);
512
+ const nodes = st && Array.isArray(st.nodes) ? st.nodes : [];
513
+ if (!nodes.length) {
514
+ log('nodes test: nessun nodo configurato');
515
+ return { code: 0, results: [] };
516
+ }
517
+ // In parallelo: un peer irraggiungibile consuma il proprio timeout, e in
518
+ // sequenza quattro nodi spenti farebbero sembrare rotto il comando.
519
+ const results = await Promise.all(nodes.map(async (node) => {
520
+ const lines = [];
521
+ let outcome;
522
+ try {
523
+ outcome = await nodesTest({ ...opts, ref: node.name, log: (line) => lines.push(String(line)) });
524
+ } catch (e) {
525
+ outcome = { code: 1, result: 'errore', detail: String(e && e.message || e) };
526
+ }
527
+ return {
528
+ name: node.name,
529
+ shared: node.shared === true,
530
+ result: outcome.result || 'sconosciuto',
531
+ line: lines.length ? lines[lines.length - 1] : `nodes test [${node.name}]: esito non riportato`,
532
+ };
533
+ }));
534
+ for (const entry of results) log(entry.line);
535
+
536
+ // L'incoerenza che conta: dichiarato condiviso, canale non verificabile.
537
+ // Non e' per forza un difetto — un dispositivo spento finisce qui — ma e'
538
+ // esattamente cio' che lo stato mostrato non dice.
539
+ const declaredNotProven = results.filter((entry) => entry.shared && entry.result !== 'ok');
540
+ if (declaredNotProven.length) {
541
+ log('');
542
+ log(`nodes test: ${declaredNotProven.length} nodo/i risultano condivisi ma il canale inverso non risponde: ${declaredNotProven.map((entry) => entry.name).join(', ')}`);
543
+ // La riga esiste per essere letta da chi ha il problema. Dice DOVE
544
+ // guardare (qui, sull'hub), COSA deve esserci (le porte del pool di quel
545
+ // dispositivo) e la parola da cercare (permitlisten).
546
+ //
547
+ // La concessione sta sull'HUB, non sul dispositivo: il dispositivo CHIEDE
548
+ // il bind reverse, ma e' lo sshd di questa macchina a concederlo o negarlo
549
+ // in base alla riga di `authorized_keys` che porta la sua chiave. Mandare
550
+ // l'operatore a cercare sul dispositivo sarebbe peggio di una riga vaga —
551
+ // lo si manderebbe dalla parte sbagliata, che e' esattamente il modo in cui
552
+ // questo difetto e' rimasto aperto per giorni.
553
+ log('nodes test: "Share attivo" e\' lo stato desiderato, non una verifica. Un dispositivo spento finisce qui; se e\' acceso, il bind inverso viene rifiutato prima di salire: su QUESTO hub, la chiave di quel dispositivo in ~/.ssh/authorized_keys deve concedere le porte del suo pool (opzione SSH permitlisten).');
554
+ }
555
+ // Esito 0 anche con incoerenze: questo comando RIFERISCE, non giudica. Un
556
+ // codice diverso da zero farebbe fallire ogni script che lo usa per stampare
557
+ // lo stato, e un dispositivo spento non e' un errore dell'installazione.
558
+ return { code: 0, results, declaredNotProven: declaredNotProven.map((entry) => entry.name) };
559
+ }
560
+
478
561
  // probe HTTP di default (fetch built-in, Node >=18). Ritorna {ok, status}.
479
562
  async function defaultHttpProbe(url, headers) {
480
563
  const res = await fetch(url, { headers, redirect: 'manual' });
@@ -576,7 +659,7 @@ function nodesSetToken(opts) {
576
659
  }
577
660
 
578
661
  module.exports = {
579
- nodesAdd, nodesList, nodesInspect, nodesEdit, nodesRemove, nodesTest,
662
+ nodesAdd, nodesList, nodesInspect, nodesEdit, nodesRemove, nodesTest, nodesTestAll,
580
663
  nodesUp, nodesDown, nodesRestart, nodesSetToken,
581
664
  // helper esposti per test/riuso
582
665
  assignLocalPort, bindLocalPort, reserveLocalPort, defaultKeyPath, ensureKey, readSecretToken,
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+ // lib/nodes/identity.js — la chiave di questo nodo, e cosa sappiamo delle altrui.
3
+ //
4
+ // Primo passo del modello di autorita' per-nodo, e il piu' modesto: «chiavi per
5
+ // nodo e directory delle pubbliche, nessun effetto sui permessi». Quella
6
+ // clausola e' un vincolo, non una nota: qui si OSSERVA e si REGISTRA, non si
7
+ // concede e non si nega. Nessuna via cambia comportamento perche' una chiave
8
+ // c'e' o non c'e'.
9
+ //
10
+ // PERCHE' UN PASSO CHE NON CAMBIA NULLA VALE COMUNQUE. Tutto il modello poggia
11
+ // su una proprieta' sola: che `holderKey` identifichi davvero un titolare. Se
12
+ // l'identita' di un peer potesse cambiare in silenzio, i grant dei passi 3-5
13
+ // sarebbero legati a un nome che chiunque puo' prendersi, e nessuna firma
14
+ // varrebbe niente. Quella proprieta' si guadagna adesso, osservando presto e a
15
+ // lungo, o non si guadagna piu': una chiave legata oggi ha una storia domani.
16
+ //
17
+ // LA REGOLA: una chiave gia' legata NON viene mai sovrascritta. La prima
18
+ // osservazione lega (TOFU), una successiva diversa e' un CONFLITTO che si
19
+ // registra e si mostra. Sovrascrivere sarebbe comodo — il peer ha reinstallato,
20
+ // ha rigenerato, e' tutto normale — ed e' esattamente il comportamento che
21
+ // rende l'identita' inutile: chi sa rispondere a un probe diventerebbe il peer.
22
+ //
23
+ // STATO REALE, perche' non si creda piu' di cio' che c'e': OGGI IL PAIRING E'
24
+ // L'UNICO SCRITTORE di chiavi, e scrive inline in `/pair/confirm` e nel
25
+ // coordinatore. `observePeerKey` NON HA CHIAMANTI: e' la primitiva del primo
26
+ // percorso che imparera' una chiave FUORI dal pairing, e arriva con lui. Fino
27
+ // ad allora nessuna chiave puo' essere sovrascritta perche' non esiste un
28
+ // secondo scrittore — la proprieta' regge per assenza, non per controllo, e la
29
+ // differenza va detta invece che lasciata credere.
30
+ //
31
+ // Rilievo dell'audit indipendente: una funzione provata e senza chiamanti e' il
32
+ // segnale piu' forte che una garanzia esista, ed e' il piu' facile da dare per
33
+ // sbaglio.
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+ const crypto = require('node:crypto');
37
+
38
+ // Ed25519 grezza: 32 byte, in base64url. Lunghezza fissa, niente PEM sul filo,
39
+ // niente ambiguita' di codifica fra due implementazioni.
40
+ const PUBLIC_KEY_RE = /^[A-Za-z0-9_-]{43}$/;
41
+ const KEY_BASENAME = 'node-key.json';
42
+ const KEY_SCHEMA_VERSION = 1;
43
+
44
+ function keyPathFor(home) {
45
+ return path.join(home, '.nexuscrew', KEY_BASENAME);
46
+ }
47
+
48
+ // La chiave vive accanto a nodes.json, non accanto alla home: chi ha gia' un
49
+ // `nodesPath` — le route, il coordinatore di pairing, ogni test con una home
50
+ // finta — non deve ricostruire una home da cui e' gia' derivato. Derivare due
51
+ // volte lo stesso percorso da due basi diverse e' il modo in cui un test passa
52
+ // su un file e la produzione ne usa un altro.
53
+ function keyPathNextTo(nodesPath) {
54
+ return path.join(path.dirname(nodesPath), KEY_BASENAME);
55
+ }
56
+
57
+ // La privata sta in un file SUO, non dentro nodes.json. Non e' pignoleria:
58
+ // nodes.json viene letto dalla redazione, dal backup, da `nodes inspect` e
59
+ // dalle viste condivise. Tenerla fuori per COSTRUZIONE vale piu' di ricordarsi
60
+ // di redigerla in cinque punti — dimenticarne uno la pubblicherebbe, e una
61
+ // privata pubblicata una volta e' bruciata per sempre.
62
+ function readKeyFile(p) {
63
+ let st;
64
+ try {
65
+ st = fs.lstatSync(p);
66
+ } catch (e) {
67
+ if (e.code === 'ENOENT') return null;
68
+ throw e;
69
+ }
70
+ // Un symlink qui e' un modo per farci scrivere altrove o per farci leggere
71
+ // una chiave che non e' la nostra. Stessa disciplina di atomicWriteStore.
72
+ if (st.isSymbolicLink()) throw new Error('node-key.json e\' un symlink: rifiuto di usarlo');
73
+ if (!st.isFile()) throw new Error('node-key.json non e\' un file regolare');
74
+ // Se il file e' leggibile da gruppo o altri, la chiave e' gia' potenzialmente
75
+ // fuori. Non la si usa fingendo che vada bene, e non la si "ripara" in
76
+ // silenzio: un chmod nostro nasconderebbe che qualcuno l'ha letta.
77
+ if ((st.mode & 0o077) !== 0) {
78
+ throw new Error(`node-key.json ha permessi ${(st.mode & 0o777).toString(8)}: `
79
+ + 'la chiave privata e\' leggibile oltre il proprietario, va rigenerata a mano');
80
+ }
81
+ // UN FILE CHE ESISTE MA NON E' USABILE FA RUMORE, NON SI SOSTITUISCE.
82
+ // La prima stesura tornava `null` per uno schema sbagliato — cioe' «genera
83
+ // pure una chiave nuova» — e LANCIAVA su un JSON corrotto, perche' il parse
84
+ // stava fuori dal try. Due esiti opposti per due modi di essere illeggibile,
85
+ // e quello comodo era il piu' pericoloso: rigenerare cambia l'identita' di
86
+ // questo nodo, e ogni peer che aveva legato la vecchia si ritrova per sempre
87
+ // con una chiave che non corrisponde. Meglio fermarsi e farlo sapere.
88
+ // Rilievo dell'audit indipendente; risolto nel verso opposto a quello
89
+ // proposto, perche' la coerenza va cercata sul ramo sicuro.
90
+ let parsed;
91
+ try {
92
+ parsed = JSON.parse(fs.readFileSync(p, 'utf8'));
93
+ } catch (e) {
94
+ throw new Error(`node-key.json illeggibile (${e.message}): `
95
+ + 'non lo sostituisco da solo, perche\' rigenerare cambierebbe l\'identita\' di questo nodo');
96
+ }
97
+ const rotto = !parsed || typeof parsed !== 'object' || Array.isArray(parsed)
98
+ || parsed.schemaVersion !== KEY_SCHEMA_VERSION
99
+ || typeof parsed.privateKeyPem !== 'string' || !parsed.privateKeyPem.includes('PRIVATE KEY');
100
+ if (rotto) {
101
+ throw new Error('node-key.json non ha la forma attesa: '
102
+ + 'non lo sostituisco da solo, perche\' rigenerare cambierebbe l\'identita\' di questo nodo');
103
+ }
104
+ return parsed;
105
+ }
106
+
107
+ // La pubblica si DERIVA dalla privata a ogni caricamento, e non si conserva
108
+ // accanto. Due copie della stessa cosa possono divergere, e una pubblica che
109
+ // non corrisponde alla privata e' un'identita' che firma e non verifica —
110
+ // il genere di guasto che si scopre solo quando serve.
111
+ function publicKeyFromPrivate(privateKey) {
112
+ const jwk = crypto.createPublicKey(privateKey).export({ format: 'jwk' });
113
+ if (!jwk || jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || typeof jwk.x !== 'string') {
114
+ throw new Error('chiave del nodo non Ed25519');
115
+ }
116
+ return jwk.x;
117
+ }
118
+
119
+ function writeKeyFile(p, privateKeyPem) {
120
+ const dir = path.dirname(p);
121
+ fs.mkdirSync(dir, { recursive: true });
122
+ const tmp = path.join(dir, `.${KEY_BASENAME}.${crypto.randomBytes(6).toString('hex')}.tmp`);
123
+ try {
124
+ fs.writeFileSync(tmp, `${JSON.stringify({
125
+ schemaVersion: KEY_SCHEMA_VERSION,
126
+ privateKeyPem,
127
+ }, null, 2)}\n`, { mode: 0o600 });
128
+ fs.chmodSync(tmp, 0o600); // 0600 a prescindere da umask
129
+ fs.renameSync(tmp, p);
130
+ } catch (e) {
131
+ try { fs.unlinkSync(tmp); } catch (_) { /* best-effort */ }
132
+ throw e;
133
+ }
134
+ }
135
+
136
+ // ensureNodeKey: idempotente. La prima chiamata genera, le successive leggono.
137
+ // `created` distingue i due casi per chi vuole registrarlo una volta sola.
138
+ // `afterWriteSeam` e' un seam di prova, come `sessionExistsSeam` e `fleetSeam`
139
+ // altrove: viene chiamato subito dopo la scrittura e serve a far accadere la
140
+ // corsa in modo deterministico. Senza, il test della corsa e' verde per
141
+ // costruzione — il file esiste gia' e il percorso di CREAZIONE non viene mai
142
+ // eseguito. L'ho scoperto perche' il controllo negativo non falliva.
143
+ function ensureNodeKey({ home, keyPath, afterWriteSeam } = {}) {
144
+ const p = keyPath || keyPathFor(home);
145
+ const existing = readKeyFile(p);
146
+ if (existing) {
147
+ const privateKey = crypto.createPrivateKey(existing.privateKeyPem);
148
+ return { publicKey: publicKeyFromPrivate(privateKey), privateKey, path: p, created: false };
149
+ }
150
+ const { privateKey } = crypto.generateKeyPairSync('ed25519');
151
+ const pem = privateKey.export({ format: 'pem', type: 'pkcs8' }).toString();
152
+ writeKeyFile(p, pem);
153
+ if (typeof afterWriteSeam === 'function') afterWriteSeam(p);
154
+
155
+ // SI RILEGGE DAL DISCO invece di restituire la chiave appena generata, e non
156
+ // e' pignoleria: due processi che partono insieme sulla PRIMA creazione
157
+ // vedono entrambi il file assente, generano due chiavi diverse e i due rename
158
+ // si sovrascrivono. Chi perde la corsa restituirebbe in memoria una chiave
159
+ // che sul disco non esiste piu' — e se nel frattempo l'ha mandata a un peer
160
+ // dentro un pairing, quel peer lega un'identita' che non potremo mai
161
+ // dimostrare: da li' in poi ogni nostra chiave gli risulta un conflitto, per
162
+ // sempre, ed e' esattamente il guasto che questo modulo esiste per impedire.
163
+ // Rileggendo, i due processi convergono su chi ha vinto il rename.
164
+ const suDisco = readKeyFile(p);
165
+ const effettiva = suDisco ? crypto.createPrivateKey(suDisco.privateKeyPem) : privateKey;
166
+ return { publicKey: publicKeyFromPrivate(effettiva), privateKey: effettiva, path: p, created: true };
167
+ }
168
+
169
+ function isPublicKey(value) {
170
+ return typeof value === 'string' && PUBLIC_KEY_RE.test(value);
171
+ }
172
+
173
+ // observePeerKey — il cuore del passo, ed e' quattro righe di logica e un
174
+ // invariante. Torna un nodo NUOVO (non muta l'ingresso) e l'esito osservato.
175
+ //
176
+ // 'bound' la prima volta: si lega e si registra COME e QUANDO
177
+ // 'unchanged' la chiave e' quella di prima
178
+ // 'conflict' e' arrivata una chiave DIVERSA. Non si sovrascrive nulla.
179
+ //
180
+ // `source` ('pairing' | 'peer-assertion') non e' decorazione: al passo 3 un
181
+ // grant potra' pretendere una chiave legata AL PAIRING, cioe' nello stesso atto
182
+ // in cui l'operatore ha deciso di fidarsi, e non una arrivata dopo su un canale
183
+ // che il peer stesso controlla. Se non lo registriamo adesso, quella
184
+ // distinzione non e' piu' ricostruibile.
185
+ function observePeerKey(node, { publicKey, source = 'peer-assertion', now = Date.now() } = {}) {
186
+ if (!isPublicKey(publicKey)) return { node, outcome: 'invalid' };
187
+ if (!node.publicKey) {
188
+ return {
189
+ node: { ...node, publicKey, keySource: source, keyBoundAt: new Date(now).toISOString() },
190
+ outcome: 'bound',
191
+ };
192
+ }
193
+ if (node.publicKey === publicKey) {
194
+ // Una chiave gia' legata al pairing non viene "promossa" ne' declassata da
195
+ // un'osservazione successiva: la provenienza e' quella del legame, non
196
+ // dell'ultima volta che l'abbiamo rivista.
197
+ return { node, outcome: 'unchanged' };
198
+ }
199
+ // Conflitto. Si conserva il primo legame e si registra cosa e' arrivato,
200
+ // perche' l'operatore possa decidere: un peer reinstallato e una sostituzione
201
+ // di identita' hanno la stessa forma sul filo, e a distinguerli non e' il
202
+ // codice ma chi sa cosa e' successo a quel dispositivo.
203
+ return {
204
+ node: { ...node, keyConflict: { seen: publicKey, at: new Date(now).toISOString(), source } },
205
+ outcome: 'conflict',
206
+ };
207
+ }
208
+
209
+ // La directory: cosa questo nodo sa delle identita' altrui. Sola lettura,
210
+ // nessun segreto — le pubbliche sono pubbliche, ed e' il punto.
211
+ function publicKeyDirectory(store) {
212
+ const nodes = store && Array.isArray(store.nodes) ? store.nodes : [];
213
+ return nodes.map((node) => ({
214
+ name: node.name,
215
+ instanceId: node.nodeId || null,
216
+ publicKey: node.publicKey || null,
217
+ source: node.publicKey ? (node.keySource || 'peer-assertion') : null,
218
+ boundAt: node.keyBoundAt || null,
219
+ conflict: node.keyConflict || null,
220
+ }));
221
+ }
222
+
223
+ module.exports = {
224
+ ensureNodeKey,
225
+ observePeerKey,
226
+ publicKeyDirectory,
227
+ publicKeyFromPrivate,
228
+ isPublicKey,
229
+ keyPathFor,
230
+ keyPathNextTo,
231
+ PUBLIC_KEY_RE,
232
+ };
@@ -18,6 +18,7 @@ const os = require('node:os');
18
18
  const path = require('node:path');
19
19
  const crypto = require('node:crypto');
20
20
  const reversePool = require('./reverse-pool.js');
21
+ const identity = require('./identity.js');
21
22
 
22
23
  const SCHEMA_VERSION = 3;
23
24
  const PREVIOUS_SCHEMA_VERSION = 2;
@@ -108,8 +109,27 @@ const NODE_KEYS = new Set([
108
109
  'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
109
110
  'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
110
111
  'direction', 'reversePort', 'shared', 'label', 'reversePool',
112
+ // Quali CELLE di questo nodo il peer puo' vedere. Distinto da `visibility`,
113
+ // che governa il TRANSITO (attraverso chi passa il traffico) e non l'accesso.
114
+ 'cellVisibility', 'cells',
115
+ // Identita' crittografica del peer — passo 1 del modello di autorita'. Oggi
116
+ // NON governa niente: si osserva e si registra. `keySource` distingue una
117
+ // chiave legata al pairing (nello stesso atto in cui si e' deciso di fidarsi)
118
+ // da una arrivata dopo su un canale che il peer controlla; al passo 3 un
119
+ // grant potra' pretendere la prima. `keyConflict` conserva una chiave DIVERSA
120
+ // vista dopo il legame, senza sostituire quella legata.
121
+ 'publicKey', 'keySource', 'keyBoundAt', 'keyConflict',
111
122
  ]);
112
123
 
124
+ // Nome di cella: stessa forma usata da fleet/definitions.js, cells/routes.js e
125
+ // mcp/cells.js. E' `cell` la chiave del permesso — unica nel nodo e immutabile —
126
+ // non `tmuxSession`, che e' derivata e ammette override non canonici.
127
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
128
+ const MAX_CELLS = 128;
129
+
130
+ // Un conflitto di chiave e' un record chiuso: cosa e' arrivato, quando, da dove.
131
+ const KEY_CONFLICT_KEYS = new Set(['seen', 'at', 'source']);
132
+
113
133
  const REVERSE_POOL_SLOT_STATES = new Set(['active', 'ready', 'reserved', 'draining', 'quarantined', 'retired']);
114
134
  const REVERSE_POOL_VERIFICATIONS = new Set(['verified', 'unverifiable', 'missing', 'invalidated']);
115
135
  const REVERSE_POOL_PHASES = new Set(['active', 'prepared', 'switched', 'abandoned']);
@@ -265,6 +285,28 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
265
285
  if (selected.some((id) => typeof id !== 'string' || !NODE_ID_RE.test(id))) return null;
266
286
  out.selected = selected;
267
287
  }
288
+ // Scope celle. Il default e' `all` quando il campo e' ASSENTE: un default
289
+ // fail-closed renderebbe muta l'intera flotta al primo aggiornamento, senza
290
+ // che nessuno abbia deciso nulla. Restringere deve restare un atto esplicito.
291
+ // `selected` con lista vuota significa invece "nessuna cella", ed e' proprio
292
+ // per distinguerlo da "campo assente" che la modalita' e' un campo a parte.
293
+ if (n.cellVisibility !== undefined) {
294
+ if (!['all', 'none', 'selected'].includes(n.cellVisibility)) return null;
295
+ out.cellVisibility = n.cellVisibility;
296
+ } else {
297
+ out.cellVisibility = 'all';
298
+ }
299
+ if (n.cells !== undefined) {
300
+ // Elencare celle senza dichiarare la modalita' e' ambiguo: l'elenco
301
+ // sembrerebbe un permesso mentre non lo e'. Meglio rifiutare che indovinare.
302
+ if (out.cellVisibility !== 'selected') return null;
303
+ if (!Array.isArray(n.cells) || n.cells.length > MAX_CELLS) return null;
304
+ const cells = [...new Set(n.cells)];
305
+ if (cells.some((id) => typeof id !== 'string' || !CELL_ID_RE.test(id))) return null;
306
+ out.cells = cells;
307
+ } else if (out.cellVisibility === 'selected') {
308
+ out.cells = [];
309
+ }
268
310
  // Campo opzionale per compatibilita' con gli store 0.8.0: se assente, ssh
269
311
  // continua a usare la porta risolta da ~/.ssh/config o il default OpenSSH.
270
312
  if (n.sshPort !== undefined) out.sshPort = n.sshPort;
@@ -280,6 +322,38 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
280
322
  if (typeof n.nodeId !== 'string' || !NODE_ID_RE.test(n.nodeId)) return null;
281
323
  out.nodeId = n.nodeId;
282
324
  }
325
+ // Identita' del peer. Schema chiuso come il resto: una chiave malformata e'
326
+ // un record rifiutato, non una chiave "quasi buona" che qualcuno confrontera'
327
+ // piu' tardi credendola valida.
328
+ if (n.publicKey !== undefined) {
329
+ if (!identity.isPublicKey(n.publicKey)) return null;
330
+ out.publicKey = n.publicKey;
331
+ }
332
+ if (n.keySource !== undefined) {
333
+ // Solo due provenienze, perche' e' la distinzione che serve al passo 3.
334
+ // Un terzo valore inventato passerebbe i confronti e non significherebbe
335
+ // niente.
336
+ if (n.keySource !== 'pairing' && n.keySource !== 'peer-assertion') return null;
337
+ if (out.publicKey === undefined) return null; // provenienza senza chiave: incoerente
338
+ out.keySource = n.keySource;
339
+ }
340
+ if (n.keyBoundAt !== undefined) {
341
+ if (typeof n.keyBoundAt !== 'string' || Number.isNaN(Date.parse(n.keyBoundAt))) return null;
342
+ if (out.publicKey === undefined) return null;
343
+ out.keyBoundAt = n.keyBoundAt;
344
+ }
345
+ if (n.keyConflict !== undefined) {
346
+ const c = n.keyConflict;
347
+ if (!c || typeof c !== 'object' || Array.isArray(c)) return null;
348
+ for (const k of Object.keys(c)) { if (!KEY_CONFLICT_KEYS.has(k)) return null; }
349
+ if (!identity.isPublicKey(c.seen)) return null;
350
+ if (typeof c.at !== 'string' || Number.isNaN(Date.parse(c.at))) return null;
351
+ if (c.source !== 'pairing' && c.source !== 'peer-assertion') return null;
352
+ // Un conflitto senza chiave legata non e' un conflitto: e' la prima
353
+ // osservazione, e va scritta come tale.
354
+ if (out.publicKey === undefined) return null;
355
+ out.keyConflict = { seen: c.seen, at: c.at, source: c.source };
356
+ }
283
357
  if (label) out.label = label;
284
358
  return out;
285
359
  }
@@ -577,8 +651,23 @@ function redactNode(n) {
577
651
  };
578
652
  if (n.identityFile || n.keyPath) out.hasIdentity = true;
579
653
  if (n.visibility === 'selected') out.selected = [...(n.selected || [])];
654
+ // Lo scope celle non e' un segreto: e' una decisione dell'operatore e va
655
+ // mostrata, come `selected`. La Settings UI deve poterla leggere per dirla.
656
+ out.cellVisibility = n.cellVisibility || 'all';
657
+ if (out.cellVisibility === 'selected') out.cells = [...(n.cells || [])];
580
658
  if (n.sshPort !== undefined) out.sshPort = n.sshPort;
581
659
  if (n.nodeId) out.nodeId = n.nodeId;
660
+ // La pubblica di un peer NON e' un segreto: e' quella la cosa che va
661
+ // confrontata, e nasconderla renderebbe impossibile accorgersi di un
662
+ // conflitto guardando. Il conflitto viaggia con lei per la stessa ragione:
663
+ // se si vede la chiave ma non che ne e' arrivata un'altra, la vista
664
+ // rassicura invece di informare.
665
+ if (n.publicKey) {
666
+ out.publicKey = n.publicKey;
667
+ out.keySource = n.keySource || 'peer-assertion';
668
+ if (n.keyBoundAt) out.keyBoundAt = n.keyBoundAt;
669
+ if (n.keyConflict) out.keyConflict = { ...n.keyConflict };
670
+ }
582
671
  if (n.reversePool) {
583
672
  out.reversePool = {
584
673
  base: n.reversePool.base,
@@ -13,6 +13,13 @@ function createNotifier({ hub, push }) {
13
13
  urgency: frame.urgency === 'high' ? 'high' : 'normal',
14
14
  ...(frame.session ? { session: String(frame.session) } : {}),
15
15
  ...(frame.lang ? { lang: String(frame.lang) } : {}),
16
+ // Provenienza federata. `originNode` e' VERIFICATO (catena visited
17
+ // costruita dal server); `originCell` e' soltanto ATTESTATO dal nodo di
18
+ // origine, che l'ha verificata da se'. La differenza va fino alla UI: chi
19
+ // guarda deve poter distinguere cio' che e' provato da cio' che e'
20
+ // dichiarato, altrimenti il mittente diventa un campo di phishing.
21
+ ...(frame.originNode ? { originNode: String(frame.originNode) } : {}),
22
+ ...(frame.originCell ? { originCell: String(frame.originCell) } : {}),
16
23
  ts: Date.now(),
17
24
  });
18
25
  let pushed = 0;