@mmmbuto/nexuscrew 0.9.0 → 0.9.2
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 +91 -0
- package/README.md +22 -4
- package/frontend/dist/assets/index-uRh_hSop.js +93 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +14 -0
- package/lib/config.js +9 -0
- package/lib/fleet/builtin.js +57 -2
- package/lib/live-host/routes.js +9 -3
- package/lib/nodes/commands.js +15 -4
- package/lib/nodes/store.js +43 -0
- package/lib/nodes/tunnel.js +19 -0
- package/lib/proxy/federation.js +25 -1
- package/lib/proxy/panel-auth.js +63 -33
- package/lib/proxy/panel-proxy.js +41 -10
- package/lib/server.js +128 -6
- package/lib/settings/pairing-coordinator.js +32 -0
- package/lib/settings/public-peering-routes.js +13 -1
- package/package.json +1 -1
- package/skills/aidesktop/SKILL.md +201 -0
- package/skills/aidesktop/docker/Dockerfile +21 -0
- package/skills/aidesktop/docker/custom-cont-init.d/10-cdp-relay.sh +20 -0
- package/skills/aidesktop/docker/docker-compose.example.yml +75 -0
- package/skills/live/SKILL.md +90 -0
- package/skills/nexuscrew/SKILL.md +113 -0
- package/frontend/dist/assets/index-zjL6kZ7J.js +0 -93
- package/skills/alibaba-token-media/SKILL.md +0 -152
- package/skills/alibaba-token-media/agents/openai.yaml +0 -4
- package/skills/alibaba-token-media/references/api-contract.md +0 -97
- package/skills/alibaba-token-media/scripts/alibaba_token_media.py +0 -550
- package/skills/fill-forms/SKILL.md +0 -177
- package/skills/fill-forms/agents/openai.yaml +0 -4
- package/skills/fill-forms/references/overlay-technique.md +0 -99
- package/skills/fill-forms/requirements.txt +0 -4
- package/skills/fill-forms/scripts/dump_docx.py +0 -70
- package/skills/fill-forms/scripts/fill_docx.py +0 -207
- package/skills/fill-forms/scripts/fill_pdf.py +0 -424
- package/skills/fill-forms/scripts/inspect_pdf.py +0 -188
- package/skills/fill-forms/scripts/prepare_signature.py +0 -171
package/frontend/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-uRh_hSop.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-0vuhL1YP.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.9.
|
|
1
|
+
{"version":"0.9.2"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -66,6 +66,8 @@ Usage:
|
|
|
66
66
|
nexuscrew nodes cells <name|nodeId> all|none|Cella1,Cella2
|
|
67
67
|
[--selected NODE_ID,...]
|
|
68
68
|
nexuscrew nodes share <name|nodeId> on|off [--json]
|
|
69
|
+
nexuscrew nodes panel <name|nodeId> on|off
|
|
70
|
+
nexuscrew nodes live-host <name|nodeId> on|off
|
|
69
71
|
nexuscrew nodes invite --ssh TARGET [--ssh-port PORT] [--name SLUG]
|
|
70
72
|
[--label TEXT] [--json]
|
|
71
73
|
printf '%s' "$PAIRING_URL" | nexuscrew nodes pair|join
|
|
@@ -1167,6 +1169,18 @@ async function dispatchNodes(rest, flags, opts = {}) {
|
|
|
1167
1169
|
}
|
|
1168
1170
|
return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { panelAccess: arg === 'on' } }).code };
|
|
1169
1171
|
}
|
|
1172
|
+
// Designazione della cella ospite Live (0.9.1): chi puo' dire quale cella di
|
|
1173
|
+
// QUESTO nodo fa da host Live. Stessa forma di `panel`, stessa ragione:
|
|
1174
|
+
// default negato, si concede un nodo per volta, mai per anzianita' del
|
|
1175
|
+
// pairing.
|
|
1176
|
+
if (sub === 'live-host') {
|
|
1177
|
+
const arg = rest[3];
|
|
1178
|
+
if (arg !== 'on' && arg !== 'off') {
|
|
1179
|
+
log('nodes live-host: uso `nodes live-host <nodo> on|off`');
|
|
1180
|
+
return { code: 1 };
|
|
1181
|
+
}
|
|
1182
|
+
return { code: nodesCmds.nodesEdit({ ...opts, log, ref, patch: { liveHostAccess: arg === 'on' } }).code };
|
|
1183
|
+
}
|
|
1170
1184
|
if (sub === 'remove') {
|
|
1171
1185
|
if (flags.yes !== true) { log('nodes remove: conferma richiesta con --yes'); return { code: 1 }; }
|
|
1172
1186
|
return { code: nodesCmds.nodesRemove({ ...opts, log, ref }).code };
|
package/lib/config.js
CHANGED
|
@@ -17,6 +17,14 @@ function baseDefaults() {
|
|
|
17
17
|
return {
|
|
18
18
|
bind: '127.0.0.1',
|
|
19
19
|
port: 41820,
|
|
20
|
+
// Seconda porta loopback, SOLO per /panel/* (P0 sicurezza 2026-08-16):
|
|
21
|
+
// un pannello vive su un'origin diversa dal control plane — la porta fa
|
|
22
|
+
// parte dell'origin, quindi un iframe cross-porta non legge il
|
|
23
|
+
// localStorage del padre, qualunque JS esegua il container. Nessuna API
|
|
24
|
+
// di controllo e nessun token verificato su questa porta: solo ticket
|
|
25
|
+
// monouso + cookie di visione (lib/proxy/panel-auth.js), che restano
|
|
26
|
+
// esattamente come sono — si spostano, non si riprogettano.
|
|
27
|
+
panelPort: 41821,
|
|
20
28
|
tokenPath: path.join(os.homedir(), '.nexuscrew', 'token'),
|
|
21
29
|
tmuxBin: 'tmux',
|
|
22
30
|
// Shared-server safety is operational hardening, not a same-UID security
|
|
@@ -79,6 +87,7 @@ function readConfigJson() {
|
|
|
79
87
|
function envOverrides() {
|
|
80
88
|
const e = {};
|
|
81
89
|
if (process.env.NEXUSCREW_PORT) e.port = Number(process.env.NEXUSCREW_PORT);
|
|
90
|
+
if (process.env.NEXUSCREW_PANEL_PORT) e.panelPort = Number(process.env.NEXUSCREW_PANEL_PORT);
|
|
82
91
|
if (process.env.NEXUSCREW_TOKEN_FILE) e.tokenPath = process.env.NEXUSCREW_TOKEN_FILE;
|
|
83
92
|
if (process.env.NEXUSCREW_TMUX) e.tmuxBin = process.env.NEXUSCREW_TMUX;
|
|
84
93
|
if (process.env.NEXUSCREW_PROTECT_SHARED_TMUX_SERVER !== undefined) {
|
package/lib/fleet/builtin.js
CHANGED
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
// mutazione fleet e ogni up (§9d): passano solo status/schema/capabilities.
|
|
27
27
|
// - promptMode 'send-keys' inietta via bracketed paste; se il command e' gia'
|
|
28
28
|
// uscito (sessione morta) NON digita (§9e).
|
|
29
|
+
const fs = require('node:fs');
|
|
29
30
|
const os = require('node:os');
|
|
30
31
|
const path = require('node:path');
|
|
31
32
|
const {
|
|
@@ -127,11 +128,41 @@ function backfillAgyEngine(defsPath, defs, cfg = {}) {
|
|
|
127
128
|
// meccanismo condiviso (validPanelUrl in definitions.js): l'engine non ha una
|
|
128
129
|
// sua strada per imporre l'URL, dichiara solo il valore, e resta sovrascrivibile
|
|
129
130
|
// per-cella come qualunque altro panelUrl (vedi parseCell).
|
|
131
|
+
// Risolve un eseguibile del PATH nel suo path assoluto REALE. Il realpath non
|
|
132
|
+
// e' pignoleria: `validateCommandTrust` usa `lstat` e rifiuta i symlink — e in
|
|
133
|
+
// molte installazioni il primo hit nel PATH e' proprio un symlink. Senza questo
|
|
134
|
+
// passaggio l'engine sarebbe rifiutato dalla stessa trust boundary del
|
|
135
|
+
// progetto, con un messaggio che parla di path assoluti mentre il problema e'
|
|
136
|
+
// un link.
|
|
137
|
+
function risolviEseguibile(nome) {
|
|
138
|
+
const dirs = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
|
|
139
|
+
for (const dir of dirs) {
|
|
140
|
+
try {
|
|
141
|
+
const reale = fs.realpathSync(path.join(dir, nome));
|
|
142
|
+
const st = fs.statSync(reale);
|
|
143
|
+
if (st.isFile() && (st.mode & 0o100)) return reale;
|
|
144
|
+
} catch (_) { /* voce del PATH inservibile: si prova la prossima */ }
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Il command DEVE essere un path assoluto: `validateCommandTrust` e' la trust
|
|
150
|
+
// boundary degli engine, e un nome relativo si risolverebbe via PATH — cioe'
|
|
151
|
+
// via qualcosa che l'ambiente puo' cambiare sotto di noi. Dichiarare 'docker'
|
|
152
|
+
// rendeva questo engine built-in NON avviabile: veniva offerto nell'elenco e
|
|
153
|
+
// rifiutato al salvataggio con «command deve essere un path assoluto», un
|
|
154
|
+
// errore che descrive la forma del valore invece di dire che il comando non
|
|
155
|
+
// era stato risolto.
|
|
156
|
+
//
|
|
157
|
+
// Se docker non e' installato si ricade sul path convenzionale: l'engine resta
|
|
158
|
+
// visibile e il rifiuto diventa «non accessibile (ENOENT)», che nomina la
|
|
159
|
+
// causa vera — docker manca — invece di sembrare un difetto di
|
|
160
|
+
// configurazione.
|
|
130
161
|
function defaultDesktopEngine() {
|
|
131
162
|
return {
|
|
132
163
|
id: 'desktop.local',
|
|
133
164
|
label: 'Desktop',
|
|
134
|
-
command: 'docker',
|
|
165
|
+
command: risolviEseguibile('docker') || '/usr/bin/docker',
|
|
135
166
|
args: ['exec', '-it', '-u', 'abc', 'ai-desktop', 'bash'],
|
|
136
167
|
promptMode: 'send-keys',
|
|
137
168
|
panelUrl: 'https://127.0.0.1:6901',
|
|
@@ -161,13 +192,37 @@ function defaultDesktopEngine() {
|
|
|
161
192
|
// Resta MANUALE: chi ha il container se lo aggiunge nella propria procedura
|
|
162
193
|
// — e' il posto dove qualcuno sa gia' che il container esiste.
|
|
163
194
|
function backfillDesktopEngine(defsPath, defs) {
|
|
164
|
-
if (!defs
|
|
195
|
+
if (!defs) return defs;
|
|
196
|
+
const esistente = defs.engines.find((engine) => engine.id === 'desktop.local');
|
|
197
|
+
if (esistente) return riparaDesktopEngine(defsPath, defs, esistente);
|
|
165
198
|
if (defs.engines.length >= CAPS.MAX_ENGINES) return defs;
|
|
166
199
|
const draft = draftFrom(defs);
|
|
167
200
|
draft.engines.push(defaultDesktopEngine());
|
|
168
201
|
try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
|
|
169
202
|
}
|
|
170
203
|
|
|
204
|
+
// Le installazioni che hanno gia' ricevuto il backfill portano in
|
|
205
|
+
// configurazione un `command` RELATIVO, che la trust boundary rifiuta: per
|
|
206
|
+
// loro l'aggiornamento del default non cambia nulla, perche' il backfill salta
|
|
207
|
+
// cio' che esiste gia'. Senza questa riparazione il difetto resterebbe
|
|
208
|
+
// esattamente dove e' stato visto — su una macchina gia' configurata.
|
|
209
|
+
//
|
|
210
|
+
// Prudente per costruzione: interviene SOLO se il comando non e' assoluto e si
|
|
211
|
+
// chiama ancora `docker`. Un path assoluto — o un comando che l'utente ha
|
|
212
|
+
// cambiato in altro — non viene toccato: quella e' una scelta, non un residuo.
|
|
213
|
+
function riparaDesktopEngine(defsPath, defs, esistente) {
|
|
214
|
+
const cmd = typeof esistente.command === 'string' ? esistente.command : '';
|
|
215
|
+
if (!cmd || path.isAbsolute(cmd)) return defs;
|
|
216
|
+
if (path.basename(cmd) !== 'docker') return defs;
|
|
217
|
+
const risolto = risolviEseguibile('docker');
|
|
218
|
+
if (!risolto) return defs; // niente docker qui: meglio invariato che finto
|
|
219
|
+
const draft = draftFrom(defs);
|
|
220
|
+
const voce = draft.engines.find((engine) => engine.id === 'desktop.local');
|
|
221
|
+
if (!voce) return defs;
|
|
222
|
+
voce.command = risolto;
|
|
223
|
+
try { return atomicWrite(defsPath, draft); } catch (_) { return defs; }
|
|
224
|
+
}
|
|
225
|
+
|
|
171
226
|
// Backfill dell'engine Kimi Code CLI nativo: installazioni esistenti ricevono
|
|
172
227
|
// kimi.native in modo idempotente e non distruttivo. Nessun platform gate: il
|
|
173
228
|
// CLI gira ovunque giri Node e su Termux il resolver applica gia' il workaround
|
package/lib/live-host/routes.js
CHANGED
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
//
|
|
4
4
|
// Montate dietro requireToken (come /api/cells, /api/fleet): solo il token del nodo
|
|
5
5
|
// passa. NON sono proxabili via /node/<name>: il blocklist local-only del proxy nega
|
|
6
|
-
// /api/live-host (
|
|
7
|
-
//
|
|
6
|
+
// /api/live-host li' (v. lib/proxy/node-proxy.js). Sono pero' raggiungibili via
|
|
7
|
+
// /api/route (0.9.1), la via allowlistata della federazione, dietro un permesso
|
|
8
|
+
// per-peer negato di default (liveHostAccess, v. lib/proxy/federation.js) — sullo
|
|
9
|
+
// stesso modello del pannello: concesso dal nodo che POSSIEDE la cella, mai da chi
|
|
10
|
+
// chiede. Senza quel permesso il peer riceve un rifiuto che nomina la causa
|
|
11
|
+
// (`live-host-not-granted`), non un silenzio.
|
|
8
12
|
//
|
|
9
13
|
// Invarianti (contratto rev6 §2, §9):
|
|
10
14
|
// - hostCell unico per nodo, CAS su revision (due designazioni concorrenti non
|
|
@@ -96,7 +100,9 @@ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Dat
|
|
|
96
100
|
// POST /api/live-host/designate { cellId, expectedRevision }.
|
|
97
101
|
// expectedRevision e' OBBLIGATORIO e integer (>=0): nessun CAS permissivo, la UI
|
|
98
102
|
// legge sempre la revision dal GET prima di scrivere (stato iniziale = 0).
|
|
99
|
-
// cellId deve appartenere al roster
|
|
103
|
+
// cellId deve appartenere al roster LOCALE di questo nodo — chiamante locale
|
|
104
|
+
// o peer federato con liveHostAccess, la cella designabile e' sempre e solo
|
|
105
|
+
// una di QUESTO nodo, mai una del chiamante.
|
|
100
106
|
r.post('/designate', express.json({ limit: '4kb' }), async (req, res) => {
|
|
101
107
|
if (readonly()) return res.status(403).json({ error: 'READONLY: designazione cella ospite bloccata' });
|
|
102
108
|
const body = req.body || {};
|
package/lib/nodes/commands.js
CHANGED
|
@@ -99,8 +99,14 @@ function bindLocalPort(port, createServerImpl = net.createServer) {
|
|
|
99
99
|
// Selezione OS-aware per il pairing: nodes.json evita collisioni logiche, il
|
|
100
100
|
// bind reale evita porte gia' occupate da processi estranei. La socket resta
|
|
101
101
|
// riservata fino all'avvio del supervisor SSH e viene poi rilasciata una volta.
|
|
102
|
+
// Ogni LISTENER locale conta: localPort di controllo e panelLocalPort inoltrata
|
|
103
|
+
// condividono lo stesso loopback, e la stessa regola di univocita' dello store.
|
|
102
104
|
async function reserveLocalPort(st, opts = {}) {
|
|
103
|
-
const used = new Set(
|
|
105
|
+
const used = new Set();
|
|
106
|
+
for (const n of st.nodes || []) {
|
|
107
|
+
if (Number.isInteger(n.localPort)) used.add(n.localPort);
|
|
108
|
+
if (Number.isInteger(n.panelLocalPort)) used.add(n.panelLocalPort);
|
|
109
|
+
}
|
|
104
110
|
const createServerImpl = opts.createServerImpl || net.createServer;
|
|
105
111
|
for (let port = opts.start || LOCAL_PORT_BASE; port <= 65535; port += 1) {
|
|
106
112
|
if (used.has(port)) continue;
|
|
@@ -281,10 +287,11 @@ function nodesEdit(opts) {
|
|
|
281
287
|
// aperto il tunnel.
|
|
282
288
|
// `panelAccess` segue la stessa regola dello scope celle: riguarda chi accede
|
|
283
289
|
// ai PANNELLI di questo nodo, non chi ha aperto il tunnel — quindi vale in
|
|
284
|
-
// entrambe le direzioni.
|
|
290
|
+
// entrambe le direzioni. `liveHostAccess` e' identica: riguarda chi puo'
|
|
291
|
+
// designare la cella ospite Live di QUESTO nodo, non chi ha aperto il tunnel.
|
|
285
292
|
const allowed = node.direction === 'inbound'
|
|
286
|
-
? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells', 'panelAccess'])
|
|
287
|
-
: new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells', 'panelAccess']);
|
|
293
|
+
? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess'])
|
|
294
|
+
: new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess']);
|
|
288
295
|
const keys = Object.keys(supplied);
|
|
289
296
|
const invalid = keys.find((key) => !allowed.has(key));
|
|
290
297
|
if (invalid) {
|
|
@@ -295,6 +302,10 @@ function nodesEdit(opts) {
|
|
|
295
302
|
log('nodes edit: panelAccess vuole un booleano');
|
|
296
303
|
return { code: 1, reason: 'invalid-panel-access' };
|
|
297
304
|
}
|
|
305
|
+
if (Object.hasOwn(supplied, 'liveHostAccess') && typeof supplied.liveHostAccess !== 'boolean') {
|
|
306
|
+
log('nodes edit: liveHostAccess vuole un booleano');
|
|
307
|
+
return { code: 1, reason: 'invalid-live-host-access' };
|
|
308
|
+
}
|
|
298
309
|
if (keys.length === 0) { log('nodes edit: nessuna modifica richiesta'); return { code: 1, reason: 'empty-patch' }; }
|
|
299
310
|
const patch = { ...supplied };
|
|
300
311
|
if (Object.hasOwn(patch, 'label')) {
|
package/lib/nodes/store.js
CHANGED
|
@@ -119,6 +119,12 @@ const NODE_KEYS = new Set([
|
|
|
119
119
|
// come l'operatore stesso; qui no, e di proposito: default NEGATO, si concede
|
|
120
120
|
// per singolo nodo.
|
|
121
121
|
'panelAccess',
|
|
122
|
+
// Se questo peer puo' designare/leggere/rimuovere la cella ospite Live di
|
|
123
|
+
// questo nodo. Stessa forma di panelAccess e stessa ragione: la designazione
|
|
124
|
+
// e' un'azione del nodo che POSSIEDE la cella, non di chi la chiede — un peer
|
|
125
|
+
// pairato non la acquisisce diventando "trusted come l'operatore". Default
|
|
126
|
+
// NEGATO, si concede per singolo nodo.
|
|
127
|
+
'liveHostAccess',
|
|
122
128
|
// Identita' crittografica del peer — passo 1 del modello di autorita'. Oggi
|
|
123
129
|
// NON governa niente: si osserva e si registra. `keySource` distingue una
|
|
124
130
|
// chiave legata al pairing (nello stesso atto in cui si e' deciso di fidarsi)
|
|
@@ -126,6 +132,13 @@ const NODE_KEYS = new Set([
|
|
|
126
132
|
// grant potra' pretendere la prima. `keyConflict` conserva una chiave DIVERSA
|
|
127
133
|
// vista dopo il legame, senza sostituire quella legata.
|
|
128
134
|
'publicKey', 'keySource', 'keyBoundAt', 'keyConflict',
|
|
135
|
+
// P0 stessa-origin, meta' remota: la porta pannello del peer inoltrata sul
|
|
136
|
+
// NOSTRO loopback (panelLocalPort) e la sua origine remota (panelRemotePort).
|
|
137
|
+
// COPPIA obbligatoria: un -L ha due estremita', mezza coppia e' un forward
|
|
138
|
+
// impossibile, non "meno pannello". Campi assenti = peer accoppiato prima di
|
|
139
|
+
// questa negoziazione: via storica, nessun comportamento nuovo. Le porte non
|
|
140
|
+
// sono segreti (come localPort/remotePort, che stanno qui accanto).
|
|
141
|
+
'panelLocalPort', 'panelRemotePort',
|
|
129
142
|
]);
|
|
130
143
|
|
|
131
144
|
// Nome di cella: stessa forma usata da fleet/definitions.js, cells/routes.js e
|
|
@@ -269,6 +282,9 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
269
282
|
// pairato. Migrazione silenziosa verso il permesso e' esattamente cio' che
|
|
270
283
|
// non deve succedere.
|
|
271
284
|
panelAccess: n.panelAccess === undefined ? false : n.panelAccess,
|
|
285
|
+
// Stessa regola, stesso motivo: un record senza il campo non acquisisce la
|
|
286
|
+
// designazione live per anzianita' del pairing.
|
|
287
|
+
liveHostAccess: n.liveHostAccess === undefined ? false : n.liveHostAccess,
|
|
272
288
|
visibility: n.visibility || 'network',
|
|
273
289
|
};
|
|
274
290
|
if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
|
|
@@ -277,6 +293,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
277
293
|
if (typeof out.autostart !== 'boolean') return null;
|
|
278
294
|
if (typeof out.shared !== 'boolean') return null;
|
|
279
295
|
if (typeof out.panelAccess !== 'boolean') return null;
|
|
296
|
+
if (typeof out.liveHostAccess !== 'boolean') return null;
|
|
280
297
|
if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
|
|
281
298
|
if (identityFile) out.identityFile = identityFile;
|
|
282
299
|
// Keep the old public field while reading v1 so old callers/tests and a
|
|
@@ -286,6 +303,16 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
286
303
|
if (!isPort(n.reversePort)) return null;
|
|
287
304
|
out.reversePort = n.reversePort;
|
|
288
305
|
}
|
|
306
|
+
// Coppia pannello (P0 stessa-origin remota): o entrambe le porte o nessuna.
|
|
307
|
+
// Una sola meta' non viene indovinata — sarebbe un -L monco. E la porta
|
|
308
|
+
// locale del pannello non puo' stare su quella del control plane: ssh
|
|
309
|
+
// cadrebbe con ExitOnForwardFailure portandosi via il tunnel intero.
|
|
310
|
+
if (n.panelLocalPort !== undefined || n.panelRemotePort !== undefined) {
|
|
311
|
+
if (!isPort(n.panelLocalPort) || !isPort(n.panelRemotePort)) return null;
|
|
312
|
+
if (n.panelLocalPort === n.localPort) return null;
|
|
313
|
+
out.panelLocalPort = n.panelLocalPort;
|
|
314
|
+
out.panelRemotePort = n.panelRemotePort;
|
|
315
|
+
}
|
|
289
316
|
if (n.reversePool !== undefined) {
|
|
290
317
|
if (schemaVersion < SCHEMA_VERSION) return null;
|
|
291
318
|
const parsedPool = parseReversePool(n.reversePool);
|
|
@@ -417,6 +444,13 @@ function parseStore(raw) {
|
|
|
417
444
|
names.add(node.name);
|
|
418
445
|
if (localPorts.has(node.localPort)) return null; // ogni listener locale ha un solo owner
|
|
419
446
|
localPorts.add(node.localPort);
|
|
447
|
+
if (node.panelLocalPort !== undefined) {
|
|
448
|
+
// La porta pannello inoltrata e' un ALTRO listener locale: stessa
|
|
449
|
+
// regola di localPort. Due nodi sulla stessa porta farebbero fallire
|
|
450
|
+
// il bind del secondo ssh al prossimo riavvio.
|
|
451
|
+
if (localPorts.has(node.panelLocalPort)) return null;
|
|
452
|
+
localPorts.add(node.panelLocalPort);
|
|
453
|
+
}
|
|
420
454
|
if (node.nodeId) {
|
|
421
455
|
if (node.nodeId === d.nodeId) return null; // self-reference nei dati salvati
|
|
422
456
|
if (ids.has(node.nodeId)) return null; // nodeId remoto univoco
|
|
@@ -659,6 +693,7 @@ function redactNode(n) {
|
|
|
659
693
|
autostart: !!n.autostart,
|
|
660
694
|
shared: n.shared === true,
|
|
661
695
|
panelAccess: n.panelAccess === true,
|
|
696
|
+
liveHostAccess: n.liveHostAccess === true,
|
|
662
697
|
visibility: n.visibility || 'network',
|
|
663
698
|
hasToken: !!n.token, // presenza, non il valore
|
|
664
699
|
paired: !!(n.token && n.acceptToken),
|
|
@@ -694,6 +729,14 @@ function redactNode(n) {
|
|
|
694
729
|
...(n.reversePool.lastAutoRotationAt === undefined ? {} : { lastAutoRotationAt: n.reversePool.lastAutoRotationAt }),
|
|
695
730
|
};
|
|
696
731
|
}
|
|
732
|
+
// La coppia pannello non e' un segreto (sono porte, come localPort): e' cio'
|
|
733
|
+
// che il frontend usa per mettere il frame del pannello remoto su un'origin
|
|
734
|
+
// diversa. Il nodo senza coppia non mostra campi: l'assenza SI' vede, perche'
|
|
735
|
+
// e' la via storica.
|
|
736
|
+
if (n.panelLocalPort !== undefined) {
|
|
737
|
+
out.panelLocalPort = n.panelLocalPort;
|
|
738
|
+
out.panelRemotePort = n.panelRemotePort;
|
|
739
|
+
}
|
|
697
740
|
return out;
|
|
698
741
|
}
|
|
699
742
|
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -44,6 +44,17 @@ function assertForwardSpec(node) {
|
|
|
44
44
|
if (node.keyPath !== undefined && !store.isAbsPath(node.keyPath)) throw new Error('tunnel: keyPath non valido');
|
|
45
45
|
if (node.reversePort !== undefined && !store.isPort(node.reversePort)) throw new Error('tunnel: reversePort non valida');
|
|
46
46
|
if (node.localAppPort !== undefined && !store.isPort(node.localAppPort)) throw new Error('tunnel: localAppPort non valida');
|
|
47
|
+
// Coppia pannello (P0 stessa-origin remota): o entrambe o nessuna, e mai
|
|
48
|
+
// sulla porta locale del control plane — il bind conflitto abbatterebbe il
|
|
49
|
+
// tunnel intero con ExitOnForwardFailure.
|
|
50
|
+
if (node.panelLocalPort !== undefined || node.panelRemotePort !== undefined) {
|
|
51
|
+
if (!store.isPort(node.panelLocalPort) || !store.isPort(node.panelRemotePort)) {
|
|
52
|
+
throw new Error('tunnel: coppia pannello incompleta (panelLocalPort/panelRemotePort)');
|
|
53
|
+
}
|
|
54
|
+
if (node.panelLocalPort === node.localPort) {
|
|
55
|
+
throw new Error('tunnel: panelLocalPort coincide con localPort (bind conflitto)');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
47
58
|
if (node.shared === true && node.reversePort !== undefined && !node.reversePool && !store.isPort(node.localAppPort)) {
|
|
48
59
|
throw new Error('tunnel: Share richiede localAppPort esplicita');
|
|
49
60
|
}
|
|
@@ -64,8 +75,16 @@ function buildForwardArgs(node) {
|
|
|
64
75
|
const reverse = node.shared === true && node.reversePort !== undefined && !node.reversePool ? [
|
|
65
76
|
'-R', `127.0.0.1:${node.reversePort}:127.0.0.1:${node.localAppPort}`,
|
|
66
77
|
] : [];
|
|
78
|
+
// Second forward, same supervisor: the peer's panel port lands on our
|
|
79
|
+
// loopback so the browser gets a DIFFERENT origin for the panel. Absent pair
|
|
80
|
+
// (peer paired before this negotiation) emits nothing — the tunnel stays
|
|
81
|
+
// byte-identical to what it was.
|
|
82
|
+
const panel = node.panelLocalPort !== undefined && node.panelRemotePort !== undefined ? [
|
|
83
|
+
'-L', `127.0.0.1:${node.panelLocalPort}:127.0.0.1:${node.panelRemotePort}`,
|
|
84
|
+
] : [];
|
|
67
85
|
return SSH_BASE_OPTS.concat(transport, identity ? ['-i', identity] : [], [
|
|
68
86
|
'-L', `127.0.0.1:${node.localPort}:127.0.0.1:${node.remotePort}`,
|
|
87
|
+
...panel,
|
|
69
88
|
...reverse,
|
|
70
89
|
node.ssh,
|
|
71
90
|
]);
|
package/lib/proxy/federation.js
CHANGED
|
@@ -152,6 +152,12 @@ function knownResource(resource) {
|
|
|
152
152
|
|| /^\/vl-nodes\/[a-f0-9]{32}(?:\/commands|\/events)?$/.test(resource)
|
|
153
153
|
|| resource === '/decks'
|
|
154
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)
|
|
155
161
|
|| resource === '/topology'
|
|
156
162
|
|| resource === '/diagnostics/status'
|
|
157
163
|
|| resource === '/diagnostics/logs'
|
|
@@ -210,8 +216,23 @@ function panelAllowedFor(ingress) {
|
|
|
210
216
|
return ingress.panelAccess === true;
|
|
211
217
|
}
|
|
212
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
|
+
|
|
213
232
|
function allowedResource(resource, method = 'GET') {
|
|
214
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';
|
|
215
236
|
if (resource === '/sessions') return method === 'GET' || method === 'POST';
|
|
216
237
|
if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return method === 'DELETE';
|
|
217
238
|
if (/^\/sessions\/[\w.@%:+-]{1,128}\/visibility$/.test(resource)) return method === 'PATCH';
|
|
@@ -361,6 +382,9 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
361
382
|
if (parsed && isPanelResource(parsed.resource) && !panelAllowedFor(ingress)) {
|
|
362
383
|
return res.status(403).json({ error: 'pannello non concesso a questo nodo', reason: 'panel-not-granted' });
|
|
363
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
|
+
}
|
|
364
388
|
if (!parsed || !allowedResource(parsed.resource, req.method)
|
|
365
389
|
|| !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
|
|
366
390
|
if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
|
|
@@ -1298,7 +1322,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
1298
1322
|
function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
|
|
1299
1323
|
|
|
1300
1324
|
module.exports = {
|
|
1301
|
-
isPanelResource, panelAllowedFor, routeHandler,
|
|
1325
|
+
isPanelResource, panelAllowedFor, isLiveHostResource, liveHostAllowedFor, routeHandler,
|
|
1302
1326
|
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
|
|
1303
1327
|
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
|
|
1304
1328
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
package/lib/proxy/panel-auth.js
CHANGED
|
@@ -189,39 +189,16 @@ function createPanelAuth({
|
|
|
189
189
|
res.end(JSON.stringify({ ticket, cell: cellId, expiresInSeconds: Math.floor(ticketTtlMs / 1000) }));
|
|
190
190
|
}
|
|
191
191
|
|
|
192
|
-
// ——
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
function panelAuthMiddleware(req, res, next) {
|
|
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) {
|
|
202
201
|
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
202
|
// Il ticket ha PRECEDENZA sul Bearer: la richiesta federata dell'iframe
|
|
226
203
|
// arriva qui con il Bearer dell'hop (il token del nodo, iniettato dalla
|
|
227
204
|
// via federata) ACCANTO al ticket in query — e deve entrare da iframe,
|
|
@@ -263,6 +240,56 @@ function createPanelAuth({
|
|
|
263
240
|
res.end(JSON.stringify({ error: 'unauthorized' }));
|
|
264
241
|
}
|
|
265
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
|
+
|
|
266
293
|
// —— Auth per l'upgrade WebSocket del pannello. Le WS del pannello partono
|
|
267
294
|
// dalla pagina dentro il frame e portano il cookie della NOSTRA origine
|
|
268
295
|
// (same-site): il cookie di visione deve aprirle, altrimenti il pannello
|
|
@@ -282,7 +309,9 @@ function createPanelAuth({
|
|
|
282
309
|
const qi = url.indexOf('?');
|
|
283
310
|
if (provenienza === 'locale' && qi !== -1
|
|
284
311
|
&& verifyToken(new URLSearchParams(url.slice(qi + 1)).get('token') || '')) return true;
|
|
285
|
-
|
|
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/, ''));
|
|
286
315
|
if (!cellId) return false;
|
|
287
316
|
const cookieValue = parseCookieHeader(req.headers.cookie)[COOKIE_NAME];
|
|
288
317
|
return !!(cookieValue && verifyCookie(cookieValue, cellId));
|
|
@@ -290,6 +319,7 @@ function createPanelAuth({
|
|
|
290
319
|
|
|
291
320
|
return {
|
|
292
321
|
panelAuthMiddleware,
|
|
322
|
+
consumeMiddleware,
|
|
293
323
|
authorizeUpgrade,
|
|
294
324
|
// Per test e diagnostica: verifiche dall'esterno senza passare dal wire.
|
|
295
325
|
consumeTicketForTest: consumeTicket,
|
package/lib/proxy/panel-proxy.js
CHANGED
|
@@ -113,10 +113,25 @@ function forwardHeaders(headers, targetHost) {
|
|
|
113
113
|
return out;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// Header che il PANNELLO non deve poter porre sulla nostra origine. Sono una
|
|
117
|
+
// cosa diversa dagli hop-by-hop: quelli non hanno senso oltre un hop, questi
|
|
118
|
+
// avrebbero senso e proprio per questo vanno tolti.
|
|
119
|
+
//
|
|
120
|
+
// `set-cookie`: il pannello e' servito sotto la nostra origine, quindi un suo
|
|
121
|
+
// cookie atterra li'. Non gli darebbe l'accesso al pannello di un'altra cella
|
|
122
|
+
// — il cookie di visione non e' autoportante, `verifyCookie` cerca il valore
|
|
123
|
+
// in una mappa del server e un valore inventato non c'e' — ma puo'
|
|
124
|
+
// SOVRASCRIVERE o invalidare il cookie di visione legittimo, cioe' rompere il
|
|
125
|
+
// pannello dell'utente dal di dentro. E puo' piantare cookie arbitrari sulla
|
|
126
|
+
// nostra origine.
|
|
127
|
+
const NON_INOLTRATI_DAL_PANNELLO = new Set(['set-cookie']);
|
|
128
|
+
|
|
116
129
|
function responseHeaders(headers) {
|
|
117
130
|
const out = {};
|
|
118
131
|
for (const [k, v] of Object.entries(headers || {})) {
|
|
119
|
-
|
|
132
|
+
const key = k.toLowerCase();
|
|
133
|
+
if (HOP_BY_HOP.has(key)) continue;
|
|
134
|
+
if (NON_INOLTRATI_DAL_PANNELLO.has(key)) continue;
|
|
120
135
|
out[k] = v;
|
|
121
136
|
}
|
|
122
137
|
return out;
|
|
@@ -149,14 +164,18 @@ async function resolveTarget(resolveCellPanel, cellId) {
|
|
|
149
164
|
host: parsed.hostname,
|
|
150
165
|
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
151
166
|
hostHeader: parsed.host,
|
|
152
|
-
basePath: parsed.pathname.replace(/\/$/, ''),
|
|
153
167
|
};
|
|
154
168
|
}
|
|
155
169
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
170
|
+
// IL PATH DEL PANNELLO E' `rest`, TAL QUALE. Il contratto D8 (CellPanel): il
|
|
171
|
+
// frame carica /api/panel/<cella> + il pathname del panelUrl, e il browser
|
|
172
|
+
// risolve le sotto-risorse RELATIVE rispetto a quell'origine — al proxy
|
|
173
|
+
// arrivano sempre path assoluti del pannello. Ricomporli col basePath del
|
|
174
|
+
// panelUrl (joinPath, rimossa) li RADDOPPIAVA appena il panelUrl aveva un
|
|
175
|
+
// path: /vnc/lite.html diventava /vnc/lite.html/vnc/lite.html, il pannello
|
|
176
|
+
// rispondeva 404 e il frame restava su «not found» — provato nel browser
|
|
177
|
+
// vero. Il pathname del panelUrl NON e' un prefisso da applicare: e' la
|
|
178
|
+
// prima richiesta che il client fa, non una radice.
|
|
160
179
|
|
|
161
180
|
// Risponde con o senza Express. Il resto del progetto monta le route su un
|
|
162
181
|
// router Express e usa `res.status().json()`, ma questo modulo e' un pezzo di
|
|
@@ -190,7 +209,7 @@ function createPanelProxy({ resolveCellPanel, log = () => {}, requestImpl }) {
|
|
|
190
209
|
host: target.host,
|
|
191
210
|
port: target.port,
|
|
192
211
|
method: req.method,
|
|
193
|
-
path: `${
|
|
212
|
+
path: `${parsed.rest}${stripLocalTokenQuery(parsed.search)}`,
|
|
194
213
|
headers: forwardHeaders(req.headers, target.hostHeader),
|
|
195
214
|
...(target.secure ? { rejectUnauthorized: !(target.loopback) } : {}),
|
|
196
215
|
};
|
|
@@ -248,7 +267,9 @@ function handlePanelUpgrade({ req, socket, head, resolveCellPanel, verifyToken,
|
|
|
248
267
|
log({ event: 'panel-proxy', outcome: 'rejected', reason: 'unauthorized', cell: '' });
|
|
249
268
|
return kill(401, 'Unauthorized');
|
|
250
269
|
}
|
|
251
|
-
|
|
270
|
+
// Il prefisso varia con la porta: /api/panel sul control plane (compat),
|
|
271
|
+
// /panel nudo sulla porta pannello dedicata (P0 sicurezza 2026-08-16).
|
|
272
|
+
const parsed = splitPanelPath(req.url.replace(/^\/(?:api\/)?panel/, ''));
|
|
252
273
|
if (!parsed) return kill(404, 'Not Found');
|
|
253
274
|
Promise.resolve(resolveTarget(resolveCellPanel, parsed.cellId)).then((target) => {
|
|
254
275
|
if (!target.ok) {
|
|
@@ -265,14 +286,24 @@ function handlePanelUpgrade({ req, socket, head, resolveCellPanel, verifyToken,
|
|
|
265
286
|
host: target.host,
|
|
266
287
|
port: target.port,
|
|
267
288
|
method: 'GET',
|
|
268
|
-
path: `${
|
|
289
|
+
path: `${parsed.rest}${stripLocalTokenQuery(parsed.search)}`,
|
|
269
290
|
headers,
|
|
270
291
|
...(target.secure ? { rejectUnauthorized: !(target.loopback) } : {}),
|
|
271
292
|
});
|
|
272
293
|
} catch (_) { return kill(502, 'Bad Gateway'); }
|
|
273
294
|
upstream.on('upgrade', (upRes, upSocket, upHead) => {
|
|
274
295
|
const lines = [`HTTP/1.1 ${upRes.statusCode} ${upRes.statusMessage}`];
|
|
275
|
-
|
|
296
|
+
// La 101 e' una risposta come le altre per il browser, che ne persiste i
|
|
297
|
+
// cookie: `set-cookie` va tolto QUI come nel ramo HTTP. Non si puo' pero'
|
|
298
|
+
// riusare `responseHeaders()`, perche' toglierebbe anche gli hop-by-hop —
|
|
299
|
+
// e nell'handshake `connection`/`upgrade` SONO la risposta.
|
|
300
|
+
for (const [k, v] of Object.entries(upRes.headers)) {
|
|
301
|
+
if (NON_INOLTRATI_DAL_PANNELLO.has(k.toLowerCase())) continue;
|
|
302
|
+
// Un header ripetuto arriva come array (`set-cookie` sempre): va emesso
|
|
303
|
+
// su piu' righe, non unito in una sola con la virgola.
|
|
304
|
+
if (Array.isArray(v)) { for (const uno of v) lines.push(`${k}: ${uno}`); continue; }
|
|
305
|
+
lines.push(`${k}: ${v}`);
|
|
306
|
+
}
|
|
276
307
|
try {
|
|
277
308
|
socket.write(`${lines.join('\r\n')}\r\n\r\n`);
|
|
278
309
|
if (upHead && upHead.length) socket.write(upHead);
|