@mmmbuto/nexuscrew 0.8.0 → 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.
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ const http = require('node:http');
4
+ const net = require('node:net');
5
+ const express = require('express');
6
+ const store = require('../nodes/store.js');
7
+ const { bearerFrom } = require('../auth/middleware.js');
8
+ const { safeEqual } = require('../nodes/peering.js');
9
+ const {
10
+ sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
11
+ } = require('./node-proxy.js');
12
+
13
+ const MAX_HOPS = 4;
14
+ const ROUTE_DELIMITER = '_';
15
+
16
+ function peerFromToken(nodesPath, token) {
17
+ const st = store.loadStore(nodesPath);
18
+ if (!st || !token) return null;
19
+ return st.nodes.find((n) => n.acceptToken && safeEqual(n.acceptToken, token)) || null;
20
+ }
21
+
22
+ function peerAllows(peer, otherId) {
23
+ if (!peer) return true;
24
+ if (peer.visibility === 'network') return true;
25
+ if (peer.visibility === 'relay-only') return false;
26
+ return Array.isArray(peer.selected) && peer.selected.includes(otherId);
27
+ }
28
+
29
+ function canTransit(ingress, egress) {
30
+ if (!ingress || !egress || ingress.name === egress.name) return !ingress;
31
+ return peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
32
+ }
33
+
34
+ function parseRoute(raw) {
35
+ const parts = String(raw || '').split('?')[0].split('/').filter(Boolean);
36
+ const idx = parts.indexOf(ROUTE_DELIMITER);
37
+ // The delimiter cannot occur in a strict peer name, so the first occurrence
38
+ // is authoritative. Resource segments may legitimately be "_" (a valid
39
+ // tmux session name) and must not be mistaken for another boundary.
40
+ if (idx < 0) return null;
41
+ const route = parts.slice(0, idx);
42
+ const resource = `/${parts.slice(idx + 1).join('/')}`;
43
+ if (route.length > MAX_HOPS || route.some((n) => !store.NODE_NAME_RE.test(n))) return null;
44
+ if (new Set(route).size !== route.length || !knownResource(resource)) return null;
45
+ return { route, resource };
46
+ }
47
+
48
+ function knownResource(resource) {
49
+ return resource === '/sessions'
50
+ || /^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)
51
+ || resource === '/config'
52
+ || resource === '/fs/dirs'
53
+ || resource === '/files'
54
+ || resource === '/files/download'
55
+ || resource === '/files/upload'
56
+ || resource === '/ws';
57
+ }
58
+
59
+ function allowedResource(resource, method = 'GET') {
60
+ if (resource === '/sessions') return method === 'GET' || method === 'POST';
61
+ if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return method === 'DELETE';
62
+ if (resource === '/config') return method === 'GET';
63
+ if (resource === '/fs/dirs') return method === 'GET';
64
+ if (resource === '/files') return method === 'GET' || method === 'DELETE';
65
+ if (resource === '/files/download') return method === 'GET';
66
+ if (resource === '/files/upload') return method === 'POST';
67
+ if (resource === '/ws') return method === 'GET';
68
+ return false;
69
+ }
70
+
71
+ function cleanHeaders(headers, credential, visited = null) {
72
+ const out = sanitizeRequestHeaders(headers, credential);
73
+ for (const key of Object.keys(out)) {
74
+ if (['x-nexuscrew-route', 'x-nexuscrew-visited', 'x-nexuscrew-hop'].includes(key.toLowerCase())) delete out[key];
75
+ }
76
+ if (Array.isArray(visited) && visited.length) out['x-nexuscrew-visited'] = visited.join(',');
77
+ return out;
78
+ }
79
+
80
+ function proxyHttp(req, res, { port, path, credential, visited = null }) {
81
+ const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited) }, (r) => {
82
+ res.writeHead(r.statusCode, sanitizeResponseHeaders(r.headers)); r.pipe(res);
83
+ });
84
+ up.setTimeout(30000, () => up.destroy());
85
+ up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile' }); else res.destroy(); });
86
+ req.pipe(up);
87
+ }
88
+
89
+ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false }) {
90
+ return (req, res) => {
91
+ const parsed = parseRoute(req.url);
92
+ if (!parsed || !allowedResource(parsed.resource, req.method)) return res.status(404).json({ error: 'not found' });
93
+ if (readonly() && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
94
+ const st = store.loadStore(nodesPath);
95
+ if (!st) return res.status(503).json({ error: 'node store unavailable' });
96
+ const visited = controlledVisited(req, ingress, st.nodeId);
97
+ if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
98
+ if (parsed.route.length === 0) {
99
+ return proxyHttp(req, res, { port: typeof localPort === 'function' ? localPort() : localPort, path: `/api${parsed.resource}${queryOf(req.url)}`, credential: localCredential() });
100
+ }
101
+ const next = st && store.getNode(st, parsed.route[0]);
102
+ if (!next || !next.token || (ingress && !canTransit(ingress, next))) return res.status(403).json({ error: 'route non consentita' });
103
+ const rest = parsed.route.slice(1);
104
+ const path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}${queryOf(req.url)}`;
105
+ proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited });
106
+ };
107
+ }
108
+
109
+ function queryOf(url) {
110
+ const i = String(url).indexOf('?');
111
+ return i < 0 ? '' : stripLocalTokenQuery(String(url).slice(i));
112
+ }
113
+
114
+ function controlledVisited(req, ingress, instanceId) {
115
+ const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
116
+ const seen = raw ? raw.split(',').filter(Boolean) : [];
117
+ if (seen.some((id) => !store.NODE_ID_RE.test(id)) || seen.includes(instanceId) || seen.length > MAX_HOPS) return null;
118
+ return [...seen, instanceId];
119
+ }
120
+
121
+ async function collectTopology({ nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch }) {
122
+ const st = store.loadStore(nodesPath);
123
+ if (!st) return { instanceId: null, nodes: [] };
124
+ const seen = new Set(visited.filter((x) => store.NODE_ID_RE.test(x)));
125
+ seen.add(st.nodeId);
126
+ const out = [];
127
+ for (const n of st.nodes) {
128
+ if (!n.nodeId || seen.has(n.nodeId) || (ingress && !canTransit(ingress, n))) continue;
129
+ out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
130
+ if (ttl <= 1 || !n.token) continue;
131
+ try {
132
+ const u = `http://127.0.0.1:${n.localPort}/federation/topology?ttl=${ttl - 1}&visited=${encodeURIComponent([...seen].join(','))}`;
133
+ const r = await fetchImpl(u, { headers: { authorization: `Bearer ${n.token}` } });
134
+ const j = await r.json();
135
+ if (!r.ok || j.instanceId !== n.nodeId || !Array.isArray(j.nodes)) continue;
136
+ for (const child of j.nodes) {
137
+ if (!child || !store.NODE_ID_RE.test(child.instanceId) || child.instanceId === n.nodeId || seen.has(child.instanceId)
138
+ || !store.NODE_NAME_RE.test(child.name)
139
+ || !Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
140
+ || child.route.some((x) => !store.NODE_NAME_RE.test(x))
141
+ || new Set(child.route).size !== child.route.length
142
+ || child.name !== child.route[child.route.length - 1]
143
+ || child.route.includes(n.name)) continue;
144
+ out.push({ instanceId: child.instanceId, name: child.name, route: [n.name, ...child.route], direct: false });
145
+ }
146
+ } catch (_) {}
147
+ }
148
+ const ids = new Set(); const routes = new Set(); const unique = [];
149
+ for (const n of out.sort((a, b) => a.route.length - b.route.length)) {
150
+ const routeKey = n.route.join('/');
151
+ if (ids.has(n.instanceId) || routes.has(routeKey)) continue;
152
+ ids.add(n.instanceId); routes.add(routeKey); unique.push(n);
153
+ }
154
+ return { instanceId: st.nodeId, nodes: unique };
155
+ }
156
+
157
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly }) {
158
+ const r = express.Router();
159
+ r.use((req, res, next) => {
160
+ const peer = peerFromToken(nodesPath, bearerFrom(req));
161
+ if (!peer) return res.status(401).json({ error: 'unauthorized peer' });
162
+ req.peer = peer; next();
163
+ });
164
+ r.get('/topology', async (req, res) => {
165
+ const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
166
+ const visited = String(req.query.visited || '').split(',');
167
+ res.json(await collectTopology({ nodesPath, ingress: req.peer, ttl, visited, fetchImpl }));
168
+ });
169
+ r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
170
+ return r;
171
+ }
172
+
173
+ function localRouter({ nodesPath, localPort, localCredential, readonly }) {
174
+ const r = express.Router();
175
+ r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly })(req, res));
176
+ return r;
177
+ }
178
+
179
+ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null }) {
180
+ if (readonly()) return reject(socket, 403);
181
+ const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
182
+ if (!parsed || parsed.resource !== '/ws') return reject(socket, 404);
183
+ const st = store.loadStore(nodesPath);
184
+ if (!st) return reject(socket, 503);
185
+ const visited = controlledVisited(req, ingress, st.nodeId);
186
+ if (!visited) return reject(socket, 409);
187
+ let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential(); let path = '/ws';
188
+ if (parsed.route.length) {
189
+ const next = store.getNode(st, parsed.route[0]);
190
+ if (!next || !next.token || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
191
+ port = next.localPort; credential = next.token;
192
+ const rest = parsed.route.slice(1);
193
+ path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}/ws`;
194
+ }
195
+ const up = net.connect({ host: '127.0.0.1', port });
196
+ up.once('connect', () => {
197
+ const headers = cleanHeaders(req.headers, credential, parsed.route.length ? visited : null);
198
+ const lines = [`GET ${path} HTTP/1.1`, `Host: 127.0.0.1:${port}`];
199
+ for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
200
+ lines.push('Connection: Upgrade', 'Upgrade: websocket', '', '');
201
+ up.write(lines.join('\r\n')); if (head && head.length) up.write(head);
202
+ if (activeSockets && typeof activeSockets.add === 'function') {
203
+ activeSockets.add(socket); activeSockets.add(up);
204
+ const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
205
+ socket.once('close', remove); up.once('close', remove);
206
+ }
207
+ socket.pipe(up); up.pipe(socket);
208
+ });
209
+ up.on('error', () => reject(socket, 502)); socket.on('error', () => up.destroy());
210
+ }
211
+
212
+ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
213
+
214
+ module.exports = {
215
+ MAX_HOPS, ROUTE_DELIMITER, peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
216
+ collectTopology, peerRouter, localRouter, forwardUpgrade,
217
+ };
@@ -49,6 +49,19 @@ function isStrippedRequestHeader(lk) {
49
49
  return false;
50
50
  }
51
51
 
52
+ function connectionOptions(headers) {
53
+ const names = new Set();
54
+ for (const [key, value] of Object.entries(headers || {})) {
55
+ if (key.toLowerCase() !== 'connection') continue;
56
+ const raw = Array.isArray(value) ? value.join(',') : String(value || '');
57
+ for (const item of raw.split(',')) {
58
+ const name = item.trim().toLowerCase();
59
+ if (/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name)) names.add(name);
60
+ }
61
+ }
62
+ return names;
63
+ }
64
+
52
65
  // Divide "/vps/api/x?y=1" -> { name:'vps', rest:'/api/x', search:'?y=1' }.
53
66
  // name RAW (non decodificato): validato poi contro l'allowlist strict, cosi'
54
67
  // '..', '%2e', nomi lunghi falliscono il regex -> 404. rest/search preservano
@@ -79,8 +92,9 @@ function isTransitiveRest(rest) {
79
92
  // del nodo. host lo mette node http.request da host/port (loopback da config).
80
93
  function sanitizeRequestHeaders(headers, remoteToken) {
81
94
  const out = {};
95
+ const dynamicHop = connectionOptions(headers);
82
96
  for (const [k, v] of Object.entries(headers || {})) {
83
- if (isStrippedRequestHeader(k.toLowerCase())) continue;
97
+ if (isStrippedRequestHeader(k.toLowerCase()) || dynamicHop.has(k.toLowerCase())) continue;
84
98
  out[k] = v;
85
99
  }
86
100
  if (remoteToken) out.authorization = `Bearer ${remoteToken}`;
@@ -91,8 +105,9 @@ function sanitizeRequestHeaders(headers, remoteToken) {
91
105
  // token remoto non viene MAI messo qui dal proxy; il nodo remoto non lo riflette.
92
106
  function sanitizeResponseHeaders(headers) {
93
107
  const out = {};
108
+ const dynamicHop = connectionOptions(headers);
94
109
  for (const [k, v] of Object.entries(headers || {})) {
95
- if (HOP_BY_HOP.has(k.toLowerCase())) continue;
110
+ if (HOP_BY_HOP.has(k.toLowerCase()) || dynamicHop.has(k.toLowerCase())) continue;
96
111
  out[k] = v;
97
112
  }
98
113
  return out;
@@ -187,19 +202,17 @@ function abortUpgrade(socket, code) {
187
202
  function buildUpgradeRequest(method, rest, search, headers, remoteToken, localPort) {
188
203
  const lines = [];
189
204
  const cleanSearch = stripLocalTokenQuery(search);
205
+ const clean = sanitizeRequestHeaders(headers, remoteToken);
190
206
  lines.push(`${method} ${rest}${cleanSearch} HTTP/1.1`);
191
207
  lines.push(`Host: 127.0.0.1:${localPort}`);
192
- for (const [k, v] of Object.entries(headers || {})) {
208
+ for (const [k, v] of Object.entries(clean)) {
193
209
  const lk = k.toLowerCase();
194
- if (lk === 'host') continue;
195
- if (lk === 'connection' || lk === 'upgrade') continue; // ri-aggiunti controllati
196
- if (isStrippedRequestHeader(lk)) continue; // authorization/cookie/proxy-*/x-forwarded-*/hop-by-hop
210
+ if (lk === 'host' || lk === 'connection' || lk === 'upgrade') continue;
197
211
  const val = Array.isArray(v) ? v.join(', ') : v;
198
212
  lines.push(`${k}: ${val}`);
199
213
  }
200
214
  lines.push('Connection: Upgrade');
201
215
  lines.push('Upgrade: websocket');
202
- if (remoteToken) lines.push(`Authorization: Bearer ${remoteToken}`);
203
216
  return `${lines.join('\r\n')}\r\n\r\n`;
204
217
  }
205
218
 
@@ -287,6 +300,6 @@ module.exports = {
287
300
  handleNodeUpgrade,
288
301
  // esposti per i test
289
302
  splitNodePath, isTransitiveRest, sanitizeRequestHeaders, sanitizeResponseHeaders,
290
- buildUpgradeRequest, stripLocalTokenQuery, isStrippedRequestHeader, bearerFromUpgrade,
303
+ buildUpgradeRequest, stripLocalTokenQuery, isStrippedRequestHeader, connectionOptions, bearerFromUpgrade,
291
304
  MUTATING, HOP_BY_HOP, PROXY_TIMEOUT_MS,
292
305
  };
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');
@@ -24,7 +26,8 @@ const { fsRoutes } = require('./fs/routes.js');
24
26
  const nodesStore = require('./nodes/store.js');
25
27
  const nodesTunnel = require('./nodes/tunnel.js');
26
28
  const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
27
- const { settingsRoutes } = require('./settings/routes.js');
29
+ const federation = require('./proxy/federation.js');
30
+ const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
28
31
  const decksStore = require('./decks/store.js');
29
32
  const { decksRoutes } = require('./decks/routes.js');
30
33
  const { createEventsHub } = require('./notify/events.js');
@@ -123,10 +126,29 @@ function createServer(opts = {}) {
123
126
  }
124
127
  }
125
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
+
126
147
  const app = express();
127
148
  const distDir = path.join(__dirname, '..', 'frontend', 'dist');
128
149
  // no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
129
150
  app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
151
+ app.use('/pair', publicPeeringRoutes({ cfg, nodesPath }));
130
152
 
131
153
  // Tutte le /api dietro Bearer: sul loopback il gate vero è il tunnel,
132
154
  // ma il token chiude anche altri processi locali della stessa macchina.
@@ -145,6 +167,7 @@ function createServer(opts = {}) {
145
167
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
146
168
  });
147
169
  api.post('/sessions', express.json({ limit: '4kb' }), async (req, res) => {
170
+ if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: creazione sessione bloccata' });
148
171
  try {
149
172
  const { name, cwd, preset } = req.body || {};
150
173
  await createSession(cfg.tmuxBin, { name, cwd, preset },
@@ -153,6 +176,7 @@ function createServer(opts = {}) {
153
176
  } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
154
177
  });
155
178
  api.delete('/sessions/:name', async (req, res) => {
179
+ if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: eliminazione sessione bloccata' });
156
180
  const name = String(req.params.name || '');
157
181
  try {
158
182
  const fleet = await fleetP;
@@ -167,6 +191,7 @@ function createServer(opts = {}) {
167
191
  api.get('/config', (_req, res) => res.json({
168
192
  readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
169
193
  bind: cfg.bind, port: cfg.port,
194
+ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
170
195
  presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
171
196
  }));
172
197
  api.use('/files', filesRoutes({
@@ -174,6 +199,7 @@ function createServer(opts = {}) {
174
199
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
175
200
  paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
176
201
  notifier,
202
+ readonly: proxyReadonly,
177
203
  }));
178
204
  // MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
179
205
  // del router /api; gate READONLY sui mutanti dentro notifyRoutes.
@@ -195,7 +221,10 @@ function createServer(opts = {}) {
195
221
  const st = nodesStore.loadStore(nodesPath);
196
222
  if (!st) return res.json({ nodeId: null, nodes: [] });
197
223
  const view = nodesStore.redactStore(st);
198
- const nodes = view.nodes.map((n) => ({ ...n, tunnel: nodesTunnel.readTunnelState(os.homedir(), n.name) }));
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
+ }));
199
228
  const out = { nodeId: view.nodeId, nodes };
200
229
  if (view.rendezvous) out.rendezvous = view.rendezvous;
201
230
  res.json(out);
@@ -205,6 +234,13 @@ function createServer(opts = {}) {
205
234
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
206
235
  // READONLY route-level e la redazione token vivono dentro settingsRoutes.
207
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
+ }));
208
244
  api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
209
245
  api.post('/voice/transcribe',
210
246
  express.raw({ type: () => true, limit: '25mb' }),
@@ -227,6 +263,9 @@ function createServer(opts = {}) {
227
263
  });
228
264
 
229
265
  app.use('/api', api);
266
+ app.use('/federation', federation.peerRouter({
267
+ nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, readonly: proxyReadonly,
268
+ }));
230
269
 
231
270
  // Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
232
271
  // di risolvere <name>: requireToken(token) davanti al router, nessuna route
@@ -282,6 +321,19 @@ function createServer(opts = {}) {
282
321
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
283
322
  return;
284
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
+ }
285
337
  if (pathname === '/node' || pathname.startsWith('/node/')) {
286
338
  handleNodeUpgrade({
287
339
  req, socket, head, resolveNode,
@@ -330,13 +382,44 @@ function createServer(opts = {}) {
330
382
  function start(opts = {}) {
331
383
  const { server, cfg } = createServer(opts);
332
384
  const log = opts.log || console.log;
333
- server.listen(cfg.port, cfg.bind, () => {
385
+ const requestedPort = cfg.port;
386
+ const onListening = () => {
387
+ cfg.port = server.address().port;
334
388
  // Il token NON si stampa allo startup: finirebbe nei log del servizio
335
- // (journalctl/logfile), che `nexuscrew logs` espone contratto A2
336
- // "token mai nei log". Per l'URL completo: `nexuscrew url` (o --qr).
337
- log(`nexuscrew on http://${cfg.bind}:${cfg.port} (token: usa \`nexuscrew url\`)`);
389
+ // (journalctl/logfile). L'apertura autenticata passa da `nexuscrew show`.
390
+ log(`nexuscrew on http://${cfg.bind}:${cfg.port} (open with \`nexuscrew show\`)`);
338
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;
339
421
  });
422
+ server.listen(requestedPort, cfg.bind, onListening);
340
423
  return server;
341
424
  }
342
425