@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.
- package/CHANGELOG.md +164 -2
- package/README.md +1 -0
- package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
- package/frontend/dist/assets/index-zjL6kZ7J.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 +40 -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 +15 -0
- package/lib/fleet/builtin.js +161 -19
- package/lib/fleet/catalogs/opencode-go.json +328 -0
- 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 +444 -55
- 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 +184 -0
- package/lib/live-host/store.js +96 -0
- package/lib/mcp/tools.js +51 -0
- package/lib/nodes/commands.js +9 -2
- package/lib/nodes/store.js +14 -0
- package/lib/nodes/tunnel.js +4 -1
- package/lib/proxy/federation.js +106 -9
- package/lib/proxy/node-proxy.js +33 -0
- package/lib/proxy/panel-auth.js +307 -0
- package/lib/proxy/panel-proxy.js +305 -0
- package/lib/server.js +127 -4
- package/package.json +1 -1
- package/skills/alibaba-token-media/SKILL.md +19 -0
- package/skills/crew/SKILL.md +15 -0
- package/skills/fill-forms/SKILL.md +23 -0
- package/skills/mail-assistant/SKILL.md +15 -0
- package/skills/memory/SKILL.md +15 -0
- package/skills/nexuscrew-agent/SKILL.md +18 -0
- package/skills/vl-msa/SKILL.md +15 -0
- package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
- package/frontend/dist/assets/index-_c-1_3iR.js +0 -93
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,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 };
|