@mmmbuto/nexuscrew 0.8.57 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +164 -2
  2. package/README.md +1 -0
  3. package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
  4. package/frontend/dist/assets/index-zjL6kZ7J.js +93 -0
  5. package/frontend/dist/index.html +2 -2
  6. package/frontend/dist/version.json +1 -1
  7. package/lib/audio/adapters.js +50 -6
  8. package/lib/cli/commands.js +40 -2
  9. package/lib/cli/doctor.js +95 -12
  10. package/lib/cli/init.js +25 -3
  11. package/lib/cli/path.js +43 -10
  12. package/lib/cli/pidfile.js +23 -2
  13. package/lib/config.js +15 -0
  14. package/lib/fleet/builtin.js +161 -19
  15. package/lib/fleet/catalogs/opencode-go.json +328 -0
  16. package/lib/fleet/cell-exec.js +87 -9
  17. package/lib/fleet/cell-lease-server.js +719 -0
  18. package/lib/fleet/cell-lease.js +112 -0
  19. package/lib/fleet/definitions.js +101 -7
  20. package/lib/fleet/launch-broker.js +115 -3
  21. package/lib/fleet/lease-client.js +191 -0
  22. package/lib/fleet/lease-routes.js +92 -0
  23. package/lib/fleet/lease-verifier.js +230 -0
  24. package/lib/fleet/managed.js +444 -55
  25. package/lib/fleet/prompt-delivery.js +50 -2
  26. package/lib/fleet/provider.js +1 -1
  27. package/lib/fleet/runtime.js +53 -6
  28. package/lib/live-host/bridge.js +369 -0
  29. package/lib/live-host/routes.js +184 -0
  30. package/lib/live-host/store.js +96 -0
  31. package/lib/mcp/tools.js +51 -0
  32. package/lib/nodes/commands.js +9 -2
  33. package/lib/nodes/store.js +14 -0
  34. package/lib/nodes/tunnel.js +4 -1
  35. package/lib/proxy/federation.js +106 -9
  36. package/lib/proxy/node-proxy.js +33 -0
  37. package/lib/proxy/panel-auth.js +307 -0
  38. package/lib/proxy/panel-proxy.js +305 -0
  39. package/lib/server.js +127 -4
  40. package/package.json +1 -1
  41. package/skills/alibaba-token-media/SKILL.md +19 -0
  42. package/skills/crew/SKILL.md +15 -0
  43. package/skills/fill-forms/SKILL.md +23 -0
  44. package/skills/mail-assistant/SKILL.md +15 -0
  45. package/skills/memory/SKILL.md +15 -0
  46. package/skills/nexuscrew-agent/SKILL.md +18 -0
  47. package/skills/vl-msa/SKILL.md +15 -0
  48. package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
  49. package/frontend/dist/assets/index-_c-1_3iR.js +0 -93
