@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/CHANGELOG.md +340 -0
- package/frontend/dist/assets/index-BJ-_5bxw.js +93 -0
- package/frontend/dist/assets/{index-_kotgT8Z.css → index-CYi_lhCg.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +11 -0
- package/frontend/dist/version.json +1 -1
- package/lib/audio/dispatch.js +6 -1
- package/lib/cells/scope-guard.js +239 -0
- package/lib/cells/scope.js +144 -0
- package/lib/cli/commands.js +279 -4
- package/lib/fleet/builtin.js +129 -4
- package/lib/fleet/catalogs/alibaba-token-plan-qwen3.8.json +3 -3
- package/lib/fleet/definitions.js +128 -7
- package/lib/fleet/managed.js +229 -20
- package/lib/fleet/model-probe.js +129 -0
- package/lib/fleet/routes.js +27 -1
- package/lib/fleet/runtime.js +3 -3
- package/lib/mcp/server.js +35 -1
- package/lib/mcp/tools.js +67 -4
- package/lib/nodes/commands.js +86 -3
- package/lib/nodes/identity.js +232 -0
- package/lib/nodes/store.js +89 -0
- package/lib/notify/notifier.js +7 -0
- package/lib/notify/routes.js +75 -3
- package/lib/proxy/federation.js +100 -8
- package/lib/server.js +100 -4
- package/lib/settings/pairing-coordinator.js +16 -0
- package/lib/settings/public-peering-routes.js +24 -1
- package/lib/settings/routes.js +1 -1
- package/lib/tmux/actions.js +33 -1
- package/package.json +1 -1
- package/frontend/dist/assets/index-Cefi613r.js +0 -93
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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');
|
package/lib/nodes/commands.js
CHANGED
|
@@ -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
|
+
};
|
package/lib/nodes/store.js
CHANGED
|
@@ -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,
|
package/lib/notify/notifier.js
CHANGED
|
@@ -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;
|