@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +197 -2
  2. package/README.md +23 -4
  3. package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
  4. package/frontend/dist/assets/index-Bu_-2-Uu.js +93 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/dist/version.json +1 -1
  7. package/lib/audio/adapters.js +50 -6
  8. package/lib/cli/commands.js +54 -2
  9. package/lib/cli/doctor.js +95 -12
  10. package/lib/cli/init.js +25 -3
  11. package/lib/cli/path.js +43 -10
  12. package/lib/cli/pidfile.js +23 -2
  13. package/lib/config.js +24 -0
  14. package/lib/fleet/builtin.js +161 -19
  15. package/lib/fleet/catalogs/opencode-go.json +50 -4
  16. package/lib/fleet/cell-exec.js +87 -9
  17. package/lib/fleet/cell-lease-server.js +719 -0
  18. package/lib/fleet/cell-lease.js +112 -0
  19. package/lib/fleet/definitions.js +101 -7
  20. package/lib/fleet/launch-broker.js +115 -3
  21. package/lib/fleet/lease-client.js +191 -0
  22. package/lib/fleet/lease-routes.js +92 -0
  23. package/lib/fleet/lease-verifier.js +230 -0
  24. package/lib/fleet/managed.js +366 -48
  25. package/lib/fleet/prompt-delivery.js +50 -2
  26. package/lib/fleet/provider.js +1 -1
  27. package/lib/fleet/runtime.js +53 -6
  28. package/lib/live-host/bridge.js +369 -0
  29. package/lib/live-host/routes.js +190 -0
  30. package/lib/live-host/store.js +96 -0
  31. package/lib/mcp/tools.js +51 -0
  32. package/lib/nodes/commands.js +21 -3
  33. package/lib/nodes/store.js +57 -0
  34. package/lib/nodes/tunnel.js +23 -1
  35. package/lib/proxy/federation.js +130 -9
  36. package/lib/proxy/node-proxy.js +33 -0
  37. package/lib/proxy/panel-auth.js +337 -0
  38. package/lib/proxy/panel-proxy.js +336 -0
  39. package/lib/server.js +252 -7
  40. package/lib/settings/pairing-coordinator.js +32 -0
  41. package/lib/settings/public-peering-routes.js +13 -1
  42. package/package.json +1 -1
  43. package/skills/aidesktop/SKILL.md +201 -0
  44. package/skills/aidesktop/docker/Dockerfile +21 -0
  45. package/skills/aidesktop/docker/custom-cont-init.d/10-cdp-relay.sh +20 -0
  46. package/skills/aidesktop/docker/docker-compose.example.yml +75 -0
  47. package/skills/crew/SKILL.md +15 -0
  48. package/skills/live/SKILL.md +90 -0
  49. package/skills/mail-assistant/SKILL.md +15 -0
  50. package/skills/memory/SKILL.md +15 -0
  51. package/skills/nexuscrew/SKILL.md +113 -0
  52. package/skills/nexuscrew-agent/SKILL.md +18 -0
  53. package/skills/vl-msa/SKILL.md +15 -0
  54. package/frontend/dist/assets/index-BEGNtmx2.js +0 -93
  55. package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
  56. package/skills/alibaba-token-media/SKILL.md +0 -133
  57. package/skills/alibaba-token-media/agents/openai.yaml +0 -4
  58. package/skills/alibaba-token-media/references/api-contract.md +0 -97
  59. package/skills/alibaba-token-media/scripts/alibaba_token_media.py +0 -550
  60. package/skills/fill-forms/SKILL.md +0 -154
  61. package/skills/fill-forms/agents/openai.yaml +0 -4
  62. package/skills/fill-forms/references/overlay-technique.md +0 -99
  63. package/skills/fill-forms/requirements.txt +0 -4
  64. package/skills/fill-forms/scripts/dump_docx.py +0 -70
  65. package/skills/fill-forms/scripts/fill_docx.py +0 -207
  66. package/skills/fill-forms/scripts/fill_pdf.py +0 -424
  67. package/skills/fill-forms/scripts/inspect_pdf.py +0 -188
  68. package/skills/fill-forms/scripts/prepare_signature.py +0 -171
@@ -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
- function cleanHeaders(headers, credential, visited = null, hopProof = null) {
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
- res.writeHead(r.statusCode, sanitizeResponseHeaders(r.headers)); r.pipe(res);
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
- proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited });
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
- if (!parsed || parsed.resource !== '/ws') return reject(socket, 404);
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(); let path = '/ws';
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}/ws`;
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
- const headers = cleanHeaders(req.headers, credential, visited, hopProof);
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,
@@ -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
+ };