@mmmbuto/nexuscrew 0.8.58 → 0.9.1
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 +197 -2
- package/README.md +23 -4
- package/frontend/dist/assets/index-0vuhL1YP.css +32 -0
- package/frontend/dist/assets/index-Bu_-2-Uu.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 +54 -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 +24 -0
- package/lib/fleet/builtin.js +161 -19
- package/lib/fleet/catalogs/opencode-go.json +50 -4
- 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 +366 -48
- 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 +190 -0
- package/lib/live-host/store.js +96 -0
- package/lib/mcp/tools.js +51 -0
- package/lib/nodes/commands.js +21 -3
- package/lib/nodes/store.js +57 -0
- package/lib/nodes/tunnel.js +23 -1
- package/lib/proxy/federation.js +130 -9
- package/lib/proxy/node-proxy.js +33 -0
- package/lib/proxy/panel-auth.js +337 -0
- package/lib/proxy/panel-proxy.js +336 -0
- package/lib/server.js +252 -7
- package/lib/settings/pairing-coordinator.js +32 -0
- package/lib/settings/public-peering-routes.js +13 -1
- package/package.json +1 -1
- package/skills/aidesktop/SKILL.md +201 -0
- package/skills/aidesktop/docker/Dockerfile +21 -0
- package/skills/aidesktop/docker/custom-cont-init.d/10-cdp-relay.sh +20 -0
- package/skills/aidesktop/docker/docker-compose.example.yml +75 -0
- package/skills/crew/SKILL.md +15 -0
- package/skills/live/SKILL.md +90 -0
- package/skills/mail-assistant/SKILL.md +15 -0
- package/skills/memory/SKILL.md +15 -0
- package/skills/nexuscrew/SKILL.md +113 -0
- package/skills/nexuscrew-agent/SKILL.md +18 -0
- package/skills/vl-msa/SKILL.md +15 -0
- package/frontend/dist/assets/index-BEGNtmx2.js +0 -93
- package/frontend/dist/assets/index-CYi_lhCg.css +0 -32
- package/skills/alibaba-token-media/SKILL.md +0 -133
- package/skills/alibaba-token-media/agents/openai.yaml +0 -4
- package/skills/alibaba-token-media/references/api-contract.md +0 -97
- package/skills/alibaba-token-media/scripts/alibaba_token_media.py +0 -550
- package/skills/fill-forms/SKILL.md +0 -154
- package/skills/fill-forms/agents/openai.yaml +0 -4
- package/skills/fill-forms/references/overlay-technique.md +0 -99
- package/skills/fill-forms/requirements.txt +0 -4
- package/skills/fill-forms/scripts/dump_docx.py +0 -70
- package/skills/fill-forms/scripts/fill_docx.py +0 -207
- package/skills/fill-forms/scripts/fill_pdf.py +0 -424
- package/skills/fill-forms/scripts/inspect_pdf.py +0 -188
- package/skills/fill-forms/scripts/prepare_signature.py +0 -171
|
@@ -0,0 +1,190 @@
|
|
|
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 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.
|
|
12
|
+
//
|
|
13
|
+
// Invarianti (contratto rev6 §2, §9):
|
|
14
|
+
// - hostCell unico per nodo, CAS su revision (due designazioni concorrenti non
|
|
15
|
+
// lasciano due celle rosse).
|
|
16
|
+
// - API-first: la route e' l'unica autorita'; il frontend riflette la risposta e
|
|
17
|
+
// su errore resta sullo stato precedente (nessun ottimismo qui — la logica UI
|
|
18
|
+
// sta nel modulo frontend host-designation.js).
|
|
19
|
+
// - Cella inattiva PRESERVA la designazione: lo store non cancella mai hostCell per
|
|
20
|
+
// inattivita; `eligible` e' derivato dal roster al momento del GET.
|
|
21
|
+
// - readonly => 403 su designate/clear.
|
|
22
|
+
|
|
23
|
+
const express = require('express');
|
|
24
|
+
const { CELL_ID_RE } = require('./store.js');
|
|
25
|
+
|
|
26
|
+
// Ricava l'elenco celle LOCALI dal fleet (definizioni, attive e non). Una cella
|
|
27
|
+
// federata non compare qui: e' il check che chiude "designa solo una cella di questo
|
|
28
|
+
// nodo". Ritorna null se il fleet non e' interrogabile (la route decide come gestirlo).
|
|
29
|
+
async function localCells(fleetP) {
|
|
30
|
+
const fleet = await fleetP;
|
|
31
|
+
if (!fleet || fleet.available !== true) return null;
|
|
32
|
+
const statusFn = fleet && (typeof fleet.status === 'function' ? fleet.status : fleet.cellStatus);
|
|
33
|
+
if (typeof statusFn !== 'function') return null;
|
|
34
|
+
const st = await statusFn.call(fleet);
|
|
35
|
+
return Array.isArray(st && st.cells) ? st.cells : [];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isActive(cell) {
|
|
39
|
+
return !!(cell && cell.active === true && cell.tmux !== false);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// --- Seam lease↔designazione (2026-08-15, decisione di Dev: grace = false) -----
|
|
43
|
+
//
|
|
44
|
+
// L'idoneita' dell'host designato non e' piu' solo «sessione tmux viva»: con
|
|
45
|
+
// remain-on-exit la sessione sopravvive alla morte del supervisore, e la
|
|
46
|
+
// garanzia «l'host designato e' vivo» la puo' dare SOLO il lease (fetta 2b: il
|
|
47
|
+
// leaseId e' FIRMATO nel proof, quindi lo stato del lease identifica senza
|
|
48
|
+
// ambiguita' chi lo detiene). Regole:
|
|
49
|
+
// - eligible = attiva AND lease 'live'. In grace NON c'e' garanzia: eligible
|
|
50
|
+
// false, ma host.lease='grace' dice «recupero in corso» — chi legge
|
|
51
|
+
// distingue «non idonea perche' morta» da «non idonea perche' in recupero».
|
|
52
|
+
// - I cinque stati (live|grace|expired|none|unavailable) restano DISTINCTI
|
|
53
|
+
// fino a chi legge: collassarli e' rifare il difetto a un piano piu' su.
|
|
54
|
+
// - FALLBACK FAIL-OPEN DICHIARATO: senza fleet.lease (installazione senza
|
|
55
|
+
// lease) eligible torna tmux-only e host.lease='unavailable'. Non e' «va
|
|
56
|
+
// bene lo stesso»: e' «la garanzia non e' disponibile qui», detto esplicito
|
|
57
|
+
// perche' un eligible=true silenzioso verrebbe letto come confermata.
|
|
58
|
+
// - hostCell resta PRESERVATO in ogni caso (invariante dello store): oscilla
|
|
59
|
+
// l'idoneita', non la scelta dell'operatore.
|
|
60
|
+
function hostLeaseState(fleet, hostCell) {
|
|
61
|
+
if (hostCell == null) return null;
|
|
62
|
+
const lease = fleet && fleet.lease;
|
|
63
|
+
if (!lease || typeof lease.status !== 'function') return 'unavailable';
|
|
64
|
+
const st = lease.status(hostCell);
|
|
65
|
+
return (st && typeof st.state === 'string') ? st.state : 'none';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function eligibleOf(fleet, cell, hostCell) {
|
|
69
|
+
const leaseState = hostLeaseState(fleet, hostCell);
|
|
70
|
+
if (leaseState === null) return false; // senza soggetto non c'e' idoneita'
|
|
71
|
+
if (leaseState === 'unavailable') return isActive(cell); // fail-open dichiarato
|
|
72
|
+
return isActive(cell) && leaseState === 'live';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function liveHostRoutes({ fleetP, store, readonly = () => false, now = () => Date.now(), bridge = null }) {
|
|
76
|
+
const r = express.Router();
|
|
77
|
+
|
|
78
|
+
// GET /api/live-host — { hostCell, revision, eligible, host: {lease}, at }.
|
|
79
|
+
// hostCell e revision vengono dallo store (preservato); eligible e' la verita'
|
|
80
|
+
// COMPOSTA roster+lease (si veda hostLeaseState sopra); host.lease espone lo
|
|
81
|
+
// stato del lease della cella designata, distinto, perche' chi legge distingue.
|
|
82
|
+
r.get('/', async (_req, res) => {
|
|
83
|
+
try {
|
|
84
|
+
const snap = store.snapshot();
|
|
85
|
+
let eligible = false;
|
|
86
|
+
let lease = null;
|
|
87
|
+
if (snap.hostCell != null) {
|
|
88
|
+
const fleet = await fleetP.catch(() => null);
|
|
89
|
+
const cells = await localCells(fleetP).catch(() => []);
|
|
90
|
+
const cell = Array.isArray(cells) ? cells.find((c) => c && c.cell === snap.hostCell) : null;
|
|
91
|
+
lease = hostLeaseState(fleet, snap.hostCell);
|
|
92
|
+
eligible = eligibleOf(fleet, cell, snap.hostCell);
|
|
93
|
+
}
|
|
94
|
+
res.json({ hostCell: snap.hostCell, revision: snap.revision, eligible, host: { lease }, at: now() });
|
|
95
|
+
} catch (e) {
|
|
96
|
+
res.status(500).json({ error: String(e && e.message || e) });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// POST /api/live-host/designate { cellId, expectedRevision }.
|
|
101
|
+
// expectedRevision e' OBBLIGATORIO e integer (>=0): nessun CAS permissivo, la UI
|
|
102
|
+
// legge sempre la revision dal GET prima di scrivere (stato iniziale = 0).
|
|
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.
|
|
106
|
+
r.post('/designate', express.json({ limit: '4kb' }), async (req, res) => {
|
|
107
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: designazione cella ospite bloccata' });
|
|
108
|
+
const body = req.body || {};
|
|
109
|
+
if (Object.keys(body).some((k) => !['cellId', 'expectedRevision'].includes(k))
|
|
110
|
+
|| typeof body.cellId !== 'string' || !CELL_ID_RE.test(body.cellId)
|
|
111
|
+
|| !(Number.isInteger(body.expectedRevision) && body.expectedRevision >= 0)) {
|
|
112
|
+
return res.status(400).json({ error: 'designazione non valida' });
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const fleet = await fleetP.catch(() => null);
|
|
116
|
+
const cells = await localCells(fleetP);
|
|
117
|
+
if (cells === null) return res.status(503).json({ error: 'fleet non disponibile, riprova' });
|
|
118
|
+
const cell = cells.find((c) => c && c.cell === body.cellId);
|
|
119
|
+
if (!cell) return res.status(404).json({ error: 'cella non appartiene a questo nodo' });
|
|
120
|
+
const result = await store.compareAndSet(body.expectedRevision, body.cellId);
|
|
121
|
+
if (!result.ok) return res.status(409).json({
|
|
122
|
+
error: 'revision superata: rileggi e riprova', revision: result.revision, hostCell: result.hostCell,
|
|
123
|
+
});
|
|
124
|
+
res.json({
|
|
125
|
+
hostCell: result.hostCell, revision: result.revision,
|
|
126
|
+
eligible: eligibleOf(fleet, cell, result.hostCell),
|
|
127
|
+
host: { lease: hostLeaseState(fleet, result.hostCell) },
|
|
128
|
+
at: now(),
|
|
129
|
+
});
|
|
130
|
+
} catch (e) {
|
|
131
|
+
res.status(500).json({ error: String(e && e.message || e) });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// POST /api/live-host/clear { expectedRevision } — rimuove la designazione (CAS).
|
|
136
|
+
r.post('/clear', express.json({ limit: '4kb' }), async (req, res) => {
|
|
137
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: rimozione cella ospite bloccata' });
|
|
138
|
+
const body = req.body || {};
|
|
139
|
+
if (Object.keys(body).some((k) => k !== 'expectedRevision')
|
|
140
|
+
|| !(Number.isInteger(body.expectedRevision) && body.expectedRevision >= 0)) {
|
|
141
|
+
return res.status(400).json({ error: 'rimozione non valida' });
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const result = await store.compareAndSet(body.expectedRevision, null);
|
|
145
|
+
if (!result.ok) return res.status(409).json({
|
|
146
|
+
error: 'revision superata: rileggi e riprova', revision: result.revision, hostCell: result.hostCell,
|
|
147
|
+
});
|
|
148
|
+
res.json({ hostCell: null, revision: result.revision, at: now() });
|
|
149
|
+
} catch (e) {
|
|
150
|
+
res.status(500).json({ error: String(e && e.message || e) });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// POST /api/live-host/bridge — risolve il puntamento per l'avvio di una Live
|
|
155
|
+
// (fetta 3). La richiesta NON è parametrizzabile: la designazione è la
|
|
156
|
+
// condizione (LC3) e nessun chiamante può scegliere il target (MC3.4). Il
|
|
157
|
+
// ponte risponde sempre 200: i `none` con reason sono esiti legittimi e
|
|
158
|
+
// distinti (nessuna designazione / cella non idonea / fallback), non errori.
|
|
159
|
+
// Body opzionale e vuoto: un body con campi è un 400, non viene ignorato.
|
|
160
|
+
r.post('/bridge', express.json({ limit: '1kb' }), async (req, res) => {
|
|
161
|
+
const body = req.body || {};
|
|
162
|
+
if (Object.keys(body).length > 0) return res.status(400).json({ error: 'la risoluzione non accetta parametri' });
|
|
163
|
+
if (readonly()) return res.json({ mode: 'none', reason: 'readonly', at: now() });
|
|
164
|
+
if (!bridge) return res.status(503).json({ error: 'ponte Live non configurato su questo nodo' });
|
|
165
|
+
try {
|
|
166
|
+
const result = await bridge.resolveForLive();
|
|
167
|
+
res.json(result);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
// Il contratto (MC1.5) vuole che un guasto del ponte non fermi la Live:
|
|
170
|
+
// anche l'inaspettato collassa in `none` dichiarato, mai un 500.
|
|
171
|
+
// `bridge-error` e' l'ultima rete: un'eccezione che nessun ramo previsto ha
|
|
172
|
+
// classificato. Va NOMINATA come le altre, non lasciata fuori dall'elenco
|
|
173
|
+
// — una causa non dichiarata e' una causa che nessuno cerchera'.
|
|
174
|
+
res.json({ mode: 'none', reason: 'bridge-error', detail: String(e && e.message || e), at: now() });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Stesso body-error handling di cellsRoutes: payload troppo grande / JSON invalido.
|
|
179
|
+
r.use((err, _req, res, _next) => {
|
|
180
|
+
if (err && (err.type === 'entity.too.large' || err.status === 413)) {
|
|
181
|
+
return res.status(413).json({ error: 'body troppo grande' });
|
|
182
|
+
}
|
|
183
|
+
if (err instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
|
|
184
|
+
return res.status(err.status || 400).json({ error: String(err.message || err) });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return r;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
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
|
@@ -99,8 +99,14 @@ function bindLocalPort(port, createServerImpl = net.createServer) {
|
|
|
99
99
|
// Selezione OS-aware per il pairing: nodes.json evita collisioni logiche, il
|
|
100
100
|
// bind reale evita porte gia' occupate da processi estranei. La socket resta
|
|
101
101
|
// riservata fino all'avvio del supervisor SSH e viene poi rilasciata una volta.
|
|
102
|
+
// Ogni LISTENER locale conta: localPort di controllo e panelLocalPort inoltrata
|
|
103
|
+
// condividono lo stesso loopback, e la stessa regola di univocita' dello store.
|
|
102
104
|
async function reserveLocalPort(st, opts = {}) {
|
|
103
|
-
const used = new Set(
|
|
105
|
+
const used = new Set();
|
|
106
|
+
for (const n of st.nodes || []) {
|
|
107
|
+
if (Number.isInteger(n.localPort)) used.add(n.localPort);
|
|
108
|
+
if (Number.isInteger(n.panelLocalPort)) used.add(n.panelLocalPort);
|
|
109
|
+
}
|
|
104
110
|
const createServerImpl = opts.createServerImpl || net.createServer;
|
|
105
111
|
for (let port = opts.start || LOCAL_PORT_BASE; port <= 65535; port += 1) {
|
|
106
112
|
if (used.has(port)) continue;
|
|
@@ -279,15 +285,27 @@ function nodesEdit(opts) {
|
|
|
279
285
|
// Lo scope celle vale per QUALUNQUE peer accoppiato, in entrambe le
|
|
280
286
|
// direzioni: riguarda chi accede alle celle di questo nodo, non chi ha
|
|
281
287
|
// aperto il tunnel.
|
|
288
|
+
// `panelAccess` segue la stessa regola dello scope celle: riguarda chi accede
|
|
289
|
+
// ai PANNELLI di questo nodo, non chi ha aperto il tunnel — quindi vale in
|
|
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.
|
|
282
292
|
const allowed = node.direction === 'inbound'
|
|
283
|
-
? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells'])
|
|
284
|
-
: new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells']);
|
|
293
|
+
? new Set(['label', 'visibility', 'selected', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess'])
|
|
294
|
+
: new Set(['label', 'ssh', 'sshPort', 'autostart', 'cellVisibility', 'cells', 'panelAccess', 'liveHostAccess']);
|
|
285
295
|
const keys = Object.keys(supplied);
|
|
286
296
|
const invalid = keys.find((key) => !allowed.has(key));
|
|
287
297
|
if (invalid) {
|
|
288
298
|
log(`nodes edit: campo "${invalid}" non modificabile per un peer ${node.direction}`);
|
|
289
299
|
return { code: 1, reason: 'field-not-editable' };
|
|
290
300
|
}
|
|
301
|
+
if (Object.hasOwn(supplied, 'panelAccess') && typeof supplied.panelAccess !== 'boolean') {
|
|
302
|
+
log('nodes edit: panelAccess vuole un booleano');
|
|
303
|
+
return { code: 1, reason: 'invalid-panel-access' };
|
|
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
|
+
}
|
|
291
309
|
if (keys.length === 0) { log('nodes edit: nessuna modifica richiesta'); return { code: 1, reason: 'empty-patch' }; }
|
|
292
310
|
const patch = { ...supplied };
|
|
293
311
|
if (Object.hasOwn(patch, 'label')) {
|
package/lib/nodes/store.js
CHANGED
|
@@ -112,6 +112,19 @@ 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',
|
|
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',
|
|
115
128
|
// Identita' crittografica del peer — passo 1 del modello di autorita'. Oggi
|
|
116
129
|
// NON governa niente: si osserva e si registra. `keySource` distingue una
|
|
117
130
|
// chiave legata al pairing (nello stesso atto in cui si e' deciso di fidarsi)
|
|
@@ -119,6 +132,13 @@ const NODE_KEYS = new Set([
|
|
|
119
132
|
// grant potra' pretendere la prima. `keyConflict` conserva una chiave DIVERSA
|
|
120
133
|
// vista dopo il legame, senza sostituire quella legata.
|
|
121
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',
|
|
122
142
|
]);
|
|
123
143
|
|
|
124
144
|
// Nome di cella: stessa forma usata da fleet/definitions.js, cells/routes.js e
|
|
@@ -257,6 +277,14 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
257
277
|
// requests the optional reverse (-R) channel. Old stores therefore migrate
|
|
258
278
|
// safely to private without a schema bump.
|
|
259
279
|
shared: n.shared === undefined ? false : n.shared,
|
|
280
|
+
// Default negato: un record esistente, scritto quando questo campo non
|
|
281
|
+
// c'era, NON acquisisce l'accesso ai pannelli per il fatto di essere gia'
|
|
282
|
+
// pairato. Migrazione silenziosa verso il permesso e' esattamente cio' che
|
|
283
|
+
// non deve succedere.
|
|
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,
|
|
260
288
|
visibility: n.visibility || 'network',
|
|
261
289
|
};
|
|
262
290
|
if (!['auto', 'ssh', 'autossh', 'inbound'].includes(out.transport)) return null;
|
|
@@ -264,6 +292,8 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
264
292
|
if (direction === 'outbound' && out.transport === 'inbound') return null;
|
|
265
293
|
if (typeof out.autostart !== 'boolean') return null;
|
|
266
294
|
if (typeof out.shared !== 'boolean') return null;
|
|
295
|
+
if (typeof out.panelAccess !== 'boolean') return null;
|
|
296
|
+
if (typeof out.liveHostAccess !== 'boolean') return null;
|
|
267
297
|
if (!['network', 'relay-only', 'selected'].includes(out.visibility)) return null;
|
|
268
298
|
if (identityFile) out.identityFile = identityFile;
|
|
269
299
|
// Keep the old public field while reading v1 so old callers/tests and a
|
|
@@ -273,6 +303,16 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
273
303
|
if (!isPort(n.reversePort)) return null;
|
|
274
304
|
out.reversePort = n.reversePort;
|
|
275
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
|
+
}
|
|
276
316
|
if (n.reversePool !== undefined) {
|
|
277
317
|
if (schemaVersion < SCHEMA_VERSION) return null;
|
|
278
318
|
const parsedPool = parseReversePool(n.reversePool);
|
|
@@ -404,6 +444,13 @@ function parseStore(raw) {
|
|
|
404
444
|
names.add(node.name);
|
|
405
445
|
if (localPorts.has(node.localPort)) return null; // ogni listener locale ha un solo owner
|
|
406
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
|
+
}
|
|
407
454
|
if (node.nodeId) {
|
|
408
455
|
if (node.nodeId === d.nodeId) return null; // self-reference nei dati salvati
|
|
409
456
|
if (ids.has(node.nodeId)) return null; // nodeId remoto univoco
|
|
@@ -645,6 +692,8 @@ function redactNode(n) {
|
|
|
645
692
|
transport: n.transport || 'ssh',
|
|
646
693
|
autostart: !!n.autostart,
|
|
647
694
|
shared: n.shared === true,
|
|
695
|
+
panelAccess: n.panelAccess === true,
|
|
696
|
+
liveHostAccess: n.liveHostAccess === true,
|
|
648
697
|
visibility: n.visibility || 'network',
|
|
649
698
|
hasToken: !!n.token, // presenza, non il valore
|
|
650
699
|
paired: !!(n.token && n.acceptToken),
|
|
@@ -680,6 +729,14 @@ function redactNode(n) {
|
|
|
680
729
|
...(n.reversePool.lastAutoRotationAt === undefined ? {} : { lastAutoRotationAt: n.reversePool.lastAutoRotationAt }),
|
|
681
730
|
};
|
|
682
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
|
+
}
|
|
683
740
|
return out;
|
|
684
741
|
}
|
|
685
742
|
|
package/lib/nodes/tunnel.js
CHANGED
|
@@ -44,6 +44,17 @@ function assertForwardSpec(node) {
|
|
|
44
44
|
if (node.keyPath !== undefined && !store.isAbsPath(node.keyPath)) throw new Error('tunnel: keyPath non valido');
|
|
45
45
|
if (node.reversePort !== undefined && !store.isPort(node.reversePort)) throw new Error('tunnel: reversePort non valida');
|
|
46
46
|
if (node.localAppPort !== undefined && !store.isPort(node.localAppPort)) throw new Error('tunnel: localAppPort non valida');
|
|
47
|
+
// Coppia pannello (P0 stessa-origin remota): o entrambe o nessuna, e mai
|
|
48
|
+
// sulla porta locale del control plane — il bind conflitto abbatterebbe il
|
|
49
|
+
// tunnel intero con ExitOnForwardFailure.
|
|
50
|
+
if (node.panelLocalPort !== undefined || node.panelRemotePort !== undefined) {
|
|
51
|
+
if (!store.isPort(node.panelLocalPort) || !store.isPort(node.panelRemotePort)) {
|
|
52
|
+
throw new Error('tunnel: coppia pannello incompleta (panelLocalPort/panelRemotePort)');
|
|
53
|
+
}
|
|
54
|
+
if (node.panelLocalPort === node.localPort) {
|
|
55
|
+
throw new Error('tunnel: panelLocalPort coincide con localPort (bind conflitto)');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
47
58
|
if (node.shared === true && node.reversePort !== undefined && !node.reversePool && !store.isPort(node.localAppPort)) {
|
|
48
59
|
throw new Error('tunnel: Share richiede localAppPort esplicita');
|
|
49
60
|
}
|
|
@@ -64,8 +75,16 @@ function buildForwardArgs(node) {
|
|
|
64
75
|
const reverse = node.shared === true && node.reversePort !== undefined && !node.reversePool ? [
|
|
65
76
|
'-R', `127.0.0.1:${node.reversePort}:127.0.0.1:${node.localAppPort}`,
|
|
66
77
|
] : [];
|
|
78
|
+
// Second forward, same supervisor: the peer's panel port lands on our
|
|
79
|
+
// loopback so the browser gets a DIFFERENT origin for the panel. Absent pair
|
|
80
|
+
// (peer paired before this negotiation) emits nothing — the tunnel stays
|
|
81
|
+
// byte-identical to what it was.
|
|
82
|
+
const panel = node.panelLocalPort !== undefined && node.panelRemotePort !== undefined ? [
|
|
83
|
+
'-L', `127.0.0.1:${node.panelLocalPort}:127.0.0.1:${node.panelRemotePort}`,
|
|
84
|
+
] : [];
|
|
67
85
|
return SSH_BASE_OPTS.concat(transport, identity ? ['-i', identity] : [], [
|
|
68
86
|
'-L', `127.0.0.1:${node.localPort}:127.0.0.1:${node.remotePort}`,
|
|
87
|
+
...panel,
|
|
69
88
|
...reverse,
|
|
70
89
|
node.ssh,
|
|
71
90
|
]);
|
|
@@ -568,7 +587,10 @@ function startTunnel(opts) {
|
|
|
568
587
|
let pid = child && child.pid;
|
|
569
588
|
const cleanupIfOwned = () => {
|
|
570
589
|
const current = pidf.readPidfile(pidPath);
|
|
571
|
-
|
|
590
|
+
// allowLive: la garanzia e' il match pid+runId del NOSTRO spawn — il pidfile
|
|
591
|
+
// e' quello che questa via ha appena scritto per un figlio suo, che in questi
|
|
592
|
+
// rami d'errore e' stato appena segnato o non e' mai partito.
|
|
593
|
+
if (current && current.pid === pid && current.runId === runId) pidf.removePidfile(pidPath, { allowLive: true });
|
|
572
594
|
removeStateIfOwned(home, name, { pid, runId });
|
|
573
595
|
};
|
|
574
596
|
if (child && typeof child.on === 'function') {
|