@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
package/lib/server.js
CHANGED
|
@@ -23,7 +23,11 @@ const VERSION = require('../package.json').version;
|
|
|
23
23
|
const { transcribe } = require('./voice/transcribe.js');
|
|
24
24
|
const { selectProvider } = require('./fleet/provider.js');
|
|
25
25
|
const { fleetRoutes } = require('./fleet/routes.js');
|
|
26
|
+
const { leaseRoutes } = require('./fleet/lease-routes.js');
|
|
26
27
|
const { cellsRoutes } = require('./cells/routes.js');
|
|
28
|
+
const { liveHostRoutes } = require('./live-host/routes.js');
|
|
29
|
+
const { createLiveHostStore, liveHostPath } = require('./live-host/store.js');
|
|
30
|
+
const { createLiveBridge } = require('./live-host/bridge.js');
|
|
27
31
|
const { fsRoutes } = require('./fs/routes.js');
|
|
28
32
|
const nodesStore = require('./nodes/store.js');
|
|
29
33
|
const nodesTunnel = require('./nodes/tunnel.js');
|
|
@@ -33,6 +37,8 @@ const { createReverseSlotListeners } = require('./nodes/reverse-slot-listeners.j
|
|
|
33
37
|
const reverseRotation = require('./nodes/reverse-rotation.js');
|
|
34
38
|
const topologyCache = require('./nodes/topology-cache.js');
|
|
35
39
|
const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
|
|
40
|
+
const { createPanelProxy, handlePanelUpgrade } = require('./proxy/panel-proxy.js');
|
|
41
|
+
const { createPanelAuth } = require('./proxy/panel-auth.js');
|
|
36
42
|
const federation = require('./proxy/federation.js');
|
|
37
43
|
const { audioRoutes } = require('./audio/routes.js');
|
|
38
44
|
const { isConsent: isAudioConsent } = require('./audio/consent.js');
|
|
@@ -526,6 +532,14 @@ function createServer(opts = {}) {
|
|
|
526
532
|
}
|
|
527
533
|
|
|
528
534
|
const app = express();
|
|
535
|
+
// CSP frame-ancestors (P0 sicurezza 2026-08-16, rimedio 4 del report): la
|
|
536
|
+
// pagina dell'app non deve poter essere incorporata in un iframe altrui —
|
|
537
|
+
// clickjacking sul control plane. Ortogonale alla porta pannello: quello
|
|
538
|
+
// difende CHI E' NEL FRAME, questo difende l'app dall'ESSERE il frame.
|
|
539
|
+
app.use((_req, res, next) => {
|
|
540
|
+
res.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
|
|
541
|
+
next();
|
|
542
|
+
});
|
|
529
543
|
reverseSlotListeners = createReverseSlotListeners({
|
|
530
544
|
app, diagnostics, createServerImpl: cfg.reverseSlotCreateServerImpl,
|
|
531
545
|
// `routeUpgrade` e' hoisted: qui si cattura solo il riferimento, la
|
|
@@ -535,7 +549,19 @@ function createServer(opts = {}) {
|
|
|
535
549
|
const distDir = path.join(__dirname, '..', 'frontend', 'dist');
|
|
536
550
|
// no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
|
|
537
551
|
app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
|
|
538
|
-
|
|
552
|
+
// Il panelServer nasce piu' avanti nella stessa funzione: il pairing legge
|
|
553
|
+
// la sua porta VIVA tramite questo ref, risolto a ogni join (pattern dello
|
|
554
|
+
// stesso tipo dell'hoisting di routeUpgrade qui sopra).
|
|
555
|
+
const panelListenerRef = { server: null };
|
|
556
|
+
app.use('/pair', publicPeeringRoutes({
|
|
557
|
+
cfg, nodesPath,
|
|
558
|
+
panelPort: () => {
|
|
559
|
+
const srv = panelListenerRef.server;
|
|
560
|
+
if (!srv || !srv.listening) return null;
|
|
561
|
+
const addr = srv.address();
|
|
562
|
+
return addr && Number.isInteger(addr.port) && addr.port > 0 ? addr.port : null;
|
|
563
|
+
},
|
|
564
|
+
}));
|
|
539
565
|
// VL micro-device nodes use a separate, scoped credential surface. These
|
|
540
566
|
// routes never accept the NexusCrew UI bearer and expose only pair, poll and
|
|
541
567
|
// self-unpair. Operator management remains behind /api + federation ACL.
|
|
@@ -549,9 +575,121 @@ function createServer(opts = {}) {
|
|
|
549
575
|
if (!reverseSlotListeners.respond(req, res)) res.status(404).json({ error: 'reverse slot non disponibile' });
|
|
550
576
|
});
|
|
551
577
|
|
|
552
|
-
//
|
|
578
|
+
// Pannello per-cella (D8): inoltra il traffico verso il `panelUrl` di UNA cella
|
|
579
|
+
// LOCALE. La destinazione non arriva mai dal chiamante — si risolve dallo stato
|
|
580
|
+
// della cella — e il token di NexusCrew non prosegue verso il pannello, che e'
|
|
581
|
+
// un servizio terzo e non un nodo. Local-only come /api/live-host: il pass-through
|
|
582
|
+
// /node/<name>/ NON deve portarci un peer qualsiasi (vedi LOCAL_ONLY_PREFIXES in
|
|
583
|
+
// proxy/node-proxy.js). L'attraversamento verso i nodi del proprietario passera'
|
|
584
|
+
// dalla via allowlistata della federazione, dietro il gate di proprieta'.
|
|
585
|
+
async function resolveCellPanel(cellId) {
|
|
586
|
+
const fleet = await fleetP;
|
|
587
|
+
if (!fleet || fleet.available !== true) return null; // fleet non interrogabile
|
|
588
|
+
const statusFn = typeof fleet.status === 'function' ? fleet.status : fleet.cellStatus;
|
|
589
|
+
if (typeof statusFn !== 'function') return null;
|
|
590
|
+
const st = await statusFn.call(fleet);
|
|
591
|
+
const cells = Array.isArray(st && st.cells) ? st.cells : [];
|
|
592
|
+
// La chiave e' `cell`, non `id`: e' cio' che cellStatus PRODUCE
|
|
593
|
+
// (fleet/runtime.js scrive `cell: c.id`, dove `id` e' il nome interno della
|
|
594
|
+
// definizione). Cercare `c.id` qui non trovava MAI nulla, quindi ogni cella
|
|
595
|
+
// con un pannello configurato rispondeva «pannello non disponibile» — e la
|
|
596
|
+
// UI mostrava una causa col nome sbagliato. Tutti gli altri consumatori di
|
|
597
|
+
// cellStatus usano gia' `c.cell` (cells/routes.js, live-host/bridge.js,
|
|
598
|
+
// live-host/routes.js): questo era l'unico fuori posto.
|
|
599
|
+
const cell = cells.find((c) => c && c.cell === cellId);
|
|
600
|
+
if (!cell) return undefined; // cella sconosciuta
|
|
601
|
+
return typeof cell.panelUrl === 'string' ? cell.panelUrl.trim() : '';
|
|
602
|
+
}
|
|
603
|
+
// Tutte le altre /api dietro Bearer: sul loopback il gate vero è il tunnel,
|
|
553
604
|
// ma il token chiude anche altri processi locali della stessa macchina.
|
|
554
605
|
const api = express.Router();
|
|
606
|
+
// L'INGRESSO al pannello sta PRIMA del requireToken generale: un <iframe> e'
|
|
607
|
+
// una navigazione del browser e non porta header, quindi l'autenticazione
|
|
608
|
+
// qui e' dedicata — Bearer per la PWA, ticket monouso + cookie di visione
|
|
609
|
+
// per l'iframe (proxy/panel-auth.js, misura 2026-08-15). Il cookie vale
|
|
610
|
+
// SOLO sul path della cella che lo ha emesso: non e' e non diventa
|
|
611
|
+
// un'autenticazione dell'origine.
|
|
612
|
+
const panelAuth = createPanelAuth({
|
|
613
|
+
verifyToken: (t) => verify(tokenHolder.value, t),
|
|
614
|
+
resolveCellPanel,
|
|
615
|
+
// Senza questo segreto il pannello non saprebbe distinguere la PWA
|
|
616
|
+
// dall'ultimo hop di una route federata — che entra qui col Bearer che il
|
|
617
|
+
// proxy si e' iniettato da se'. E' lo stesso segreto per-processo che
|
|
618
|
+
// firma gli hop di sotto: due valori diversi renderebbero ogni federata
|
|
619
|
+
// 'sospetta', cioe' chiuderebbero il pannello remoto invece di proteggerlo.
|
|
620
|
+
hopSecret: () => hopSecret,
|
|
621
|
+
log: (entry) => diagnostics.record('panel-auth', 'info', entry.outcome, {
|
|
622
|
+
cell: entry.cell, reason: entry.reason, state: entry.outcome,
|
|
623
|
+
}),
|
|
624
|
+
});
|
|
625
|
+
api.use('/panel', panelAuth.panelAuthMiddleware, createPanelProxy({
|
|
626
|
+
resolveCellPanel,
|
|
627
|
+
log: (entry) => diagnostics.record('panel-proxy', 'info', entry.outcome, {
|
|
628
|
+
cell: entry.cell, reason: entry.reason, state: entry.outcome,
|
|
629
|
+
}),
|
|
630
|
+
}));
|
|
631
|
+
// Porta pannello dedicata (P0 sicurezza 2026-08-16): un iframe
|
|
632
|
+
// same-origin senza sandbox legge il localStorage del padre — il JS di un
|
|
633
|
+
// pannello ostile puo' prendersi il token e agire come l'operatore. La
|
|
634
|
+
// porta fa parte dell'origin: spostando SOLO il consumo (ticket in query,
|
|
635
|
+
// cookie di visione) su una porta sua, il browser vede un'origin diversa e
|
|
636
|
+
// non c'e' piu' nulla da rubare. Nessuna API di controllo qui, nessun
|
|
637
|
+
// Bearer verificato: l'emissione del ticket resta sopra, dietro
|
|
638
|
+
// requireToken — e' l'unica operazione che richiede provare CHI chiede.
|
|
639
|
+
const panelApp = express();
|
|
640
|
+
panelApp.use('/panel', panelAuth.consumeMiddleware, createPanelProxy({
|
|
641
|
+
resolveCellPanel,
|
|
642
|
+
log: (entry) => diagnostics.record('panel-proxy', 'info', entry.outcome, {
|
|
643
|
+
cell: entry.cell, reason: entry.reason, state: entry.outcome,
|
|
644
|
+
}),
|
|
645
|
+
}));
|
|
646
|
+
// Qualunque altra cosa (niente /api, niente SPA): 404 secco. Questa porta
|
|
647
|
+
// non ha nient'altro da offrire, e non deve mai imparare a offrirlo per
|
|
648
|
+
// errore — un catch-all che rispondesse con l'app sarebbe esattamente il
|
|
649
|
+
// difetto che questa porta esiste per chiudere.
|
|
650
|
+
panelApp.use((_req, res) => res.status(404).json({ error: 'not found' }));
|
|
651
|
+
const panelServer = http.createServer(panelApp);
|
|
652
|
+
const panelServerV6 = http.createServer(panelApp);
|
|
653
|
+
panelListenerRef.server = panelServer;
|
|
654
|
+
function routePanelUpgrade(req, socket, head) {
|
|
655
|
+
let pathname;
|
|
656
|
+
try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
|
|
657
|
+
catch (_) { try { socket.destroy(); } catch (_e) {} return; }
|
|
658
|
+
if (!pathname.startsWith('/panel/')) { try { socket.destroy(); } catch (_) {} return; }
|
|
659
|
+
handlePanelUpgrade({
|
|
660
|
+
req, socket, head, resolveCellPanel,
|
|
661
|
+
verifyToken: (t) => verify(tokenHolder.value, t),
|
|
662
|
+
authorize: panelAuth.authorizeUpgrade,
|
|
663
|
+
log: (entry) => diagnostics.record('panel-proxy', 'info', entry.outcome, {
|
|
664
|
+
cell: entry.cell, reason: entry.reason, state: entry.outcome,
|
|
665
|
+
}),
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
panelServer.on('upgrade', routePanelUpgrade);
|
|
669
|
+
panelServerV6.on('upgrade', routePanelUpgrade);
|
|
670
|
+
panelServer.on('close', () => { try { panelServerV6.close(); } catch (_) {} });
|
|
671
|
+
// L'ingresso ai pannelli REMOTI ha la stessa malattia dell'iframe locale: la
|
|
672
|
+
// via federata /api/route/<nodi>/_/panel/... sta per finire sotto requireToken
|
|
673
|
+
// e una navigazione del browser non porta header. QUI il ticket non si può
|
|
674
|
+
// validare — è del nodo che lo ha emesso — quindi per le panel-resource
|
|
675
|
+
// federate il requireToken NON si applica: decide il nodo proprietario (gate
|
|
676
|
+
// panelAccess + panelAuth col ticket/cookie, e l'ultimo hop lo riconosce
|
|
677
|
+
// dalla prova di hop: di là il Bearer non apre più il pannello, altrimenti
|
|
678
|
+
// il contenuto uscirebbe per ogni peer con panelAccess senza che nessuno
|
|
679
|
+
// abbia mai preso un ticket). Resta della PWA autenticata l'EMISSIONE: il POST
|
|
680
|
+
// .../panel/<cella>/ticket transita di sotto, col Bearer, come ogni altra /api.
|
|
681
|
+
const panelFederatoRouter = federation.localRouter({
|
|
682
|
+
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
683
|
+
hopSecret: () => hopSecret,
|
|
684
|
+
});
|
|
685
|
+
api.use('/route', (req, res, next) => {
|
|
686
|
+
const i = req.url.indexOf('/_/');
|
|
687
|
+
if (i === -1) return next();
|
|
688
|
+
const resource = req.url.slice(i + 2);
|
|
689
|
+
if (!/^\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(resource)) return next();
|
|
690
|
+
if (req.method === 'POST' && /^\/panel\/[A-Za-z0-9._-]{1,32}\/ticket\/?$/.test(resource)) return next();
|
|
691
|
+
panelFederatoRouter(req, res, next);
|
|
692
|
+
});
|
|
555
693
|
api.use(requireToken(tokenStore));
|
|
556
694
|
// Origine per lo scope celle: stessa prova di hop usata da audio e notify.
|
|
557
695
|
// requireCell:false perche' qui interessa QUALE NODO parla, non quale cella
|
|
@@ -656,11 +794,26 @@ function createServer(opts = {}) {
|
|
|
656
794
|
res.json(await setSessionVisibility(cfg.tmuxBin, name, req.body?.technical === true));
|
|
657
795
|
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
658
796
|
});
|
|
797
|
+
// Mappa nome-nodo -> porta pannello inoltrata sul NOSTRO loopback (coppia
|
|
798
|
+
// panel negoziata nel pairing). E' la fonte con cui il frontend mette il
|
|
799
|
+
// frame di una cella REMOTA su un'origin diversa: la lettura e' questa, non
|
|
800
|
+
// /api/nodes, perche' qui non c'e' nessun probe di health federato — la
|
|
801
|
+
// mappa costa quanto la lettura dello store. Un nodo assente dalla mappa e
|
|
802
|
+
// un peer accoppiato prima di questa negoziazione: via storica.
|
|
803
|
+
const nodePanelPorts = () => {
|
|
804
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
805
|
+
const out = {};
|
|
806
|
+
if (st) for (const n of st.nodes) {
|
|
807
|
+
if (n.panelLocalPort !== undefined) out[n.name] = n.panelLocalPort;
|
|
808
|
+
}
|
|
809
|
+
return out;
|
|
810
|
+
};
|
|
659
811
|
api.get('/config', (_req, res) => res.json({
|
|
660
812
|
readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
|
|
661
|
-
bind: cfg.bind, port: cfg.port,
|
|
813
|
+
bind: cfg.bind, port: cfg.port, panelPort: cfg.panelPort,
|
|
662
814
|
protectSharedTmuxServer: cfg.protectSharedTmuxServer !== false,
|
|
663
815
|
instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
816
|
+
nodePanelPorts: nodePanelPorts(),
|
|
664
817
|
presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
|
|
665
818
|
}));
|
|
666
819
|
api.use('/files', filesRoutes({
|
|
@@ -710,6 +863,10 @@ function createServer(opts = {}) {
|
|
|
710
863
|
federatedRate: createSpeakRateLimiter(),
|
|
711
864
|
}));
|
|
712
865
|
api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
|
|
866
|
+
// Fetta 2b (D3): superficie child del lease Live via canale nativo del
|
|
867
|
+
// bridge (HTTP loopback + Bearer). La cella e' derivata dalla sessione; il
|
|
868
|
+
// proof firmato dal verifier per-installazione autorizza refresh/recovery.
|
|
869
|
+
api.use('/lease', leaseRoutes({ fleetP, readonly: proxyReadonly }));
|
|
713
870
|
// Audio Share. L'identita' del nodo NON e' un campo di cfg: si legge dal node
|
|
714
871
|
// store, la stessa fonte usata da /api/cells e /api/peers. Lo stato Fleet e'
|
|
715
872
|
// asincrono e va atteso: leggerlo come se fosse sincrono lascerebbe la
|
|
@@ -776,6 +933,28 @@ function createServer(opts = {}) {
|
|
|
776
933
|
})),
|
|
777
934
|
readonly: proxyReadonly,
|
|
778
935
|
}));
|
|
936
|
+
// Cella ospite Live (contratto rev6 §2): hostCell unico per nodo con CAS. Lo store
|
|
937
|
+
// vive accanto al token (stessa dir isolata nei test); il proxy nega /api/live-host
|
|
938
|
+
// via /node (local-only), ma la via /api/route la instrada (0.9.1) dietro un
|
|
939
|
+
// permesso per-peer negato di default (liveHostAccess, v. lib/proxy/federation.js).
|
|
940
|
+
// Fetta 3: il ponte risolve il puntamento all'avvio di una Live (POST /bridge
|
|
941
|
+
// nello stesso gruppo, stessa auth, stesso local-only). Isolabile da cfg
|
|
942
|
+
// (rev5 MC0): spento non crea connessioni e la Live resta standard.
|
|
943
|
+
api.use('/live-host', liveHostRoutes({
|
|
944
|
+
fleetP,
|
|
945
|
+
store: createLiveHostStore({ filePath: liveHostPath(cfg) }),
|
|
946
|
+
readonly: proxyReadonly,
|
|
947
|
+
bridge: createLiveBridge({
|
|
948
|
+
cfg,
|
|
949
|
+
fleetP,
|
|
950
|
+
tokenGet: () => tokenHolder.value,
|
|
951
|
+
filesRoot: cfg.filesRoot,
|
|
952
|
+
log: opts.log || console.log,
|
|
953
|
+
}),
|
|
954
|
+
}));
|
|
955
|
+
// Pannello per-cella: montato con il suo ingresso dedicato (ticket+cookie)
|
|
956
|
+
// PRIMA del requireToken generale — vedi il blocco sopra, dove nascono
|
|
957
|
+
// `panelAuth` e `resolveCellPanel`.
|
|
779
958
|
api.use('/vl-nodes', vlNodeApiRoutes({
|
|
780
959
|
storePath: vlNodesPath, broker: vlNodeBroker, ownerId: vlOwnerId, readonly: proxyReadonly,
|
|
781
960
|
}));
|
|
@@ -920,6 +1099,10 @@ function createServer(opts = {}) {
|
|
|
920
1099
|
watcher.close(); previews.close(); eventsHub.closeAll(); updater.close();
|
|
921
1100
|
for (const timer of reverseWatchers.values()) clearInterval(timer);
|
|
922
1101
|
reverseWatchers.clear(); rotatableReverse.clear(); void reverseSlotListeners?.closeAll();
|
|
1102
|
+
// Il pannello non sopravvive al control plane: senza requireToken sopra,
|
|
1103
|
+
// non c'e' un secondo lifecycle da tenere in vita da soli. panelServerV6
|
|
1104
|
+
// si chiude a cascata dal listener 'close' gia' registrato su panelServer.
|
|
1105
|
+
try { panelServer.close(); } catch (_) {}
|
|
923
1106
|
});
|
|
924
1107
|
// noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
|
|
925
1108
|
// (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
|
|
@@ -954,7 +1137,14 @@ function createServer(opts = {}) {
|
|
|
954
1137
|
if (pathname.startsWith('/api/route/')) {
|
|
955
1138
|
let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
|
|
956
1139
|
const given = bearerFrom(req) || u.searchParams.get('token') || '';
|
|
957
|
-
|
|
1140
|
+
// Le WebSocket di un pannello REMOTO partono dalla pagina nel frame e
|
|
1141
|
+
// portano solo il cookie di visione: il token qui non c'è e non deve
|
|
1142
|
+
// esserci. L'HUB non può validarle (il cookie è del nodo che lo ha
|
|
1143
|
+
// emesso): transita e decide il proprietario, gate panelAccess compreso
|
|
1144
|
+
// — stessa forma del bypass HTTP sulle panel-resource federate.
|
|
1145
|
+
const panelFederato = /^\/api\/route\/[^?#]*\/_\/panel\/[A-Za-z0-9._-]{1,32}(?:\/.*)?$/.test(pathname)
|
|
1146
|
+
&& /(?:^|;\s*)npanel=/.test(String(req.headers.cookie || ''));
|
|
1147
|
+
if (!panelFederato && !verify(tokenHolder.value, given)) return socket.destroy();
|
|
958
1148
|
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
|
|
959
1149
|
return;
|
|
960
1150
|
}
|
|
@@ -964,6 +1154,19 @@ function createServer(opts = {}) {
|
|
|
964
1154
|
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets, hopSecret: () => hopSecret });
|
|
965
1155
|
return;
|
|
966
1156
|
}
|
|
1157
|
+
if (pathname.startsWith('/api/panel/')) {
|
|
1158
|
+
handlePanelUpgrade({
|
|
1159
|
+
req, socket, head, resolveCellPanel,
|
|
1160
|
+
verifyToken: (t) => verify(tokenHolder.value, t),
|
|
1161
|
+
// Il cookie di visione apre le WS del pannello (la pagina nel frame
|
|
1162
|
+
// non puo' mettere header); Bearer e ?token= restano validi come prima.
|
|
1163
|
+
authorize: panelAuth.authorizeUpgrade,
|
|
1164
|
+
log: (entry) => diagnostics.record('panel-proxy', 'info', entry.outcome, {
|
|
1165
|
+
cell: entry.cell, reason: entry.reason, state: entry.outcome,
|
|
1166
|
+
}),
|
|
1167
|
+
});
|
|
1168
|
+
return;
|
|
1169
|
+
}
|
|
967
1170
|
if (pathname === '/node' || pathname.startsWith('/node/')) {
|
|
968
1171
|
handleNodeUpgrade({
|
|
969
1172
|
req, socket, head, resolveNode,
|
|
@@ -991,7 +1194,7 @@ function createServer(opts = {}) {
|
|
|
991
1194
|
// async questo handler rischierebbe di perdere i frame che arrivano prima
|
|
992
1195
|
// di bindWs. `/ws` attacca per NOME DI SESSIONE e senza questo gate ogni
|
|
993
1196
|
// filtro sugli elenchi sarebbe decorativo — basterebbe indovinare
|
|
994
|
-
// `cloud-
|
|
1197
|
+
// `cloud-X`.
|
|
995
1198
|
const wsScope = wsCellScope(req);
|
|
996
1199
|
bindWs(ws, {
|
|
997
1200
|
openAttach,
|
|
@@ -1020,11 +1223,14 @@ function createServer(opts = {}) {
|
|
|
1020
1223
|
fleetP.then((fleet) => (typeof fleet.close === 'function' ? fleet.close() : null)).catch(() => {});
|
|
1021
1224
|
});
|
|
1022
1225
|
|
|
1023
|
-
return {
|
|
1226
|
+
return {
|
|
1227
|
+
app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics,
|
|
1228
|
+
panelApp, panelServer, panelServerV6,
|
|
1229
|
+
};
|
|
1024
1230
|
}
|
|
1025
1231
|
|
|
1026
1232
|
function start(opts = {}) {
|
|
1027
|
-
const { server, cfg } = createServer(opts);
|
|
1233
|
+
const { server, cfg, panelServer, panelServerV6 } = createServer({ ...opts, cellLeaseEnabled: true });
|
|
1028
1234
|
const log = opts.log || console.log;
|
|
1029
1235
|
const requestedPort = cfg.port;
|
|
1030
1236
|
const nodesPath = opts.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
|
|
@@ -1033,12 +1239,51 @@ function start(opts = {}) {
|
|
|
1033
1239
|
if (typeof opts.onListenError === 'function') return opts.onListenError(error);
|
|
1034
1240
|
throw error;
|
|
1035
1241
|
};
|
|
1242
|
+
// Porta pannello (P0 sicurezza 2026-08-16): scelta libera con lo STESSO
|
|
1243
|
+
// meccanismo di fallback della principale, ma piu' leggero — nessun peer
|
|
1244
|
+
// dipende ancora da un valore stabile persistito (a differenza di cfg.port,
|
|
1245
|
+
// che i tunnel pairati referenziano), quindi niente scrittura su
|
|
1246
|
+
// config.json: il frontend la scopre da /api/config a ogni avvio. Un
|
|
1247
|
+
// fallimento qui non deve MAI abbattere il control plane: e' un log, non
|
|
1248
|
+
// un throw — il pannello resta un'estensione, non un requisito di avvio.
|
|
1249
|
+
const startPanelV6 = () => {
|
|
1250
|
+
panelServerV6.once('error', (error) => {
|
|
1251
|
+
log(`panel: IPv6 loopback listener not available (${(error && error.code) || error}); IPv4 still serves the panel.`);
|
|
1252
|
+
});
|
|
1253
|
+
panelServerV6.listen(cfg.panelPort, '::1');
|
|
1254
|
+
};
|
|
1255
|
+
const startPanelServer = () => {
|
|
1256
|
+
const requestedPanelPort = cfg.panelPort;
|
|
1257
|
+
const tryPanelFallback = (candidate, remaining) => {
|
|
1258
|
+
panelServer.once('error', (error) => {
|
|
1259
|
+
if (error && error.code === 'EADDRINUSE' && remaining > 1) {
|
|
1260
|
+
tryPanelFallback(candidate >= 65535 ? 41821 : candidate + 1, remaining - 1);
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
log(`panel: preferred port ${requestedPanelPort} busy and no fallback available (${(error && error.code) || error}); panel disabled this run.`);
|
|
1264
|
+
});
|
|
1265
|
+
panelServer.listen(candidate, cfg.bind, () => {
|
|
1266
|
+
cfg.panelPort = panelServer.address().port;
|
|
1267
|
+
log(`panel port ${requestedPanelPort} busy; selected ${cfg.panelPort}`);
|
|
1268
|
+
startPanelV6();
|
|
1269
|
+
});
|
|
1270
|
+
};
|
|
1271
|
+
panelServer.once('error', (error) => {
|
|
1272
|
+
if (error && error.code === 'EADDRINUSE') { tryPanelFallback(requestedPanelPort >= 65535 ? 41821 : requestedPanelPort + 1, 200); return; }
|
|
1273
|
+
log(`panel: failed to start (${(error && error.code) || error}); panel disabled this run.`);
|
|
1274
|
+
});
|
|
1275
|
+
panelServer.listen(requestedPanelPort, cfg.bind, () => {
|
|
1276
|
+
cfg.panelPort = panelServer.address().port;
|
|
1277
|
+
startPanelV6();
|
|
1278
|
+
});
|
|
1279
|
+
};
|
|
1036
1280
|
const onListening = () => {
|
|
1037
1281
|
cfg.port = server.address().port;
|
|
1038
1282
|
// Il token NON si stampa allo startup: finirebbe nei log del servizio
|
|
1039
1283
|
// (journalctl/logfile). L'apertura autenticata passa da `nexuscrew show`.
|
|
1040
1284
|
log(`nexuscrew on http://${cfg.bind}:${cfg.port} (open with \`nexuscrew show\`)`);
|
|
1041
1285
|
log('localhost-only — reach it via a user-controlled SSH or VPN channel.');
|
|
1286
|
+
startPanelServer();
|
|
1042
1287
|
};
|
|
1043
1288
|
const persistFallback = () => {
|
|
1044
1289
|
const selected = server.address().port;
|
|
@@ -87,6 +87,7 @@ function createPairHandler(deps) {
|
|
|
87
87
|
|
|
88
88
|
let provisionalPort = null;
|
|
89
89
|
let portReservation = null;
|
|
90
|
+
let panelPortReservation = null;
|
|
90
91
|
let rollbackCredential = null;
|
|
91
92
|
let created = false;
|
|
92
93
|
let rolledBack = false;
|
|
@@ -98,6 +99,10 @@ function createPairHandler(deps) {
|
|
|
98
99
|
try { await portReservation.release(); } catch (_) { /* best-effort */ }
|
|
99
100
|
portReservation = null;
|
|
100
101
|
}
|
|
102
|
+
if (panelPortReservation) {
|
|
103
|
+
try { await panelPortReservation.release(); } catch (_) { /* best-effort */ }
|
|
104
|
+
panelPortReservation = null;
|
|
105
|
+
}
|
|
101
106
|
if (rollbackCredential && provisionalPort) {
|
|
102
107
|
try {
|
|
103
108
|
await pairFetch(`http://127.0.0.1:${provisionalPort}/pair/cancel`, {
|
|
@@ -296,6 +301,26 @@ function createPairHandler(deps) {
|
|
|
296
301
|
// --- tunnel-final: connessione privata, solo -L -------------------------
|
|
297
302
|
// reversePort resta negoziata per un futuro Share opt-in, ma il builder
|
|
298
303
|
// non emette -R finche' shared non diventa true.
|
|
304
|
+
//
|
|
305
|
+
// Porta pannello del peer (P0 stessa-origin, meta' remota): se il peer
|
|
306
|
+
// l'ha annunciata nel join, si riserva una controparte locale e la
|
|
307
|
+
// coppia entra nel record — il supervisor finale inoltrera' entrambe.
|
|
308
|
+
// Nessuna porta disponibile NON fa fallire il pairing: il pannello e
|
|
309
|
+
// un'estensione, il legame viene prima. Stesso patto di publicKey: un
|
|
310
|
+
// peer piu' vecchio non annuncia e si accoppia esattamente come prima.
|
|
311
|
+
const peerPanelPort = nodesStore.isPort(joined.panelPort) ? joined.panelPort : null;
|
|
312
|
+
let panelLocalPort = null;
|
|
313
|
+
if (peerPanelPort) {
|
|
314
|
+
try {
|
|
315
|
+
panelPortReservation = await nodesCmds.reserveLocalPort(
|
|
316
|
+
nodesStore.loadStoreStrict(nodesPath),
|
|
317
|
+
{ createServerImpl: seams.createPanelPortServer || seams.createPortServer },
|
|
318
|
+
);
|
|
319
|
+
panelLocalPort = panelPortReservation.port;
|
|
320
|
+
} catch (_) {
|
|
321
|
+
panelPortReservation = null; panelLocalPort = null; // fail-open dichiarato: via storica
|
|
322
|
+
}
|
|
323
|
+
}
|
|
299
324
|
st = nodesStore.loadStoreStrict(nodesPath);
|
|
300
325
|
if (joinedPool && st.schemaVersion < nodesStore.SCHEMA_VERSION) {
|
|
301
326
|
// This device is a client of the hub-owned pool. It persists the
|
|
@@ -313,8 +338,15 @@ function createPairHandler(deps) {
|
|
|
313
338
|
: {}),
|
|
314
339
|
...(joinedPool ? { reversePool: joinedPool } : {}),
|
|
315
340
|
...(joinedRoles ? { roles: joinedRoles, rolesKnown: true } : {}),
|
|
341
|
+
...(panelLocalPort ? { panelLocalPort, panelRemotePort: peerPanelPort } : {}),
|
|
316
342
|
});
|
|
317
343
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
344
|
+
// La riserva della porta pannello ha protetto la scelta fino alla
|
|
345
|
+
// scrittura: come la porta di controllo, si rilascia prima dello spawn.
|
|
346
|
+
if (panelPortReservation) {
|
|
347
|
+
try { await panelPortReservation.release(); } catch (_) { /* best-effort */ }
|
|
348
|
+
panelPortReservation = null;
|
|
349
|
+
}
|
|
318
350
|
nodesTunnel.stopTunnel({ home, name: b.name });
|
|
319
351
|
const finalStart = nodesTunnel.startForward({
|
|
320
352
|
home, node: nodesStore.getNode(st, b.name), localAppPort: runtimePort(),
|
|
@@ -28,6 +28,12 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
28
28
|
const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
|
|
29
29
|
const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
|
|
30
30
|
const reversePoolLedgerPath = deps.reversePoolLedgerPath || cfg.reversePoolLedgerPath || reversePool.defaultLedgerPath(home);
|
|
31
|
+
// Porta pannello di QUESTA installazione, letta al momento del join: non
|
|
32
|
+
// cfg.panelPort (che resta al valore richiesto anche quando il listener non
|
|
33
|
+
// e' mai partito), ma la porta su cui il panelServer ascolta ADESSO. Null
|
|
34
|
+
// quando il pannello e' spento per questo run: meglio il silenzio che far
|
|
35
|
+
// inoltrare al client una porta che nessuno ascolta.
|
|
36
|
+
const panelPortLive = typeof deps.panelPort === 'function' ? deps.panelPort : () => null;
|
|
31
37
|
// La pubblica di questa installazione, per il passo 1 del modello di
|
|
32
38
|
// autorita'. Si legge PIGRAMENTE e non all'avvio del router: un errore sul
|
|
33
39
|
// file di chiave non deve impedire di montare le route di pairing — il passo
|
|
@@ -170,13 +176,19 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
170
176
|
return res.status(410).json({ error: 'invito scaduto o gia usato' });
|
|
171
177
|
}
|
|
172
178
|
const mia = localPublicKey();
|
|
179
|
+
const pannello = panelPortLive();
|
|
173
180
|
res.json({ paired: true, instanceId: poolStore.nodeId, reversePort,
|
|
174
181
|
reversePool: { base: assignedPool.base, slots: assignedPool.slots.map((slot) => slot.port) },
|
|
175
182
|
credential, roles: readRoles(configPath),
|
|
176
183
|
// Lo scambio e' simmetrico e avviene QUI, dentro l'atto che consuma
|
|
177
184
|
// l'invito monouso: e' il solo momento in cui l'operatore ha deciso,
|
|
178
185
|
// su entrambe le macchine, che questi due nodi si conoscono.
|
|
179
|
-
...(mia ? { publicKey: mia } : {})
|
|
186
|
+
...(mia ? { publicKey: mia } : {}),
|
|
187
|
+
// Stesso patto della pubblica: la porta pannello si annuncia nel
|
|
188
|
+
// join, e un peer piu' vecchio che non la capisce si accoppia come
|
|
189
|
+
// sempre. Il campo assente non e' un errore, e' una versione (o un
|
|
190
|
+
// pannello spento per questo run).
|
|
191
|
+
...(pannello ? { panelPort: pannello } : {}) });
|
|
180
192
|
} catch (e) {
|
|
181
193
|
if (credential) try { peering.consumePending({ pendingPath, credential, now }); } catch (_) {}
|
|
182
194
|
res.status(e.status || 500).json({ error: String(e.message || e), ...(e.code ? { code: e.code } : {}) });
|