@mmmbuto/nexuscrew 0.8.52-rc.9 → 0.8.52
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 +243 -0
- package/frontend/dist/assets/{index-_kotgT8Z.css → index-CYi_lhCg.css} +1 -1
- package/frontend/dist/assets/index-Cxwpa2Y_.js +93 -0
- 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 +23 -1
- 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/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 +71 -6
- 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/package.json +1 -1
- package/frontend/dist/assets/index-Cefi613r.js +0 -93
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)' });
|
package/lib/proxy/federation.js
CHANGED
|
@@ -60,6 +60,22 @@ function classifyShareFailure(health) {
|
|
|
60
60
|
return { code: 'share-peer-unreachable', detail };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
// Porta del canale reverse realmente in gioco, vista DALL'HUB: lo slot attivo
|
|
64
|
+
// del pool quando c'e', altrimenti la `localPort` del peer — che e' il punto
|
|
65
|
+
// su cui l'hub sonda davvero (cfr. nodes test, ramo inbound). NON
|
|
66
|
+
// `reversePort`: quello e' il campo negoziato che vive sul CLIENT, e sull'hub
|
|
67
|
+
// e' assente. Il test del comportamento ha trovato questo scambio; il valore
|
|
68
|
+
// e' il dato che manca per capire un rifiuto, quindi sbagliarlo lo renderebbe
|
|
69
|
+
// peggio che inutile, mandando a cercare sulla porta sbagliata.
|
|
70
|
+
function activeReversePort(peer) {
|
|
71
|
+
const pool = peer && peer.reversePool;
|
|
72
|
+
if (pool && Array.isArray(pool.slots)) {
|
|
73
|
+
const slot = pool.slots[pool.activeSlot];
|
|
74
|
+
if (slot && Number.isInteger(slot.port)) return slot.port;
|
|
75
|
+
}
|
|
76
|
+
return peer && Number.isInteger(peer.localPort) ? peer.localPort : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
63
79
|
function peerFromToken(nodesPath, token) {
|
|
64
80
|
const st = store.loadStore(nodesPath);
|
|
65
81
|
if (!st || !token) return null;
|
|
@@ -151,11 +167,25 @@ function knownResource(resource) {
|
|
|
151
167
|
// ADMITS a further node, so leaving it federated would let a peer break the
|
|
152
168
|
// very boundary the owner chose. Minting an invite is done on the hub.
|
|
153
169
|
|| resource === '/ws'
|
|
170
|
+
// Un avviso all'operatore attraversa la federazione con la stessa spina
|
|
171
|
+
// dorsale dell'audio: origine provata dalla catena `visited`, ACL del
|
|
172
|
+
// target, budget dedicato. NON e' la coda di NC-NEXT-07 (§5 del modello di
|
|
173
|
+
// autorita'): quella differisce un'esecuzione, questo mostra del testo e
|
|
174
|
+
// non esegue nulla. `/audio/speak` esce da un altoparlante in una stanza
|
|
175
|
+
// fisica ed e' gia' federato: una notifica e' meno invasiva di cosi'.
|
|
176
|
+
|| resource === '/notify'
|
|
154
177
|
|| resource === '/audio/capability'
|
|
155
178
|
|| resource === '/audio/speak'
|
|
156
179
|
|| resource === '/audio/speak/status'
|
|
157
180
|
|| resource === '/audio/stop'
|
|
158
|
-
|
|
181
|
+
// I modelli dichiarati seguono gli engine, perche' sono la stessa cosa
|
|
182
|
+
// vista da un'altra angolazione: un engine gestito NON parte se il suo id
|
|
183
|
+
// di modello non e' nel catalogo, e un modello dichiarato e' cio' che
|
|
184
|
+
// estende quel catalogo. `define-engine` federato + `define-model` locale
|
|
185
|
+
// significava poter creare a distanza un engine che non si puo' rendere
|
|
186
|
+
// avviabile a distanza — un'asimmetria che rompeva la modifica remota a
|
|
187
|
+
// meta' strada, senza proteggere nulla.
|
|
188
|
+
|| /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
|
|
159
189
|
}
|
|
160
190
|
|
|
161
191
|
function allowedResource(resource, method = 'GET') {
|
|
@@ -187,7 +217,16 @@ function allowedResource(resource, method = 'GET') {
|
|
|
187
217
|
if (resource === '/diagnostics/verbose') return method === 'PATCH';
|
|
188
218
|
if (resource === '/ws') return method === 'GET';
|
|
189
219
|
if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
|
|
190
|
-
|
|
220
|
+
// `model-test` e' un POST che non muta niente: interroga il CATALOGO del
|
|
221
|
+
// fornitore (`GET .../models`) con la credenziale di quel nodo e ne ricava un
|
|
222
|
+
// enum. Non genera testo, quindi non consuma token. Resta comunque una
|
|
223
|
+
// mutazione ai fini di READONLY (sotto): un nodo dichiarato di sola lettura
|
|
224
|
+
// non emette richieste autenticate su comando di un peer.
|
|
225
|
+
if (/^\/fleet\/(credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource)) return method === 'POST';
|
|
226
|
+
// Notify: solo POST. `/events`, `/asks` e `/push/*` restano NON federati —
|
|
227
|
+
// SSE non puo' autenticarsi col Bearer e un ask ha un canale di ritorno che
|
|
228
|
+
// e' un paste nel tmux locale, quindi non attraverserebbe comunque.
|
|
229
|
+
if (resource === '/notify') return method === 'POST';
|
|
191
230
|
// Audio: only capability read, speak and stop are exposed through Hydra.
|
|
192
231
|
// audio.consent is a LOCAL mutation and MUST stay unreachable federated.
|
|
193
232
|
if (resource === '/audio/capability') return method === 'GET';
|
|
@@ -806,7 +845,7 @@ async function probeReverseOwner(peer, fetchImpl = fetch) {
|
|
|
806
845
|
});
|
|
807
846
|
}
|
|
808
847
|
|
|
809
|
-
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null }) {
|
|
848
|
+
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null, hopSecret = null, diagnostics = null }) {
|
|
810
849
|
const r = express.Router();
|
|
811
850
|
r.use((req, res, next) => {
|
|
812
851
|
const peer = peerFromToken(nodesPath, bearerFrom(req));
|
|
@@ -1042,6 +1081,21 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
1042
1081
|
// guasto definitivo senza interpretare una stringa. Solo il canale
|
|
1043
1082
|
// non ancora salito e' ritentabile.
|
|
1044
1083
|
const failure = classifyShareFailure(health);
|
|
1084
|
+
// L'hub sa di aver rifiutato e sa perche', e finora non lo scriveva
|
|
1085
|
+
// da nessuna parte: l'errore viveva solo nel toast del dispositivo,
|
|
1086
|
+
// che e' l'unico posto dove chi amministra l'hub non puo' guardarlo.
|
|
1087
|
+
// Un rifiuto ripetuto diventava cosi' un mistero — e' costato ore su
|
|
1088
|
+
// un caso reale, risolto solo leggendo i log di sshd con i privilegi
|
|
1089
|
+
// di root. Meta bounded: nome nodo, codice tipizzato, porta tentata.
|
|
1090
|
+
// Nessun testo remoto, nessuna credenziale.
|
|
1091
|
+
if (diagnostics && typeof diagnostics.record === 'function') {
|
|
1092
|
+
diagnostics.record('warn', 'share', 'SHARE_CHANNEL_REFUSED',
|
|
1093
|
+
'Share refused: the reverse channel did not come up', {
|
|
1094
|
+
node: req.peer.name,
|
|
1095
|
+
code: failure.code,
|
|
1096
|
+
port: activeReversePort(req.peer),
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1045
1099
|
return res.status(409).json({
|
|
1046
1100
|
error: 'canale share non raggiungibile',
|
|
1047
1101
|
code: failure.code,
|
|
@@ -1073,7 +1127,7 @@ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecre
|
|
|
1073
1127
|
return r;
|
|
1074
1128
|
}
|
|
1075
1129
|
|
|
1076
|
-
function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null }) {
|
|
1130
|
+
function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null, hopSecret = null }) {
|
|
1077
1131
|
if (readonly()) return reject(socket, 403);
|
|
1078
1132
|
const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
|
|
1079
1133
|
if (!parsed || parsed.resource !== '/ws') return reject(socket, 404);
|
|
@@ -1092,7 +1146,18 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1092
1146
|
}
|
|
1093
1147
|
const up = net.connect({ host: '127.0.0.1', port });
|
|
1094
1148
|
up.once('connect', () => {
|
|
1095
|
-
|
|
1149
|
+
// Ultimo hop: l'upgrade rientra nell'API locale col Bearer locale e da li'
|
|
1150
|
+
// in poi sarebbe indistinguibile da un attach diretto di chi possiede quel
|
|
1151
|
+
// token. La catena PIU' la prova di hop sono cio' che lo distingue —
|
|
1152
|
+
// esattamente come fa routeHandler per il percorso HTTP. Senza, il nodo che
|
|
1153
|
+
// possiede la sessione non sa quale peer stia aprendo il suo PTY, e ogni
|
|
1154
|
+
// permesso per-cella diventa decorativo: `/ws` attacca per NOME di
|
|
1155
|
+
// sessione, quindi basta indovinarlo.
|
|
1156
|
+
const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
|
|
1157
|
+
const hopProof = !parsed.route.length && secret
|
|
1158
|
+
? signHop(secret, { method: req.method || 'GET', path, visited })
|
|
1159
|
+
: null;
|
|
1160
|
+
const headers = cleanHeaders(req.headers, credential, visited, hopProof);
|
|
1096
1161
|
const lines = [`GET ${path} HTTP/1.1`, `Host: 127.0.0.1:${port}`];
|
|
1097
1162
|
for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
1098
1163
|
lines.push('Connection: Upgrade', 'Upgrade: websocket', '', '');
|
|
@@ -1110,7 +1175,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1110
1175
|
function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
|
|
1111
1176
|
|
|
1112
1177
|
module.exports = {
|
|
1113
|
-
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure,
|
|
1178
|
+
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
|
|
1114
1179
|
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
1115
1180
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
1116
1181
|
probeHealth, waitForHealthyPeer, canListenLoopback, preflightHubReverse, notifyHubShare, reconcilePeerShare, runShareRevokeBoot, probeReverseOwner,
|
package/lib/server.js
CHANGED
|
@@ -39,12 +39,20 @@ const { isConsent: isAudioConsent } = require('./audio/consent.js');
|
|
|
39
39
|
const { createOriginResolver } = require('./audio/origin.js');
|
|
40
40
|
const { createAudioAcl } = require('./audio/acl.js');
|
|
41
41
|
const { createDispatcher } = require('./audio/dispatch.js');
|
|
42
|
+
const { createCellScopeGuard } = require('./cells/scope-guard.js');
|
|
43
|
+
const { createCellScope } = require('./cells/scope.js');
|
|
44
|
+
const { cellIdFromTmuxSession } = require('./fleet/definitions.js');
|
|
45
|
+
// Budget a tre finestre, riusato dalle notifiche federate: e' gia' scritto per
|
|
46
|
+
// limitare il danno di un nodo che mente sulla propria cella (il tetto per
|
|
47
|
+
// target si calcola sul NODO, che la catena visited verifica).
|
|
48
|
+
const { createSpeakRateLimiter } = require('./audio/rate-limit.js');
|
|
42
49
|
const { createSpeakQueue } = require('./audio/queue.js');
|
|
43
50
|
const { createReceiptStore } = require('./audio/receipt.js');
|
|
44
51
|
const audioAdapters = require('./audio/adapters.js');
|
|
45
52
|
const audioGroups = require('./audio/groups.js');
|
|
46
53
|
const { bridgeSecretPath, loadOrCreateBridgeSecret, createNonceCache } = require('./audio/bridge-auth.js');
|
|
47
|
-
const { createHopSecret } = require('./proxy/hop-proof.js');
|
|
54
|
+
const { createHopSecret, verifyHop, HOP_HEADER } = require('./proxy/hop-proof.js');
|
|
55
|
+
const { parseVisitedChain } = require('./audio/origin.js');
|
|
48
56
|
const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
|
|
49
57
|
const decksStore = require('./decks/store.js');
|
|
50
58
|
const { decksRoutes } = require('./decks/routes.js');
|
|
@@ -105,6 +113,10 @@ function createServer(opts = {}) {
|
|
|
105
113
|
// alla porta sbagliata (bug reale: 401/"unauthorized" dal servizio su cfg.port).
|
|
106
114
|
let server = null;
|
|
107
115
|
const previews = createPreviewSampler(cfg.tmuxBin);
|
|
116
|
+
// Seam di test per l'esistenza di una sessione: senza, un test sullo scope
|
|
117
|
+
// dell'attach non discrimina, perche' ogni sessione risulta assente e il
|
|
118
|
+
// rifiuto arriverebbe comunque — un verde che non prova nulla.
|
|
119
|
+
const sessionExistsImpl = cfg.sessionExistsSeam || ((name) => sessionExists(cfg.tmuxBin, name));
|
|
108
120
|
// MCP bridge (notify/ask/push): lo stato vive accanto al token (dirname del
|
|
109
121
|
// tokenPath = ~/.nexuscrew di default) cosi' le istanze isolate via opts/env
|
|
110
122
|
// nei test NON scrivono mai nella home reale. Tutto lazy: vapid.json/asks.json
|
|
@@ -541,6 +553,51 @@ function createServer(opts = {}) {
|
|
|
541
553
|
// ma il token chiude anche altri processi locali della stessa macchina.
|
|
542
554
|
const api = express.Router();
|
|
543
555
|
api.use(requireToken(tokenStore));
|
|
556
|
+
// Origine per lo scope celle: stessa prova di hop usata da audio e notify.
|
|
557
|
+
// requireCell:false perche' qui interessa QUALE NODO parla, non quale cella
|
|
558
|
+
// dichiari — la cella e' solo attestata e non deve decidere un permesso.
|
|
559
|
+
const cellScopeOrigin = createOriginResolver({
|
|
560
|
+
localNodeId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
561
|
+
hopSecret: () => hopSecret,
|
|
562
|
+
});
|
|
563
|
+
const cellScoper = createCellScope({
|
|
564
|
+
nodesPath,
|
|
565
|
+
cellForSession: (session) => cellIdFromTmuxSession(session),
|
|
566
|
+
});
|
|
567
|
+
// Variante SINCRONA per l'upgrade WebSocket: verifica la prova di hop e la
|
|
568
|
+
// catena senza passare dal resolver async. Un upgrade non federato — o con
|
|
569
|
+
// una prova che non regge — resta senza restrizioni, esattamente come una
|
|
570
|
+
// richiesta locale: la prova la costruisce il proxy, un peer non puo' ne'
|
|
571
|
+
// toglierla ne' falsificarla.
|
|
572
|
+
const wsCellScope = (req) => {
|
|
573
|
+
try {
|
|
574
|
+
const headers = (req && req.headers) || {};
|
|
575
|
+
const proof = headers[HOP_HEADER];
|
|
576
|
+
if (!proof) return cellScoper.resolve({ trust: 'local-bridge' });
|
|
577
|
+
const self = (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
578
|
+
const visited = parseVisitedChain(headers['x-nexuscrew-visited'], self);
|
|
579
|
+
if (!visited) return cellScoper.resolve({ trust: 'federated', visited: [] });
|
|
580
|
+
const ok = verifyHop(hopSecret, {
|
|
581
|
+
method: req.method || 'GET', path: '/ws', visited,
|
|
582
|
+
}, proof);
|
|
583
|
+
if (!ok) return cellScoper.resolve({ trust: 'federated', visited: [] });
|
|
584
|
+
return cellScoper.resolve({ trust: 'federated', visited });
|
|
585
|
+
} catch (_) {
|
|
586
|
+
// Fail-closed: se non si riesce a stabilire l'origine, nessuna sessione.
|
|
587
|
+
return cellScoper.resolve({ trust: 'federated', visited: [] });
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
// Scope celle (NC-E): un punto solo decide cosa un peer federato vede e puo'
|
|
591
|
+
// toccare. Sta qui, prima di ogni route, perche' i canali da cui una cella
|
|
592
|
+
// trapela sono molti e crescono: filtrarli uno per uno significa dimenticarne
|
|
593
|
+
// uno, e un permesso che vale su quasi tutti i canali non e' un permesso.
|
|
594
|
+
// Le richieste locali non lo attraversano: il proprietario della macchina non
|
|
595
|
+
// si limita da solo.
|
|
596
|
+
api.use(createCellScopeGuard({
|
|
597
|
+
nodesPath,
|
|
598
|
+
cellForSession: (session) => cellIdFromTmuxSession(session),
|
|
599
|
+
resolveOrigin: (req) => cellScopeOrigin.resolve(req, { requireCell: false }),
|
|
600
|
+
}));
|
|
544
601
|
api.get('/sessions', async (_req, res) => {
|
|
545
602
|
try {
|
|
546
603
|
const sessions = await listSessions(cfg.tmuxBin);
|
|
@@ -615,6 +672,19 @@ function createServer(opts = {}) {
|
|
|
615
672
|
}));
|
|
616
673
|
// MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
|
|
617
674
|
// del router /api; gate READONLY sui mutanti dentro notifyRoutes.
|
|
675
|
+
// Identita' e instradamento condivisi con l'audio: stessa fonte (node store),
|
|
676
|
+
// stesso segreto di hop, stessa inventory. Due modelli di provenienza
|
|
677
|
+
// divergenti sarebbero un bug latente.
|
|
678
|
+
const federatedNodeId = () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null;
|
|
679
|
+
const federatedPeers = async () => {
|
|
680
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
681
|
+
if (!st) return [];
|
|
682
|
+
const topology = await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath, fetchImpl: healthFetch });
|
|
683
|
+
return nodesInventory.buildInventory({
|
|
684
|
+
direct: nodesStore.redactStore(st).nodes,
|
|
685
|
+
topology: topology && Array.isArray(topology.nodes) ? topology.nodes : [],
|
|
686
|
+
});
|
|
687
|
+
};
|
|
618
688
|
api.use(notifyRoutes({
|
|
619
689
|
cfg,
|
|
620
690
|
notifier,
|
|
@@ -622,6 +692,22 @@ function createServer(opts = {}) {
|
|
|
622
692
|
asks: asksStore,
|
|
623
693
|
paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
|
|
624
694
|
sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
|
|
695
|
+
localNodeId: federatedNodeId,
|
|
696
|
+
// requireCell resta true: una notifica federata porta sempre una cella
|
|
697
|
+
// attestata, che serve ad attribuirla e a calcolarne il budget.
|
|
698
|
+
originResolver: createOriginResolver({
|
|
699
|
+
localNodeId: federatedNodeId,
|
|
700
|
+
hopSecret: () => hopSecret,
|
|
701
|
+
}),
|
|
702
|
+
acl: createAudioAcl({ nodesPath }),
|
|
703
|
+
dispatcher: createDispatcher({
|
|
704
|
+
localNodeId: federatedNodeId,
|
|
705
|
+
peers: federatedPeers,
|
|
706
|
+
localPort: () => (server && server.address() ? server.address().port : cfg.port),
|
|
707
|
+
localToken: () => tokenHolder.value,
|
|
708
|
+
statuses: new Set(['delivered', 'refused', 'unreachable', 'unknown']),
|
|
709
|
+
}),
|
|
710
|
+
federatedRate: createSpeakRateLimiter(),
|
|
625
711
|
}));
|
|
626
712
|
api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
|
|
627
713
|
// Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
|
|
@@ -787,6 +873,7 @@ function createServer(opts = {}) {
|
|
|
787
873
|
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
|
|
788
874
|
fetchImpl: healthFetch, hopSecret: () => hopSecret,
|
|
789
875
|
roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
|
|
876
|
+
diagnostics,
|
|
790
877
|
}));
|
|
791
878
|
|
|
792
879
|
// Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
|
|
@@ -868,13 +955,13 @@ function createServer(opts = {}) {
|
|
|
868
955
|
let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
|
|
869
956
|
const given = bearerFrom(req) || u.searchParams.get('token') || '';
|
|
870
957
|
if (!verify(tokenHolder.value, given)) return socket.destroy();
|
|
871
|
-
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets });
|
|
958
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
|
|
872
959
|
return;
|
|
873
960
|
}
|
|
874
961
|
if (pathname.startsWith('/federation/route/')) {
|
|
875
962
|
const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
|
|
876
963
|
if (!ingress) return socket.destroy();
|
|
877
|
-
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets });
|
|
964
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
|
|
878
965
|
return;
|
|
879
966
|
}
|
|
880
967
|
if (pathname === '/node' || pathname.startsWith('/node/')) {
|
|
@@ -900,10 +987,19 @@ function createServer(opts = {}) {
|
|
|
900
987
|
// non possono settare header sui WS -> il flusso locale resta identico
|
|
901
988
|
// (token nel frame attach, mai in URL).
|
|
902
989
|
const preauth = req ? verify(tokenHolder.value, bearerFrom(req)) : false;
|
|
990
|
+
// Scope celle sull'ATTACH. Calcolato in modo sincrono di proposito: rendere
|
|
991
|
+
// async questo handler rischierebbe di perdere i frame che arrivano prima
|
|
992
|
+
// di bindWs. `/ws` attacca per NOME DI SESSIONE e senza questo gate ogni
|
|
993
|
+
// filtro sugli elenchi sarebbe decorativo — basterebbe indovinare
|
|
994
|
+
// `cloud-Dev`.
|
|
995
|
+
const wsScope = wsCellScope(req);
|
|
903
996
|
bindWs(ws, {
|
|
904
997
|
openAttach,
|
|
905
998
|
verifyToken: (t) => preauth || verify(tokenHolder.value, t),
|
|
906
|
-
|
|
999
|
+
// Una sessione fuori scope viene trattata come inesistente: il client
|
|
1000
|
+
// riceve 4404, lo stesso codice di una sessione che non c'e'. Dire "esiste
|
|
1001
|
+
// ma non puoi" rivelerebbe proprio cio' che lo scope nasconde.
|
|
1002
|
+
isValidSession: (name) => sessionExistsImpl(name) && wsScope.allowsSession(name),
|
|
907
1003
|
runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
|
|
908
1004
|
countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
|
|
909
1005
|
defaults: { readonlyDefault: cfg.readonlyDefault, tmuxBin: cfg.tmuxBin },
|
|
@@ -32,6 +32,7 @@ const os = require('node:os');
|
|
|
32
32
|
const crypto = require('node:crypto');
|
|
33
33
|
|
|
34
34
|
const nodesStore = require('../nodes/store.js');
|
|
35
|
+
const identity = require('../nodes/identity.js');
|
|
35
36
|
const nodesCmds = require('../nodes/commands.js');
|
|
36
37
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
37
38
|
const peering = require('../nodes/peering.js');
|
|
@@ -223,6 +224,15 @@ function createPairHandler(deps) {
|
|
|
223
224
|
// Pairing establishes a private client-to-hub connection. Sharing
|
|
224
225
|
// this device back through the hub is a separate explicit action.
|
|
225
226
|
shared: false,
|
|
227
|
+
// La nostra pubblica, passo 1 del modello di autorita'. Se non si
|
|
228
|
+
// riesce a leggerla si accoppia senza: il passo 1 non ha effetti, e
|
|
229
|
+
// un guasto sulla chiave non deve togliere la capacita' di
|
|
230
|
+
// accoppiare un dispositivo.
|
|
231
|
+
...(() => {
|
|
232
|
+
try {
|
|
233
|
+
return { publicKey: identity.ensureNodeKey({ keyPath: identity.keyPathNextTo(nodesPath) }).publicKey };
|
|
234
|
+
} catch (_) { return {}; }
|
|
235
|
+
})(),
|
|
226
236
|
roles: readRoles(configPath),
|
|
227
237
|
}),
|
|
228
238
|
});
|
|
@@ -295,6 +305,12 @@ function createPairHandler(deps) {
|
|
|
295
305
|
st = nodesStore.updateNode(st, b.name, {
|
|
296
306
|
token: joined.credential, nodeId: joined.instanceId, reversePort: joined.reversePort,
|
|
297
307
|
shared: false,
|
|
308
|
+
// L'altra meta' dello scambio. Un hub piu' vecchio non manda la sua
|
|
309
|
+
// pubblica e il pairing riesce lo stesso — nessun campo, nessun
|
|
310
|
+
// cambiamento di comportamento.
|
|
311
|
+
...(identity.isPublicKey(joined.publicKey)
|
|
312
|
+
? { publicKey: joined.publicKey, keySource: 'pairing', keyBoundAt: new Date().toISOString() }
|
|
313
|
+
: {}),
|
|
298
314
|
...(joinedPool ? { reversePool: joinedPool } : {}),
|
|
299
315
|
...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
|
|
300
316
|
});
|
|
@@ -13,6 +13,7 @@ const express = require('express');
|
|
|
13
13
|
|
|
14
14
|
const nodesStore = require('../nodes/store.js');
|
|
15
15
|
const peering = require('../nodes/peering.js');
|
|
16
|
+
const identity = require('../nodes/identity.js');
|
|
16
17
|
const reversePool = require('../nodes/reverse-pool.js');
|
|
17
18
|
const { readRoles } = require('../cli/commands.js');
|
|
18
19
|
const { configJsonPath } = require('../config.js');
|
|
@@ -27,6 +28,15 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
27
28
|
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
28
29
|
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
29
30
|
const reversePoolLedgerPath = deps.reversePoolLedgerPath || cfg.reversePoolLedgerPath || reversePool.defaultLedgerPath(home);
|
|
31
|
+
// La pubblica di questa installazione, per il passo 1 del modello di
|
|
32
|
+
// autorita'. Si legge PIGRAMENTE e non all'avvio del router: un errore sul
|
|
33
|
+
// file di chiave non deve impedire di montare le route di pairing — il passo
|
|
34
|
+
// 1 non ha effetti, e «non riesco a leggere la mia chiave» non e' una buona
|
|
35
|
+
// ragione per non poter piu' accoppiare un dispositivo.
|
|
36
|
+
const localPublicKey = () => {
|
|
37
|
+
try { return identity.ensureNodeKey({ keyPath: identity.keyPathNextTo(nodesPath) }).publicKey; }
|
|
38
|
+
catch (_) { return null; }
|
|
39
|
+
};
|
|
30
40
|
const r = express.Router();
|
|
31
41
|
const attempts = new Map();
|
|
32
42
|
r.use(express.json({ limit: '8kb' }));
|
|
@@ -150,15 +160,23 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
150
160
|
name, remotePort: b.port, reversePort, reversePool: assignedPool, instanceId: b.instanceId, acceptToken: b.acceptToken,
|
|
151
161
|
shared: false,
|
|
152
162
|
label: nodesStore.sanitizeLabel(b.label, name),
|
|
163
|
+
// La pubblica del peer, se la manda. Un peer piu' vecchio non la manda
|
|
164
|
+
// e si accoppia come sempre: il passo 1 osserva, non pretende.
|
|
165
|
+
...(identity.isPublicKey(b.publicKey) ? { publicKey: b.publicKey } : {}),
|
|
153
166
|
...(peerRoles ? { roles: { ...peerRoles, node: false }, rolesKnown: true } : { rolesKnown: false }),
|
|
154
167
|
} });
|
|
155
168
|
if (!peering.consumeInvite({ invitesPath, invite: b.invite, now })) {
|
|
156
169
|
peering.consumePending({ pendingPath, credential, now });
|
|
157
170
|
return res.status(410).json({ error: 'invito scaduto o gia usato' });
|
|
158
171
|
}
|
|
172
|
+
const mia = localPublicKey();
|
|
159
173
|
res.json({ paired: true, instanceId: poolStore.nodeId, reversePort,
|
|
160
174
|
reversePool: { base: assignedPool.base, slots: assignedPool.slots.map((slot) => slot.port) },
|
|
161
|
-
credential, roles: readRoles(configPath)
|
|
175
|
+
credential, roles: readRoles(configPath),
|
|
176
|
+
// Lo scambio e' simmetrico e avviene QUI, dentro l'atto che consuma
|
|
177
|
+
// l'invito monouso: e' il solo momento in cui l'operatore ha deciso,
|
|
178
|
+
// su entrambe le macchine, che questi due nodi si conoscono.
|
|
179
|
+
...(mia ? { publicKey: mia } : {}) });
|
|
162
180
|
} catch (e) {
|
|
163
181
|
if (credential) try { peering.consumePending({ pendingPath, credential, now }); } catch (_) {}
|
|
164
182
|
res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) });
|
|
@@ -188,6 +206,11 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
188
206
|
direction: 'inbound', transport: 'inbound', autostart: true,
|
|
189
207
|
visibility: 'network', shared: pending.shared === true, nodeId: pending.instanceId,
|
|
190
208
|
token: pending.acceptToken, acceptToken: b.credential,
|
|
209
|
+
// Legata al pairing, non asserita: e' `keySource` a dire la differenza,
|
|
210
|
+
// e al passo 3 un grant potra' pretendere questa e non l'altra.
|
|
211
|
+
...(identity.isPublicKey(pending.publicKey)
|
|
212
|
+
? { publicKey: pending.publicKey, keySource: 'pairing', keyBoundAt: new Date().toISOString() }
|
|
213
|
+
: {}),
|
|
191
214
|
...(pending.roles ? { roles: pending.roles } : {}),
|
|
192
215
|
rolesKnown: pending.rolesKnown === true,
|
|
193
216
|
...(pending.label ? { label: pending.label } : {}),
|
package/lib/settings/routes.js
CHANGED
|
@@ -72,7 +72,7 @@ const { publicPeeringRoutes } = require('./public-peering-routes.js');
|
|
|
72
72
|
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone', 'autoUpdate', 'alternateScreen']);
|
|
73
73
|
const ROLE_KEYS = new Set(['client', 'node']);
|
|
74
74
|
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
|
|
75
|
-
const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected']);
|
|
75
|
+
const EDIT_KEYS = new Set(['label', 'ssh', 'sshPort', 'autostart', 'visibility', 'selected', 'cellVisibility', 'cells']);
|
|
76
76
|
const LOCAL_AUDIO_TEST_TEXT = 'NexusCrew audio test.';
|
|
77
77
|
|
|
78
78
|
// Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
|
package/package.json
CHANGED