@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.
- package/README.md +43 -51
- package/bin/nexuscrew.js +10 -4
- package/frontend/dist/assets/index-COsoJxXQ.css +32 -0
- package/frontend/dist/assets/index-Dey9rdY-.js +90 -0
- package/frontend/dist/assets/qr-scanner-worker.min-D85Z9gVD.js +98 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +175 -100
- package/lib/cli/init.js +32 -7
- 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 +277 -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/tunnel.js +22 -7
- package/lib/proxy/federation.js +217 -0
- package/lib/proxy/node-proxy.js +21 -8
- package/lib/server.js +90 -7
- package/lib/settings/routes.js +216 -4
- 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,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
|
+
};
|
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');
|
|
@@ -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) => ({
|
|
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
|
-
|
|
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)
|
|
336
|
-
|
|
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
|
|