@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/README.md +22 -4
  3. package/frontend/dist/assets/index-uRh_hSop.js +93 -0
  4. package/frontend/dist/index.html +1 -1
  5. package/frontend/dist/version.json +1 -1
  6. package/lib/cli/commands.js +14 -0
  7. package/lib/config.js +9 -0
  8. package/lib/fleet/builtin.js +57 -2
  9. package/lib/live-host/routes.js +9 -3
  10. package/lib/nodes/commands.js +15 -4
  11. package/lib/nodes/store.js +43 -0
  12. package/lib/nodes/tunnel.js +19 -0
  13. package/lib/proxy/federation.js +25 -1
  14. package/lib/proxy/panel-auth.js +63 -33
  15. package/lib/proxy/panel-proxy.js +41 -10
  16. package/lib/server.js +128 -6
  17. package/lib/settings/pairing-coordinator.js +32 -0
  18. package/lib/settings/public-peering-routes.js +13 -1
  19. package/package.json +1 -1
  20. package/skills/aidesktop/SKILL.md +201 -0
  21. package/skills/aidesktop/docker/Dockerfile +21 -0
  22. package/skills/aidesktop/docker/custom-cont-init.d/10-cdp-relay.sh +20 -0
  23. package/skills/aidesktop/docker/docker-compose.example.yml +75 -0
  24. package/skills/live/SKILL.md +90 -0
  25. package/skills/nexuscrew/SKILL.md +113 -0
  26. package/frontend/dist/assets/index-zjL6kZ7J.js +0 -93
  27. package/skills/alibaba-token-media/SKILL.md +0 -152
  28. package/skills/alibaba-token-media/agents/openai.yaml +0 -4
  29. package/skills/alibaba-token-media/references/api-contract.md +0 -97
  30. package/skills/alibaba-token-media/scripts/alibaba_token_media.py +0 -550
  31. package/skills/fill-forms/SKILL.md +0 -177
  32. package/skills/fill-forms/agents/openai.yaml +0 -4
  33. package/skills/fill-forms/references/overlay-technique.md +0 -99
  34. package/skills/fill-forms/requirements.txt +0 -4
  35. package/skills/fill-forms/scripts/dump_docx.py +0 -70
  36. package/skills/fill-forms/scripts/fill_docx.py +0 -207
  37. package/skills/fill-forms/scripts/fill_pdf.py +0 -424
  38. package/skills/fill-forms/scripts/inspect_pdf.py +0 -188
  39. package/skills/fill-forms/scripts/prepare_signature.py +0 -171
@@ -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-zjL6kZ7J.js"></script>
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.0"}
1
+ {"version":"0.9.2"}
@@ -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) {
@@ -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 || defs.engines.some((engine) => engine.id === 'desktop.local')) return 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
@@ -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 (federazione default-deny nessuno designa da remoto una cella di
7
- // questo nodo). La designazione e' quindi un'azione intrinsecamente locale.
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 locale (federazione default-deny).
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 || {};
@@ -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((st.nodes || []).map((n) => n.localPort));
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')) {
@@ -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
 
@@ -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
  ]);
@@ -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,
@@ -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
- // —— 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) {
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
- const cellId = cellFromPanelPath(url.replace(/^\/api\/panel/, ''));
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,
@@ -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
- if (HOP_BY_HOP.has(k.toLowerCase())) continue;
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
- function joinPath(basePath, rest) {
157
- if (!basePath) return rest;
158
- return rest === '/' ? `${basePath}/` : `${basePath}${rest}`;
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: `${joinPath(target.basePath, parsed.rest)}${stripLocalTokenQuery(parsed.search)}`,
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
- const parsed = splitPanelPath(req.url.replace(/^\/api\/panel/, ''));
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: `${joinPath(target.basePath, parsed.rest)}${stripLocalTokenQuery(parsed.search)}`,
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
- for (const [k, v] of Object.entries(upRes.headers)) lines.push(`${k}: ${v}`);
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);