@mmmbuto/nexuscrew 0.8.0 → 0.8.3

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,263 @@
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 topologyCache = require('../nodes/topology-cache.js');
8
+ const { bearerFrom } = require('../auth/middleware.js');
9
+ const { safeEqual } = require('../nodes/peering.js');
10
+ const {
11
+ sanitizeRequestHeaders, sanitizeResponseHeaders, stripLocalTokenQuery,
12
+ } = require('./node-proxy.js');
13
+
14
+ const MAX_HOPS = 4;
15
+ const ROUTE_DELIMITER = '_';
16
+
17
+ function peerFromToken(nodesPath, token) {
18
+ const st = store.loadStore(nodesPath);
19
+ if (!st || !token) return null;
20
+ return st.nodes.find((n) => n.acceptToken && safeEqual(n.acceptToken, token)) || null;
21
+ }
22
+
23
+ function peerAllows(peer, otherId) {
24
+ if (!peer) return true;
25
+ if (peer.visibility === 'network') return true;
26
+ if (peer.visibility === 'relay-only') return false;
27
+ return Array.isArray(peer.selected) && peer.selected.includes(otherId);
28
+ }
29
+
30
+ function canTransit(ingress, egress) {
31
+ if (!ingress || !egress || ingress.name === egress.name) return !ingress;
32
+ return peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
33
+ }
34
+
35
+ function parseRoute(raw) {
36
+ const parts = String(raw || '').split('?')[0].split('/').filter(Boolean);
37
+ const idx = parts.indexOf(ROUTE_DELIMITER);
38
+ // The delimiter cannot occur in a strict peer name, so the first occurrence
39
+ // is authoritative. Resource segments may legitimately be "_" (a valid
40
+ // tmux session name) and must not be mistaken for another boundary.
41
+ if (idx < 0) return null;
42
+ const route = parts.slice(0, idx);
43
+ const resource = `/${parts.slice(idx + 1).join('/')}`;
44
+ if (route.length > MAX_HOPS || route.some((n) => !store.NODE_NAME_RE.test(n))) return null;
45
+ if (new Set(route).size !== route.length || !knownResource(resource)) return null;
46
+ return { route, resource };
47
+ }
48
+
49
+ function knownResource(resource) {
50
+ return resource === '/sessions'
51
+ || /^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)
52
+ || resource === '/config'
53
+ || resource === '/fs/dirs'
54
+ || resource === '/files'
55
+ || resource === '/files/download'
56
+ || resource === '/files/upload'
57
+ || 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);
59
+ }
60
+
61
+ function allowedResource(resource, method = 'GET') {
62
+ if (resource === '/sessions') return method === 'GET' || method === 'POST';
63
+ if (/^\/sessions\/[\w.@%:+-]{1,128}$/.test(resource)) return method === 'DELETE';
64
+ if (resource === '/config') return method === 'GET';
65
+ if (resource === '/fs/dirs') return method === 'GET';
66
+ if (resource === '/files') return method === 'GET' || method === 'DELETE';
67
+ if (resource === '/files/download') return method === 'GET';
68
+ if (resource === '/files/upload') return method === 'POST';
69
+ if (resource === '/ws') return method === 'GET';
70
+ 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';
72
+ return false;
73
+ }
74
+
75
+ function cleanHeaders(headers, credential, visited = null) {
76
+ const out = sanitizeRequestHeaders(headers, credential);
77
+ for (const key of Object.keys(out)) {
78
+ if (['x-nexuscrew-route', 'x-nexuscrew-visited', 'x-nexuscrew-hop'].includes(key.toLowerCase())) delete out[key];
79
+ }
80
+ if (Array.isArray(visited) && visited.length) out['x-nexuscrew-visited'] = visited.join(',');
81
+ return out;
82
+ }
83
+
84
+ function proxyHttp(req, res, { port, path, credential, visited = null }) {
85
+ const up = http.request({ host: '127.0.0.1', port, method: req.method, path, headers: cleanHeaders(req.headers, credential, visited) }, (r) => {
86
+ res.writeHead(r.statusCode, sanitizeResponseHeaders(r.headers)); r.pipe(res);
87
+ });
88
+ up.setTimeout(30000, () => up.destroy());
89
+ up.on('error', () => { if (!res.headersSent) res.status(502).json({ error: 'peer non raggiungibile' }); else res.destroy(); });
90
+ req.pipe(up);
91
+ }
92
+
93
+ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false }) {
94
+ return (req, res) => {
95
+ const parsed = parseRoute(req.url);
96
+ if (!parsed || !allowedResource(parsed.resource, req.method)) return res.status(404).json({ error: 'not found' });
97
+ if (readonly() && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
98
+ const st = store.loadStore(nodesPath);
99
+ if (!st) return res.status(503).json({ error: 'node store unavailable' });
100
+ const visited = controlledVisited(req, ingress, st.nodeId);
101
+ if (!visited) return res.status(409).json({ error: 'federation cycle rejected' });
102
+ if (parsed.route.length === 0) {
103
+ return proxyHttp(req, res, { port: typeof localPort === 'function' ? localPort() : localPort, path: `/api${parsed.resource}${queryOf(req.url)}`, credential: localCredential() });
104
+ }
105
+ const next = st && store.getNode(st, parsed.route[0]);
106
+ if (!next || !next.token || (ingress && !canTransit(ingress, next))) return res.status(403).json({ error: 'route non consentita' });
107
+ const rest = parsed.route.slice(1);
108
+ const path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}${queryOf(req.url)}`;
109
+ proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited });
110
+ };
111
+ }
112
+
113
+ function queryOf(url) {
114
+ const i = String(url).indexOf('?');
115
+ return i < 0 ? '' : stripLocalTokenQuery(String(url).slice(i));
116
+ }
117
+
118
+ function controlledVisited(req, ingress, instanceId) {
119
+ const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
120
+ const seen = raw ? raw.split(',').filter(Boolean) : [];
121
+ if (seen.some((id) => !store.NODE_ID_RE.test(id)) || seen.includes(instanceId) || seen.length > MAX_HOPS) return null;
122
+ return [...seen, instanceId];
123
+ }
124
+
125
+ async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch }) {
126
+ const st = store.loadStore(nodesPath);
127
+ if (!st) return { instanceId: null, nodes: [], authoritative: [] };
128
+ const seen = new Set(visited.filter((x) => store.NODE_ID_RE.test(x)));
129
+ seen.add(st.nodeId);
130
+ const out = [];
131
+ const authoritative = [];
132
+ for (const n of st.nodes) {
133
+ if (!n.nodeId || seen.has(n.nodeId) || (ingress && !canTransit(ingress, n))) continue;
134
+ out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
135
+ if (ttl <= 1 || !n.token) continue;
136
+ try {
137
+ const u = `http://127.0.0.1:${n.localPort}/federation/topology?ttl=${ttl - 1}&visited=${encodeURIComponent([...seen].join(','))}`;
138
+ const r = await fetchImpl(u, { headers: { authorization: `Bearer ${n.token}` } });
139
+ const j = await r.json();
140
+ if (!r.ok || j.instanceId !== n.nodeId || !Array.isArray(j.nodes)) continue;
141
+ authoritative.push(n.name);
142
+ for (const child of j.nodes) {
143
+ if (!child || !store.NODE_ID_RE.test(child.instanceId) || child.instanceId === n.nodeId || seen.has(child.instanceId)
144
+ || !store.NODE_NAME_RE.test(child.name)
145
+ || !Array.isArray(child.route) || child.route.length < 1 || child.route.length >= ttl
146
+ || child.route.some((x) => !store.NODE_NAME_RE.test(x))
147
+ || new Set(child.route).size !== child.route.length
148
+ || child.name !== child.route[child.route.length - 1]
149
+ || child.route.includes(n.name)) continue;
150
+ out.push({ instanceId: child.instanceId, name: child.name, route: [n.name, ...child.route], direct: false });
151
+ }
152
+ } catch (_) {}
153
+ }
154
+ const ids = new Set(); const routes = new Set(); const unique = [];
155
+ for (const n of out.sort((a, b) => a.route.length - b.route.length)) {
156
+ const routeKey = n.route.join('/');
157
+ if (ids.has(n.instanceId) || routes.has(routeKey)) continue;
158
+ ids.add(n.instanceId); routes.add(routeKey); unique.push(n);
159
+ }
160
+ return { instanceId: st.nodeId, nodes: unique, authoritative };
161
+ }
162
+
163
+ async function collectTopology(opts) {
164
+ const out = await collectTopologyDetailed(opts);
165
+ return { instanceId: out.instanceId, nodes: out.nodes };
166
+ }
167
+
168
+ // Local roster: live topology plus a credential-free cache of previously seen
169
+ // transitive nodes. Stale entries are never returned by the peer endpoint.
170
+ async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000) }) {
171
+ const live = await collectTopologyDetailed({ nodesPath, fetchImpl });
172
+ const st = store.loadStore(nodesPath);
173
+ const directNames = new Set(((st && st.nodes) || []).map((n) => n.name));
174
+ const authoritative = new Set(live.authoritative);
175
+ const liveIds = new Set(live.nodes.map((n) => n.instanceId));
176
+ const liveRoutes = new Set(live.nodes.map((n) => n.route.join('/')));
177
+ const cacheFile = cachePath || topologyCache.defaultPath();
178
+ const old = topologyCache.loadCache(cacheFile) || topologyCache.emptyCache();
179
+ const next = new Map();
180
+
181
+ for (const entry of old.nodes) {
182
+ const first = entry.route[0];
183
+ if (!directNames.has(first)) continue;
184
+ if (authoritative.has(first) && !liveIds.has(entry.instanceId) && !liveRoutes.has(entry.route.join('/'))) continue;
185
+ next.set(entry.instanceId, entry);
186
+ }
187
+ for (const n of live.nodes) {
188
+ if (n.route.length > 1) next.set(n.instanceId, { instanceId: n.instanceId, name: n.name, route: [...n.route], lastSeen: now });
189
+ }
190
+ const cached = [...next.values()].sort((a, b) => a.route.join('/').localeCompare(b.route.join('/'))).slice(0, topologyCache.MAX_ENTRIES);
191
+ const serialized = { schemaVersion: topologyCache.SCHEMA_VERSION, nodes: cached };
192
+ if (JSON.stringify(serialized) !== JSON.stringify(old)) {
193
+ try { topologyCache.atomicWriteCache(cacheFile, serialized); } catch (_) {}
194
+ }
195
+
196
+ const nodes = live.nodes.map((n) => ({ ...n, stale: false, lastSeen: now }));
197
+ for (const n of cached) {
198
+ if (!liveIds.has(n.instanceId) && !liveRoutes.has(n.route.join('/'))) nodes.push({ ...n, direct: false, stale: true });
199
+ }
200
+ return { instanceId: live.instanceId, nodes };
201
+ }
202
+
203
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly }) {
204
+ const r = express.Router();
205
+ r.use((req, res, next) => {
206
+ const peer = peerFromToken(nodesPath, bearerFrom(req));
207
+ if (!peer) return res.status(401).json({ error: 'unauthorized peer' });
208
+ req.peer = peer; next();
209
+ });
210
+ r.get('/topology', async (req, res) => {
211
+ const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
212
+ const visited = String(req.query.visited || '').split(',');
213
+ res.json(await collectTopology({ nodesPath, ingress: req.peer, ttl, visited, fetchImpl }));
214
+ });
215
+ r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
216
+ return r;
217
+ }
218
+
219
+ function localRouter({ nodesPath, localPort, localCredential, readonly }) {
220
+ const r = express.Router();
221
+ r.use((req, res) => routeHandler({ nodesPath, localPort, localCredential, readonly })(req, res));
222
+ return r;
223
+ }
224
+
225
+ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredential, ingress, readonly = () => false, activeSockets = null }) {
226
+ if (readonly()) return reject(socket, 403);
227
+ const parsed = parseRoute(req.url.replace(/^\/(?:api\/route|federation\/route)/, ''));
228
+ if (!parsed || parsed.resource !== '/ws') return reject(socket, 404);
229
+ const st = store.loadStore(nodesPath);
230
+ if (!st) return reject(socket, 503);
231
+ const visited = controlledVisited(req, ingress, st.nodeId);
232
+ if (!visited) return reject(socket, 409);
233
+ let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential(); let path = '/ws';
234
+ if (parsed.route.length) {
235
+ const next = store.getNode(st, parsed.route[0]);
236
+ if (!next || !next.token || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
237
+ port = next.localPort; credential = next.token;
238
+ const rest = parsed.route.slice(1);
239
+ path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}/ws`;
240
+ }
241
+ const up = net.connect({ host: '127.0.0.1', port });
242
+ up.once('connect', () => {
243
+ const headers = cleanHeaders(req.headers, credential, parsed.route.length ? visited : null);
244
+ const lines = [`GET ${path} HTTP/1.1`, `Host: 127.0.0.1:${port}`];
245
+ for (const [k, v] of Object.entries(headers)) lines.push(`${k}: ${Array.isArray(v) ? v.join(', ') : v}`);
246
+ lines.push('Connection: Upgrade', 'Upgrade: websocket', '', '');
247
+ up.write(lines.join('\r\n')); if (head && head.length) up.write(head);
248
+ if (activeSockets && typeof activeSockets.add === 'function') {
249
+ activeSockets.add(socket); activeSockets.add(up);
250
+ const remove = () => { activeSockets.delete(socket); activeSockets.delete(up); };
251
+ socket.once('close', remove); up.once('close', remove);
252
+ }
253
+ socket.pipe(up); up.pipe(socket);
254
+ });
255
+ up.on('error', () => reject(socket, 502)); socket.on('error', () => up.destroy());
256
+ }
257
+
258
+ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConnection: close\r\n\r\n`); } catch (_) {} }
259
+
260
+ module.exports = {
261
+ MAX_HOPS, ROUTE_DELIMITER, peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
262
+ collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
263
+ };
@@ -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');
@@ -97,6 +100,7 @@ function createServer(opts = {}) {
97
100
  // token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
98
101
  // il valore che il proxy inietta upstream, non esce mai verso il browser.
99
102
  const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
103
+ const topologyCachePath = cfg.topologyCachePath || require('./nodes/topology-cache.js').defaultPath(cfg.home || os.homedir());
100
104
  const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
101
105
  const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
102
106
  function resolveNode(name) {
@@ -123,10 +127,29 @@ function createServer(opts = {}) {
123
127
  }
124
128
  }
125
129
 
130
+ // New Hydra peers are ordinary local+remote nodes. Only the side that owns
131
+ // the outbound SSH alias dials; inbound records are reached through its -R.
132
+ {
133
+ const st = nodesStore.loadStore(nodesPath);
134
+ for (const node of (st && st.nodes) || []) {
135
+ if (node.direction !== 'inbound' && node.autostart === true) {
136
+ const tr = nodesTunnel.startForward({
137
+ home: cfg.home || os.homedir(), node, localAppPort: cfg.port,
138
+ spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
139
+ sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
140
+ });
141
+ if (!tr.started && tr.reason !== 'already running') {
142
+ process.stderr.write(`[nexuscrew] peer ${node.name} autostart failed: ${tr.reason || 'unknown'}\n`);
143
+ }
144
+ }
145
+ }
146
+ }
147
+
126
148
  const app = express();
127
149
  const distDir = path.join(__dirname, '..', 'frontend', 'dist');
128
150
  // no-store on everything (HTML+assets+API): this is a local, token-adjacent tool.
129
151
  app.use((_req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
152
+ app.use('/pair', publicPeeringRoutes({ cfg, nodesPath }));
130
153
 
131
154
  // Tutte le /api dietro Bearer: sul loopback il gate vero è il tunnel,
132
155
  // ma il token chiude anche altri processi locali della stessa macchina.
@@ -145,6 +168,7 @@ function createServer(opts = {}) {
145
168
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
146
169
  });
147
170
  api.post('/sessions', express.json({ limit: '4kb' }), async (req, res) => {
171
+ if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: creazione sessione bloccata' });
148
172
  try {
149
173
  const { name, cwd, preset } = req.body || {};
150
174
  await createSession(cfg.tmuxBin, { name, cwd, preset },
@@ -153,6 +177,7 @@ function createServer(opts = {}) {
153
177
  } catch (e) { res.status(e.status || 500).json({ error: String(e.message || e) }); }
154
178
  });
155
179
  api.delete('/sessions/:name', async (req, res) => {
180
+ if (proxyReadonly()) return res.status(403).json({ error: 'READONLY: eliminazione sessione bloccata' });
156
181
  const name = String(req.params.name || '');
157
182
  try {
158
183
  const fleet = await fleetP;
@@ -167,6 +192,7 @@ function createServer(opts = {}) {
167
192
  api.get('/config', (_req, res) => res.json({
168
193
  readonlyDefault: cfg.readonlyDefault, version: VERSION, uiVersion: uiBuildVersion(distDir),
169
194
  bind: cfg.bind, port: cfg.port,
195
+ instanceId: (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
170
196
  presets: ['shell', 'claude', 'codex-vl', 'pi', ...Object.keys(cfg.sessionPresets || {})],
171
197
  }));
172
198
  api.use('/files', filesRoutes({
@@ -174,6 +200,7 @@ function createServer(opts = {}) {
174
200
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
175
201
  paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
176
202
  notifier,
203
+ readonly: proxyReadonly,
177
204
  }));
178
205
  // MCP bridge (design §2): /notify, /push/*, /asks — dietro lo stesso Bearer
179
206
  // del router /api; gate READONLY sui mutanti dentro notifyRoutes.
@@ -195,7 +222,10 @@ function createServer(opts = {}) {
195
222
  const st = nodesStore.loadStore(nodesPath);
196
223
  if (!st) return res.json({ nodeId: null, nodes: [] });
197
224
  const view = nodesStore.redactStore(st);
198
- const nodes = view.nodes.map((n) => ({ ...n, tunnel: nodesTunnel.readTunnelState(os.homedir(), n.name) }));
225
+ const nodes = view.nodes.map((n) => ({
226
+ ...n,
227
+ tunnel: n.direction === 'inbound' ? { status: 'up', managed: false } : nodesTunnel.readTunnelState(os.homedir(), n.name),
228
+ }));
199
229
  const out = { nodeId: view.nodeId, nodes };
200
230
  if (view.rendezvous) out.rendezvous = view.rendezvous;
201
231
  res.json(out);
@@ -205,6 +235,13 @@ function createServer(opts = {}) {
205
235
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
206
236
  // READONLY route-level e la redazione token vivono dentro settingsRoutes.
207
237
  api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
238
+ api.get('/topology', async (_req, res) => {
239
+ try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
240
+ catch (e) { res.status(500).json({ error: String(e.message || e) }); }
241
+ });
242
+ api.use('/route', federation.localRouter({
243
+ nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, readonly: proxyReadonly,
244
+ }));
208
245
  api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
209
246
  api.post('/voice/transcribe',
210
247
  express.raw({ type: () => true, limit: '25mb' }),
@@ -227,6 +264,9 @@ function createServer(opts = {}) {
227
264
  });
228
265
 
229
266
  app.use('/api', api);
267
+ app.use('/federation', federation.peerRouter({
268
+ nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, readonly: proxyReadonly,
269
+ }));
230
270
 
231
271
  // Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
232
272
  // di risolvere <name>: requireToken(token) davanti al router, nessuna route
@@ -282,6 +322,19 @@ function createServer(opts = {}) {
282
322
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req));
283
323
  return;
284
324
  }
325
+ if (pathname.startsWith('/api/route/')) {
326
+ let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
327
+ const given = bearerFrom(req) || u.searchParams.get('token') || '';
328
+ if (!verify(tokenHolder.value, given)) return socket.destroy();
329
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets });
330
+ return;
331
+ }
332
+ if (pathname.startsWith('/federation/route/')) {
333
+ const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
334
+ if (!ingress) return socket.destroy();
335
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets });
336
+ return;
337
+ }
285
338
  if (pathname === '/node' || pathname.startsWith('/node/')) {
286
339
  handleNodeUpgrade({
287
340
  req, socket, head, resolveNode,
@@ -330,13 +383,44 @@ function createServer(opts = {}) {
330
383
  function start(opts = {}) {
331
384
  const { server, cfg } = createServer(opts);
332
385
  const log = opts.log || console.log;
333
- server.listen(cfg.port, cfg.bind, () => {
386
+ const requestedPort = cfg.port;
387
+ const onListening = () => {
388
+ cfg.port = server.address().port;
334
389
  // 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\`)`);
390
+ // (journalctl/logfile). L'apertura autenticata passa da `nexuscrew show`.
391
+ log(`nexuscrew on http://${cfg.bind}:${cfg.port} (open with \`nexuscrew show\`)`);
338
392
  log('localhost-only — reach it via SSH/autossh/VPN tunnel.');
393
+ };
394
+ const persistFallback = () => {
395
+ const selected = server.address().port;
396
+ const configPath = opts.configPath || configJsonPath();
397
+ let current = {};
398
+ try {
399
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
400
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) current = parsed;
401
+ } catch (_) {}
402
+ writeConfigAtomic(configPath, { ...current, port: selected });
403
+ log(`preferred port ${requestedPort} busy; selected ${selected}`);
404
+ onListening();
405
+ };
406
+ const tryFallback = (candidate, remaining) => {
407
+ server.once('error', (error) => {
408
+ if (error && error.code === 'EADDRINUSE' && remaining > 1) {
409
+ tryFallback(candidate >= 65535 ? 41820 : candidate + 1, remaining - 1);
410
+ return;
411
+ }
412
+ throw error;
413
+ });
414
+ server.listen(candidate, cfg.bind, persistFallback);
415
+ };
416
+ server.once('error', (error) => {
417
+ if (error && error.code === 'EADDRINUSE' && requestedPort !== 0 && opts.autoPort !== false) {
418
+ tryFallback(requestedPort >= 65535 ? 41820 : requestedPort + 1, 200);
419
+ return;
420
+ }
421
+ throw error;
339
422
  });
423
+ server.listen(requestedPort, cfg.bind, onListening);
340
424
  return server;
341
425
  }
342
426