@@ -0,0 +1,184 @@
1
+ 'use strict';
2
+ // lib/live-host/routes.js — route /api/live-host (control plane della designazione).
3
+ //
4
+ // Montate dietro requireToken (come /api/cells, /api/fleet): solo il token del nodo
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.
8
+ //
9
+ // Invarianti (contratto rev6 §2, §9):
10
+ // - hostCell unico per nodo, CAS su revision (due designazioni concorrenti non
11
+ // lasciano due celle rosse).
12
+ // - API-first: la route e' l'unica autorita'; il frontend riflette la risposta e
13
+ // su errore resta sullo stato precedente (nessun ottimismo qui — la logica UI
14
+ // sta nel modulo frontend host-designation.js).
15
+ // - Cella inattiva PRESERVA la designazione: lo store non cancella mai hostCell per
16
+ // inattivita; `eligible` e' derivato dal roster al momento del GET.
17
+ // - readonly => 403 su designate/clear.
18
+
19
+ const express = require('express');
20
+ const { CELL_ID_RE } = require('./store.js');
21
+
22
+ // Ricava l'elenco celle LOCALI dal fleet (definizioni, attive e non). Una cella
23
+ // federata non compare qui: e' il check che chiude "designa solo una cella di questo
24
+ // nodo". Ritorna null se il fleet non e' interrogabile (la route decide come gestirlo).
25
+ async function localCells(fleetP) {
26
+ const fleet = await fleetP;
27
+ if (!fleet || fleet.available !== true) return null;
28
+ const statusFn = fleet && (typeof fleet.status === 'function' ? fleet.status : fleet.cellStatus);
29
+ if (typeof statusFn !== 'function') return null;
30
+ const st = await statusFn.call(fleet);
31
+ return Array.isArray(st && st.cells) ? st.cells : [];
32
+ }
33
+
34
+ function isActive(cell) {
35
+ return !!(cell && cell.active === true && cell.tmux !== false);
36
+ }
37
+
38
+ // --- Seam lease↔designazione (2026-08-15, decisione di Dev: grace = false) -----
39
+ //
40
+ // L'idoneita' dell'host designato non e' piu' solo «sessione tmux viva»: con
41
+ // remain-on-exit la sessione sopravvive alla morte del supervisore, e la
42
+ // garanzia «l'host designato e' vivo» la puo' dare SOLO il lease (fetta 2b: il
43
+ // leaseId e' FIRMATO nel proof, quindi lo stato del lease identifica senza
44
+ // ambiguita' chi lo detiene). Regole:
45
+ // - eligible = attiva AND lease 'live'. In grace NON c'e' garanzia: eligible
46
+ // false, ma host.lease='grace' dice «recupero in corso» — chi legge
47
+ // distingue «non idonea perche' morta» da «non idonea perche' in recupero».
48
+ // - I cinque stati (live|grace|expired|none|unavailable) restano DISTINCTI
49
+ // fino a chi legge: collassarli e' rifare il difetto a un piano piu' su.
50
+ // - FALLBACK FAIL-OPEN DICHIARATO: senza fleet.lease (installazione senza
51
+ // lease) eligible torna tmux-only e host.lease='unavailable'. Non e' «va
52
+ // bene lo stesso»: e' «la garanzia non e' disponibile qui», detto esplicito
53
+ // perche' un eligible=true silenzioso verrebbe letto come confermata.
54
+ // - hostCell resta PRESERVATO in ogni caso (invariante dello store): oscilla
55
+ // l'idoneita', non la scelta dell'operatore.
56
+ function hostLeaseState(fleet, hostCell) {
57
+ if (hostCell == null) return null;
58
+ const lease = fleet && fleet.lease;
59
+ if (!lease || typeof lease.status !== 'function') return 'unavailable';
60
+ const st = lease.status(hostCell);
61
+ return (st && typeof st.state === 'string') ? st.state : 'none';
62
+ }
63
+
64
+ function eligibleOf(fleet, cell, hostCell) {
65
+ const leaseState = hostLeaseState(fleet, hostCell);
66
+ if (leaseState === null) return false; // senza soggetto non c'e' idoneita'
67
+ if (leaseState === 'unavailable') return isActive(cell); // fail-open dichiarato
68
+ return isActive(cell) && leaseState === 'live';
69
+ }
70
+
71
+ function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Date.now(), bridge = null }) {
72
+ const r = express.Router();
73
+
74
+ // GET /api/live-host — { hostCell, revision, eligible, host: {lease}, at }.
75
+ // hostCell e revision vengono dallo store (preservato); eligible e' la verita'
76
+ // COMPOSTA roster+lease (si veda hostLeaseState sopra); host.lease espone lo
77
+ // stato del lease della cella designata, distinto, perche' chi legge distingue.
78
+ r.get('/', async (_req, res) => {
79
+ try {
80
+ const snap = store.snapshot();
81
+ let eligible = false;
82
+ let lease = null;
83
+ if (snap.hostCell != null) {
84
+ const fleet = await fleetP.catch(() => null);
85
+ const cells = await localCells(fleetP).catch(() => []);
86
+ const cell = Array.isArray(cells) ? cells.find((c) => c && c.cell === snap.hostCell) : null;
87
+ lease = hostLeaseState(fleet, snap.hostCell);
88
+ eligible = eligibleOf(fleet, cell, snap.hostCell);
89
+ }
90
+ res.json({ hostCell: snap.hostCell, revision: snap.revision, eligible, host: { lease }, at: now() });
91
+ } catch (e) {
92
+ res.status(500).json({ error: String(e && e.message || e) });
93
+ }
94
+ });
95
+
96
+ // POST /api/live-host/designate { cellId, expectedRevision }.
97
+ // expectedRevision e' OBBLIGATORIO e integer (>=0): nessun CAS permissivo, la UI
98
+ // legge sempre la revision dal GET prima di scrivere (stato iniziale = 0).
99
+ // cellId deve appartenere al roster locale (federazione default-deny).
100
+ r.post('/designate', express.json({ limit: '4kb' }), async (req, res) => {
101
+ if (readonly()) return res.status(403).json({ error: 'READONLY: designazione cella ospite bloccata' });
102
+ const body = req.body || {};
103
+ if (Object.keys(body).some((k) => !['cellId', 'expectedRevision'].includes(k))
104
+ || typeof body.cellId !== 'string' || !CELL_ID_RE.test(body.cellId)
105
+ || !(Number.isInteger(body.expectedRevision) && body.expectedRevision >= 0)) {
106
+ return res.status(400).json({ error: 'designazione non valida' });
107
+ }
108
+ try {
109
+ const fleet = await fleetP.catch(() => null);
110
+ const cells = await localCells(fleetP);
111
+ if (cells === null) return res.status(503).json({ error: 'fleet non disponibile, riprova' });
112
+ const cell = cells.find((c) => c && c.cell === body.cellId);
113
+ if (!cell) return res.status(404).json({ error: 'cella non appartiene a questo nodo' });
114
+ const result = await store.compareAndSet(body.expectedRevision, body.cellId);
115
+ if (!result.ok) return res.status(409).json({
116
+ error: 'revision superata: rileggi e riprova', revision: result.revision, hostCell: result.hostCell,
117
+ });
118
+ res.json({
119
+ hostCell: result.hostCell, revision: result.revision,
120
+ eligible: eligibleOf(fleet, cell, result.hostCell),
121
+ host: { lease: hostLeaseState(fleet, result.hostCell) },
122
+ at: now(),
123
+ });
124
+ } catch (e) {
125
+ res.status(500).json({ error: String(e && e.message || e) });
126
+ }
127
+ });
128
+
129
+ // POST /api/live-host/clear { expectedRevision } — rimuove la designazione (CAS).
130
+ r.post('/clear', express.json({ limit: '4kb' }), async (req, res) => {
131
+ if (readonly()) return res.status(403).json({ error: 'READONLY: rimozione cella ospite bloccata' });
132
+ const body = req.body || {};
133
+ if (Object.keys(body).some((k) => k !== 'expectedRevision')
134
+ || !(Number.isInteger(body.expectedRevision) && body.expectedRevision >= 0)) {
135
+ return res.status(400).json({ error: 'rimozione non valida' });
136
+ }
137
+ try {
138
+ const result = await store.compareAndSet(body.expectedRevision, null);
139
+ if (!result.ok) return res.status(409).json({
140
+ error: 'revision superata: rileggi e riprova', revision: result.revision, hostCell: result.hostCell,
141
+ });
142
+ res.json({ hostCell: null, revision: result.revision, at: now() });
143
+ } catch (e) {
144
+ res.status(500).json({ error: String(e && e.message || e) });
145
+ }
146
+ });
147
+
148
+ // POST /api/live-host/bridge — risolve il puntamento per l'avvio di una Live
149
+ // (fetta 3). La richiesta NON è parametrizzabile: la designazione è la
150
+ // condizione (LC3) e nessun chiamante può scegliere il target (MC3.4). Il
151
+ // ponte risponde sempre 200: i `none` con reason sono esiti legittimi e
152
+ // distinti (nessuna designazione / cella non idonea / fallback), non errori.
153
+ // Body opzionale e vuoto: un body con campi è un 400, non viene ignorato.
154
+ r.post('/bridge', express.json({ limit: '1kb' }), async (req, res) => {
155
+ const body = req.body || {};
156
+ if (Object.keys(body).length > 0) return res.status(400).json({ error: 'la risoluzione non accetta parametri' });
157
+ if (readonly()) return res.json({ mode: 'none', reason: 'readonly', at: now() });
158
+ if (!bridge) return res.status(503).json({ error: 'ponte Live non configurato su questo nodo' });
159
+ try {
160
+ const result = await bridge.resolveForLive();
161
+ res.json(result);
162
+ } catch (e) {
163
+ // Il contratto (MC1.5) vuole che un guasto del ponte non fermi la Live:
164
+ // anche l'inaspettato collassa in `none` dichiarato, mai un 500.
165
+ // `bridge-error` e' l'ultima rete: un'eccezione che nessun ramo previsto ha
166
+ // classificato. Va NOMINATA come le altre, non lasciata fuori dall'elenco
167
+ // — una causa non dichiarata e' una causa che nessuno cerchera'.
168
+ res.json({ mode: 'none', reason: 'bridge-error', detail: String(e && e.message || e), at: now() });
169
+ }
170
+ });
171
+
172
+ // Stesso body-error handling di cellsRoutes: payload troppo grande / JSON invalido.
173
+ r.use((err, _req, res, _next) => {
174
+ if (err && (err.type === 'entity.too.large' || err.status === 413)) {
175
+ return res.status(413).json({ error: 'body troppo grande' });
176
+ }
177
+ if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
178
+ return res.status(err.status || 400).json({ error: String(err.message || err) });
179
+ });
180
+
181
+ return r;
182
+ }
183
+
184
+ module.exports = { liveHostRoutes };
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+ // lib/live-host/store.js — stato della designazione "cella ospite Live" di un nodo.
3
+ //
4
+ // Un solo hostCell per nodo (contratto rev6 §2.2): chiave unica, non una convenzione
5
+ // ripetuta in N punti. Lo stato vive su disco (sopravvive a riavvii di cella e di
6
+ // NexusCrew) e l'aggiornamento e' un CAS su `revision`: due designazioni concorrenti
7
+ // non possono lasciare due celle rosse — il perdente rilegge la revision e rinuncia.
8
+ //
9
+ // La designazione e' PURAMENTE un marker di scelta del nodo. L'eligibilita' (la cella
10
+ // e' anche attiva in questo momento?) e' derivata dal roster Fleet e non si persiste
11
+ // qui: `active` resta `sessions.has(tmuxSession)` (runtime.js), la marcatura non lo
12
+ // tocca. Una cella spenta PRESERVA la designazione — resta hostCell, semplicemente
13
+ // ineligible finche' non torna attiva.
14
+
15
+ const path = require('node:path');
16
+ const os = require('node:os');
17
+ const { readJsonSafe, atomicWriteJson } = require('../notify/persist.js');
18
+
19
+ const CELL_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
20
+
21
+ // Path del file di stato: override esplicito -> dir del token (~/.nexuscrew) -> home.
22
+ // Stessa convenzione di consentPath/groupsPath, cosi' i test isolano lo stato via
23
+ // cfg.tokenPath senza toccare la home reale.
24
+ function liveHostPath(cfg = {}, home = (cfg.home || os.homedir())) {
25
+ if (cfg.liveHostPath) return cfg.liveHostPath;
26
+ if (cfg.tokenPath) return path.join(path.dirname(cfg.tokenPath), 'live-host.json');
27
+ return path.join(home, '.nexuscrew', 'live-host.json');
28
+ }
29
+
30
+ // Normalizza il grezzo letto da disco in {revision, hostCell}. Un file assente o
31
+ // garbage (readJsonSafe -> {}) e' lo stato iniziale legittimo {0, null}; un hostCell
32
+ // non-string o fuori formato viene chiuso a null senza promuovere garbage.
33
+ function normalize(raw) {
34
+ const revision = raw && Number.isInteger(raw.revision) && raw.revision >= 0 ? raw.revision : 0;
35
+ const hostCell = raw && typeof raw.hostCell === 'string' && CELL_ID_RE.test(raw.hostCell)
36
+ ? raw.hostCell : null;
37
+ return { revision, hostCell };
38
+ }
39
+
40
+ function readLiveHost(filePath) {
41
+ return normalize(readJsonSafe(filePath));
42
+ }
43
+
44
+ // createLiveHostStore({filePath, now}) — CAS store.
45
+ //
46
+ // read+compare+write sono sincroni, ma le route sono async e due richieste concorrenti
47
+ // possono entrambe aver letto la stessa revision prima che una delle due scriva. La
48
+ // promise-chain serializza la sezione read-modify-write: nessun await dentro la
49
+ // callback, quindi il check e la scrittura sono una sola transazione atomica rispetto
50
+ // agli altri CAS in volo. Esattamente uno dei due CAS concorrenti avanza la revision.
51
+ function createLiveHostStore({ filePath, now = () => Date.now() } = {}) {
52
+ if (!filePath || typeof filePath !== 'string') {
53
+ throw new Error('createLiveHostStore: filePath richiesto');
54
+ }
55
+ let chain = Promise.resolve();
56
+
57
+ function serialize(fn) {
58
+ const run = chain.then(fn);
59
+ // La catena non si rompe mai su un errore di un CAS: un fallimento non deve
60
+ // bloccare i successivi (un CAS che lancia risolve comunque il run).
61
+ chain = run.then(() => {}, () => {});
62
+ return run;
63
+ }
64
+
65
+ // Snapshot read-only corrente (dopo normalizzazione).
66
+ function snapshot() {
67
+ return readLiveHost(filePath);
68
+ }
69
+
70
+ // CAS: scrive nextHostCell (stringa valida | null) solo se la revision corrente
71
+ // coincide con expectedRevision. Ritorna:
72
+ // { ok:true, revision:<nuova>, hostCell:<nuovo>, at }
73
+ // { ok:false, conflict:true, revision:<corrente>, hostCell:<corrente> }
74
+ // Fail-closed: expectedRevision deve essere un integer che coincide con la
75
+ // revision corrente. Uno expected non-integer (es. undefined) NON e' un
76
+ // lasciapassare: e' un conflitto. Lo stato iniziale e' revision 0 ed e'
77
+ // conoscibile (la UI lo legge dal GET), quindi non esiste un "primo scrittore
78
+ // senza revision" legittimo: l'assenza del campo va rifiutata, non tollerata.
79
+ function compareAndSet(expectedRevision, nextHostCell) {
80
+ return serialize(() => {
81
+ const cur = readLiveHost(filePath);
82
+ if (!Number.isInteger(expectedRevision) || cur.revision !== expectedRevision) {
83
+ return { ok: false, conflict: true, revision: cur.revision, hostCell: cur.hostCell };
84
+ }
85
+ const hostCell = nextHostCell === null ? null
86
+ : (typeof nextHostCell === 'string' && CELL_ID_RE.test(nextHostCell) ? nextHostCell : null);
87
+ const revised = { revision: cur.revision + 1, hostCell, updatedAt: now() };
88
+ atomicWriteJson(filePath, revised);
89
+ return { ok: true, revision: revised.revision, hostCell: revised.hostCell, at: revised.updatedAt };
90
+ });
91
+ }
92
+
93
+ return { snapshot, compareAndSet, filePath };
94
+ }
95
+
96
+ module.exports = { createLiveHostStore, liveHostPath, readLiveHost, normalize, CELL_ID_RE };
package/lib/mcp/tools.js CHANGED
@@ -863,6 +863,57 @@ const TOOLS = [
863
863
  return { receipt };
864
864
  },
