@mmmbuto/nexuscrew 0.7.7 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -25
- package/bin/nexuscrew.js +10 -4
- package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
- package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
- package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/sw.js +29 -0
- package/frontend/dist/version.json +1 -0
- package/lib/auth/middleware.js +7 -1
- package/lib/auth/token.js +31 -1
- package/lib/cli/commands.js +502 -43
- package/lib/cli/doctor.js +160 -0
- package/lib/cli/fleet-service.js +16 -3
- package/lib/cli/init.js +69 -11
- package/lib/cli/path.js +24 -0
- package/lib/cli/service.js +14 -8
- package/lib/cli/url.js +63 -0
- package/lib/config.js +5 -0
- package/lib/decks/routes.js +81 -0
- package/lib/decks/store.js +123 -0
- package/lib/files/routes.js +57 -1
- package/lib/files/store.js +16 -1
- package/lib/fleet/builtin.js +151 -24
- package/lib/fleet/definitions.js +26 -0
- package/lib/fleet/managed.js +381 -0
- package/lib/fleet/routes.js +5 -4
- package/lib/mcp/server.js +381 -0
- package/lib/nodes/commands.js +411 -0
- package/lib/nodes/peering.js +116 -0
- package/lib/nodes/store.js +425 -0
- package/lib/nodes/tunnel-supervisor.js +102 -0
- package/lib/nodes/tunnel.js +315 -0
- package/lib/notify/asks.js +150 -0
- package/lib/notify/events.js +59 -0
- package/lib/notify/notifier.js +37 -0
- package/lib/notify/persist.js +73 -0
- package/lib/notify/push.js +289 -0
- package/lib/notify/routes.js +260 -0
- package/lib/proxy/federation.js +217 -0
- package/lib/proxy/node-proxy.js +305 -0
- package/lib/pty/attach.js +34 -9
- package/lib/pty/provider.js +21 -6
- package/lib/server.js +291 -14
- package/lib/settings/routes.js +685 -0
- package/lib/ws/bridge.js +10 -1
- package/package.json +8 -2
- package/skills/nexuscrew-agent/SKILL.md +83 -0
- package/skills/nexuscrew-agent/bin/nc-deliver +23 -0
- package/skills/nexuscrew-agent/bin/nc-send +48 -0
- package/frontend/dist/assets/index-DUbtTZMj.css +0 -32
- package/frontend/dist/assets/index-EVa9bnAC.js +0 -81
package/lib/server.js
CHANGED
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
const http = require('node:http');
|
|
3
3
|
const path = require('node:path');
|
|
4
4
|
const os = require('node:os');
|
|
5
|
+
const fs = require('node:fs');
|
|
5
6
|
const { execFileSync } = require('node:child_process');
|
|
6
7
|
const express = require('express');
|
|
7
8
|
const { WebSocketServer } = require('ws');
|
|
8
|
-
const { defaults, loadConfig, assertLoopback } = require('./config.js');
|
|
9
|
+
const { defaults, loadConfig, assertLoopback, configJsonPath } = require('./config.js');
|
|
10
|
+
const { writeConfigAtomic } = require('./cli/init.js');
|
|
9
11
|
const { listSessions, attachedClients } = require('./tmux/list.js');
|
|
10
12
|
const { runAction, pasteToSession } = require('./tmux/actions.js');
|
|
11
13
|
const { createSession, killSession, isProtectedSession } = require('./tmux/lifecycle.js');
|
|
@@ -13,7 +15,7 @@ const { createPreviewSampler } = require('./tmux/preview.js');
|
|
|
13
15
|
const { openAttach } = require('./pty/attach.js');
|
|
14
16
|
const { bindWs } = require('./ws/bridge.js');
|
|
15
17
|
const { loadOrCreateToken, verify } = require('./auth/token.js');
|
|
16
|
-
const { requireToken } = require('./auth/middleware.js');
|
|
18
|
+
const { requireToken, bearerFrom } = require('./auth/middleware.js');
|
|
17
19
|
const { filesRoutes } = require('./files/routes.js');
|
|
18
20
|
const { createOutboxWatcher } = require('./files/watcher.js');
|
|
19
21
|
const VERSION = require('../package.json').version;
|
|
@@ -21,6 +23,18 @@ const { transcribe } = require('./voice/transcribe.js');
|
|
|
21
23
|
const { selectProvider } = require('./fleet/provider.js');
|
|
22
24
|
const { fleetRoutes } = require('./fleet/routes.js');
|
|
23
25
|
const { fsRoutes } = require('./fs/routes.js');
|
|
26
|
+
const nodesStore = require('./nodes/store.js');
|
|
27
|
+
const nodesTunnel = require('./nodes/tunnel.js');
|
|
28
|
+
const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
|
|
29
|
+
const federation = require('./proxy/federation.js');
|
|
30
|
+
const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
|
|
31
|
+
const decksStore = require('./decks/store.js');
|
|
32
|
+
const { decksRoutes } = require('./decks/routes.js');
|
|
33
|
+
const { createEventsHub } = require('./notify/events.js');
|
|
34
|
+
const { createPushService } = require('./notify/push.js');
|
|
35
|
+
const { createAsksStore } = require('./notify/asks.js');
|
|
36
|
+
const { createNotifier } = require('./notify/notifier.js');
|
|
37
|
+
const { notifyRoutes } = require('./notify/routes.js');
|
|
24
38
|
|
|
25
39
|
function sessionExists(tmuxBin, name) {
|
|
26
40
|
if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
|
|
@@ -28,27 +42,118 @@ function sessionExists(tmuxBin, name) {
|
|
|
28
42
|
catch (_) { return false; }
|
|
29
43
|
}
|
|
30
44
|
|
|
45
|
+
function uiBuildVersion(distDir) {
|
|
46
|
+
try {
|
|
47
|
+
const x = JSON.parse(require('node:fs').readFileSync(path.join(distDir, 'version.json'), 'utf8'));
|
|
48
|
+
return typeof x.version === 'string' ? x.version : null;
|
|
49
|
+
} catch (_) { return null; }
|
|
50
|
+
}
|
|
51
|
+
|
|
31
52
|
function createServer(opts = {}) {
|
|
32
53
|
const cfg = loadConfig(opts);
|
|
33
54
|
assertLoopback(cfg.bind);
|
|
34
|
-
|
|
55
|
+
// Token holder LIVE (audit F7 / §4b(3)): requireToken/verify leggono tokenStore.get()
|
|
56
|
+
// ad ogni richiesta, cosi' una rotazione via Settings API invalida il VECCHIO token
|
|
57
|
+
// (401) e attiva il NUOVO (200) SENZA restart. Prima il token era catturato una volta
|
|
58
|
+
// allo startup e restava valido fino al restart manuale.
|
|
59
|
+
const tokenHolder = { value: loadOrCreateToken(cfg.tokenPath) };
|
|
60
|
+
const tokenStore = {
|
|
61
|
+
get: () => tokenHolder.value,
|
|
62
|
+
reload: () => { tokenHolder.value = loadOrCreateToken(cfg.tokenPath); return tokenHolder.value; },
|
|
63
|
+
};
|
|
64
|
+
const proxySockets = new Set();
|
|
65
|
+
// wss viene creato piu' sotto; closeSessions lo raggiunge a request-time (mai durante
|
|
66
|
+
// createServer) per chiudere le sessioni WS attive sulla rotazione token (§4b(3)).
|
|
67
|
+
let wss = null;
|
|
68
|
+
const closeSessions = () => {
|
|
69
|
+
if (wss) {
|
|
70
|
+
for (const ws of wss.clients) { try { ws.close(4001, 'token rotated'); } catch (_) { /* best-effort */ } }
|
|
71
|
+
}
|
|
72
|
+
for (const socket of proxySockets) { try { socket.destroy(); } catch (_) {} }
|
|
73
|
+
proxySockets.clear();
|
|
74
|
+
};
|
|
35
75
|
const watcher = createOutboxWatcher({ root: cfg.filesRoot });
|
|
36
76
|
const previews = createPreviewSampler(cfg.tmuxBin);
|
|
77
|
+
// MCP bridge (notify/ask/push): lo stato vive accanto al token (dirname del
|
|
78
|
+
// tokenPath = ~/.nexuscrew di default) cosi' le istanze isolate via opts/env
|
|
79
|
+
// nei test NON scrivono mai nella home reale. Tutto lazy: vapid.json/asks.json
|
|
80
|
+
// nascono al primo uso, non allo startup.
|
|
81
|
+
const notifyDir = cfg.notifyDir || path.dirname(cfg.tokenPath);
|
|
82
|
+
// READONLY come floor anche dentro il push service (F3): niente generazione
|
|
83
|
+
// VAPID ne' cleanup subscription quando il server e' readonly.
|
|
84
|
+
const bridgeReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
85
|
+
const eventsHub = createEventsHub();
|
|
86
|
+
const pushSvc = createPushService({
|
|
87
|
+
dir: notifyDir, webpushImpl: cfg.webpushImpl,
|
|
88
|
+
readonly: bridgeReadonly, maxSubs: cfg.pushMaxSubs, lookupImpl: cfg.pushLookupImpl,
|
|
89
|
+
});
|
|
90
|
+
const asksStore = createAsksStore({ dir: notifyDir });
|
|
91
|
+
const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
|
|
37
92
|
const attachedWs = new Map(); // ws -> session (per il push dei frame files)
|
|
38
93
|
// selectProvider sceglie UNA volta (startup) il provider external|builtin|disabled
|
|
39
94
|
// (design §4b/§9b/§9g) e ritorna {mode,reason,fleet}; routes consumano il .fleet,
|
|
40
95
|
// quindi fleetP resta una Promise<Fleet> (createServer non diventa async).
|
|
41
96
|
const fleetP = selectProvider(cfg).then((p) => p.fleet);
|
|
42
97
|
|
|
98
|
+
// Multi-node (B1): nodes.json e' la fonte dati (B0). Il proxy risolve <name>
|
|
99
|
+
// -> {localPort, token} leggendo lo store ad ogni richiesta (fresh: rotazione
|
|
100
|
+
// token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
|
|
101
|
+
// il valore che il proxy inietta upstream, non esce mai verso il browser.
|
|
102
|
+
const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
|
|
103
|
+
const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
|
|
104
|
+
const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
105
|
+
function resolveNode(name) {
|
|
106
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
107
|
+
if (!st) return null;
|
|
108
|
+
const node = nodesStore.getNode(st, name);
|
|
109
|
+
if (!node) return null;
|
|
110
|
+
return { localPort: node.localPort, token: node.token || null };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// A node-role installation must republish itself after service/reboot. The
|
|
114
|
+
// detached supervisor is idempotent and owns retry/backoff independently.
|
|
115
|
+
if (cfg.roles && cfg.roles.node === true) {
|
|
116
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
117
|
+
if (st && st.rendezvous) {
|
|
118
|
+
const tr = nodesTunnel.startReverse({
|
|
119
|
+
home: cfg.home || os.homedir(), rendezvous: st.rendezvous,
|
|
120
|
+
spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
|
|
121
|
+
sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
|
|
122
|
+
});
|
|
123
|
+
if (!tr.started && tr.reason !== 'already running') {
|
|
124
|
+
process.stderr.write(`[nexuscrew] reverse tunnel autostart failed: ${tr.reason || 'unknown'}\n`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// New Hydra peers are ordinary local+remote nodes. Only the side that owns
|
|
130
|
+
// the outbound SSH alias dials; inbound records are reached through its -R.
|
|
131
|
+
{
|
|
132
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
133
|
+
for (const node of (st && st.nodes) || []) {
|
|
134
|
+
if (node.direction !== 'inbound' && node.autostart === true) {
|
|
135
|
+
const tr = nodesTunnel.startForward({
|
|
136
|
+
home: cfg.home || os.homedir(), node, localAppPort: cfg.port,
|
|
137
|
+
spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
|
|
138
|
+
sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
|
|
139
|
+
});
|
|
140
|
+
if (!tr.started && tr.reason !== 'already running') {
|
|
141
|
+
process.stderr.write(`[nexuscrew] peer ${node.name} autostart failed: ${tr.reason || 'unknown'}\n`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
43
147
|
const app = express();
|
|
44
148
|
const distDir = path.join(__dirname, '..', 'frontend', 'dist');
|
|
45
149
|
// no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
|
|
46
150
|
app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
|
|
151
|
+
app.use('/pair', publicPeeringRoutes({ cfg, nodesPath }));
|
|
47
152
|
|
|
48
153
|
// Tutte le /api dietro Bearer: sul loopback il gate vero è il tunnel,
|
|
49
154
|
// ma il token chiude anche altri processi locali della stessa macchina.
|
|
50
155
|
const api = express.Router();
|
|
51
|
-
api.use(requireToken(
|
|
156
|
+
api.use(requireToken(tokenStore));
|
|
52
157
|
api.get('/sessions', async (_req, res) => {
|
|
53
158
|
try {
|
|
54
159
|
const sessions = await listSessions(cfg.tmuxBin);
|
|
@@ -62,6 +167,7 @@ function createServer(opts = {}) {
|
|
|
62
167
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
63
168
|
});
|
|
64
169
|
api.post('/sessions', express.json({ limit: '4kb' }), async (req, res) => {
|
|
170
|
+
if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: creazione sessione bloccata' });
|
|
65
171
|
try {
|
|
66
172
|
const { name, cwd, preset } = req.body || {};
|
|
67
173
|
await createSession(cfg.tmuxBin, { name, cwd, preset },
|
|
@@ -70,6 +176,7 @@ function createServer(opts = {}) {
|
|
|
70
176
|
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
71
177
|
});
|
|
72
178
|
api.delete('/sessions/:name', async (req, res) => {
|
|
179
|
+
if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: eliminazione sessione bloccata' });
|
|
73
180
|
const name = String(req.params.name || '');
|
|
74
181
|
try {
|
|
75
182
|
const fleet = await fleetP;
|
|
@@ -82,17 +189,58 @@ function createServer(opts = {}) {
|
|
|
82
189
|
} catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
|
|
83
190
|
});
|
|
84
191
|
api.get('/config', (_req, res) => res.json({
|
|
85
|
-
readonlyDefault: cfg.readonlyDefault, version: VERSION,
|
|
192
|
+
readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
|
|
86
193
|
bind: cfg.bind, port: cfg.port,
|
|
194
|
+
instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
|
|
87
195
|
presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
|
|
88
196
|
}));
|
|
89
197
|
api.use('/files', filesRoutes({
|
|
90
198
|
cfg,
|
|
91
199
|
sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
|
|
92
200
|
paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
|
|
201
|
+
notifier,
|
|
202
|
+
readonly: proxyReadonly,
|
|
203
|
+
}));
|
|
204
|
+
// MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
|
|
205
|
+
// del router /api; gate READONLY sui mutanti dentro notifyRoutes.
|
|
206
|
+
api.use(notifyRoutes({
|
|
207
|
+
cfg,
|
|
208
|
+
notifier,
|
|
209
|
+
push: pushSvc,
|
|
210
|
+
asks: asksStore,
|
|
211
|
+
paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
|
|
212
|
+
sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
|
|
93
213
|
}));
|
|
94
214
|
api.use('/fleet', fleetRoutes(fleetP, cfg));
|
|
215
|
+
api.use('/decks', decksRoutes({ cfg, decksPath }));
|
|
95
216
|
api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
|
|
217
|
+
// /nodes (read-only, per la settings UI B2): stesso formato di `nodes list --json`
|
|
218
|
+
// (token SEMPRE redatti via redactStore) + stato tunnel per-nodo.
|
|
219
|
+
api.get('/nodes', (_req, res) => {
|
|
220
|
+
try {
|
|
221
|
+
const st = nodesStore.loadStore(nodesPath);
|
|
222
|
+
if (!st) return res.json({ nodeId: null, nodes: [] });
|
|
223
|
+
const view = nodesStore.redactStore(st);
|
|
224
|
+
const nodes = view.nodes.map((n) => ({
|
|
225
|
+
...n,
|
|
226
|
+
tunnel: n.direction === 'inbound' ? { status: 'up', managed: false } : nodesTunnel.readTunnelState(os.homedir(), n.name),
|
|
227
|
+
}));
|
|
228
|
+
const out = { nodeId: view.nodeId, nodes };
|
|
229
|
+
if (view.rendezvous) out.rendezvous = view.rendezvous;
|
|
230
|
+
res.json(out);
|
|
231
|
+
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
232
|
+
});
|
|
233
|
+
// Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
|
|
234
|
+
// wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
|
|
235
|
+
// READONLY route-level e la redazione token vivono dentro settingsRoutes.
|
|
236
|
+
api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
|
|
237
|
+
api.get('/topology', async (_req, res) => {
|
|
238
|
+
try { res.json(await federation.collectTopology({ nodesPath })); }
|
|
239
|
+
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
240
|
+
});
|
|
241
|
+
api.use('/route', federation.localRouter({
|
|
242
|
+
nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
243
|
+
}));
|
|
96
244
|
api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
|
|
97
245
|
api.post('/voice/transcribe',
|
|
98
246
|
express.raw({ type: () => true, limit: '25mb' }),
|
|
@@ -102,21 +250,115 @@ function createServer(opts = {}) {
|
|
|
102
250
|
res.json({ text: out.text || '' });
|
|
103
251
|
} catch (e) { res.status(e.status || 502).json({ error: e.message }); }
|
|
104
252
|
});
|
|
253
|
+
// SSE eventi UI (notify/ask, MCP bridge §2a): EventSource non puo' settare
|
|
254
|
+
// header -> il token e' accettato anche in query, SOLO perche' il bind e'
|
|
255
|
+
// loopback-only (stesso pattern dell'upgrade WS del proxy /node). Montata
|
|
256
|
+
// PRIMA del router /api (che e' Bearer-only) e sempre sul token live.
|
|
257
|
+
app.get('/api/events', (req, res) => {
|
|
258
|
+
const given = bearerFrom(req) || String(req.query.token || '');
|
|
259
|
+
if (!verify(tokenHolder.value, given)) {
|
|
260
|
+
return res.status(401).json({ error: 'unauthorized' });
|
|
261
|
+
}
|
|
262
|
+
eventsHub.handle(req, res);
|
|
263
|
+
});
|
|
264
|
+
|
|
105
265
|
app.use('/api', api);
|
|
266
|
+
app.use('/federation', federation.peerRouter({
|
|
267
|
+
nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
268
|
+
}));
|
|
269
|
+
|
|
270
|
+
// Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
|
|
271
|
+
// di risolvere <name>: requireToken(token) davanti al router, nessuna route
|
|
272
|
+
// proxy montata prima del middleware auth. Montato PRIMA dello static/catch-all.
|
|
273
|
+
app.use('/node', requireToken(tokenStore), createNodeProxy({ resolveNode, readonly: proxyReadonly }));
|
|
106
274
|
|
|
107
275
|
app.use(express.static(distDir));
|
|
276
|
+
// Deck multi-finestra (§5b): /deck/<name> serve la STESSA SPA (stesso origin,
|
|
277
|
+
// stesso token via fragment). <name> e' una chiave strict client-side, mai usata
|
|
278
|
+
// per costruire path: validazione ^[a-z0-9-]{1,32}$, nome invalido → 404 secco
|
|
279
|
+
// (niente traversal, niente fallback silenzioso alla SPA su nomi sporchi).
|
|
280
|
+
const DECK_NAME_RE = /^[a-z0-9-]{1,32}$/;
|
|
281
|
+
// Cattura TUTTO cio' che sta sotto /deck/ (anche slash/segmenti extra o encoded)
|
|
282
|
+
// e valida il remainder: qualunque cosa non sia un nome deck strict → 404,
|
|
283
|
+
// senza mai cadere nel catch-all SPA con un nome sporco.
|
|
284
|
+
app.get(/^\/deck\/(.*)$/, (req, res) => {
|
|
285
|
+
// parita' col client: deckFromPath accetta UN trailing slash (/deck/main/),
|
|
286
|
+
// il server deve fare lo stesso; piu' di uno resta 404 (regex strict).
|
|
287
|
+
const name = req.params[0].replace(/\/$/, '');
|
|
288
|
+
if (!DECK_NAME_RE.test(name)) {
|
|
289
|
+
return res.status(404).type('text/plain').send('invalid deck name');
|
|
290
|
+
}
|
|
291
|
+
return res.sendFile(path.join(distDir, 'index.html'));
|
|
292
|
+
});
|
|
108
293
|
app.get('*', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
|
|
109
294
|
|
|
110
295
|
const server = http.createServer(app);
|
|
111
296
|
// Close the watcher when the HTTP server closes. Registered HERE (inside
|
|
112
297
|
// createServer) — not in start() — so every createServer consumer is covered,
|
|
113
298
|
// not only the start() path. watcher.close() is idempotent.
|
|
114
|
-
server.on('close', () => { watcher.close(); previews.close(); });
|
|
115
|
-
|
|
116
|
-
|
|
299
|
+
server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); });
|
|
300
|
+
// noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
|
|
301
|
+
// (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
|
|
302
|
+
// dell'HTTP (auth locale -> name strict -> inject token) prima del piping.
|
|
303
|
+
wss = new WebSocketServer({ noServer: true, maxPayload: 1 << 20 });
|
|
304
|
+
// Browser/mobile/tunnel possono lasciare TCP half-open senza un close event.
|
|
305
|
+
// Il ping applicativo fa emergere il guasto; terminate genera un close 1006
|
|
306
|
+
// lato browser, che il client riconnette senza richiedere refresh pagina.
|
|
307
|
+
const heartbeat = setInterval(() => {
|
|
308
|
+
for (const client of wss.clients) {
|
|
309
|
+
if (client.isAlive === false) { try { client.terminate(); } catch (_) {} continue; }
|
|
310
|
+
client.isAlive = false;
|
|
311
|
+
try { client.ping(); } catch (_) { try { client.terminate(); } catch (_e) {} }
|
|
312
|
+
}
|
|
313
|
+
}, opts.wsHeartbeatMs || 30000);
|
|
314
|
+
if (typeof heartbeat.unref === 'function') heartbeat.unref();
|
|
315
|
+
server.on('close', () => clearInterval(heartbeat));
|
|
316
|
+
server.on('upgrade', (req, socket, head) => {
|
|
317
|
+
let pathname;
|
|
318
|
+
try { pathname = new URL(req.url, 'http://127.0.0.1').pathname; }
|
|
319
|
+
catch (_) { try { socket.destroy(); } catch (_e) {} return; }
|
|
320
|
+
if (pathname === '/ws') {
|
|
321
|
+
wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (pathname.startsWith('/api/route/')) {
|
|
325
|
+
let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
|
|
326
|
+
const given = bearerFrom(req) || u.searchParams.get('token') || '';
|
|
327
|
+
if (!verify(tokenHolder.value, given)) return socket.destroy();
|
|
328
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets });
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (pathname.startsWith('/federation/route/')) {
|
|
332
|
+
const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
|
|
333
|
+
if (!ingress) return socket.destroy();
|
|
334
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (pathname === '/node' || pathname.startsWith('/node/')) {
|
|
338
|
+
handleNodeUpgrade({
|
|
339
|
+
req, socket, head, resolveNode,
|
|
340
|
+
verifyToken: (t) => verify(tokenHolder.value, t),
|
|
341
|
+
readonly: proxyReadonly,
|
|
342
|
+
activeSockets: proxySockets,
|
|
343
|
+
});
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
try { socket.destroy(); } catch (_) {}
|
|
347
|
+
});
|
|
348
|
+
wss.on('connection', (ws, req) => {
|
|
349
|
+
ws.isAlive = true;
|
|
350
|
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
351
|
+
// Preauth via header (B2 attach remoto): quando l'upgrade arriva dal proxy
|
|
352
|
+
// /node/<name> di un hub, il proxy ha gia' iniettato `Authorization: Bearer
|
|
353
|
+
// <token di QUESTO nodo>` (§4b(2)#3) mentre il frame attach porta il token
|
|
354
|
+
// del hub. Un Bearer valido sull'upgrade vale come auth: e' lo STESSO
|
|
355
|
+
// verify dello stesso token locale, solo su un canale diverso. I browser
|
|
356
|
+
// non possono settare header sui WS -> il flusso locale resta identico
|
|
357
|
+
// (token nel frame attach, mai in URL).
|
|
358
|
+
const preauth = req ? verify(tokenHolder.value, bearerFrom(req)) : false;
|
|
117
359
|
bindWs(ws, {
|
|
118
360
|
openAttach,
|
|
119
|
-
verifyToken: (t) => verify(
|
|
361
|
+
verifyToken: (t) => preauth || verify(tokenHolder.value, t),
|
|
120
362
|
isValidSession: (name) => sessionExists(cfg.tmuxBin, name),
|
|
121
363
|
runAction: (sess, action) => runAction(cfg.tmuxBin, sess, action),
|
|
122
364
|
countClients: (sess) => attachedClients(cfg.tmuxBin, sess),
|
|
@@ -134,15 +376,50 @@ function createServer(opts = {}) {
|
|
|
134
376
|
}
|
|
135
377
|
});
|
|
136
378
|
|
|
137
|
-
return { app, server, wss, cfg, token, watcher, fleetP };
|
|
379
|
+
return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP };
|
|
138
380
|
}
|
|
139
381
|
|
|
140
382
|
function start(opts = {}) {
|
|
141
|
-
const { server, cfg
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
383
|
+
const { server, cfg } = createServer(opts);
|
|
384
|
+
const log = opts.log || console.log;
|
|
385
|
+
const requestedPort = cfg.port;
|
|
386
|
+
const onListening = () => {
|
|
387
|
+
cfg.port = server.address().port;
|
|
388
|
+
// Il token NON si stampa allo startup: finirebbe nei log del servizio
|
|
389
|
+
// (journalctl/logfile). L'apertura autenticata passa da `nexuscrew show`.
|
|
390
|
+
log(`nexuscrew on http://${cfg.bind}:${cfg.port} (open with \`nexuscrew show\`)`);
|
|
391
|
+
log('localhost-only — reach it via SSH/autossh/VPN tunnel.');
|
|
392
|
+
};
|
|
393
|
+
const persistFallback = () => {
|
|
394
|
+
const selected = server.address().port;
|
|
395
|
+
const configPath = opts.configPath || configJsonPath();
|
|
396
|
+
let current = {};
|
|
397
|
+
try {
|
|
398
|
+
const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
399
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) current = parsed;
|
|
400
|
+
} catch (_) {}
|
|
401
|
+
writeConfigAtomic(configPath, { ...current, port: selected });
|
|
402
|
+
log(`preferred port ${requestedPort} busy; selected ${selected}`);
|
|
403
|
+
onListening();
|
|
404
|
+
};
|
|
405
|
+
const tryFallback = (candidate, remaining) => {
|
|
406
|
+
server.once('error', (error) => {
|
|
407
|
+
if (error && error.code === 'EADDRINUSE' && remaining > 1) {
|
|
408
|
+
tryFallback(candidate >= 65535 ? 41820 : candidate + 1, remaining - 1);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
throw error;
|
|
412
|
+
});
|
|
413
|
+
server.listen(candidate, cfg.bind, persistFallback);
|
|
414
|
+
};
|
|
415
|
+
server.once('error', (error) => {
|
|
416
|
+
if (error && error.code === 'EADDRINUSE' && requestedPort !== 0 && opts.autoPort !== false) {
|
|
417
|
+
tryFallback(requestedPort >= 65535 ? 41820 : requestedPort + 1, 200);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
throw error;
|
|
145
421
|
});
|
|
422
|
+
server.listen(requestedPort, cfg.bind, onListening);
|
|
146
423
|
return server;
|
|
147
424
|
}
|
|
148
425
|
|