@mmmbuto/nexuscrew 0.8.57 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +164 -2
- package/README.md +1 -0
- package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
- package/frontend/dist/assets/index-zjL6kZ7J.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/audio/adapters.js +50 -6
- package/lib/cli/commands.js +40 -2
- package/lib/cli/doctor.js +95 -12
- package/lib/cli/init.js +25 -3
- package/lib/cli/path.js +43 -10
- package/lib/cli/pidfile.js +23 -2
- package/lib/config.js +15 -0
- package/lib/fleet/builtin.js +161 -19
- package/lib/fleet/catalogs/opencode-go.json +328 -0
- package/lib/fleet/cell-exec.js +87 -9
- package/lib/fleet/cell-lease-server.js +719 -0
- package/lib/fleet/cell-lease.js +112 -0
- package/lib/fleet/definitions.js +101 -7
- package/lib/fleet/launch-broker.js +115 -3
- package/lib/fleet/lease-client.js +191 -0
- package/lib/fleet/lease-routes.js +92 -0
- package/lib/fleet/lease-verifier.js +230 -0
- package/lib/fleet/managed.js +444 -55
- package/lib/fleet/prompt-delivery.js +50 -2
- package/lib/fleet/provider.js +1 -1
- package/lib/fleet/runtime.js +53 -6
- package/lib/live-host/bridge.js +369 -0
- package/lib/live-host/routes.js +184 -0
- package/lib/live-host/store.js +96 -0
- package/lib/mcp/tools.js +51 -0
- package/lib/nodes/commands.js +9 -2
- package/lib/nodes/store.js +14 -0
- package/lib/nodes/tunnel.js +4 -1
- package/lib/proxy/federation.js +106 -9
- package/lib/proxy/node-proxy.js +33 -0
- package/lib/proxy/panel-auth.js +307 -0
- package/lib/proxy/panel-proxy.js +305 -0
- package/lib/server.js +127 -4
- package/package.json +1 -1
- package/skills/alibaba-token-media/SKILL.md +19 -0
- package/skills/crew/SKILL.md +15 -0
- package/skills/fill-forms/SKILL.md +23 -0
- package/skills/mail-assistant/SKILL.md +15 -0
- package/skills/memory/SKILL.md +15 -0
- package/skills/nexuscrew-agent/SKILL.md +18 -0
- package/skills/vl-msa/SKILL.md +15 -0
- package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
- package/frontend/dist/assets/index-_c-1_3iR.js +0 -93
|
@@ -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 = {
|
package/lib/nodes/commands.js
CHANGED
|
@@ -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')) {
|
package/lib/nodes/store.js
CHANGED
|
@@ -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),
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -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
|
-
|
|
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') {
|
package/lib/proxy/federation.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
|
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}
|
|
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
|
-
|
|
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,
|