@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.
- package/README.md +66 -60
- package/bin/nexuscrew.js +10 -4
- package/frontend/dist/assets/index-BFyPeZsL.js +90 -0
- package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
- package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +314 -110
- package/lib/cli/doctor.js +1 -1
- package/lib/cli/init.js +40 -10
- package/lib/cli/pidfile.js +2 -2
- package/lib/cli/service.js +0 -5
- package/lib/decks/store.js +8 -2
- package/lib/files/routes.js +3 -1
- package/lib/fleet/builtin.js +15 -5
- package/lib/fleet/managed.js +278 -107
- package/lib/mcp/server.js +24 -5
- package/lib/nodes/commands.js +27 -12
- package/lib/nodes/peering.js +116 -0
- package/lib/nodes/store.js +91 -24
- package/lib/nodes/topology-cache.js +75 -0
- package/lib/nodes/tunnel.js +22 -7
- package/lib/proxy/federation.js +263 -0
- package/lib/proxy/node-proxy.js +21 -8
- package/lib/server.js +91 -7
- package/lib/settings/routes.js +221 -5
- package/package.json +2 -2
- package/frontend/dist/assets/index-BBOgTCoO.css +0 -32
- package/frontend/dist/assets/index-DQrDBogT.js +0 -83
|
@@ -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
|
+
};
|
package/lib/proxy/node-proxy.js
CHANGED
|
@@ -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(
|
|
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
|
|
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) => ({
|
|
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
|
-
|
|
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)
|
|
336
|
-
|
|
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
|
|