@mmmbuto/nexuscrew 0.8.52-rc.2 → 0.8.52-rc.21
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/frontend/dist/assets/index-CemRuvGr.js +93 -0
- package/frontend/dist/assets/{index-DNQLAGoa.css → index-_kotgT8Z.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 +127 -0
- package/lib/cli/commands.js +23 -1
- package/lib/mcp/tools.js +17 -0
- package/lib/nodes/commands.js +86 -3
- package/lib/nodes/store.js +35 -0
- package/lib/notify/asks.js +23 -3
- package/lib/notify/notifier.js +7 -0
- package/lib/notify/routes.js +95 -3
- package/lib/proxy/federation.js +77 -11
- package/lib/server.js +100 -4
- package/lib/settings/routes.js +1 -1
- package/lib/vl-nodes/broker.js +140 -3
- package/lib/vl-nodes/routes.js +38 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-TqepO9Zm.js +0 -93
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/cells/scope.js — quali celle di QUESTO nodo un peer federato puo' vedere.
|
|
3
|
+
//
|
|
4
|
+
// Gemello di lib/audio/acl.js, e per le stesse ragioni:
|
|
5
|
+
// * la sorgente della decisione e' il node store locale, mai il corpo della
|
|
6
|
+
// richiesta. Un peer non dichiara il proprio scope: lo subisce.
|
|
7
|
+
// * l'identita' arriva dalla catena `visited` che costruisce il server
|
|
8
|
+
// (controlledVisited), non da un campo dichiarato.
|
|
9
|
+
//
|
|
10
|
+
// Due invarianti che una versione semplificata romperebbe in silenzio:
|
|
11
|
+
//
|
|
12
|
+
// 1. In multi-hop lo scope e' l'INTERSEZIONE fra chi consegna e chi origina.
|
|
13
|
+
// `canTransit` autorizza il transito, non l'accesso: un peer B puo'
|
|
14
|
+
// instradare una richiesta di C. Guardare solo B lascerebbe C ereditare i
|
|
15
|
+
// permessi di B. lib/audio/acl.js risolve lo stesso caso controllando
|
|
16
|
+
// entrambi, e qui va replicato invece che semplificato.
|
|
17
|
+
// 2. Una sessione tmux e' concessa solo se lo e' la CELLA a cui appartiene, e
|
|
18
|
+
// una sessione che non appartiene a nessuna cella non e' "libera": e'
|
|
19
|
+
// fuori scope. Altrimenti basterebbe una tmux creata a mano per aggirare
|
|
20
|
+
// il permesso — e `/ws` attacca proprio per nome di sessione.
|
|
21
|
+
//
|
|
22
|
+
// Il permesso e' indicizzato per `nodeId`, non per `name`: il name e' uno slug
|
|
23
|
+
// locale rinominabile, il nodeId e' l'identita' stabile provata dal pairing.
|
|
24
|
+
const nodesStore = require('../nodes/store.js');
|
|
25
|
+
|
|
26
|
+
// Scope di un singolo nodo, normalizzato. `all` non elenca nulla di proposito:
|
|
27
|
+
// una lista che significa "tutte" invecchierebbe a ogni cella nuova.
|
|
28
|
+
function scopeOf(node) {
|
|
29
|
+
if (!node) return { mode: 'none', cells: new Set() };
|
|
30
|
+
const mode = node.cellVisibility || 'all';
|
|
31
|
+
if (mode === 'all') return { mode: 'all', cells: null };
|
|
32
|
+
if (mode === 'none') return { mode: 'none', cells: new Set() };
|
|
33
|
+
return { mode: 'selected', cells: new Set(Array.isArray(node.cells) ? node.cells : []) };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Intersezione di due scope. `all` e' l'elemento neutro; `none` assorbe.
|
|
37
|
+
function intersect(a, b) {
|
|
38
|
+
if (a.mode === 'none' || b.mode === 'none') return { mode: 'none', cells: new Set() };
|
|
39
|
+
if (a.mode === 'all') return b;
|
|
40
|
+
if (b.mode === 'all') return a;
|
|
41
|
+
return { mode: 'selected', cells: new Set([...a.cells].filter((c) => b.cells.has(c))) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function findByNodeId(st, nodeId) {
|
|
45
|
+
if (!st || !Array.isArray(st.nodes) || !nodeId) return null;
|
|
46
|
+
return st.nodes.find((n) => n && n.nodeId === nodeId) || null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// createCellScope(): deps esplicite, nessun accesso globale.
|
|
50
|
+
// nodesPath percorso del node store
|
|
51
|
+
// loadStoreImpl iniettabile nei test
|
|
52
|
+
// cellForSession mappa tmuxSession -> id cella (null se non e' di una cella)
|
|
53
|
+
function createCellScope({ nodesPath, loadStoreImpl = nodesStore.loadStore, cellForSession = () => null } = {}) {
|
|
54
|
+
// Scope "tutto", usato dal percorso locale: il proprietario della macchina
|
|
55
|
+
// non si limita da solo, e questo modulo non e' il posto dove decidere
|
|
56
|
+
// altrimenti.
|
|
57
|
+
function openScope() {
|
|
58
|
+
return buildApi({ mode: 'all', cells: null });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildApi(resolved, localNodeId = null) {
|
|
62
|
+
const allowsCell = (cell) => {
|
|
63
|
+
if (resolved.mode === 'all') return true;
|
|
64
|
+
if (resolved.mode === 'none') return false;
|
|
65
|
+
return typeof cell === 'string' && resolved.cells.has(cell);
|
|
66
|
+
};
|
|
67
|
+
const allowsSession = (session) => {
|
|
68
|
+
if (resolved.mode === 'all') return true;
|
|
69
|
+
const cell = cellForSession(session);
|
|
70
|
+
// Nessuna cella dietro la sessione => fuori scope. Vedi invariante 2.
|
|
71
|
+
if (!cell) return false;
|
|
72
|
+
return allowsCell(cell);
|
|
73
|
+
};
|
|
74
|
+
// Un tile di deck che punta a un ALTRO nodo non e' una cella di questo
|
|
75
|
+
// hub: lo scope celle governa le celle locali, e la topologia ha gia' le
|
|
76
|
+
// sue regole (canTransit, allowlist federata). Quando pero' non sappiamo
|
|
77
|
+
// chi siamo, un `ownerId` non confrontabile si tratta come LOCALE: e' il
|
|
78
|
+
// verso fail-closed, perche' l'errore costa un tile in meno invece di un
|
|
79
|
+
// nome di cella in piu'.
|
|
80
|
+
const tileIsRemote = (tile) => {
|
|
81
|
+
if (!tile || typeof tile !== 'object') return false;
|
|
82
|
+
if (typeof tile.node === 'string' && tile.node) return true;
|
|
83
|
+
if (typeof tile.ownerId === 'string' && tile.ownerId) {
|
|
84
|
+
return localNodeId ? tile.ownerId !== localNodeId : false;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
return {
|
|
89
|
+
mode: resolved.mode,
|
|
90
|
+
cells: resolved.cells ? [...resolved.cells] : null,
|
|
91
|
+
allowsCell,
|
|
92
|
+
allowsSession,
|
|
93
|
+
allowsTile: (tile) => tileIsRemote(tile) || allowsSession(tile && tile.session),
|
|
94
|
+
// Filtri: gli elenchi si restringono con lo STESSO predicato che decide
|
|
95
|
+
// le azioni. Due implementazioni divergenti sarebbero un bug latente.
|
|
96
|
+
filterCells: (list) => (Array.isArray(list) ? list.filter((c) => allowsCell(c && (c.cell ?? c.id))) : []),
|
|
97
|
+
filterSessions: (list) => (Array.isArray(list) ? list.filter((s) => allowsSession(s && (s.name ?? s.tmuxSession))) : []),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// resolve({trust, visited}) -> api dello scope.
|
|
102
|
+
// trust 'local-bridge' (o assente e non federato) : nessuna restrizione
|
|
103
|
+
// trust 'federated' : intersezione consegnante ∩ origine
|
|
104
|
+
function resolve({ trust, visited } = {}) {
|
|
105
|
+
if (trust !== 'federated') return openScope();
|
|
106
|
+
const chain = Array.isArray(visited) ? visited : [];
|
|
107
|
+
// Serve almeno origine + questo nodo. Una catena piu' corta non identifica
|
|
108
|
+
// nessun peer: fail-closed.
|
|
109
|
+
if (chain.length < 2) return buildApi({ mode: 'none', cells: new Set() });
|
|
110
|
+
|
|
111
|
+
let st;
|
|
112
|
+
try { st = loadStoreImpl(nodesPath); } catch (_) { st = null; }
|
|
113
|
+
if (!st) return buildApi({ mode: 'none', cells: new Set() });
|
|
114
|
+
|
|
115
|
+
const deliveringId = chain[chain.length - 2];
|
|
116
|
+
const originId = chain[0];
|
|
117
|
+
const delivering = scopeOf(findByNodeId(st, deliveringId));
|
|
118
|
+
// Quando origine e consegnante coincidono (hop singolo) l'intersezione con
|
|
119
|
+
// se stesso e' identita': nessun caso speciale da mantenere allineato.
|
|
120
|
+
const origin = originId === deliveringId ? delivering : scopeOf(findByNodeId(st, originId));
|
|
121
|
+
return buildApi(intersect(delivering, origin), st.nodeId || null);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { resolve };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = { createCellScope, scopeOf, intersect };
|
package/lib/cli/commands.js
CHANGED
|
@@ -61,6 +61,7 @@ Usage:
|
|
|
61
61
|
[--persist]
|
|
62
62
|
nexuscrew nodes rename <name|nodeId> --label TEXT
|
|
63
63
|
nexuscrew nodes visibility <name|nodeId> network|relay-only|selected
|
|
64
|
+
nexuscrew nodes cells <name|nodeId> all|none|Cella1,Cella2
|
|
64
65
|
[--selected NODE_ID,...]
|
|
65
66
|
nexuscrew nodes share <name|nodeId> on|off [--json]
|
|
66
67
|
nexuscrew nodes invite --ssh TARGET [--ssh-port PORT] [--name SLUG]
|
|
@@ -985,11 +986,32 @@ async function dispatchNodes(rest, flags, opts = {}) {
|
|
|
985
986
|
: String(flags.selected).split(',').map((value) => value.trim()).filter(Boolean);
|
|
986
987
|
return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { visibility, selected } }).code };
|
|
987
988
|
}
|
|
989
|
+
// Scope celle (NC-E): quali celle di QUESTO nodo il peer puo' vedere.
|
|
990
|
+
// Diverso da `visibility`, che governa il transito e non l'accesso.
|
|
991
|
+
if (sub === 'cells') {
|
|
992
|
+
const arg = rest[3];
|
|
993
|
+
if (!arg) {
|
|
994
|
+
log('nodes cells: uso `nodes cells <nodo> all|none|Cella1,Cella2`');
|
|
995
|
+
return { code: 1 };
|
|
996
|
+
}
|
|
997
|
+
if (arg === 'all' || arg === 'none') {
|
|
998
|
+
return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { cellVisibility: arg, cells: undefined } }).code };
|
|
999
|
+
}
|
|
1000
|
+
const cells = String(arg).split(',').map((v) => v.trim()).filter(Boolean);
|
|
1001
|
+
if (!cells.length) { log('nodes cells: elenco vuoto — usa `none` se intendi nessuna cella'); return { code: 1 }; }
|
|
1002
|
+
return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { cellVisibility: 'selected', cells } }).code };
|
|
1003
|
+
}
|
|
988
1004
|
if (sub === 'remove') {
|
|
989
1005
|
if (flags.yes !== true) { log('nodes remove: conferma richiesta con --yes'); return { code: 1 }; }
|
|
990
1006
|
return { code: nodesCmds.nodesRemove({ ...opts, log, ref }).code };
|
|
991
1007
|
}
|
|
992
|
-
|
|
1008
|
+
// Senza riferimento non e' un errore: e' la domanda "quali nodi sono davvero
|
|
1009
|
+
// condivisi?", che prima si poteva fare solo un nodo alla volta.
|
|
1010
|
+
if (sub === 'test') {
|
|
1011
|
+
return ref
|
|
1012
|
+
? { code: (await nodesCmds.nodesTest({ ...opts, log, ref })).code }
|
|
1013
|
+
: { code: (await nodesCmds.nodesTestAll({ ...opts, log })).code };
|
|
1014
|
+
}
|
|
993
1015
|
if (['up', 'down', 'connect', 'disconnect', 'restart', 'reconnect'].includes(sub)) {
|
|
994
1016
|
const fn = sub === 'up' || sub === 'connect' ? nodesCmds.nodesUp
|
|
995
1017
|
: sub === 'restart' || sub === 'reconnect' ? nodesCmds.nodesRestart : nodesCmds.nodesDown;
|
package/lib/mcp/tools.js
CHANGED
|
@@ -237,6 +237,10 @@ const TOOLS = [
|
|
|
237
237
|
type: 'string',
|
|
238
238
|
description: 'lingua opzionale del testo: it, en, es o locale BCP-47 equivalente (es. it-IT)',
|
|
239
239
|
},
|
|
240
|
+
target: {
|
|
241
|
+
type: 'string',
|
|
242
|
+
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.',
|
|
243
|
+
},
|
|
240
244
|
},
|
|
241
245
|
required: ['title'],
|
|
242
246
|
},
|
|
@@ -245,6 +249,10 @@ const TOOLS = [
|
|
|
245
249
|
const body = argString(args, 'body', { max: 2000 });
|
|
246
250
|
const urgency = argString(args, 'urgency', { max: 16 });
|
|
247
251
|
const rawLang = argString(args, 'lang', { max: 35 });
|
|
252
|
+
const target = argString(args, 'target', { max: 64 });
|
|
253
|
+
if (target !== undefined && !/^[a-f0-9]{32}$/i.test(target)) {
|
|
254
|
+
throw new Error('target deve essere un instanceId di nodo (32 hex)');
|
|
255
|
+
}
|
|
248
256
|
if (urgency !== undefined && urgency !== 'normal' && urgency !== 'high') {
|
|
249
257
|
throw new Error('urgency deve essere "normal" o "high"');
|
|
250
258
|
}
|
|
@@ -258,9 +266,18 @@ const TOOLS = [
|
|
|
258
266
|
...(body ? { body } : {}),
|
|
259
267
|
...(urgency ? { urgency } : {}),
|
|
260
268
|
...(lang ? { lang } : {}),
|
|
269
|
+
...(target ? { target } : {}),
|
|
261
270
|
};
|
|
262
271
|
if (session) payload.session = session;
|
|
263
272
|
const j = await ctx.api('POST', '/api/notify', payload);
|
|
273
|
+
// Con un target remoto l'esito e' quello del nodo che ha consegnato:
|
|
274
|
+
// `delivered` da solo direbbe "0 tentativi qui", che e' vero e inutile.
|
|
275
|
+
// Restituirlo cosi' evita di far leggere un successo dove c'e' un rifiuto.
|
|
276
|
+
// Nessun conteggio: il dispatcher non lo propaga, e un ramo che non puo'
|
|
277
|
+
// mai essere vero e' codice morto travestito da informazione
|
|
278
|
+
// (rilievo R1 di DevAuditor su rc.14). Se un giorno servira' il dettaglio,
|
|
279
|
+
// va fatto propagare da forward(), non dedotto qui.
|
|
280
|
+
if (target) return { status: j.status, ...(j.reason ? { reason: j.reason } : {}) };
|
|
264
281
|
return { delivered: j.delivered };
|
|
265
282
|
},
|
|
266
283
|
},
|
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,
|
package/lib/nodes/store.js
CHANGED
|
@@ -108,8 +108,17 @@ const NODE_KEYS = new Set([
|
|
|
108
108
|
'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
|
|
109
109
|
'roles', 'rolesKnown', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
|
|
110
110
|
'direction', 'reversePort', 'shared', 'label', 'reversePool',
|
|
111
|
+
// Quali CELLE di questo nodo il peer puo' vedere. Distinto da `visibility`,
|
|
112
|
+
// che governa il TRANSITO (attraverso chi passa il traffico) e non l'accesso.
|
|
113
|
+
'cellVisibility', 'cells',
|
|
111
114
|
]);
|
|
112
115
|
|
|
116
|
+
// Nome di cella: stessa forma usata da fleet/definitions.js, cells/routes.js e
|
|
117
|
+
// mcp/cells.js. E' `cell` la chiave del permesso — unica nel nodo e immutabile —
|
|
118
|
+
// non `tmuxSession`, che e' derivata e ammette override non canonici.
|
|
119
|
+
const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
|
|
120
|
+
const MAX_CELLS = 128;
|
|
121
|
+
|
|
113
122
|
const REVERSE_POOL_SLOT_STATES = new Set(['active', 'ready', 'reserved', 'draining', 'quarantined', 'retired']);
|
|
114
123
|
const REVERSE_POOL_VERIFICATIONS = new Set(['verified', 'unverifiable', 'missing', 'invalidated']);
|
|
115
124
|
const REVERSE_POOL_PHASES = new Set(['active', 'prepared', 'switched', 'abandoned']);
|
|
@@ -265,6 +274,28 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
265
274
|
if (selected.some((id) => typeof id !== 'string' || !NODE_ID_RE.test(id))) return null;
|
|
266
275
|
out.selected = selected;
|
|
267
276
|
}
|
|
277
|
+
// Scope celle. Il default e' `all` quando il campo e' ASSENTE: un default
|
|
278
|
+
// fail-closed renderebbe muta l'intera flotta al primo aggiornamento, senza
|
|
279
|
+
// che nessuno abbia deciso nulla. Restringere deve restare un atto esplicito.
|
|
280
|
+
// `selected` con lista vuota significa invece "nessuna cella", ed e' proprio
|
|
281
|
+
// per distinguerlo da "campo assente" che la modalita' e' un campo a parte.
|
|
282
|
+
if (n.cellVisibility !== undefined) {
|
|
283
|
+
if (!['all', 'none', 'selected'].includes(n.cellVisibility)) return null;
|
|
284
|
+
out.cellVisibility = n.cellVisibility;
|
|
285
|
+
} else {
|
|
286
|
+
out.cellVisibility = 'all';
|
|
287
|
+
}
|
|
288
|
+
if (n.cells !== undefined) {
|
|
289
|
+
// Elencare celle senza dichiarare la modalita' e' ambiguo: l'elenco
|
|
290
|
+
// sembrerebbe un permesso mentre non lo e'. Meglio rifiutare che indovinare.
|
|
291
|
+
if (out.cellVisibility !== 'selected') return null;
|
|
292
|
+
if (!Array.isArray(n.cells) || n.cells.length > MAX_CELLS) return null;
|
|
293
|
+
const cells = [...new Set(n.cells)];
|
|
294
|
+
if (cells.some((id) => typeof id !== 'string' || !CELL_ID_RE.test(id))) return null;
|
|
295
|
+
out.cells = cells;
|
|
296
|
+
} else if (out.cellVisibility === 'selected') {
|
|
297
|
+
out.cells = [];
|
|
298
|
+
}
|
|
268
299
|
// Campo opzionale per compatibilita' con gli store 0.8.0: se assente, ssh
|
|
269
300
|
// continua a usare la porta risolta da ~/.ssh/config o il default OpenSSH.
|
|
270
301
|
if (n.sshPort !== undefined) out.sshPort = n.sshPort;
|
|
@@ -577,6 +608,10 @@ function redactNode(n) {
|
|
|
577
608
|
};
|
|
578
609
|
if (n.identityFile || n.keyPath) out.hasIdentity = true;
|
|
579
610
|
if (n.visibility === 'selected') out.selected = [...(n.selected || [])];
|
|
611
|
+
// Lo scope celle non e' un segreto: e' una decisione dell'operatore e va
|
|
612
|
+
// mostrata, come `selected`. La Settings UI deve poterla leggere per dirla.
|
|
613
|
+
out.cellVisibility = n.cellVisibility || 'all';
|
|
614
|
+
if (out.cellVisibility === 'selected') out.cells = [...(n.cells || [])];
|
|
580
615
|
if (n.sshPort !== undefined) out.sshPort = n.sshPort;
|
|
581
616
|
if (n.nodeId) out.nodeId = n.nodeId;
|
|
582
617
|
if (n.reversePool) {
|
package/lib/notify/asks.js
CHANGED
|
@@ -66,7 +66,7 @@ function createAsksStore(opts = {}) {
|
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
function openCount() {
|
|
69
|
-
return load().filter((a) => !a.answered).length;
|
|
69
|
+
return load().filter((a) => !a.answered && !a.dismissed).length;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function create({ question, options, session }) {
|
|
@@ -99,7 +99,7 @@ function createAsksStore(opts = {}) {
|
|
|
99
99
|
|
|
100
100
|
function list({ open = false } = {}) {
|
|
101
101
|
const all = load();
|
|
102
|
-
return (open ? all.filter((a) => !a.answered) : all.slice())
|
|
102
|
+
return (open ? all.filter((a) => !a.answered && !a.dismissed) : all.slice())
|
|
103
103
|
.map((a) => ({ ...a, ...(a.options ? { options: a.options.slice() } : {}) }));
|
|
104
104
|
}
|
|
105
105
|
|
|
@@ -114,6 +114,10 @@ function createAsksStore(opts = {}) {
|
|
|
114
114
|
function claim(id) {
|
|
115
115
|
const ask = get(id);
|
|
116
116
|
if (!ask) return { ok: false, reason: 'unknown' };
|
|
117
|
+
// Un ask dismissato non e' piu' risponibile: senza questa guardia la
|
|
118
|
+
// sequenza dismiss -> claim -> commit lascia lo stato ibrido
|
|
119
|
+
// `dismissed && answered` (F-02).
|
|
120
|
+
if (ask.dismissed) return { ok: false, reason: 'dismissed' };
|
|
117
121
|
if (ask.answered) return { ok: false, reason: 'answered' };
|
|
118
122
|
if (answering.has(id)) return { ok: false, reason: 'answering' };
|
|
119
123
|
answering.add(id);
|
|
@@ -137,6 +141,22 @@ function createAsksStore(opts = {}) {
|
|
|
137
141
|
return true;
|
|
138
142
|
}
|
|
139
143
|
|
|
144
|
+
// Dismiss (scarta la domanda): NON cancella la riga — la marca `dismissed`,
|
|
145
|
+
// perche' lo storico serve (come `answered`). Non compete con una answer in
|
|
146
|
+
// corso: 409 se c'e' un claim attivo (answering). Idempotente: scartare due
|
|
147
|
+
// volte non e' un errore. Un ask dismissato non e' piu' open (non appare in
|
|
148
|
+
// list({open:true})/openCount) ma resta nello storico (list({open:false})).
|
|
149
|
+
function dismiss(id) {
|
|
150
|
+
const ask = get(id);
|
|
151
|
+
if (!ask) return { ok: false, reason: 'unknown' };
|
|
152
|
+
if (answering.has(id)) return { ok: false, reason: 'answering' };
|
|
153
|
+
if (ask.dismissed) return { ok: true, ask: { ...ask }, idempotent: true };
|
|
154
|
+
ask.dismissed = true;
|
|
155
|
+
ask.dismissedTs = now();
|
|
156
|
+
save();
|
|
157
|
+
return { ok: true, ask: { ...ask } };
|
|
158
|
+
}
|
|
159
|
+
|
|
140
160
|
// Retrocompat (usata nei test di store): claim+commit in un colpo.
|
|
141
161
|
function markAnswered(id, text) {
|
|
142
162
|
const c = claim(id);
|
|
@@ -144,7 +164,7 @@ function createAsksStore(opts = {}) {
|
|
|
144
164
|
return commit(id, text);
|
|
145
165
|
}
|
|
146
166
|
|
|
147
|
-
return { create, get, list, openCount, claim, release, commit, markAnswered, validate, filePath, MAX_OPEN };
|
|
167
|
+
return { create, get, list, openCount, claim, release, commit, markAnswered, dismiss, validate, filePath, MAX_OPEN };
|
|
148
168
|
}
|
|
149
169
|
|
|
150
170
|
module.exports = { createAsksStore };
|
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;
|
package/lib/notify/routes.js
CHANGED
|
@@ -20,8 +20,18 @@
|
|
|
20
20
|
const express = require('express');
|
|
21
21
|
const { isValidSession } = require('../files/store.js');
|
|
22
22
|
const { normalizeNotificationLang } = require('./language.js');
|
|
23
|
+
const { HOP_HEADER } = require('../proxy/hop-proof.js');
|
|
23
24
|
|
|
24
|
-
const
|
|
25
|
+
const TARGET_RE = /^[a-f0-9]{32}$/i;
|
|
26
|
+
|
|
27
|
+
// `url` NON e' ammesso, ne' in locale ne' federato: sw.js fa
|
|
28
|
+
// clients.openWindow(url) sul click, quindi accettarlo da un peer sarebbe un
|
|
29
|
+
// open-redirect dentro la PWA autenticata. Il solo url legittimo lo genera in
|
|
30
|
+
// casa la route degli ask (deep-link /#ask=<id>), che non passa da qui.
|
|
31
|
+
const NOTIFY_KEYS = new Set(['title', 'body', 'urgency', 'session', 'lang', 'target']);
|
|
32
|
+
// Chiavi accettate SOLO su un ingresso federato provato: le mette il
|
|
33
|
+
// dispatcher del nodo di origine, non un chiamante locale.
|
|
34
|
+
const FEDERATED_KEYS = new Set(['originCell', 'originNode']);
|
|
25
35
|
const ASK_KEYS = new Set(['question', 'options', 'session']);
|
|
26
36
|
const RATE_MAX = 6;
|
|
27
37
|
const RATE_WINDOW_MS = 60 * 1000;
|
|
@@ -91,7 +101,14 @@ function replyLabel(cfg) {
|
|
|
91
101
|
return clean || 'human';
|
|
92
102
|
}
|
|
93
103
|
|
|
94
|
-
function notifyRoutes({
|
|
104
|
+
function notifyRoutes({
|
|
105
|
+
cfg, notifier, push, asks, paste, sessionExists,
|
|
106
|
+
// Federazione delle notifiche. Assenti (test unitari, montaggi parziali) la
|
|
107
|
+
// route resta esattamente quella locale di prima: nessun percorso nuovo si
|
|
108
|
+
// apre per omissione.
|
|
109
|
+
localNodeId = () => null, originResolver = null, acl = null, dispatcher = null,
|
|
110
|
+
federatedRate = null,
|
|
111
|
+
}) {
|
|
95
112
|
const r = express.Router();
|
|
96
113
|
const json = express.json({ limit: '16kb' });
|
|
97
114
|
|
|
@@ -110,8 +127,14 @@ function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
|
|
|
110
127
|
if (!b || typeof b !== 'object' || Array.isArray(b)) {
|
|
111
128
|
return res.status(400).json({ error: 'body deve essere un oggetto JSON' });
|
|
112
129
|
}
|
|
130
|
+
// Un ingresso e' federato solo se porta la prova di hop. Si stabilisce
|
|
131
|
+
// PRIMA di guardare il body: quali chiavi sono lecite dipende da come la
|
|
132
|
+
// richiesta e' arrivata, non da cosa dichiara.
|
|
133
|
+
const federated = !!(originResolver && req.headers && req.headers[HOP_HEADER]);
|
|
113
134
|
for (const k of Object.keys(b)) {
|
|
114
|
-
if (
|
|
135
|
+
if (NOTIFY_KEYS.has(k)) continue;
|
|
136
|
+
if (federated && FEDERATED_KEYS.has(k)) continue;
|
|
137
|
+
return res.status(400).json({ error: `chiave non ammessa: "${k}" (schema: title, body?, urgency?, session?, lang?, target?)` });
|
|
115
138
|
}
|
|
116
139
|
if (typeof b.title !== 'string' || !b.title.trim()) {
|
|
117
140
|
return res.status(400).json({ error: 'title deve essere una stringa non vuota' });
|
|
@@ -130,6 +153,55 @@ function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
|
|
|
130
153
|
if (b.session !== undefined && !isValidSession(b.session)) {
|
|
131
154
|
return res.status(400).json({ error: 'session non valida' });
|
|
132
155
|
}
|
|
156
|
+
if (b.target !== undefined && !TARGET_RE.test(String(b.target))) {
|
|
157
|
+
return res.status(400).json({ error: 'target deve essere un instanceId di nodo' });
|
|
158
|
+
}
|
|
159
|
+
const self = localNodeId();
|
|
160
|
+
|
|
161
|
+
// --- ingresso FEDERATO: la notifica arriva da un altro nodo -----------
|
|
162
|
+
if (federated) {
|
|
163
|
+
const resolved = await originResolver.resolve(req, { requireCell: true });
|
|
164
|
+
if (!resolved.ok) return res.status(403).json({ status: 'refused', reason: resolved.reason });
|
|
165
|
+
// Il target e' esatto e va confermato QUI: una route puo' consegnare a
|
|
166
|
+
// un nodo diverso da quello che il mittente credeva.
|
|
167
|
+
if (!self || b.target !== self) {
|
|
168
|
+
return res.status(404).json({ status: 'refused', reason: 'wrong-target' });
|
|
169
|
+
}
|
|
170
|
+
const verdict = acl ? acl.allows(resolved) : { allowed: false, reason: 'acl-unavailable' };
|
|
171
|
+
if (!verdict.allowed) return res.status(403).json({ status: 'refused', reason: verdict.reason });
|
|
172
|
+
// Budget SEPARATO da quello locale: senza, un peer rumoroso non solo
|
|
173
|
+
// spamma ma affama le notifiche delle celle di casa, che condividono
|
|
174
|
+
// lo stesso bucket da 6/60s.
|
|
175
|
+
if (federatedRate) {
|
|
176
|
+
const quota = federatedRate.check({ origin: resolved.origin, target: self, urgency: b.urgency });
|
|
177
|
+
if (!quota.allowed) {
|
|
178
|
+
return res.status(429).json({ status: 'refused', reason: `rate-${quota.bucket}` });
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const delivered = await notifier.emit({
|
|
182
|
+
title: b.title.trim(), body: b.body, urgency: b.urgency, lang,
|
|
183
|
+
// Il mittente NON e' `b.session`: quel campo lo dichiara il chiamante.
|
|
184
|
+
// Qui vale solo cio' che la catena ha provato, piu' la cella che il
|
|
185
|
+
// nodo di origine attesta.
|
|
186
|
+
originNode: resolved.origin.node,
|
|
187
|
+
originCell: resolved.origin.cell,
|
|
188
|
+
});
|
|
189
|
+
return res.json({ status: 'delivered', delivered });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- target remoto: instrada, non consegnare qui -----------------------
|
|
193
|
+
if (b.target !== undefined && dispatcher && self && b.target !== self) {
|
|
194
|
+
const out = await dispatcher.dispatch({
|
|
195
|
+
resource: '/notify',
|
|
196
|
+
target: b.target,
|
|
197
|
+
// La cella di origine e' quella DICHIARATA dal chiamante locale: viene
|
|
198
|
+
// trasmessa come attestazione, e il target la trattera' come tale.
|
|
199
|
+
origin: { node: self, cell: b.session || 'unknown' },
|
|
200
|
+
payload: { title: b.title.trim(), ...(b.body ? { body: b.body } : {}), ...(b.urgency ? { urgency: b.urgency } : {}), ...(lang ? { lang } : {}) },
|
|
201
|
+
});
|
|
202
|
+
return res.json(out);
|
|
203
|
+
}
|
|
204
|
+
|
|
133
205
|
const sender = b.session || 'unknown';
|
|
134
206
|
if (!allowNotify(sender)) {
|
|
135
207
|
return res.status(429).json({ error: 'rate limit notify superato (limite globale per token + per sessione)' });
|
|
@@ -214,6 +286,25 @@ function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
|
|
|
214
286
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
215
287
|
});
|
|
216
288
|
|
|
289
|
+
// Dismiss (scarta domanda): NON cancella la riga, la marca `dismissed` (lo
|
|
290
|
+
// storico serve). Stesso mutGate degli altri mutanti (F3: scrittura durevole).
|
|
291
|
+
// Idempotente; 404 se id inesistente; 409 se answering (claim attivo: non si
|
|
292
|
+
// scarta una risposta in corso). Emette il frame per le UI aperte come fa
|
|
293
|
+
// POST /asks con emitRaw: la card sparisce senza aspettare il poll.
|
|
294
|
+
r.delete('/asks/:id', mutGate, (req, res) => {
|
|
295
|
+
try {
|
|
296
|
+
const id = String(req.params.id || '');
|
|
297
|
+
const out = asks.dismiss(id);
|
|
298
|
+
if (!out.ok) {
|
|
299
|
+
if (out.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
|
|
300
|
+
if (out.reason === 'answering') return res.status(409).json({ error: 'risposta in corso: non si scarta un ask in answering' });
|
|
301
|
+
return res.status(500).json({ error: 'dismiss non riuscito' });
|
|
302
|
+
}
|
|
303
|
+
notifier.emitRaw({ type: 'ask-dismissed', id });
|
|
304
|
+
res.json({ dismissed: true, id });
|
|
305
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
306
|
+
});
|
|
307
|
+
|
|
217
308
|
// Answer: READONLY floor (il paste e' una scrittura PTY). F2 (audit): il
|
|
218
309
|
// ciclo e' claim atomico (open -> answering, sincrono, PRIMA dell'await del
|
|
219
310
|
// paste) -> paste -> commit su successo / release su fallimento. Una sola
|
|
@@ -230,6 +321,7 @@ function notifyRoutes({ cfg, notifier, push, asks, paste, sessionExists }) {
|
|
|
230
321
|
const claim = asks.claim(id);
|
|
231
322
|
if (!claim.ok) {
|
|
232
323
|
if (claim.reason === 'unknown') return res.status(404).json({ error: 'ask inesistente' });
|
|
324
|
+
if (claim.reason === 'dismissed') return res.status(409).json({ error: 'ask gia\' scartato (dismissed)' });
|
|
233
325
|
if (claim.reason === 'answering') return res.status(409).json({ error: 'risposta gia\' in corso da un\'altra richiesta' });
|
|
234
326
|
return res.status(409).json({ error: 'ask gia\' risposto' });
|
|
235
327
|
}
|