@mmmbuto/nexuscrew 0.8.7 → 0.8.10
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 +77 -20
- package/frontend/dist/assets/index-CbUkgtAz.js +91 -0
- package/frontend/dist/assets/index-ChGJawuv.css +32 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/auth/token.js +1 -1
- package/lib/cli/commands.js +236 -141
- package/lib/cli/doctor.js +17 -14
- package/lib/cli/init.js +2 -2
- package/lib/cli/pidfile.js +3 -2
- package/lib/config.js +6 -0
- package/lib/decks/store.js +5 -2
- package/lib/fleet/builtin.js +69 -5
- package/lib/fleet/routes.js +13 -2
- package/lib/mcp/server.js +161 -0
- package/lib/nodes/commands.js +95 -123
- package/lib/nodes/health.js +33 -14
- package/lib/nodes/peering.js +75 -12
- package/lib/nodes/store.js +30 -20
- package/lib/nodes/tunnel-supervisor.js +29 -9
- package/lib/nodes/tunnel.js +217 -72
- package/lib/proxy/federation.js +81 -9
- package/lib/server.js +47 -32
- package/lib/settings/routes.js +247 -92
- package/lib/update/core.js +157 -0
- package/lib/update/manager.js +245 -0
- package/lib/update/runner.js +144 -0
- package/package.json +2 -2
- package/skills/nexuscrew-agent/SKILL.md +6 -2
- package/frontend/dist/assets/index-D2TrRtWQ.js +0 -90
- package/frontend/dist/assets/index-DrEuy6A6.css +0 -32
package/lib/proxy/federation.js
CHANGED
|
@@ -29,7 +29,10 @@ function peerAllows(peer, otherId) {
|
|
|
29
29
|
|
|
30
30
|
function canTransit(ingress, egress) {
|
|
31
31
|
if (!ingress || !egress || ingress.name === egress.name) return !ingress;
|
|
32
|
-
|
|
32
|
+
// `shared` is the explicit publication gate. Visibility remains the hub ACL,
|
|
33
|
+
// but it cannot make a private peer routable on its own.
|
|
34
|
+
return egress.shared === true
|
|
35
|
+
&& peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
function parseRoute(raw) {
|
|
@@ -54,8 +57,15 @@ function knownResource(resource) {
|
|
|
54
57
|
|| resource === '/files'
|
|
55
58
|
|| resource === '/files/download'
|
|
56
59
|
|| resource === '/files/upload'
|
|
60
|
+
|| resource === '/decks'
|
|
61
|
+
|| /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
|
|
62
|
+
|| resource === '/topology'
|
|
63
|
+
// A connected client may ask its hub to mint a hub-owned, one-time
|
|
64
|
+
// pairing invite. This is the only settings mutation exposed through
|
|
65
|
+
// Hydra: the rest of /settings stays unreachable.
|
|
66
|
+
|| resource === '/settings/peering/invite'
|
|
57
67
|
|| resource === '/ws'
|
|
58
|
-
|| /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell)$/.test(resource);
|
|
68
|
+
|| /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource);
|
|
59
69
|
}
|
|
60
70
|
|
|
61
71
|
function allowedResource(resource, method = 'GET') {
|
|
@@ -66,9 +76,15 @@ function allowedResource(resource, method = 'GET') {
|
|
|
66
76
|
if (resource === '/files') return method === 'GET' || method === 'DELETE';
|
|
67
77
|
if (resource === '/files/download') return method === 'GET';
|
|
68
78
|
if (resource === '/files/upload') return method === 'POST';
|
|
79
|
+
if (resource === '/decks') return method === 'GET' || method === 'POST';
|
|
80
|
+
if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
|
|
81
|
+
return method === 'PUT' || method === 'PATCH' || method === 'DELETE';
|
|
82
|
+
}
|
|
83
|
+
if (resource === '/topology') return method === 'GET';
|
|
84
|
+
if (resource === '/settings/peering/invite') return method === 'POST';
|
|
69
85
|
if (resource === '/ws') return method === 'GET';
|
|
70
86
|
if (/^\/fleet\/(status|schema|definitions)$/.test(resource)) return method === 'GET';
|
|
71
|
-
if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell)$/.test(resource)) return method === 'POST';
|
|
87
|
+
if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource)) return method === 'POST';
|
|
72
88
|
return false;
|
|
73
89
|
}
|
|
74
90
|
|
|
@@ -103,7 +119,8 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
|
|
|
103
119
|
return proxyHttp(req, res, { port: typeof localPort === 'function' ? localPort() : localPort, path: `/api${parsed.resource}${queryOf(req.url)}`, credential: localCredential() });
|
|
104
120
|
}
|
|
105
121
|
const next = st && store.getNode(st, parsed.route[0]);
|
|
106
|
-
|
|
122
|
+
const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
|
|
123
|
+
if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return res.status(403).json({ error: 'route non consentita' });
|
|
107
124
|
const rest = parsed.route.slice(1);
|
|
108
125
|
const path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}${queryOf(req.url)}`;
|
|
109
126
|
proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited });
|
|
@@ -155,6 +172,14 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
|
|
|
155
172
|
} else if (expectedInstanceId && body.instanceId !== expectedInstanceId) {
|
|
156
173
|
out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'peer instanceId inatteso — tunnel/porta punta al nodo sbagliato';
|
|
157
174
|
} else {
|
|
175
|
+
if (body.roles !== undefined) {
|
|
176
|
+
const roles = store.parseRoles(body.roles);
|
|
177
|
+
if (!roles) {
|
|
178
|
+
out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'health roles non validi';
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
out.roles = roles; out.rolesKnown = true;
|
|
182
|
+
}
|
|
158
183
|
out.reachability = 'ok'; out.status = 'healthy'; out.detail = 'ok';
|
|
159
184
|
}
|
|
160
185
|
} else if (r.status === 401) {
|
|
@@ -186,7 +211,11 @@ async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HO
|
|
|
186
211
|
const out = [];
|
|
187
212
|
const authoritative = [];
|
|
188
213
|
for (const n of st.nodes) {
|
|
189
|
-
|
|
214
|
+
// The local installation always keeps its outbound hub visible. Inbound
|
|
215
|
+
// clients become part of Hydra only after their explicit Share toggle.
|
|
216
|
+
if (!n.nodeId || seen.has(n.nodeId)
|
|
217
|
+
|| (!ingress && n.direction === 'inbound' && n.shared !== true)
|
|
218
|
+
|| (ingress && !canTransit(ingress, n))) continue;
|
|
190
219
|
out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
|
|
191
220
|
if (ttl <= 1 || !n.token) continue;
|
|
192
221
|
try {
|
|
@@ -226,7 +255,9 @@ async function collectTopology(opts) {
|
|
|
226
255
|
async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000) }) {
|
|
227
256
|
const live = await collectTopologyDetailed({ nodesPath, fetchImpl });
|
|
228
257
|
const st = store.loadStore(nodesPath);
|
|
229
|
-
const directNames = new Set(((st && st.nodes) || [])
|
|
258
|
+
const directNames = new Set(((st && st.nodes) || [])
|
|
259
|
+
.filter((n) => n.direction !== 'inbound' || n.shared === true)
|
|
260
|
+
.map((n) => n.name));
|
|
230
261
|
const authoritative = new Set(live.authoritative);
|
|
231
262
|
const liveIds = new Set(live.nodes.map((n) => n.instanceId));
|
|
232
263
|
const liveRoutes = new Set(live.nodes.map((n) => n.route.join('/')));
|
|
@@ -256,7 +287,7 @@ async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, n
|
|
|
256
287
|
return { instanceId: live.instanceId, nodes };
|
|
257
288
|
}
|
|
258
289
|
|
|
259
|
-
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly, version = null }) {
|
|
290
|
+
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null }) {
|
|
260
291
|
const r = express.Router();
|
|
261
292
|
r.use((req, res, next) => {
|
|
262
293
|
const peer = peerFromToken(nodesPath, bearerFrom(req));
|
|
@@ -269,13 +300,53 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
|
|
|
269
300
|
// reachability (payload). Nessun segreto in risposta.
|
|
270
301
|
r.get('/health', (_req, res) => {
|
|
271
302
|
const st = store.loadStore(nodesPath);
|
|
272
|
-
|
|
303
|
+
const advertisedRoles = typeof roles === 'function' ? roles() : null;
|
|
304
|
+
res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly(),
|
|
305
|
+
...(advertisedRoles ? { roles: advertisedRoles } : {}) });
|
|
273
306
|
});
|
|
274
307
|
r.get('/topology', async (req, res) => {
|
|
275
308
|
const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
|
|
276
309
|
const visited = String(req.query.visited || '').split(',');
|
|
277
310
|
res.json(await collectTopology({ nodesPath, ingress: req.peer, ttl, visited, fetchImpl }));
|
|
278
311
|
});
|
|
312
|
+
// A connected client publishes itself through the SAME SSH connection by
|
|
313
|
+
// toggling its optional -R channel. The hub records that intent only after a
|
|
314
|
+
// real authenticated health probe succeeds; Share off is immediate/fail-safe.
|
|
315
|
+
r.post('/share', express.json({ limit: '1kb' }), async (req, res) => {
|
|
316
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: share bloccato' });
|
|
317
|
+
const body = req.body || {};
|
|
318
|
+
if (Object.keys(body).some((k) => k !== 'shared') || typeof body.shared !== 'boolean') {
|
|
319
|
+
return res.status(400).json({ error: 'body non valido: atteso {shared:boolean}' });
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
if (body.shared) {
|
|
323
|
+
const health = await probeHealth({
|
|
324
|
+
port: req.peer.localPort,
|
|
325
|
+
token: req.peer.token,
|
|
326
|
+
expectedInstanceId: req.peer.nodeId || null,
|
|
327
|
+
fetchImpl: fetchImpl || fetch,
|
|
328
|
+
});
|
|
329
|
+
if (health.status !== 'healthy') {
|
|
330
|
+
return res.status(409).json({
|
|
331
|
+
error: 'canale share non raggiungibile',
|
|
332
|
+
detail: health.detail || 'reverse SSH non pronto',
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
let st = store.loadOrInitStore(nodesPath);
|
|
337
|
+
const current = store.getNode(st, req.peer.name);
|
|
338
|
+
if (!current) return res.status(404).json({ error: 'peer non trovato' });
|
|
339
|
+
st = store.updateNode(st, current.name, {
|
|
340
|
+
shared: body.shared,
|
|
341
|
+
roles: { ...current.roles, node: body.shared },
|
|
342
|
+
rolesKnown: true,
|
|
343
|
+
});
|
|
344
|
+
store.atomicWriteStore(nodesPath, st);
|
|
345
|
+
return res.json({ shared: body.shared });
|
|
346
|
+
} catch (e) {
|
|
347
|
+
return res.status(500).json({ error: String(e && e.message || e) });
|
|
348
|
+
}
|
|
349
|
+
});
|
|
279
350
|
r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
|
|
280
351
|
return r;
|
|
281
352
|
}
|
|
@@ -297,7 +368,8 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
|
|
|
297
368
|
let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential(); let path = '/ws';
|
|
298
369
|
if (parsed.route.length) {
|
|
299
370
|
const next = store.getNode(st, parsed.route[0]);
|
|
300
|
-
|
|
371
|
+
const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
|
|
372
|
+
if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
|
|
301
373
|
port = next.localPort; credential = next.token;
|
|
302
374
|
const rest = parsed.route.slice(1);
|
|
303
375
|
path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}/ws`;
|
package/lib/server.js
CHANGED
|
@@ -36,6 +36,7 @@ const { createPushService } = require('./notify/push.js');
|
|
|
36
36
|
const { createAsksStore } = require('./notify/asks.js');
|
|
37
37
|
const { createNotifier } = require('./notify/notifier.js');
|
|
38
38
|
const { notifyRoutes } = require('./notify/routes.js');
|
|
39
|
+
const { createNpmUpdater } = require('./update/manager.js');
|
|
39
40
|
|
|
40
41
|
function sessionExists(tmuxBin, name) {
|
|
41
42
|
if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
|
|
@@ -97,6 +98,13 @@ function createServer(opts = {}) {
|
|
|
97
98
|
});
|
|
98
99
|
const asksStore = createAsksStore({ dir: notifyDir });
|
|
99
100
|
const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
|
|
101
|
+
const updater = opts.updateManager || createNpmUpdater({
|
|
102
|
+
currentVersion: VERSION,
|
|
103
|
+
home: cfg.home || os.homedir(),
|
|
104
|
+
enabled: cfg.autoUpdate !== false,
|
|
105
|
+
readonly: bridgeReadonly(),
|
|
106
|
+
...(cfg.updateSeams || {}),
|
|
107
|
+
});
|
|
100
108
|
const attachedWs = new Map(); // ws -> session (per il push dei frame files)
|
|
101
109
|
// selectProvider sceglie UNA volta (startup) il provider external|builtin|disabled
|
|
102
110
|
// (design §4b/§9b/§9g) e ritorna {mode,reason,fleet}; routes consumano il .fleet,
|
|
@@ -121,30 +129,19 @@ function createServer(opts = {}) {
|
|
|
121
129
|
return { localPort: node.localPort, token: node.token || null };
|
|
122
130
|
}
|
|
123
131
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
const tr = nodesTunnel.startReverse({
|
|
130
|
-
home: cfg.home || os.homedir(), rendezvous: st.rendezvous,
|
|
131
|
-
spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
|
|
132
|
-
sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
|
|
133
|
-
});
|
|
134
|
-
if (!tr.started && tr.reason !== 'already running') {
|
|
135
|
-
process.stderr.write(`[nexuscrew] reverse tunnel autostart failed: ${tr.reason || 'unknown'}\n`);
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// New Hydra peers are ordinary local+remote nodes. Only the side that owns
|
|
141
|
-
// the outbound SSH alias dials; inbound records are reached through its -R.
|
|
142
|
-
{
|
|
132
|
+
const runtimePort = () => (server && server.address() ? server.address().port : cfg.port);
|
|
133
|
+
let tunnelsStarted = false;
|
|
134
|
+
function startManagedTunnels() {
|
|
135
|
+
if (tunnelsStarted) return;
|
|
136
|
+
tunnelsStarted = true;
|
|
143
137
|
const st = nodesStore.loadStore(nodesPath);
|
|
138
|
+
// Exactly one connection per configured hub. Legacy `roles.node` /
|
|
139
|
+
// rendezvous data is migration-only and never starts a second hidden SSH
|
|
140
|
+
// process. Publishing this device is the optional -R on its outbound link.
|
|
144
141
|
for (const node of (st && st.nodes) || []) {
|
|
145
142
|
if (node.direction !== 'inbound' && node.autostart === true) {
|
|
146
143
|
const tr = nodesTunnel.startForward({
|
|
147
|
-
home: cfg.home || os.homedir(), node, localAppPort:
|
|
144
|
+
home: cfg.home || os.homedir(), node, localAppPort: runtimePort(),
|
|
148
145
|
spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
|
|
149
146
|
sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
|
|
150
147
|
});
|
|
@@ -243,15 +240,13 @@ function createServer(opts = {}) {
|
|
|
243
240
|
const h = healths[i] || null;
|
|
244
241
|
return { ...n, tunnel: nodesHealth.tunnelFromHealth(h), health: h };
|
|
245
242
|
});
|
|
246
|
-
|
|
247
|
-
if (view.rendezvous) out.rendezvous = view.rendezvous;
|
|
248
|
-
res.json(out);
|
|
243
|
+
res.json({ nodeId: view.nodeId, nodes });
|
|
249
244
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
250
245
|
});
|
|
251
246
|
// Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
|
|
252
247
|
// wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
|
|
253
248
|
// READONLY route-level e la redazione token vivono dentro settingsRoutes.
|
|
254
|
-
api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
|
|
249
|
+
api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort }));
|
|
255
250
|
api.get('/topology', async (_req, res) => {
|
|
256
251
|
try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
|
|
257
252
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
@@ -283,6 +278,8 @@ function createServer(opts = {}) {
|
|
|
283
278
|
app.use('/api', api);
|
|
284
279
|
app.use('/federation', federation.peerRouter({
|
|
285
280
|
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
|
|
281
|
+
fetchImpl: healthFetch,
|
|
282
|
+
roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
|
|
286
283
|
}));
|
|
287
284
|
|
|
288
285
|
// Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
|
|
@@ -296,14 +293,18 @@ function createServer(opts = {}) {
|
|
|
296
293
|
// per costruire path: validazione ^[a-z0-9-]{1,32}$, nome invalido → 404 secco
|
|
297
294
|
// (niente traversal, niente fallback silenzioso alla SPA su nomi sporchi).
|
|
298
295
|
const DECK_NAME_RE = /^[a-z0-9-]{1,32}$/;
|
|
296
|
+
const DECK_OWNER_RE = /^[a-f0-9]{16,64}$/;
|
|
299
297
|
// Cattura TUTTO cio' che sta sotto /deck/ (anche slash/segmenti extra o encoded)
|
|
300
298
|
// e valida il remainder: qualunque cosa non sia un nome deck strict → 404,
|
|
301
299
|
// senza mai cadere nel catch-all SPA con un nome sporco.
|
|
302
300
|
app.get(/^\/deck\/(.*)$/, (req, res) => {
|
|
303
301
|
// parita' col client: deckFromPath accetta UN trailing slash (/deck/main/),
|
|
304
302
|
// il server deve fare lo stesso; piu' di uno resta 404 (regex strict).
|
|
305
|
-
const
|
|
306
|
-
|
|
303
|
+
const value = req.params[0].replace(/\/$/, '');
|
|
304
|
+
const parts = value.split('/');
|
|
305
|
+
const valid = (parts.length === 1 && DECK_NAME_RE.test(parts[0]))
|
|
306
|
+
|| (parts.length === 2 && DECK_OWNER_RE.test(parts[0]) && DECK_NAME_RE.test(parts[1]));
|
|
307
|
+
if (!valid) {
|
|
307
308
|
return res.status(404).type('text/plain').send('invalid deck name');
|
|
308
309
|
}
|
|
309
310
|
return res.sendFile(path.join(distDir, 'index.html'));
|
|
@@ -314,7 +315,8 @@ function createServer(opts = {}) {
|
|
|
314
315
|
// Close the watcher when the HTTP server closes. Registered HERE (inside
|
|
315
316
|
// createServer) — not in start() — so every createServer consumer is covered,
|
|
316
317
|
// not only the start() path. watcher.close() is idempotent.
|
|
317
|
-
server.on('
|
|
318
|
+
server.on('listening', () => { updater.start(); startManagedTunnels(); });
|
|
319
|
+
server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); });
|
|
318
320
|
// noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
|
|
319
321
|
// (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
|
|
320
322
|
// dell'HTTP (auth locale -> name strict -> inject token) prima del piping.
|
|
@@ -343,13 +345,13 @@ function createServer(opts = {}) {
|
|
|
343
345
|
let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
|
|
344
346
|
const given = bearerFrom(req) || u.searchParams.get('token') || '';
|
|
345
347
|
if (!verify(tokenHolder.value, given)) return socket.destroy();
|
|
346
|
-
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort:
|
|
348
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets });
|
|
347
349
|
return;
|
|
348
350
|
}
|
|
349
351
|
if (pathname.startsWith('/federation/route/')) {
|
|
350
352
|
const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
|
|
351
353
|
if (!ingress) return socket.destroy();
|
|
352
|
-
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort:
|
|
354
|
+
federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets });
|
|
353
355
|
return;
|
|
354
356
|
}
|
|
355
357
|
if (pathname === '/node' || pathname.startsWith('/node/')) {
|
|
@@ -394,19 +396,25 @@ function createServer(opts = {}) {
|
|
|
394
396
|
}
|
|
395
397
|
});
|
|
396
398
|
|
|
397
|
-
return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP };
|
|
399
|
+
return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater };
|
|
398
400
|
}
|
|
399
401
|
|
|
400
402
|
function start(opts = {}) {
|
|
401
403
|
const { server, cfg } = createServer(opts);
|
|
402
404
|
const log = opts.log || console.log;
|
|
403
405
|
const requestedPort = cfg.port;
|
|
406
|
+
const nodesPath = opts.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
|
|
407
|
+
const pairedPeers = nodesStore.hasPairedPeers(nodesStore.loadStore(nodesPath));
|
|
408
|
+
const listenError = (error) => {
|
|
409
|
+
if (typeof opts.onListenError === 'function') return opts.onListenError(error);
|
|
410
|
+
throw error;
|
|
411
|
+
};
|
|
404
412
|
const onListening = () => {
|
|
405
413
|
cfg.port = server.address().port;
|
|
406
414
|
// Il token NON si stampa allo startup: finirebbe nei log del servizio
|
|
407
415
|
// (journalctl/logfile). L'apertura autenticata passa da `nexuscrew show`.
|
|
408
416
|
log(`nexuscrew on http://${cfg.bind}:${cfg.port} (open with \`nexuscrew show\`)`);
|
|
409
|
-
log('localhost-only — reach it via SSH
|
|
417
|
+
log('localhost-only — reach it via a user-controlled SSH or VPN channel.');
|
|
410
418
|
};
|
|
411
419
|
const persistFallback = () => {
|
|
412
420
|
const selected = server.address().port;
|
|
@@ -432,10 +440,17 @@ function start(opts = {}) {
|
|
|
432
440
|
};
|
|
433
441
|
server.once('error', (error) => {
|
|
434
442
|
if (error && error.code === 'EADDRINUSE' && requestedPort !== 0 && opts.autoPort !== false) {
|
|
443
|
+
if (pairedPeers) {
|
|
444
|
+
const refused = new Error(`preferred port ${requestedPort} is busy; paired peers exist, refusing automatic port change`);
|
|
445
|
+
refused.code = 'EADDRINUSE_PAIRED';
|
|
446
|
+
refused.cause = error;
|
|
447
|
+
listenError(refused);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
435
450
|
tryFallback(requestedPort >= 65535 ? 41820 : requestedPort + 1, 200);
|
|
436
451
|
return;
|
|
437
452
|
}
|
|
438
|
-
|
|
453
|
+
listenError(error);
|
|
439
454
|
});
|
|
440
455
|
server.listen(requestedPort, cfg.bind, onListening);
|
|
441
456
|
return server;
|