@mmmbuto/nexuscrew 0.8.57 → 0.9.0

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 (49) hide show
  1. package/CHANGELOG.md +164 -2
  2. package/README.md +1 -0
  3. package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
  4. package/frontend/dist/assets/index-zjL6kZ7J.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 +40 -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 +15 -0
  14. package/lib/fleet/builtin.js +161 -19
  15. package/lib/fleet/catalogs/opencode-go.json +328 -0
  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 +444 -55
  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 +184 -0
  30. package/lib/live-host/store.js +96 -0
  31. package/lib/mcp/tools.js +51 -0
  32. package/lib/nodes/commands.js +9 -2
  33. package/lib/nodes/store.js +14 -0
  34. package/lib/nodes/tunnel.js +4 -1
  35. package/lib/proxy/federation.js +106 -9
  36. package/lib/proxy/node-proxy.js +33 -0
  37. package/lib/proxy/panel-auth.js +307 -0
  38. package/lib/proxy/panel-proxy.js +305 -0
  39. package/lib/server.js +127 -4
  40. package/package.json +1 -1
  41. package/skills/alibaba-token-media/SKILL.md +19 -0
  42. package/skills/crew/SKILL.md +15 -0
  43. package/skills/fill-forms/SKILL.md +23 -0
  44. package/skills/mail-assistant/SKILL.md +15 -0
  45. package/skills/memory/SKILL.md +15 -0
  46. package/skills/nexuscrew-agent/SKILL.md +18 -0
  47. package/skills/vl-msa/SKILL.md +15 -0
  48. package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
  49. package/frontend/dist/assets/index-_c-1_3iR.js +0 -93
