@mmmbuto/nexuscrew 0.8.58 → 0.9.1
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 +197 -2
- package/README.md +23 -4
- package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
- package/frontend/dist/assets/index-Bu_-2-Uu.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/adapters.js +50 -6
- package/lib/cli/commands.js +54 -2
- package/lib/cli/doctor.js +95 -12
- package/lib/cli/init.js +25 -3
- package/lib/cli/path.js +43 -10
- package/lib/cli/pidfile.js +23 -2
- package/lib/config.js +24 -0
- package/lib/fleet/builtin.js +161 -19
- package/lib/fleet/catalogs/opencode-go.json +50 -4
- package/lib/fleet/cell-exec.js +87 -9
- package/lib/fleet/cell-lease-server.js +719 -0
- package/lib/fleet/cell-lease.js +112 -0
- package/lib/fleet/definitions.js +101 -7
- package/lib/fleet/launch-broker.js +115 -3
- package/lib/fleet/lease-client.js +191 -0
- package/lib/fleet/lease-routes.js +92 -0
- package/lib/fleet/lease-verifier.js +230 -0
- package/lib/fleet/managed.js +366 -48
- package/lib/fleet/prompt-delivery.js +50 -2
- package/lib/fleet/provider.js +1 -1
- package/lib/fleet/runtime.js +53 -6
- package/lib/live-host/bridge.js +369 -0
- package/lib/live-host/routes.js +190 -0
- package/lib/live-host/store.js +96 -0
- package/lib/mcp/tools.js +51 -0
- package/lib/nodes/commands.js +21 -3
- package/lib/nodes/store.js +57 -0
- package/lib/nodes/tunnel.js +23 -1
- package/lib/proxy/federation.js +130 -9
- package/lib/proxy/node-proxy.js +33 -0
- package/lib/proxy/panel-auth.js +337 -0
- package/lib/proxy/panel-proxy.js +336 -0
- package/lib/server.js +252 -7
- package/lib/settings/pairing-coordinator.js +32 -0
- package/lib/settings/public-peering-routes.js +13 -1
- package/package.json +1 -1
- package/skills/aidesktop/SKILL.md +201 -0
- package/skills/aidesktop/docker/Dockerfile +21 -0
- package/skills/aidesktop/docker/custom-cont-init.d/10-cdp-relay.sh +20 -0
- package/skills/aidesktop/docker/docker-compose.example.yml +75 -0
- package/skills/crew/SKILL.md +15 -0
- package/skills/live/SKILL.md +90 -0
- package/skills/mail-assistant/SKILL.md +15 -0
- package/skills/memory/SKILL.md +15 -0
- package/skills/nexuscrew/SKILL.md +113 -0
- package/skills/nexuscrew-agent/SKILL.md +18 -0
- package/skills/vl-msa/SKILL.md +15 -0
- package/frontend/dist/assets/index-BEGNtmx2.js +0 -93
- package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
- package/skills/alibaba-token-media/SKILL.md +0 -133
- package/skills/alibaba-token-media/agents/openai.yaml +0 -4
- package/skills/alibaba-token-media/references/api-contract.md +0 -97
- package/skills/alibaba-token-media/scripts/alibaba_token_media.py +0 -550
- package/skills/fill-forms/SKILL.md +0 -154
- package/skills/fill-forms/agents/openai.yaml +0 -4
- package/skills/fill-forms/references/overlay-technique.md +0 -99
- package/skills/fill-forms/requirements.txt +0 -4
- package/skills/fill-forms/scripts/dump_docx.py +0 -70
- package/skills/fill-forms/scripts/fill_docx.py +0 -207
- package/skills/fill-forms/scripts/fill_pdf.py +0 -424
- package/skills/fill-forms/scripts/inspect_pdf.py +0 -188
- package/skills/fill-forms/scripts/prepare_signature.py +0 -171
package/lib/proxy/federation.js
CHANGED
|
@@ -13,6 +13,7 @@ const {
|
|
|
13
13
|
sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
|
|
14
14
|
} = require('./node-proxy.js');
|
|
15
15
|
const { signHop, HOP_HEADER } = require('./hop-proof.js');
|
|
16
|
+
const { COOKIE_NAME: PANEL_COOKIE_NAME } = require('./panel-auth.js');
|
|
16
17
|
|
|
17
18
|
const MAX_HOPS = 4;
|
|
18
19
|
const ROUTE_DELIMITER = '_';
|
|
@@ -151,6 +152,12 @@ function knownResource(resource) {
|
|
|
151
152
|
|| /^\/vl-nodes\/[a-f0-9]{32}(?:\/commands|\/events)?$/.test(resource)
|
|
152
153
|
|| resource === '/decks'
|
|
153
154
|
|| /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
|
|
155
|
+
// Cella ospite Live (0.9.1): un peer designa/legge/rimuove la designazione
|
|
156
|
+
// SOLO sul nodo che la possiede, mai la propria — stesso gate per-peer del
|
|
157
|
+
// pannello (isLiveHostResource + liveHostAllowedFor), non l'allowlist
|
|
158
|
+
// generica: dietro c'e' una scelta dell'operatore del nodo target, non un
|
|
159
|
+
// dato che ogni peer pairato puo' gia' leggere.
|
|
160
|
+
|| isLiveHostResource(resource)
|
|
154
161
|
|| resource === '/topology'
|
|
155
162
|
|| resource === '/diagnostics/status'
|
|
156
163
|
|| resource === '/diagnostics/logs'
|
|
@@ -185,10 +192,47 @@ function knownResource(resource) {
|
|
|
185
192
|
// significava poter creare a distanza un engine che non si puo' rendere
|
|
186
193
|
// avviabile a distanza — un'asimmetria che rompeva la modifica remota a
|
|
187
194
|
// meta' strada, senza proteggere nulla.
|
|
195
|
+
|| isPanelResource(resource)
|
|
188
196
|
|| /^\/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);
|
|
189
197
|
}
|
|
190
198
|
|
|
199
|
+
// Il pannello di una cella e' l'unica risorsa federata con un gate PER-PEER a
|
|
200
|
+
// default negato, e la ragione e' cosa c'e' dietro: un browser con sessioni gia'
|
|
201
|
+
// autenticate. Il resto del modello tratta un peer pairato come l'operatore
|
|
202
|
+
// stesso (docs/SECURITY.md), qui no.
|
|
203
|
+
//
|
|
204
|
+
// Forma: /panel/<cellId>/<path arbitrario>. E' la prima risorsa federata che
|
|
205
|
+
// apre un sottoalbero invece di un endpoint chiuso — un pannello serve HTML, JS
|
|
206
|
+
// e asset suoi — e proprio per questo il gate va valutato PRIMA dell'allowlist:
|
|
207
|
+
// `canTransit` decide chi puo' ATTRAVERSARE questo nodo, non chi puo' aprire un
|
|
208
|
+
// pannello.
|
|
209
|
+
const PANEL_RESOURCE_RE = /^\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/;
|
|
210
|
+
function isPanelResource(resource) { return PANEL_RESOURCE_RE.test(resource); }
|
|
211
|
+
|
|
212
|
+
// `ingress` nullo = richiesta del proprietario di questo nodo (localRouter non
|
|
213
|
+
// lo passa mai): nessun gate. Da un peer, serve il permesso esplicito.
|
|
214
|
+
function panelAllowedFor(ingress) {
|
|
215
|
+
if (!ingress) return true;
|
|
216
|
+
return ingress.panelAccess === true;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// La cella ospite Live copia lo stesso modello, non un altro: chi puo'
|
|
220
|
+
// designare quale cella di QUESTO nodo fa da host Live e' una scelta del nodo
|
|
221
|
+
// che la possiede, non un dato che ogni peer pairato vede gia' — stesso gate
|
|
222
|
+
// per-peer del pannello, stesso default negato. A differenza del pannello (un
|
|
223
|
+
// sottoalbero /panel/<cella>/*, perche' serve HTML/JS/asset), qui l'insieme
|
|
224
|
+
// delle risorse e' chiuso: tre endpoint fissi, non un percorso arbitrario.
|
|
225
|
+
const LIVE_HOST_RESOURCES = new Set(['/live-host', '/live-host/designate', '/live-host/clear']);
|
|
226
|
+
function isLiveHostResource(resource) { return LIVE_HOST_RESOURCES.has(resource); }
|
|
227
|
+
function liveHostAllowedFor(ingress) {
|
|
228
|
+
if (!ingress) return true;
|
|
229
|
+
return ingress.liveHostAccess === true;
|
|
230
|
+
}
|
|
231
|
+
|
|
191
232
|
function allowedResource(resource, method = 'GET') {
|
|
233
|
+
if (isPanelResource(resource)) return method === 'GET' || method === 'POST';
|
|
234
|
+
if (resource === '/live-host') return method === 'GET';
|
|
235
|
+
if (resource === '/live-host/designate' || resource === '/live-host/clear') return method === 'POST';
|
|
192
236
|
if (resource === '/sessions') return method === 'GET' || method === 'POST';
|
|
193
237
|
if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return method === 'DELETE';
|
|
194
238
|
if (/^\/sessions\/[\w.@%:+-]{1,128}\/visibility$/.test(resource)) return method === 'PATCH';
|
|
@@ -263,7 +307,34 @@ function allowedQuery(resource, method, rawUrl) {
|
|
|
263
307
|
return true;
|
|
264
308
|
}
|
|
265
309
|
|
|
266
|
-
|
|
310
|
+
// Il cookie di visione del pannello (`npanel`) e' l'UNICO cookie che attraversa
|
|
311
|
+
// la federazione, e solo sulle panel-resource.
|
|
312
|
+
//
|
|
313
|
+
// Perche' deve passare: le sotto-risorse dentro l'iframe sono URL RELATIVI senza
|
|
314
|
+
// query — il cookie e' l'unico modo che hanno di dire chi sono — e a
|
|
315
|
+
// riconoscerlo e' il nodo PROPRIETARIO, l'unico che lo ha emesso. Senza questo,
|
|
316
|
+
// il pannello remoto serve la pagina e poi nient'altro: un frame che sembra
|
|
317
|
+
// caricato e resta vuoto. (Finche' il Bearer dell'hop apriva tutto il difetto
|
|
318
|
+
// non si vedeva: i test lo attribuivano al cookie, che non arrivava mai.)
|
|
319
|
+
//
|
|
320
|
+
// Perche' SOLO questo: `sanitizeRequestHeaders` toglie l'intero header cookie, e
|
|
321
|
+
// giustamente — i cookie del browser sono della nostra origine e verso un altro
|
|
322
|
+
// nodo non significherebbero nulla. Qui si reintroduce un valore solo, opaco,
|
|
323
|
+
// per-cella e a scadenza breve, che il destinatario ha emesso lui stesso.
|
|
324
|
+
function panelViewCookie(headers) {
|
|
325
|
+
const raw = headers && (headers.cookie || headers.Cookie);
|
|
326
|
+
const source = Array.isArray(raw) ? raw.join('; ') : String(raw || '');
|
|
327
|
+
for (const part of source.split(';')) {
|
|
328
|
+
const i = part.indexOf('=');
|
|
329
|
+
if (i <= 0) continue;
|
|
330
|
+
if (part.slice(0, i).trim() !== PANEL_COOKIE_NAME) continue;
|
|
331
|
+
const value = part.slice(i + 1).trim();
|
|
332
|
+
return value ? `${PANEL_COOKIE_NAME}=${value}` : null;
|
|
333
|
+
}
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function cleanHeaders(headers, credential, visited = null, hopProof = null, panelCookie = null) {
|
|
267
338
|
const out = sanitizeRequestHeaders(headers, credential);
|
|
268
339
|
for (const key of Object.keys(out)) {
|
|
269
340
|
if (['x-nexuscrew-route', 'x-nexuscrew-visited', 'x-nexuscrew-hop'].includes(key.toLowerCase())) delete out[key];
|
|
@@ -272,12 +343,31 @@ function cleanHeaders(headers, credential, visited = null, hopProof = null) {
|
|
|
272
343
|
// La prova di hop viene aggiunta DOPO la cancellazione: il canale e' riservato
|
|
273
344
|
// al server, un valore arrivato dal client non sopravvive mai fin qui.
|
|
274
345
|
if (hopProof) out[HOP_HEADER] = hopProof;
|
|
346
|
+
// Come sopra: dopo lo strip, e mai per risorse che non siano il pannello.
|
|
347
|
+
if (panelCookie) out.cookie = panelCookie;
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Riscrittura dei Set-Cookie verso il chiamante locale (il browser della PWA).
|
|
352
|
+
// Il pannello remoto emette il cookie di visione con `Path=/api/panel/<cella>`:
|
|
353
|
+
// quel path e' GIUSTO sul nodo che lo emette, ma il browser ha richiesto
|
|
354
|
+
// `/api/route/<nodi>/_/panel/...` — senza riscrittura il cookie non coprirebbe
|
|
355
|
+
// le sotto-risorse dell'iframe remoto e il frame resterebbe bianco con l'aria
|
|
356
|
+
// di funzionare. Si riscrive SOLO il prefisso `/api` in `/api/route/<nodi>/_`,
|
|
357
|
+
// il resto dell'ambito (per-cella) resta quello deciso dal nodo che lo emette.
|
|
358
|
+
function rewriteSetCookiePath(headers, prefix) {
|
|
359
|
+
const raw = headers['set-cookie'];
|
|
360
|
+
if (!raw) return headers;
|
|
361
|
+
const out = { ...headers };
|
|
362
|
+
out['set-cookie'] = (Array.isArray(raw) ? raw : [raw])
|
|
363
|
+
.map((line) => String(line).replace(/([Pp]ath=)\/api\//, `$1${prefix}/`));
|
|
275
364
|
return out;
|
|
276
365
|
}
|
|
277
366
|
|
|
278
|
-
function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null }) {
|
|
279
|
-
const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof) }, (r) => {
|
|
280
|
-
|
|
367
|
+
function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null }) {
|
|
368
|
+
const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof, panelCookie) }, (r) => {
|
|
369
|
+
const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
|
|
370
|
+
res.writeHead(r.statusCode, sanitizeResponseHeaders(headers)); r.pipe(res);
|
|
281
371
|
});
|
|
282
372
|
up.setTimeout(30000, () => up.destroy());
|
|
283
373
|
up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile' }); else res.destroy(); });
|
|
@@ -287,6 +377,14 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
|
|
|
287
377
|
function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null }) {
|
|
288
378
|
return (req, res) => {
|
|
289
379
|
const parsed = parseRoute(req.url);
|
|
380
|
+
// Prima dell'allowlist: un peer senza permesso non deve nemmeno sapere se la
|
|
381
|
+
// risorsa esiste per una cella o per un'altra.
|
|
382
|
+
if (parsed && isPanelResource(parsed.resource) && !panelAllowedFor(ingress)) {
|
|
383
|
+
return res.status(403).json({ error: 'pannello non concesso a questo nodo', reason: 'panel-not-granted' });
|
|
384
|
+
}
|
|
385
|
+
if (parsed && isLiveHostResource(parsed.resource) && !liveHostAllowedFor(ingress)) {
|
|
386
|
+
return res.status(403).json({ error: 'cella ospite live non concessa da questo nodo', reason: 'live-host-not-granted' });
|
|
387
|
+
}
|
|
290
388
|
if (!parsed || !allowedResource(parsed.resource, req.method)
|
|
291
389
|
|| !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
|
|
292
390
|
if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
|
|
@@ -294,6 +392,9 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
294
392
|
if (!st) return res.status(503).json({ error: 'node store unavailable' });
|
|
295
393
|
const visited = controlledVisited(req, ingress, st.nodeId);
|
|
296
394
|
if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
|
|
395
|
+
// Vale per l'ultimo hop come per il transito: se si fermasse al primo, una
|
|
396
|
+
// route a due salti servirebbe la pagina e nessuna delle sue risorse.
|
|
397
|
+
const panelCookie = isPanelResource(parsed.resource) ? panelViewCookie(req.headers) : null;
|
|
297
398
|
if (parsed.route.length === 0) {
|
|
298
399
|
// Ultimo hop: la richiesta rientra nell'API locale con il Bearer locale e
|
|
299
400
|
// da li' in poi sarebbe indistinguibile da un POST diretto. La prova di
|
|
@@ -306,6 +407,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
306
407
|
credential: localCredential(),
|
|
307
408
|
visited,
|
|
308
409
|
hopProof: signHop(secret, { method: req.method, path, visited }),
|
|
410
|
+
panelCookie,
|
|
309
411
|
});
|
|
310
412
|
}
|
|
311
413
|
const next = st && store.getNode(st, parsed.route[0]);
|
|
@@ -313,7 +415,14 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
313
415
|
if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return res.status(403).json({ error: 'route non consentita' });
|
|
314
416
|
const rest = parsed.route.slice(1);
|
|
315
417
|
const path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}${queryOf(req.url)}`;
|
|
316
|
-
|
|
418
|
+
// Verso il browser di questo nodo (ingress nullo) il cookie di visione del
|
|
419
|
+
// pannello va riscritto col prefisso federato che il browser sta usando:
|
|
420
|
+
// vedi rewriteSetCookiePath. Da un peer in transito non si tocca — la
|
|
421
|
+
// riscrittura spetta all'hub dove il browser è collegato.
|
|
422
|
+
const setCookiePrefix = !ingress && isPanelResource(parsed.resource)
|
|
423
|
+
? `/api/route/${parsed.route.join('/')}/${ROUTE_DELIMITER}`
|
|
424
|
+
: null;
|
|
425
|
+
proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie });
|
|
317
426
|
};
|
|
318
427
|
}
|
|
319
428
|
|
|
@@ -1157,19 +1266,26 @@ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecre
|
|
|
1157
1266
|
function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null, hopSecret = null }) {
|
|
1158
1267
|
if (readonly()) return reject(socket, 403);
|
|
1159
1268
|
const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
|
|
1160
|
-
|
|
1269
|
+
// Due percorsi separati: questo NON passa da routeHandler e non ne condivide i
|
|
1270
|
+
// controlli. Un gate scritto solo di la' sarebbe una porta chiusa accanto a una
|
|
1271
|
+
// aperta — e per un pannello la porta aperta e' l'unica che conta, perche' i
|
|
1272
|
+
// frame arrivano da qui.
|
|
1273
|
+
if (!parsed || (parsed.resource !== '/ws' && !isPanelResource(parsed.resource))) return reject(socket, 404);
|
|
1274
|
+
if (isPanelResource(parsed.resource) && !panelAllowedFor(ingress)) return reject(socket, 403);
|
|
1161
1275
|
const st = store.loadStore(nodesPath);
|
|
1162
1276
|
if (!st) return reject(socket, 503);
|
|
1163
1277
|
const visited = controlledVisited(req, ingress, st.nodeId);
|
|
1164
1278
|
if (!visited) return reject(socket, 409);
|
|
1165
|
-
let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential();
|
|
1279
|
+
let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential();
|
|
1280
|
+
// Ultimo hop: `/ws` entra com'e', il pannello rientra nell'API locale.
|
|
1281
|
+
let path = parsed.resource === '/ws' ? '/ws' : `/api${parsed.resource}`;
|
|
1166
1282
|
if (parsed.route.length) {
|
|
1167
1283
|
const next = store.getNode(st, parsed.route[0]);
|
|
1168
1284
|
const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
|
|
1169
1285
|
if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
|
|
1170
1286
|
port = next.localPort; credential = next.token;
|
|
1171
1287
|
const rest = parsed.route.slice(1);
|
|
1172
|
-
path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}
|
|
1288
|
+
path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}`;
|
|
1173
1289
|
}
|
|
1174
1290
|
const up = net.connect({ host: '127.0.0.1', port });
|
|
1175
1291
|
up.once('connect', () => {
|
|
@@ -1184,7 +1300,11 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1184
1300
|
const hopProof = !parsed.route.length && secret
|
|
1185
1301
|
? signHop(secret, { method: req.method || 'GET', path, visited })
|
|
1186
1302
|
: null;
|
|
1187
|
-
|
|
1303
|
+
// Il cookie di visione serve QUI quanto sull'HTTP: la WebSocket del
|
|
1304
|
+
// pannello parte dalla pagina dentro il frame, e senza cookie il frame
|
|
1305
|
+
// carica e resta nero.
|
|
1306
|
+
const panelCookie = isPanelResource(parsed.resource) ? panelViewCookie(req.headers) : null;
|
|
1307
|
+
const headers = cleanHeaders(req.headers, credential, visited, hopProof, panelCookie);
|
|
1188
1308
|
const lines = [`GET ${path} HTTP/1.1`, `Host: 127.0.0.1:${port}`];
|
|
1189
1309
|
for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
|
|
1190
1310
|
lines.push('Connection: Upgrade', 'Upgrade: websocket', '', '');
|
|
@@ -1202,6 +1322,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1202
1322
|
function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
|
|
1203
1323
|
|
|
1204
1324
|
module.exports = {
|
|
1325
|
+
isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, routeHandler,
|
|
1205
1326
|
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
|
|
1206
1327
|
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
1207
1328
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
package/lib/proxy/node-proxy.js
CHANGED
|
@@ -32,6 +32,26 @@ const CONNECT_TIMEOUT_MS = 10000;
|
|
|
32
32
|
// Metodi che mutano stato sul nodo remoto: bloccati sotto NEXUSCREW_READONLY locale.
|
|
33
33
|
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
34
34
|
|
|
35
|
+
// Path LOCAL-ONLY: non attraversano MAI la federazione via /node/<name>. La
|
|
36
|
+
// designazione "cella ospite Live" e' un'azione del nodo stesso: nessun peer puo'
|
|
37
|
+
// designare una cella di questo nodo da remoto (federazione default-deny). Il
|
|
38
|
+
// browser parla con /api/live-host solo sul proprio loopback.
|
|
39
|
+
// `/api/panel` e' local-only per la stessa ragione, piu' una sua: dietro c'e' un
|
|
40
|
+
// browser con sessioni gia' autenticate, e un peer pairato non deve poterlo
|
|
41
|
+
// aprire per il solo fatto di essere pairato. L'accesso dai nodi del proprietario
|
|
42
|
+
// passera' dalla via allowlistata della federazione, mai da questo pass-through
|
|
43
|
+
// generico — che inoltra QUALSIASI path non elencato qui.
|
|
44
|
+
// `/api/route` e `/federation/route` sono qui per una ragione che e' costata un
|
|
45
|
+
// audit: l'origine di una richiesta veniva dedotta dal PATH. Su /api/route il
|
|
46
|
+
// gestore assume di parlare col proprietario e non applica gate per-peer; ma un
|
|
47
|
+
// peer poteva farci arrivare quella forma ATTRAVERSO questo stesso pass-through
|
|
48
|
+
// (/node/<A>/api/route/_/panel/<cella>/...), e a quel punto il nodo di
|
|
49
|
+
// destinazione la vedeva come richiesta locale — con il gate del pannello
|
|
50
|
+
// saltato. La catena di inoltro ha gia' un canale suo (/api/route con i suoi
|
|
51
|
+
// hop): passare da qui era un secondo percorso per la stessa cosa, ed e' la
|
|
52
|
+
// forma di difetto che continuiamo a trovare.
|
|
53
|
+
const LOCAL_ONLY_PREFIXES = ['/api/live-host', '/api/panel', '/api/route', '/federation/route'];
|
|
54
|
+
|
|
35
55
|
// Hop-by-hop (RFC 7230 §6.1) + Proxy-*: mai inoltrati end-to-end.
|
|
36
56
|
const HOP_BY_HOP = new Set([
|
|
37
57
|
'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
|
|
@@ -88,6 +108,15 @@ function isTransitiveRest(rest) {
|
|
|
88
108
|
return false;
|
|
89
109
|
}
|
|
90
110
|
|
|
111
|
+
// Un path local-only non e' instradabile via /node, neanche in lettura: il proxy lo
|
|
112
|
+
// chiude prima di resolveNode. Controlla raw e decodificato, come isTransitiveRest.
|
|
113
|
+
function isLocalOnly(rest) {
|
|
114
|
+
const hit = (p) => LOCAL_ONLY_PREFIXES.some((px) => p === px || p.startsWith(`${px}/`));
|
|
115
|
+
if (hit(rest)) return true;
|
|
116
|
+
try { if (hit(decodeURIComponent(rest))) return true; } catch (_) { /* malformed: raw basta */ }
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
91
120
|
// Headers per l'HTTP upstream: strip client-supplied pericolosi, inietta il token
|
|
92
121
|
// del nodo. host lo mette node http.request da host/port (loopback da config).
|
|
93
122
|
function sanitizeRequestHeaders(headers, remoteToken) {
|
|
@@ -127,6 +156,9 @@ function createNodeProxy(deps) {
|
|
|
127
156
|
if (!parsed) return notFound(res); // §4b(2)#2 no name
|
|
128
157
|
if (!NODE_NAME_RE.test(parsed.name)) return notFound(res); // §4b(2)#2 strict/traversal
|
|
129
158
|
if (isTransitiveRest(parsed.rest)) return notFound(res); // §4b(2)#7 no transitive
|
|
159
|
+
if (isLocalOnly(parsed.rest)) {
|
|
160
|
+
return res.status(403).json({ error: 'local-only: azione non instradabile via federazione' });
|
|
161
|
+
}
|
|
130
162
|
const node = resolveNode(parsed.name);
|
|
131
163
|
if (!node) return notFound(res); // nome non in config -> 404 secco
|
|
132
164
|
if (readonly() && MUTATING.has(req.method)) {
|
|
@@ -242,6 +274,7 @@ function handleNodeUpgrade(ctx) {
|
|
|
242
274
|
const parsed = splitNodePath(afterNode);
|
|
243
275
|
if (!parsed || !NODE_NAME_RE.test(parsed.name)) return abortUpgrade(socket, 404);
|
|
244
276
|
if (isTransitiveRest(parsed.rest)) return abortUpgrade(socket, 404);
|
|
277
|
+
if (isLocalOnly(parsed.rest)) return abortUpgrade(socket, 403);
|
|
245
278
|
const node = resolveNode(parsed.name);
|
|
246
279
|
if (!node) return abortUpgrade(socket, 404);
|
|
247
280
|
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/proxy/panel-auth.js — l'ingresso al pannello: ticket monouso + cookie di visione.
|
|
3
|
+
//
|
|
4
|
+
// Perche' esiste (misurato, non ipotizzato — vedi la misura dell'iframe che
|
|
5
|
+
// non puo' autenticarsi, 2026-08-15): un `<iframe src>` e' una navigazione del
|
|
6
|
+
// browser e non porta header applicativi. Il proxy del pannello sta dietro
|
|
7
|
+
// requireToken, che accetta solo `Authorization: Bearer`: l'unico
|
|
8
|
+
// consumatore previsto non puo' entrare. E accettare il token in query non
|
|
9
|
+
// basterebbe: la pagina del pannello chiede le proprie risorse con URL
|
|
10
|
+
// relativi, senza query — cadrebbero tutte, e il frame resterebbe bianco.
|
|
11
|
+
//
|
|
12
|
+
// Il disegno, in quattro punti che sono vincoli e non preferenze:
|
|
13
|
+
//
|
|
14
|
+
// 1. IL TICKET NON E' IL TOKEN DEL NODO. Lo chiede la PWA, che e' gia'
|
|
15
|
+
// autenticata col Bearer; e' opaco, monouso, vive pochi secondi e vale per
|
|
16
|
+
// UNA cella. Il token del nodo non deve finire nella cronologia del
|
|
17
|
+
// browser, nei log del proxy o in un Referer.
|
|
18
|
+
// 2. IL TICKET SI CONSUMA ALLA PRIMA RICHIESTA e la risposta imposta un cookie
|
|
19
|
+
// HttpOnly SameSite Strict con `Path=/api/panel/<cella>` — ESATTAMENTE quel
|
|
20
|
+
// path, perche' le sotto-risorse relative passino e nient'altro.
|
|
21
|
+
// 3. IL COOKIE NON E' UN'AUTENTICAZIONE DELL'ORIGINE. Il progetto non ne ha
|
|
22
|
+
// una, e introdurla aprirebbe CSRF su tutte le altre route: qui si verifica
|
|
23
|
+
// SEMPRE che il cookie sia stato emesso per la cella del path, e lo scope
|
|
24
|
+
// stretto e' presidiato dai test — un cookie con Path piu' largo o un
|
|
25
|
+
// ticket riusabile devono FAR FALLIRE un test, non passare inosservati.
|
|
26
|
+
// 4. NIENTE CREDENZIALI VERSO IL PANNELLO: il cookie non viene inoltrato
|
|
27
|
+
// upstream (lo strip fa' panel-proxy, come l'Authorization), e `Referer`
|
|
28
|
+
// va tolto dagli header inoltrati insieme agli altri.
|
|
29
|
+
|
|
30
|
+
// 5. IL BEARER DEL NODO NON VALE SULLA VIA FEDERATA. L'ultimo hop rientra
|
|
31
|
+
// nell'API locale col Bearer locale e da li' in poi e' indistinguibile
|
|
32
|
+
// dalla PWA: senza questo confine il contenuto del pannello esce verso
|
|
33
|
+
// ogni peer con `panelAccess`, senza che nessuno abbia mai preso un
|
|
34
|
+
// ticket. La prova di hop (lib/proxy/hop-proof.js) e' cio' che distingue
|
|
35
|
+
// le due provenienze. Chiuso il 2026-08-15; i due casi cattivi stanno in
|
|
36
|
+
// tests/panel-auth-live.test.js e devono restare l'unico modo di provarlo.
|
|
37
|
+
|
|
38
|
+
const crypto = require('node:crypto');
|
|
39
|
+
const { CELL_ID_RE } = require('../live-host/store.js');
|
|
40
|
+
const { validPanelUrl } = require('../fleet/definitions.js');
|
|
41
|
+
const { verifyHop, HOP_HEADER } = require('./hop-proof.js');
|
|
42
|
+
|
|
43
|
+
const VISITED_HEADER = 'x-nexuscrew-visited';
|
|
44
|
+
|
|
45
|
+
const TICKET_TTL_MS = 30 * 1000; // "pochi secondi": la vita di un redirect iframe
|
|
46
|
+
const COOKIE_TTL_MS = 60 * 60 * 1000; // una sessione di visione
|
|
47
|
+
const COOKIE_NAME = 'npanel';
|
|
48
|
+
|
|
49
|
+
// Ticket e cookie non si confrontano: si CERCANO per chiave in una Map, e il
|
|
50
|
+
// valore e' un segreto casuale da 256 bit. Non c'e' quindi un compare da
|
|
51
|
+
// rendere costante — una funzione che lo promettesse senza avere chiamanti
|
|
52
|
+
// sarebbe una garanzia scritta e mai mantenuta.
|
|
53
|
+
function newSecret() {
|
|
54
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parseCookieHeader(header) {
|
|
58
|
+
const out = {};
|
|
59
|
+
for (const part of String(header || '').split(';')) {
|
|
60
|
+
const i = part.indexOf('=');
|
|
61
|
+
if (i <= 0) continue;
|
|
62
|
+
out[part.slice(0, i).trim()] = part.slice(i + 1).trim();
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// `<cella>` da `/api/panel/<cella>/<rest>` (o `/panel/...` gia' smontato).
|
|
68
|
+
function cellFromPanelPath(url) {
|
|
69
|
+
const raw = String(url || '');
|
|
70
|
+
const pathname = raw.slice(0, raw.indexOf('?') === -1 ? raw.length : raw.indexOf('?'));
|
|
71
|
+
const first = pathname.split('/').filter(Boolean)[0];
|
|
72
|
+
if (!first) return null;
|
|
73
|
+
try { return decodeURIComponent(first); } catch (_) { return null; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createPanelAuth({
|
|
77
|
+
verifyToken, // (value) => bool, sul token del nodo
|
|
78
|
+
resolveCellPanel, // (cellId) => Promise<panelUrl|null|undefined|''>
|
|
79
|
+
now = () => Date.now(),
|
|
80
|
+
ticketTtlMs = TICKET_TTL_MS,
|
|
81
|
+
cookieTtlMs = COOKIE_TTL_MS,
|
|
82
|
+
hopSecret = null, // () => Buffer|string|null, segreto per-processo
|
|
83
|
+
log = () => {},
|
|
84
|
+
} = {}) {
|
|
85
|
+
const tickets = new Map(); // ticket -> { cell, exp }
|
|
86
|
+
const cookies = new Map(); // cookieToken -> { cell, exp }
|
|
87
|
+
|
|
88
|
+
// —— Da dove arriva questa richiesta? La risposta decide se il Bearer del
|
|
89
|
+
// NODO vale, e non e' una preferenza: e' il confine descritto al punto 5.
|
|
90
|
+
//
|
|
91
|
+
// Tre esiti, e il terzo e' fail-closed:
|
|
92
|
+
// 'locale' — nessun header di hop: il Bearer vale come e' sempre valso.
|
|
93
|
+
// 'federata' — hop VERIFICATA: la richiesta e' l'ultimo salto di una route
|
|
94
|
+
// federata. Il Bearer qui e' quello che il proxy ha iniettato
|
|
95
|
+
// da se', quindi non prova nulla su CHI guarda: per vedere il
|
|
96
|
+
// pannello servono il ticket o il cookie emessi da QUESTO nodo.
|
|
97
|
+
// 'sospetta' — header presente ma non verificabile (segreto assente, catena
|
|
98
|
+
// vuota, firma che non torna). Non si indovina: si rifiuta.
|
|
99
|
+
//
|
|
100
|
+
// La catena `visited` serve solo a ricostruire il messaggio firmato: qui
|
|
101
|
+
// interessa RILEVARE il transito, non attribuirne l'origine — quello e' il
|
|
102
|
+
// mestiere di lib/audio/origin.js, che infatti la valida anche contro il
|
|
103
|
+
// nodo locale.
|
|
104
|
+
function hopKind(req) {
|
|
105
|
+
const headers = (req && req.headers) || {};
|
|
106
|
+
const proof = headers[HOP_HEADER];
|
|
107
|
+
if (!proof) return 'locale';
|
|
108
|
+
const secret = typeof hopSecret === 'function' ? hopSecret() : hopSecret;
|
|
109
|
+
if (!secret) return 'sospetta';
|
|
110
|
+
const visited = String(headers[VISITED_HEADER] || '').split(',').filter(Boolean);
|
|
111
|
+
if (!visited.length) return 'sospetta';
|
|
112
|
+
// Il path firmato e' quello con cui la richiesta e' ENTRATA nell'API
|
|
113
|
+
// (`/api/panel/...`), non il resto che il mount di express lascia in
|
|
114
|
+
// req.url: sotto un `use('/api/panel')` i due differiscono.
|
|
115
|
+
const path = req.originalUrl || req.url;
|
|
116
|
+
const ok = verifyHop(secret, { method: req.method || 'GET', path, visited }, proof);
|
|
117
|
+
return ok ? 'federata' : 'sospetta';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function sweep() {
|
|
121
|
+
const t = now();
|
|
122
|
+
for (const [k, rec] of tickets) if (rec.exp <= t) tickets.delete(k);
|
|
123
|
+
for (const [k, rec] of cookies) if (rec.exp <= t) cookies.delete(k);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function issueTicket(cellId) {
|
|
127
|
+
sweep();
|
|
128
|
+
const ticket = newSecret();
|
|
129
|
+
tickets.set(ticket, { cell: cellId, exp: now() + ticketTtlMs });
|
|
130
|
+
return ticket;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Monouso VERO: il biglietto si strappa anche se il controllo fallisce —
|
|
134
|
+
// scaduto, cella sbagliata o gia' usato sono indistinguibili dal di fuori,
|
|
135
|
+
// e nessuno dei tre lascia ritentare con lo stesso valore.
|
|
136
|
+
function consumeTicket(ticket, cellId) {
|
|
137
|
+
sweep();
|
|
138
|
+
const rec = tickets.get(ticket);
|
|
139
|
+
if (!rec) return false;
|
|
140
|
+
tickets.delete(ticket);
|
|
141
|
+
return rec.exp > now() && rec.cell === cellId;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function issueCookie(cellId) {
|
|
145
|
+
const value = newSecret();
|
|
146
|
+
cookies.set(value, { cell: cellId, exp: now() + cookieTtlMs });
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function verifyCookie(value, cellId) {
|
|
151
|
+
sweep();
|
|
152
|
+
const rec = cookies.get(value);
|
|
153
|
+
return !!rec && rec.exp > now() && rec.cell === cellId;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function cookieHeaderValue(cellId, value) {
|
|
157
|
+
const attrs = [
|
|
158
|
+
`${COOKIE_NAME}=${value}`,
|
|
159
|
+
`Path=/api/panel/${encodeURIComponent(cellId)}`,
|
|
160
|
+
'HttpOnly',
|
|
161
|
+
'SameSite=Strict',
|
|
162
|
+
`Max-Age=${Math.floor(cookieTtlMs / 1000)}`,
|
|
163
|
+
];
|
|
164
|
+
return attrs.join('; ');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// —— Emissione: POST /api/panel/<cella>/ticket, SOLO per la PWA autenticata.
|
|
168
|
+
// La cella deve esistere ed avere un pannello valido: nessun ticket per
|
|
169
|
+
// destinazioni che il proxy rifiuterebbe comunque.
|
|
170
|
+
async function handleTicketRequest(req, res, cellId) {
|
|
171
|
+
const bearer = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
|
172
|
+
if (!verifyToken(bearer)) {
|
|
173
|
+
log({ event: 'panel-auth', outcome: 'ticket-denied', reason: 'unauthorized', cell: cellId });
|
|
174
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
175
|
+
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
let panelUrl;
|
|
179
|
+
try { panelUrl = await resolveCellPanel(cellId); } catch (_) { panelUrl = null; }
|
|
180
|
+
if (panelUrl === null || panelUrl === undefined || panelUrl === '' || !validPanelUrl(panelUrl)) {
|
|
181
|
+
log({ event: 'panel-auth', outcome: 'ticket-denied', reason: 'no-panel', cell: cellId });
|
|
182
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
183
|
+
res.end(JSON.stringify({ error: 'pannello non disponibile' }));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
const ticket = issueTicket(cellId);
|
|
187
|
+
log({ event: 'panel-auth', outcome: 'ticket-issued', cell: cellId });
|
|
188
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
|
|
189
|
+
res.end(JSON.stringify({ ticket, cell: cellId, expiresInSeconds: Math.floor(ticketTtlMs / 1000) }));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// —— Consumo: ticket in query (prima richiesta dell'iframe, monouso — pone
|
|
193
|
+
// il cookie di visione), cookie (sotto-risorse), Bearer locale (compat: PWA
|
|
194
|
+
// e probe esistenti). NIENTE emissione qui — nessun corpo semantico, nessun
|
|
195
|
+
// controllo su chi ha diritto a un ticket NUOVO: quella decisione resta
|
|
196
|
+
// dov'e' oggi (handleTicketRequest, dietro requireToken sul control plane).
|
|
197
|
+
// Riusata identica su ENTRAMBE le porte (control plane per compat, porta
|
|
198
|
+
// pannello per il consumo vero): e' la logica che "si sposta", non si
|
|
199
|
+
// riscrive due volte.
|
|
200
|
+
function consumeCore(req, res, next, cellId, provenienza) {
|
|
201
|
+
const url = String(req.url || '');
|
|
202
|
+
// Il ticket ha PRECEDENZA sul Bearer: la richiesta federata dell'iframe
|
|
203
|
+
// arriva qui con il Bearer dell'hop (il token del nodo, iniettato dalla
|
|
204
|
+
// via federata) ACCANTO al ticket in query — e deve entrare da iframe,
|
|
205
|
+
// consumando il ticket e prendendo il cookie, non da PWA. Chi porta un
|
|
206
|
+
// ticket sta facendo l'ingresso del frame; la PWA non lo mette mai in query.
|
|
207
|
+
let qTicket = null;
|
|
208
|
+
const qi = url.indexOf('?');
|
|
209
|
+
if (qi !== -1) qTicket = new URLSearchParams(url.slice(qi + 1)).get('ticket');
|
|
210
|
+
if (qTicket) {
|
|
211
|
+
if (consumeTicket(qTicket, cellId)) {
|
|
212
|
+
const value = issueCookie(cellId);
|
|
213
|
+
res.setHeader('set-cookie', cookieHeaderValue(cellId, value));
|
|
214
|
+
log({ event: 'panel-auth', outcome: 'ticket-consumed', cell: cellId });
|
|
215
|
+
return next();
|
|
216
|
+
}
|
|
217
|
+
log({ event: 'panel-auth', outcome: 'denied', reason: 'ticket-invalid', cell: cellId });
|
|
218
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
219
|
+
res.end(JSON.stringify({ error: 'ticket non valido' }));
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Il Bearer apre il pannello SOLO da locale. Sulla via federata e' il
|
|
224
|
+
// token che il proxy ha iniettato da se' un istante prima: accettarlo
|
|
225
|
+
// significherebbe che chiunque raggiunga la route — e la route del
|
|
226
|
+
// pannello transita prima del requireToken, perche' un iframe non porta
|
|
227
|
+
// header — si porta via il contenuto. Di la' restano il ticket e il
|
|
228
|
+
// cookie, che questo nodo ha emesso e sa riconoscere.
|
|
229
|
+
const bearer = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
|
230
|
+
if (provenienza === 'locale' && verifyToken(bearer)) return next();
|
|
231
|
+
|
|
232
|
+
const cookieValue = parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
|
|
233
|
+
if (cookieValue && verifyCookie(cookieValue, cellId)) return next();
|
|
234
|
+
|
|
235
|
+
log({
|
|
236
|
+
event: 'panel-auth', outcome: 'denied', cell: cellId,
|
|
237
|
+
reason: provenienza === 'federata' ? 'federated-needs-ticket' : 'unauthorized',
|
|
238
|
+
});
|
|
239
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
240
|
+
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check comune a emissione e consumo: cellId valido + provenienza non sospetta.
|
|
244
|
+
// Ritorna {cellId, provenienza} o null se ha gia' risposto (404/401).
|
|
245
|
+
function gate(req, res) {
|
|
246
|
+
const url = String(req.url || '');
|
|
247
|
+
const cellId = cellFromPanelPath(url);
|
|
248
|
+
if (!cellId || !CELL_ID_RE.test(cellId)) {
|
|
249
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
250
|
+
res.end(JSON.stringify({ error: 'pannello non trovato' }));
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
const provenienza = hopKind(req);
|
|
254
|
+
if (provenienza === 'sospetta') {
|
|
255
|
+
log({ event: 'panel-auth', outcome: 'denied', reason: 'hop-non-verificabile', cell: cellId });
|
|
256
|
+
res.writeHead(401, { 'content-type': 'application/json' });
|
|
257
|
+
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return { cellId, provenienza };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// —— Il middleware STORICO: emissione + consumo nello stesso mount (control
|
|
264
|
+
// plane, /api/panel). Comportamento INVARIATO — nessuna riga di logica
|
|
265
|
+
// spostata, solo composta dai due pezzi sopra.
|
|
266
|
+
function panelAuthMiddleware(req, res, next) {
|
|
267
|
+
const g = gate(req, res);
|
|
268
|
+
if (!g) return;
|
|
269
|
+
const { cellId, provenienza } = g;
|
|
270
|
+
const url = String(req.url || '');
|
|
271
|
+
const pathname = url.slice(0, url.indexOf('?') === -1 ? url.length : url.indexOf('?'));
|
|
272
|
+
const tail = pathname.split('/').filter(Boolean).slice(1);
|
|
273
|
+
// L'emissione del ticket e' l'UNICA operazione con body semantico ed e'
|
|
274
|
+
// gestita qui, perche' sta prima di ogni requireToken: /panel/<cella>/ticket.
|
|
275
|
+
if (req.method === 'POST' && tail.length === 1 && tail[0] === 'ticket') {
|
|
276
|
+
void handleTicketRequest(req, res, cellId);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
consumeCore(req, res, next, cellId, provenienza);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// —— SOLO consumo: per la porta pannello (P0 sicurezza 2026-08-16). Nessuna
|
|
283
|
+
// emissione qui — quella resta dietro requireToken sul control plane, mai
|
|
284
|
+
// su una porta che non verifica un token. Un POST .../ticket arrivato qui
|
|
285
|
+
// (non dovrebbe mai: frameUrl non lo costruisce) cade nel consumo e viene
|
|
286
|
+
// rifiutato come qualunque richiesta senza ticket/cookie/Bearer validi.
|
|
287
|
+
function consumeMiddleware(req, res, next) {
|
|
288
|
+
const g = gate(req, res);
|
|
289
|
+
if (!g) return;
|
|
290
|
+
consumeCore(req, res, next, g.cellId, g.provenienza);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// —— Auth per l'upgrade WebSocket del pannello. Le WS del pannello partono
|
|
294
|
+
// dalla pagina dentro il frame e portano il cookie della NOSTRA origine
|
|
295
|
+
// (same-site): il cookie di visione deve aprirle, altrimenti il pannello
|
|
296
|
+
// carica l'HTML e resta nero. Il Bearer e il ?token= della PWA restano
|
|
297
|
+
// validi come prima. Il ticket in query NON si usa qui: il flusso dell'iframe
|
|
298
|
+
// ha gia' il cookie quando la pagina apre la sua prima socket.
|
|
299
|
+
//
|
|
300
|
+
// Lo stesso confine dell'HTTP vale QUI, e va scritto qui: l'upgrade non
|
|
301
|
+
// passa dal middleware — forwardUpgrade e' un percorso separato — e per un
|
|
302
|
+
// pannello e' la porta che conta, perche' i frame arrivano da questa.
|
|
303
|
+
function authorizeUpgrade(req) {
|
|
304
|
+
const url = String(req.url || '');
|
|
305
|
+
const provenienza = hopKind(req);
|
|
306
|
+
if (provenienza === 'sospetta') return false;
|
|
307
|
+
const bearer = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
|
308
|
+
if (provenienza === 'locale' && verifyToken(bearer)) return true;
|
|
309
|
+
const qi = url.indexOf('?');
|
|
310
|
+
if (provenienza === 'locale' && qi !== -1
|
|
311
|
+
&& verifyToken(new URLSearchParams(url.slice(qi + 1)).get('token') || '')) return true;
|
|
312
|
+
// Il prefisso varia con la porta: /api/panel sul control plane (compat),
|
|
313
|
+
// /panel nudo sulla porta pannello dedicata — mai entrambi assieme.
|
|
314
|
+
const cellId = cellFromPanelPath(url.replace(/^\/(?:api\/)?panel/, ''));
|
|
315
|
+
if (!cellId) return false;
|
|
316
|
+
const cookieValue = parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
|
|
317
|
+
return !!(cookieValue && verifyCookie(cookieValue, cellId));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
panelAuthMiddleware,
|
|
322
|
+
consumeMiddleware,
|
|
323
|
+
authorizeUpgrade,
|
|
324
|
+
// Per test e diagnostica: verifiche dall'esterno senza passare dal wire.
|
|
325
|
+
consumeTicketForTest: consumeTicket,
|
|
326
|
+
verifyCookieForTest: verifyCookie,
|
|
327
|
+
issueCookieForTest: issueCookie,
|
|
328
|
+
cookieHeaderValueForTest: cookieHeaderValue,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
module.exports = {
|
|
333
|
+
createPanelAuth,
|
|
334
|
+
TICKET_TTL_MS,
|
|
335
|
+
COOKIE_TTL_MS,
|
|
336
|
+
COOKIE_NAME,
|
|
337
|
+
};
|