865
865
  },
866
+ // --- lease Live del child (fetta 2b, contratto rev1 B5) ----------------------
867
+ // Tre metodi DISTINTI perche' i valori di ritorno non mentono l'uno con l'altro:
868
+ // solo register puo' rispondere {status:'pending'} (join non ancora tracciato);
869
+ // refresh su registration viva risponde {status:'live', proof} e non ha stati
870
+ // pendenti; recovery riprende la STESSA incarnazione con un proof scaduto da
871
+ // poco. L'authorizer di refresh/recovery e' il proof HMAC firmato dal verifier
872
+ // per-installazione del server; la cella e' la sessione del chiamante.
873
+ {
874
+ name: 'nc_lease_register',
875
+ description: 'Registra questa cella al lease Live del nodo: crea una registration con incarnationId propria e consegna il primo proof child. Risponde {status:"pending"} se la cella non e\' ancora tracciata dal lease del supervisore: riprova dopo retryAfterMs.',
876
+ inputSchema: { type: 'object', properties: {} },
877
+ async handler(_args, ctx) {
878
+ const identity = await ctx.identity();
879
+ const session = requireSession(identity.session, 'nc_lease_register', identity.code);
880
+ return ctx.api('POST', '/api/lease/register', { session });
881
+ },
882
+ },
883
+ {
884
+ name: 'nc_lease_refresh',
885
+ description: 'Rinnova la registration child presentando l\'ultimo proof detenuto: risponde {status:"live"} con un proof nuovo. Mai pending: senza registration risponde {status:"no-registration"} (usa nc_lease_register).',
886
+ inputSchema: {
887
+ type: 'object',
888
+ properties: { proof: { type: 'object', description: 'ultimo proof ricevuto (register o refresh precedente), tale e quale' } },
889
+ required: ['proof'],
890
+ },
891
+ async handler(args, ctx) {
892
+ const identity = await ctx.identity();
893
+ const session = requireSession(identity.session, 'nc_lease_refresh', identity.code);
894
+ const proof = args.proof && typeof args.proof === 'object' && !Array.isArray(args.proof)
895
+ ? args.proof : null;
896
+ if (!proof) throw new Error('parametro "proof" obbligatorio (oggetto ricevuto da register/refresh)');
897
+ return ctx.api('POST', '/api/lease/refresh', { session, proof });
898
+ },
899
+ },
900
+ {
901
+ name: 'nc_lease_recovery',
902
+ description: 'Riprende la registration child dopo un gap: presenta l\'ultimo proof (accettato anche scaduto da poco) e riceve un proof nuovo con la STESSA incarnazione. Ogni presentazione conta come attempt: oltre il cap serve nc_lease_register.',
903
+ inputSchema: {
904
+ type: 'object',
905
+ properties: { proof: { type: 'object', description: 'ultimo proof detenuto, anche scaduto da poco' } },
906
+ required: ['proof'],
907
+ },
908
+ async handler(args, ctx) {
909
+ const identity = await ctx.identity();
910
+ const session = requireSession(identity.session, 'nc_lease_recovery', identity.code);
911
+ const proof = args.proof && typeof args.proof === 'object' && !Array.isArray(args.proof)
912
+ ? args.proof : null;
913
+ if (!proof) throw new Error('parametro "proof" obbligatorio (oggetto ricevuto in precedenza)');
914
+ return ctx.api('POST', '/api/lease/recovery', { session, proof });
915
+ },
916
+ },
866
917
  ];