@@ -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,307 @@
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
+ // —— Il middleware: decide CHI entra nel proxy, e imposta il cookie quando
193
+ // e' la prima richiesta dell'iframe (quella col ticket).
194
+ //
195
+ // Tre chiavi, in ordine di chi le usa:
196
+ // Bearer — la PWA e le probe esistenti: tutto come prima.
197
+ // ?ticket= — l'iframe al primo ingresso: monouso, consumato ADESSO,
198
+ // e la risposta porta il cookie di visione.
199
+ // Cookie — le sotto-risorse (URL relativi, senza query): il cookie
200
+ // vale solo per la cella del path.
201
+ function panelAuthMiddleware(req, res, next) {
202
+ const url = String(req.url || '');
203
+ const cellId = cellFromPanelPath(url);
204
+ if (!cellId || !CELL_ID_RE.test(cellId)) {
205
+ res.writeHead(404, { 'content-type': 'application/json' });
206
+ res.end(JSON.stringify({ error: 'pannello non trovato' }));
207
+ return;
208
+ }
209
+ const provenienza = hopKind(req);
210
+ if (provenienza === 'sospetta') {
211
+ log({ event: 'panel-auth', outcome: 'denied', reason: 'hop-non-verificabile', cell: cellId });
212
+ res.writeHead(401, { 'content-type': 'application/json' });
213
+ res.end(JSON.stringify({ error: 'unauthorized' }));
214
+ return;
215
+ }
216
+ const pathname = url.slice(0, url.indexOf('?') === -1 ? url.length : url.indexOf('?'));
217
+ const tail = pathname.split('/').filter(Boolean).slice(1);
218
+ // L'emissione del ticket e' l'UNICA operazione con body semantico ed e'
219
+ // gestita qui, perche' sta prima di ogni requireToken: /panel/<cella>/ticket.
220
+ if (req.method === 'POST' && tail.length === 1 && tail[0] === 'ticket') {
221
+ void handleTicketRequest(req, res, cellId);
222
+ return;
223
+ }
224
+
225
+ // Il ticket ha PRECEDENZA sul Bearer: la richiesta federata dell'iframe
226
+ // arriva qui con il Bearer dell'hop (il token del nodo, iniettato dalla
227
+ // via federata) ACCANTO al ticket in query — e deve entrare da iframe,
228
+ // consumando il ticket e prendendo il cookie, non da PWA. Chi porta un
229
+ // ticket sta facendo l'ingresso del frame; la PWA non lo mette mai in query.
230
+ let qTicket = null;
231
+ const qi = url.indexOf('?');
232
+ if (qi !== -1) qTicket = new URLSearchParams(url.slice(qi + 1)).get('ticket');
233
+ if (qTicket) {
234
+ if (consumeTicket(qTicket, cellId)) {
235
+ const value = issueCookie(cellId);
236
+ res.setHeader('set-cookie', cookieHeaderValue(cellId, value));
237
+ log({ event: 'panel-auth', outcome: 'ticket-consumed', cell: cellId });
238
+ return next();
239
+ }
240
+ log({ event: 'panel-auth', outcome: 'denied', reason: 'ticket-invalid', cell: cellId });
241
+ res.writeHead(401, { 'content-type': 'application/json' });
242
+ res.end(JSON.stringify({ error: 'ticket non valido' }));
243
+ return;
244
+ }
245
+
246
+ // Il Bearer apre il pannello SOLO da locale. Sulla via federata e' il
247
+ // token che il proxy ha iniettato da se' un istante prima: accettarlo
248
+ // significherebbe che chiunque raggiunga la route — e la route del
249
+ // pannello transita prima del requireToken, perche' un iframe non porta
250
+ // header — si porta via il contenuto. Di la' restano il ticket e il
251
+ // cookie, che questo nodo ha emesso e sa riconoscere.
252
+ const bearer = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
253
+ if (provenienza === 'locale' && verifyToken(bearer)) return next();
254
+
255
+ const cookieValue = parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
256
+ if (cookieValue && verifyCookie(cookieValue, cellId)) return next();
257
+
258
+ log({
259
+ event: 'panel-auth', outcome: 'denied', cell: cellId,
260
+ reason: provenienza === 'federata' ? 'federated-needs-ticket' : 'unauthorized',
261
+ });
262
+ res.writeHead(401, { 'content-type': 'application/json' });
263
+ res.end(JSON.stringify({ error: 'unauthorized' }));
264
+ }
265
+
266
+ // —— Auth per l'upgrade WebSocket del pannello. Le WS del pannello partono
267
+ // dalla pagina dentro il frame e portano il cookie della NOSTRA origine
268
+ // (same-site): il cookie di visione deve aprirle, altrimenti il pannello
269
+ // carica l'HTML e resta nero. Il Bearer e il ?token= della PWA restano
270
+ // validi come prima. Il ticket in query NON si usa qui: il flusso dell'iframe
271
+ // ha gia' il cookie quando la pagina apre la sua prima socket.
272
+ //
273
+ // Lo stesso confine dell'HTTP vale QUI, e va scritto qui: l'upgrade non
274
+ // passa dal middleware — forwardUpgrade e' un percorso separato — e per un
275
+ // pannello e' la porta che conta, perche' i frame arrivano da questa.
276
+ function authorizeUpgrade(req) {
277
+ const url = String(req.url || '');
278
+ const provenienza = hopKind(req);
279
+ if (provenienza === 'sospetta') return false;
280
+ const bearer = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
281
+ if (provenienza === 'locale' && verifyToken(bearer)) return true;
282
+ const qi = url.indexOf('?');
283
+ if (provenienza === 'locale' && qi !== -1
284
+ && verifyToken(new URLSearchParams(url.slice(qi + 1)).get('token') || '')) return true;
285
+ const cellId = cellFromPanelPath(url.replace(/^\/api\/panel/, ''));
286
+ if (!cellId) return false;
287
+ const cookieValue = parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
288
+ return !!(cookieValue && verifyCookie(cookieValue, cellId));
289
+ }
290
+
291
+ return {
292
+ panelAuthMiddleware,
293
+ authorizeUpgrade,
294
+ // Per test e diagnostica: verifiche dall'esterno senza passare dal wire.
295
+ consumeTicketForTest: consumeTicket,
296
+ verifyCookieForTest: verifyCookie,
297
+ issueCookieForTest: issueCookie,
298
+ cookieHeaderValueForTest: cookieHeaderValue,
299
+ };
300
+ }
301
+
302
+ module.exports = {
303
+ createPanelAuth,
304
+ TICKET_TTL_MS,
305
+ COOKIE_TTL_MS,
306
+ COOKIE_NAME,
307
+ };
@@ -0,0 +1,305 @@
1
+ 'use strict';
2
+ // lib/proxy/panel-proxy.js — inoltro del pannello di UNA cella locale.
3
+ //
4
+ // Perche' esiste: `panelUrl` e' un endpoint su loopback (es. il desktop in
5
+ // container). L'iframe della PWA risolve `127.0.0.1` sul browser di CHI GUARDA,
6
+ // quindi il pannello di una cella si vede solo stando sulla stessa macchina.
7
+ // Questa route mette il traffico dalla parte giusta del loopback.
8
+ //
9
+ // TRE VINCOLI, che sono il disegno e non dettagli:
10
+ //
11
+ // 1. LA DESTINAZIONE NON ARRIVA MAI DAL CHIAMANTE. Si risolve dal `panelUrl`
12
+ // della cella indicata, e quella cella deve essere LOCALE. Non e' un
13
+ // port-forward: un cellId sconosciuto o senza pannello e' un rifiuto, non un
14
+ // default. Chi chiama sceglie QUALE cella, mai VERSO DOVE.
15
+ //
16
+ // 2. IL TOKEN DI NEXUSCREW NON ESCE DA QUI. Il pannello non e' un nodo della
17
+ // federazione: e' un servizio terzo che gira accanto. Inoltrargli la nostra
18
+ // Authorization sarebbe consegnare la credenziale del control plane a un
19
+ // container. L'header viene rimosso, e l'eventuale 401 del pannello resta
20
+ // suo — l'autenticazione al contenuto avviene dentro il frame.
21
+ //
22
+ // 3. IL VALIDATORE E' QUELLO, NON UN SECONDO. `validPanelUrl` e' importato da
23
+ // fleet/definitions.js, la stessa funzione che accetta il campo quando viene
24
+ // scritto. Riscriverne una copia qui creerebbe due decisioni sullo stesso
25
+ // fatto, e la seconda divergerebbe: e' il difetto che passiamo le giornate a
26
+ // chiudere.
27
+ //
28
+ // Certificato self-signed: il container tipico serve HTTPS con un certificato
29
+ // che nessuno ha firmato, e non possiamo installarlo nel trust store della
30
+ // macchina di chi ci ospita. Verso di lui la verifica e' disattivata di
31
+ // proposito, ma SOLO se la destinazione e' loopback — e la condizione viene
32
+ // ricontrollata qui contro la stessa lista che autorizza il campo, non data per
33
+ // scontata dal validatore. Fra noi e una porta della stessa macchina non c'e'
34
+ // un uomo in mezzo da temere; verso qualunque altro host la verifica resta
35
+ // attiva e il collegamento fallisce invece di degradare in silenzio. In cambio, il browser del visualizzatore parla solo con la NOSTRA
36
+ // origine: il frame smette di restare bianco in attesa che qualcuno accetti
37
+ // quel certificato in una scheda separata.
38
+
39
+ const http = require('node:http');
40
+ const https = require('node:https');
41
+ const { validPanelUrl, PANELURL_LOOPBACK_HOSTS } = require('../fleet/definitions.js');
42
+ const { CELL_ID_RE } = require('../live-host/store.js');
43
+
44
+ const PANEL_TIMEOUT_MS = 30000;
45
+
46
+ // Hop-by-hop (RFC 7230 §6.1) + Proxy-*: mai inoltrati end-to-end.
47
+ const HOP_BY_HOP = new Set([
48
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
49
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
50
+ ]);
51
+
52
+ // `/api/panel/<cellId>` oppure `/api/panel/<cellId>/<rest...>`.
53
+ function splitPanelPath(url) {
54
+ const raw = String(url || '');
55
+ const qIndex = raw.indexOf('?');
56
+ const pathname = qIndex === -1 ? raw : raw.slice(0, qIndex);
57
+ const search = qIndex === -1 ? '' : raw.slice(qIndex);
58
+ const parts = pathname.split('/').filter(Boolean);
59
+ if (!parts.length) return null;
60
+ const cellId = decodeURIComponent(parts[0]);
61
+ const tail = parts.slice(1);
62
+ // Un `..` verrebbe inoltrato cosi' com'e' e normalizzato dal pannello: non
63
+ // cambia host ne' porta, ma e' comunque un percorso che il chiamante disegna
64
+ // dentro il pannello. Si ferma qui, in entrambe le forme in cui puo' arrivare.
65
+ for (const seg of tail) {
66
+ const decoded = (() => { try { return decodeURIComponent(seg); } catch (_) { return seg; } })();
67
+ if (seg === '..' || decoded === '..') return null;
68
+ }
69
+ const rest = tail.length ? `/${tail.join('/')}` : '/';
70
+ return { cellId, rest, search };
71
+ }
72
+
73
+ // Il token locale puo' viaggiare in query (il browser non puo' mettere header
74
+ // sull'upgrade WebSocket), e cosi' il ticket d'ingresso dell'iframe: nessuno
75
+ // dei due deve proseguire verso il pannello.
76
+ function stripLocalTokenQuery(search) {
77
+ if (!search) return '';
78
+ const params = new URLSearchParams(search.startsWith('?') ? search.slice(1) : search);
79
+ params.delete('token');
80
+ params.delete('ticket');
81
+ const out = params.toString();
82
+ return out ? `?${out}` : '';
83
+ }
84
+
85
+ // Gli stessi header che il proxy verso i nodi non inoltra. Avevo lasciato
86
+ // passare i cookie con la motivazione «sono del pannello»: era **falsa**.
87
+ // Dietro questo proxy l'origine e' la NOSTRA, quindi il browser manda i cookie
88
+ // del nostro dominio — inoltrarli significherebbe consegnare al container la
89
+ // sessione del control plane, esattamente cio' che l'Authorization rimossa
90
+ // doveva impedire. E `x-forwarded-*` da un client sono valori che un pannello
91
+ // potrebbe credere veri. Rilievo di un audit indipendente.
92
+ function isStrippedRequestHeader(key) {
93
+ if (HOP_BY_HOP.has(key)) return true;
94
+ if (key === 'authorization' || key === 'cookie' || key === 'host') return true;
95
+ // `referer` porterebbe il ticket d'ingresso (viaggiava in query sulla prima
96
+ // richiesta dell'iframe) fino al pannello: al container non deve arrivare
97
+ // NESSUNA credenziale, nemmeno di seconda mano.
98
+ if (key === 'referer') return true;
99
+ if (key.startsWith('proxy-')) return true;
100
+ if (key === 'forwarded' || key.startsWith('x-forwarded-')) return true;
101
+ return false;
102
+ }
103
+
104
+ function forwardHeaders(headers, targetHost) {
105
+ const out = {};
106
+ for (const [k, v] of Object.entries(headers || {})) {
107
+ if (isStrippedRequestHeader(k.toLowerCase())) continue;
108
+ out[k] = v;
109
+ }
110
+ // L'host deve essere quello della destinazione: un pannello che genera
111
+ // redirect assoluti li costruisce da qui.
112
+ out.host = targetHost;
113
+ return out;
114
+ }
115
+
116
+ function responseHeaders(headers) {
117
+ const out = {};
118
+ for (const [k, v] of Object.entries(headers || {})) {
119
+ if (HOP_BY_HOP.has(k.toLowerCase())) continue;
120
+ out[k] = v;
121
+ }
122
+ return out;
123
+ }
124
+
125
+ // Risolve la destinazione, o dice PERCHE' non si puo'. I motivi restano
126
+ // distinti: «cella sconosciuta», «cella senza pannello» e «pannello non valido»
127
+ // mandano chi indaga in tre posti diversi, e un rifiuto unico li confonderebbe.
128
+ async function resolveTarget(resolveCellPanel, cellId) {
129
+ if (!CELL_ID_RE.test(cellId)) return { ok: false, reason: 'cell-id-invalid' };
130
+ let panelUrl;
131
+ try { panelUrl = await resolveCellPanel(cellId); } catch (_) { return { ok: false, reason: 'fleet-unavailable' }; }
132
+ if (panelUrl === null) return { ok: false, reason: 'fleet-unavailable' };
133
+ if (panelUrl === undefined) return { ok: false, reason: 'cell-unknown' };
134
+ if (panelUrl === '') return { ok: false, reason: 'no-panel' };
135
+ // Stesso validatore della scrittura: se un valore e' finito nello stato per
136
+ // un'altra strada, qui viene fermato comunque.
137
+ if (!validPanelUrl(panelUrl)) return { ok: false, reason: 'panel-url-invalid' };
138
+ const parsed = new URL(panelUrl);
139
+ // Guardia in profondita': la verifica TLS viene disattivata SOLO se la
140
+ // destinazione e' loopback, e questa riga lo ricontrolla contro la stessa
141
+ // lista che autorizza il campo. Se un giorno il validatore ammettesse un host
142
+ // remoto, la disattivazione NON lo seguirebbe: si parlerebbe in TLS
143
+ // verificato, o si fallirebbe — mai in chiaro con un ignoto.
144
+ const loopback = PANELURL_LOOPBACK_HOSTS.has(parsed.hostname);
145
+ return {
146
+ ok: true,
147
+ loopback,
148
+ secure: parsed.protocol === 'https:',
149
+ host: parsed.hostname,
150
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
151
+ hostHeader: parsed.host,
152
+ basePath: parsed.pathname.replace(/\/$/, ''),
153
+ };
154
+ }
155
+
156
+ function joinPath(basePath, rest) {
157
+ if (!basePath) return rest;
158
+ return rest === '/' ? `${basePath}/` : `${basePath}${rest}`;
159
+ }
160
+
161
+ // Risponde con o senza Express. Il resto del progetto monta le route su un
162
+ // router Express e usa `res.status().json()`, ma questo modulo e' un pezzo di
163
+ // trasporto: farlo dipendere dal framework significa che si rompe appena lo si
164
+ // usa altrove — ed e' successo alla prima prova con un server nudo, mentre la
165
+ // suite mockata non poteva accorgersene perche' il finto offriva `status()`.
166
+ function respond(res, code, body) {
167
+ const payload = JSON.stringify(body);
168
+ if (typeof res.status === 'function' && typeof res.json === 'function') {
169
+ res.status(code).json(body);
170
+ return;
171
+ }
172
+ res.writeHead(code, { 'content-type': 'application/json' });
173
+ res.end(payload);
174
+ }
175
+
176
+ function createPanelProxy({ resolveCellPanel, log = () => {}, requestImpl }) {
177
+ return async function panelProxy(req, res) {
178
+ const parsed = splitPanelPath(req.url);
179
+ if (!parsed) {
180
+ log({ event: 'panel-proxy', outcome: 'rejected', reason: 'no-cell', cell: '' });
181
+ return respond(res, 404, { error: 'pannello non trovato' });
182
+ }
183
+ const target = await resolveTarget(resolveCellPanel, parsed.cellId);
184
+ if (!target.ok) {
185
+ log({ event: 'panel-proxy', outcome: 'rejected', reason: target.reason, cell: parsed.cellId });
186
+ return respond(res, 404, { error: `pannello non disponibile: ${target.reason}` });
187
+ }
188
+ const request = requestImpl || (target.secure ? https.request : http.request);
189
+ const options = {
190
+ host: target.host,
191
+ port: target.port,
192
+ method: req.method,
193
+ path: `${joinPath(target.basePath, parsed.rest)}${stripLocalTokenQuery(parsed.search)}`,
194
+ headers: forwardHeaders(req.headers, target.hostHeader),
195
+ ...(target.secure ? { rejectUnauthorized: !(target.loopback) } : {}),
196
+ };
197
+ let upstream;
198
+ try {
199
+ upstream = request(options, (up) => {
200
+ res.writeHead(up.statusCode, responseHeaders(up.headers));
201
+ up.pipe(res);
202
+ });
203
+ } catch (_) {
204
+ log({ event: 'panel-proxy', outcome: 'error', reason: 'request-failed', cell: parsed.cellId });
205
+ if (!res.headersSent) respond(res, 502, { error: 'pannello non raggiungibile' });
206
+ return;
207
+ }
208
+ upstream.setTimeout(PANEL_TIMEOUT_MS, () => upstream.destroy(new Error('panel timeout')));
209
+ upstream.on('error', () => {
210
+ log({ event: 'panel-proxy', outcome: 'error', reason: 'upstream-error', cell: parsed.cellId });
211
+ if (!res.headersSent) respond(res, 502, { error: 'pannello non raggiungibile' });
212
+ else res.destroy();
213
+ });
214
+ req.on('aborted', () => upstream.destroy());
215
+ log({ event: 'panel-proxy', outcome: 'forwarded', cell: parsed.cellId });
216
+ req.pipe(upstream);
217
+ };
218
+ }
219
+
220
+ // Upgrade WebSocket. Un pannello che si apre senza i suoi frame e' nero: un
221
+ // inoltro solo-HTTP darebbe esattamente quello, con l'aria di funzionare.
222
+ //
223
+ // `authorize(req, url)` decide chi apre l'upgrade ed e' il posto del cookie di
224
+ // visione: le WS del pannello partono dalla pagina nel frame, con URL relativi
225
+ // e senza query — portano il cookie, non possono portare altro. Con il solo
226
+ // `verifyToken` (compatibilita' con i test) resta il vecchio contratto:
227
+ // Bearer oppure ?token=.
228
+ function handlePanelUpgrade({ req, socket, head, resolveCellPanel, verifyToken, authorize, log = () => {}, requestImpl }) {
229
+ // Un upgrade rifiutato deve RISPONDERE prima di chiudere. Chiudere e basta
230
+ // lascia il client in attesa finche' non decide lui di rinunciare: dal lato
231
+ // di chi guarda e' un pannello che non arriva mai, indistinguibile da uno
232
+ // lento. Trovato da una prova con socket veri — la suite mockata non poteva
233
+ // vederlo, perche' li' nessuno aspettava una risposta.
234
+ const kill = (code = 400, motivo = 'Bad Request') => {
235
+ try { socket.end(`HTTP/1.1 ${code} ${motivo}\r\nConnection: close\r\n\r\n`); } catch (_) {}
236
+ try { socket.destroy(); } catch (_) {}
237
+ };
238
+ let url;
239
+ try { url = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return kill(400, 'Bad Request'); }
240
+ const allowed = typeof authorize === 'function'
241
+ ? authorize(req, url)
242
+ : (() => {
243
+ const bearer = (req.headers.authorization || '').replace(/^Bearer\s+/i, '');
244
+ const given = bearer || url.searchParams.get('token') || '';
245
+ return verifyToken(given);
246
+ })();
247
+ if (!allowed) {
248
+ log({ event: 'panel-proxy', outcome: 'rejected', reason: 'unauthorized', cell: '' });
249
+ return kill(401, 'Unauthorized');
250
+ }
251
+ const parsed = splitPanelPath(req.url.replace(/^\/api\/panel/, ''));
252
+ if (!parsed) return kill(404, 'Not Found');
253
+ Promise.resolve(resolveTarget(resolveCellPanel, parsed.cellId)).then((target) => {
254
+ if (!target.ok) {
255
+ log({ event: 'panel-proxy', outcome: 'rejected', reason: target.reason, cell: parsed.cellId });
256
+ return kill(404, 'Not Found');
257
+ }
258
+ const request = requestImpl || (target.secure ? https.request : http.request);
259
+ const headers = forwardHeaders(req.headers, target.hostHeader);
260
+ headers.connection = 'Upgrade';
261
+ headers.upgrade = 'websocket';
262
+ let upstream;
263
+ try {
264
+ upstream = request({
265
+ host: target.host,
266
+ port: target.port,
267
+ method: 'GET',
268
+ path: `${joinPath(target.basePath, parsed.rest)}${stripLocalTokenQuery(parsed.search)}`,
269
+ headers,
270
+ ...(target.secure ? { rejectUnauthorized: !(target.loopback) } : {}),
271
+ });
272
+ } catch (_) { return kill(502, 'Bad Gateway'); }
273
+ upstream.on('upgrade', (upRes, upSocket, upHead) => {
274
+ const lines = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage}`];
275
+ for (const [k, v] of Object.entries(upRes.headers)) lines.push(`${k}: ${v}`);
276
+ try {
277
+ socket.write(`${lines.join('\r\n')}\r\n\r\n`);
278
+ if (upHead && upHead.length) socket.write(upHead);
279
+ if (head && head.length) upSocket.write(head);
280
+ } catch (_) { return kill(502, 'Bad Gateway'); }
281
+ log({ event: 'panel-proxy', outcome: 'upgraded', cell: parsed.cellId });
282
+ upSocket.pipe(socket);
283
+ socket.pipe(upSocket);
284
+ const close = () => {
285
+ try { upSocket.destroy(); } catch (_) {}
286
+ try { socket.destroy(); } catch (_) {} // qui l'upgrade e' gia' avvenuto: niente risposta HTTP
287
+ };
288
+ upSocket.on('error', close); socket.on('error', close);
289
+ upSocket.on('close', close); socket.on('close', close);
290
+ });
291
+ // Il pannello ha risposto senza accettare l'upgrade: non e' un WebSocket,
292
+ // e fingere il contrario lascerebbe il frame in attesa per sempre.
293
+ upstream.on('response', () => {
294
+ log({ event: 'panel-proxy', outcome: 'rejected', reason: 'upgrade-refused', cell: parsed.cellId });
295
+ kill(502, 'Bad Gateway');
296
+ });
297
+ upstream.on('error', () => {
298
+ log({ event: 'panel-proxy', outcome: 'error', reason: 'upstream-error', cell: parsed.cellId });
299
+ kill(502, 'Bad Gateway');
300
+ });
301
+ upstream.end();
302
+ }).catch(() => kill(500, 'Internal Server Error'));
303
+ }
304
+
305
+ module.exports = { createPanelProxy, handlePanelUpgrade, splitPanelPath, resolveTarget };