867
918
 
868
919
  module.exports = {
@@ -279,15 +279,22 @@ function nodesEdit(opts) {
279
279
  // Lo scope celle vale per QUALUNQUE peer accoppiato, in entrambe le
280
280
  // direzioni: riguarda chi accede alle celle di questo nodo, non chi ha
281
281
  // aperto il tunnel.
282
+ // `panelAccess` segue la stessa regola dello scope celle: riguarda chi accede
283
+ // ai PANNELLI di questo nodo, non chi ha aperto il tunnel — quindi vale in
284
+ // entrambe le direzioni.
282
285
  const allowed = node.direction === 'inbound'
283
- ? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells'])
284
- : new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells']);
286
+ ? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells', 'panelAccess'])
287
+ : new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells', 'panelAccess']);
285
288
  const keys = Object.keys(supplied);
286
289
  const invalid = keys.find((key) => !allowed.has(key));
287
290
  if (invalid) {
288
291
  log(`nodes edit: campo "${invalid}" non modificabile per un peer ${node.direction}`);
289
292
  return { code: 1, reason: 'field-not-editable' };
290
293
  }
294
+ if (Object.hasOwn(supplied, 'panelAccess') && typeof supplied.panelAccess !== 'boolean') {
295
+ log('nodes edit: panelAccess vuole un booleano');
296
+ return { code: 1, reason: 'invalid-panel-access' };
297
+ }
291
298
  if (keys.length === 0) { log('nodes edit: nessuna modifica richiesta'); return { code: 1, reason: 'empty-patch' }; }
292
299
  const patch = { ...supplied };
293
300
  if (Object.hasOwn(patch, 'label')) {
@@ -112,6 +112,13 @@ const NODE_KEYS = new Set([
112
112
  // Quali CELLE di questo nodo il peer puo' vedere. Distinto da `visibility`,
113
113
  // che governa il TRANSITO (attraverso chi passa il traffico) e non l'accesso.
114
114
  'cellVisibility', 'cells',
115
+ // Se questo peer puo' aprire i PANNELLI delle celle di questo nodo. Distinto
116
+ // da `cellVisibility`, che dice quali celle il peer VEDE: dietro un pannello
117
+ // c'e' un browser con sessioni gia' autenticate, e un accesso di quel tipo non
118
+ // si revoca cambiando una chiave. Il resto del modello tratta un peer pairato
119
+ // come l'operatore stesso; qui no, e di proposito: default NEGATO, si concede
120
+ // per singolo nodo.
121
+ 'panelAccess',
115
122
  // Identita' crittografica del peer — passo 1 del modello di autorita'. Oggi
116
123
  // NON governa niente: si osserva e si registra. `keySource` distingue una
117
124
  // chiave legata al pairing (nello stesso atto in cui si e' deciso di fidarsi)
@@ -257,6 +264,11 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
257
264
  // requests the optional reverse (-R) channel. Old stores therefore migrate
258
265
  // safely to private without a schema bump.
259
266
  shared: n.shared === undefined ? false : n.shared,
267
+ // Default negato: un record esistente, scritto quando questo campo non
268
+ // c'era, NON acquisisce l'accesso ai pannelli per il fatto di essere gia'
269
+ // pairato. Migrazione silenziosa verso il permesso e' esattamente cio' che
270
+ // non deve succedere.
271
+ panelAccess: n.panelAccess === undefined ? false : n.panelAccess,
260
272
  visibility: n.visibility || 'network',
261
273
  };
262
274
  if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
@@ -264,6 +276,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
264
276
  if (direction === 'outbound' && out.transport === 'inbound') return null;
265
277
  if (typeof out.autostart !== 'boolean') return null;
266
278
  if (typeof out.shared !== 'boolean') return null;
279
+ if (typeof out.panelAccess !== 'boolean') return null;
267
280
  if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
268
281
  if (identityFile) out.identityFile = identityFile;
269
282
  // Keep the old public field while reading v1 so old callers/tests and a
@@ -645,6 +658,7 @@ function redactNode(n) {
645
658
  transport: n.transport || 'ssh',
646
659
  autostart: !!n.autostart,
647
660
  shared: n.shared === true,
661
+ panelAccess: n.panelAccess === true,
648
662
  visibility: n.visibility || 'network',
649
663
  hasToken: !!n.token, // presenza, non il valore
650
664
  paired: !!(n.token && n.acceptToken),
@@ -568,7 +568,10 @@ function startTunnel(opts) {
568
568
  let pid = child && child.pid;
569
569
  const cleanupIfOwned = () => {
570
570
  const current = pidf.readPidfile(pidPath);
571
- if (current && current.pid === pid && current.runId === runId) pidf.removePidfile(pidPath);
571
+ // allowLive: la garanzia e' il match pid+runId del NOSTRO spawn — il pidfile
572
+ // e' quello che questa via ha appena scritto per un figlio suo, che in questi
573
+ // rami d'errore e' stato appena segnato o non e' mai partito.
574
+ if (current && current.pid === pid && current.runId === runId) pidf.removePidfile(pidPath, { allowLive: true });
572
575
  removeStateIfOwned(home, name, { pid, runId });
573
576
  };
574
577
  if (child && typeof child.on === 'function') {
@@ -13,6 +13,7 @@ const {
13
13
  sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
14
14
  } = require('./node-proxy.js');
15
15
  const { signHop, HOP_HEADER } = require('./hop-proof.js');
16
+ const { COOKIE_NAME: PANEL_COOKIE_NAME } = require('./panel-auth.js');
16
17
 
17
18
  const MAX_HOPS = 4;
18
19
  const ROUTE_DELIMITER = '_';
@@ -185,10 +186,32 @@ function knownResource(resource) {
185
186
  // significava poter creare a distanza un engine che non si puo' rendere
186
187
  // avviabile a distanza — un'asimmetria che rompeva la modifica remota a
187
188
  // meta' strada, senza proteggere nulla.
189
+ || isPanelResource(resource)
188
190
  || /^\/fleet\/(status|schema|definitions|credentials\/status|credentials\/(?:set|remove)|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-model|remove-model|model-test|define-cell|edit-cell|remove-cell|restore-cells|restore-engines)$/.test(resource);
189
191
  }
190
192
 
193
+ // Il pannello di una cella e' l'unica risorsa federata con un gate PER-PEER a
194
+ // default negato, e la ragione e' cosa c'e' dietro: un browser con sessioni gia'
195
+ // autenticate. Il resto del modello tratta un peer pairato come l'operatore
196
+ // stesso (docs/SECURITY.md), qui no.
197
+ //
198
+ // Forma: /panel/<cellId>/<path arbitrario>. E' la prima risorsa federata che
199
+ // apre un sottoalbero invece di un endpoint chiuso — un pannello serve HTML, JS
200
+ // e asset suoi — e proprio per questo il gate va valutato PRIMA dell'allowlist:
201
+ // `canTransit` decide chi puo' ATTRAVERSARE questo nodo, non chi puo' aprire un
202
+ // pannello.
203
+ const PANEL_RESOURCE_RE = /^\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/;
204
+ function isPanelResource(resource) { return PANEL_RESOURCE_RE.test(resource); }
205
+
206
+ // `ingress` nullo = richiesta del proprietario di questo nodo (localRouter non
207
+ // lo passa mai): nessun gate. Da un peer, serve il permesso esplicito.
208
+ function panelAllowedFor(ingress) {
209
+ if (!ingress) return true;
210
+ return ingress.panelAccess === true;
211
+ }
212
+
191
213
  function allowedResource(resource, method = 'GET') {
214
+ if (isPanelResource(resource)) return method === 'GET' || method === 'POST';
192
215
  if (resource === '/sessions') return method === 'GET' || method === 'POST';
193
216
  if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return method === 'DELETE';
194
217
  if (/^\/sessions\/[\w.@%:+-]{1,128}\/visibility$/.test(resource)) return method === 'PATCH';
@@ -263,7 +286,34 @@ function allowedQuery(resource, method, rawUrl) {
263
286
  return true;
264
287
  }
265
288
 
266
- function cleanHeaders(headers, credential, visited = null, hopProof = null) {
289
+ // Il cookie di visione del pannello (`npanel`) e' l'UNICO cookie che attraversa
290
+ // la federazione, e solo sulle panel-resource.
291
+ //
292
+ // Perche' deve passare: le sotto-risorse dentro l'iframe sono URL RELATIVI senza
293
+ // query — il cookie e' l'unico modo che hanno di dire chi sono — e a
294
+ // riconoscerlo e' il nodo PROPRIETARIO, l'unico che lo ha emesso. Senza questo,
295
+ // il pannello remoto serve la pagina e poi nient'altro: un frame che sembra
296
+ // caricato e resta vuoto. (Finche' il Bearer dell'hop apriva tutto il difetto
297
+ // non si vedeva: i test lo attribuivano al cookie, che non arrivava mai.)
298
+ //
299
+ // Perche' SOLO questo: `sanitizeRequestHeaders` toglie l'intero header cookie, e
300
+ // giustamente — i cookie del browser sono della nostra origine e verso un altro
301
+ // nodo non significherebbero nulla. Qui si reintroduce un valore solo, opaco,
302
+ // per-cella e a scadenza breve, che il destinatario ha emesso lui stesso.
303
+ function panelViewCookie(headers) {
304
+ const raw = headers && (headers.cookie || headers.Cookie);
305
+ const source = Array.isArray(raw) ? raw.join('; ') : String(raw || '');
306
+ for (const part of source.split(';')) {
307
+ const i = part.indexOf('=');
308
+ if (i <= 0) continue;
309
+ if (part.slice(0, i).trim() !== PANEL_COOKIE_NAME) continue;
310
+ const value = part.slice(i + 1).trim();
311
+ return value ? `${PANEL_COOKIE_NAME}=${value}` : null;
312
+ }
313
+ return null;
314
+ }
315
+
316
+ function cleanHeaders(headers, credential, visited = null, hopProof = null, panelCookie = null) {
267
317
  const out = sanitizeRequestHeaders(headers, credential);
268
318
  for (const key of Object.keys(out)) {
269
319
  if (['x-nexuscrew-route', 'x-nexuscrew-visited', 'x-nexuscrew-hop'].includes(key.toLowerCase())) delete out[key];
@@ -272,12 +322,31 @@ function cleanHeaders(headers, credential, visited = null, hopProof = null) {
272
322
  // La prova di hop viene aggiunta DOPO la cancellazione: il canale e' riservato
273
323
  // al server, un valore arrivato dal client non sopravvive mai fin qui.
274
324
  if (hopProof) out[HOP_HEADER] = hopProof;
325
+ // Come sopra: dopo lo strip, e mai per risorse che non siano il pannello.
326
+ if (panelCookie) out.cookie = panelCookie;
275
327
  return out;
276
328
  }
277
329
 
278
- function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null }) {
279
- const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof) }, (r) => {
280
- res.writeHead(r.statusCode, sanitizeResponseHeaders(r.headers)); r.pipe(res);
330
+ // Riscrittura dei Set-Cookie verso il chiamante locale (il browser della PWA).
331
+ // Il pannello remoto emette il cookie di visione con `Path=/api/panel/<cella>`:
332
+ // quel path e' GIUSTO sul nodo che lo emette, ma il browser ha richiesto
333
+ // `/api/route/<nodi>/_/panel/...` — senza riscrittura il cookie non coprirebbe
334
+ // le sotto-risorse dell'iframe remoto e il frame resterebbe bianco con l'aria
335
+ // di funzionare. Si riscrive SOLO il prefisso `/api` in `/api/route/<nodi>/_`,
336
+ // il resto dell'ambito (per-cella) resta quello deciso dal nodo che lo emette.
337
+ function rewriteSetCookiePath(headers, prefix) {
338
+ const raw = headers['set-cookie'];
339
+ if (!raw) return headers;
340
+ const out = { ...headers };
341
+ out['set-cookie'] = (Array.isArray(raw) ? raw : [raw])
342
+ .map((line) => String(line).replace(/([Pp]ath=)\/api\//, `$1${prefix}/`));
343
+ return out;
344
+ }
345
+
346
+ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof = null, setCookiePrefix = null, panelCookie = null }) {
347
+ const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited, hopProof, panelCookie) }, (r) => {
348
+ const headers = setCookiePrefix ? rewriteSetCookiePath(r.headers, setCookiePrefix) : r.headers;
349
+ res.writeHead(r.statusCode, sanitizeResponseHeaders(headers)); r.pipe(res);
281
350
  });
282
351
  up.setTimeout(30000, () => up.destroy());
283
352
  up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile' }); else res.destroy(); });
@@ -287,6 +356,11 @@ function proxyHttp(req, res, { port, path, credential, visited = null, hopProof
287
356
  function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false, hopSecret = null }) {
288
357
  return (req, res) => {
289
358
  const parsed = parseRoute(req.url);
359
+ // Prima dell'allowlist: un peer senza permesso non deve nemmeno sapere se la
360
+ // risorsa esiste per una cella o per un'altra.
361
+ if (parsed && isPanelResource(parsed.resource) && !panelAllowedFor(ingress)) {
362
+ return res.status(403).json({ error: 'pannello non concesso a questo nodo', reason: 'panel-not-granted' });
363
+ }
290
364
  if (!parsed || !allowedResource(parsed.resource, req.method)
291
365
  || !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
292
366
  if (readonly() && readonlyBlocksFederated(parsed.resource, req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
@@ -294,6 +368,9 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
294
368
  if (!st) return res.status(503).json({ error: 'node store unavailable' });
295
369
  const visited = controlledVisited(req, ingress, st.nodeId);
296
370
  if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
371
+ // Vale per l'ultimo hop come per il transito: se si fermasse al primo, una
372
+ // route a due salti servirebbe la pagina e nessuna delle sue risorse.
373
+ const panelCookie = isPanelResource(parsed.resource) ? panelViewCookie(req.headers) : null;
297
374
  if (parsed.route.length === 0) {
298
375
  // Ultimo hop: la richiesta rientra nell'API locale con il Bearer locale e
299
376
  // da li' in poi sarebbe indistinguibile da un POST diretto. La prova di
@@ -306,6 +383,7 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
306
383
  credential: localCredential(),
307
384
  visited,
308
385
  hopProof: signHop(secret, { method: req.method, path, visited }),
386
+ panelCookie,
309
387
  });
310
388
  }
311
389
  const next = st && store.getNode(st, parsed.route[0]);
@@ -313,7 +391,14 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
313
391
  if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return res.status(403).json({ error: 'route non consentita' });
314
392
  const rest = parsed.route.slice(1);
315
393
  const path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}${queryOf(req.url)}`;
316
- proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited });
394
+ // Verso il browser di questo nodo (ingress nullo) il cookie di visione del
395
+ // pannello va riscritto col prefisso federato che il browser sta usando:
396
+ // vedi rewriteSetCookiePath. Da un peer in transito non si tocca — la
397
+ // riscrittura spetta all'hub dove il browser è collegato.
398
+ const setCookiePrefix = !ingress && isPanelResource(parsed.resource)
399
+ ? `/api/route/${parsed.route.join('/')}/${ROUTE_DELIMITER}`
400
+ : null;
401
+ proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited, setCookiePrefix, panelCookie });
317
402
  };
318
403
  }
319
404
 
@@ -1157,19 +1242,26 @@ function localRouter({ nodesPath, localPort, localCredential, readonly, hopSecre
1157
1242
  function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null, hopSecret = null }) {
1158
1243
  if (readonly()) return reject(socket, 403);
1159
1244
  const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
1160
- if (!parsed || parsed.resource !== '/ws') return reject(socket, 404);
1245
+ // Due percorsi separati: questo NON passa da routeHandler e non ne condivide i
1246
+ // controlli. Un gate scritto solo di la' sarebbe una porta chiusa accanto a una
1247
+ // aperta — e per un pannello la porta aperta e' l'unica che conta, perche' i
1248
+ // frame arrivano da qui.
1249
+ if (!parsed || (parsed.resource !== '/ws' && !isPanelResource(parsed.resource))) return reject(socket, 404);
1250
+ if (isPanelResource(parsed.resource) && !panelAllowedFor(ingress)) return reject(socket, 403);
1161
1251
  const st = store.loadStore(nodesPath);
1162
1252
  if (!st) return reject(socket, 503);
1163
1253
  const visited = controlledVisited(req, ingress, st.nodeId);
1164
1254
  if (!visited) return reject(socket, 409);
1165
- let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential(); let path = '/ws';
1255
+ let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential();
1256
+ // Ultimo hop: `/ws` entra com'e', il pannello rientra nell'API locale.
1257
+ let path = parsed.resource === '/ws' ? '/ws' : `/api${parsed.resource}`;
1166
1258
  if (parsed.route.length) {
1167
1259
  const next = store.getNode(st, parsed.route[0]);
1168
1260
  const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
1169
1261
  if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
1170
1262
  port = next.localPort; credential = next.token;
1171
1263
  const rest = parsed.route.slice(1);
1172
- path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}/ws`;
1264
+ path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}`;
1173
1265
  }
1174
1266
  const up = net.connect({ host: '127.0.0.1', port });
1175
1267
  up.once('connect', () => {
@@ -1184,7 +1276,11 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1184
1276
  const hopProof = !parsed.route.length && secret
1185
1277
  ? signHop(secret, { method: req.method || 'GET', path, visited })
1186
1278
  : null;
1187
- const headers = cleanHeaders(req.headers, credential, visited, hopProof);
1279
+ // Il cookie di visione serve QUI quanto sull'HTTP: la WebSocket del
1280
+ // pannello parte dalla pagina dentro il frame, e senza cookie il frame
1281
+ // carica e resta nero.
1282
+ const panelCookie = isPanelResource(parsed.resource) ? panelViewCookie(req.headers) : null;
1283
+ const headers = cleanHeaders(req.headers, credential, visited, hopProof, panelCookie);
1188
1284
  const lines = [`GET ${path} HTTP/1.1`, `Host: 127.0.0.1:${port}`];
1189
1285
  for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
1190
1286
  lines.push('Connection: Upgrade', 'Upgrade: websocket', '', '');
@@ -1202,6 +1298,7 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
1202
1298
  function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
1203
1299
 
1204
1300
  module.exports = {
1301
+ isPanelResource, panelAllowedFor, routeHandler,
1205
1302
  MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS, SHARE_NOT_READY_CODE, classifyShareFailure, activeReversePort,
1206
1303
  peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery, readonlyBlocksFederated,
1207
1304
  collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,