@mmmbuto/nexuscrew 0.8.2 → 0.8.4
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 +37 -16
- package/frontend/dist/assets/index-C1AFIaRR.js +90 -0
- package/frontend/dist/index.html +1 -1
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +157 -28
- package/lib/cli/doctor.js +1 -1
- package/lib/cli/init.js +8 -3
- package/lib/cli/pidfile.js +2 -2
- package/lib/fleet/managed.js +3 -2
- package/lib/nodes/health.js +89 -0
- package/lib/nodes/store.js +78 -1
- package/lib/nodes/topology-cache.js +75 -0
- package/lib/proxy/federation.js +117 -6
- package/lib/pty/provider.js +7 -7
- package/lib/server.js +28 -10
- package/lib/settings/routes.js +65 -6
- package/package.json +4 -8
- package/frontend/dist/assets/index-Dey9rdY-.js +0 -90
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const { NODE_ID_RE, NODE_NAME_RE } = require('./store.js');
|
|
8
|
+
|
|
9
|
+
const SCHEMA_VERSION = 1;
|
|
10
|
+
const MAX_ENTRIES = 256;
|
|
11
|
+
const MAX_HOPS = 4;
|
|
12
|
+
|
|
13
|
+
function defaultPath(home = os.homedir()) {
|
|
14
|
+
return path.join(home, '.nexuscrew', 'topology-cache.json');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseEntry(raw) {
|
|
18
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
19
|
+
const keys = Object.keys(raw).sort();
|
|
20
|
+
if (keys.some((k) => !['instanceId', 'lastSeen', 'name', 'route'].includes(k))) return null;
|
|
21
|
+
if (!NODE_ID_RE.test(raw.instanceId) || !NODE_NAME_RE.test(raw.name)) return null;
|
|
22
|
+
if (!Array.isArray(raw.route) || raw.route.length < 2 || raw.route.length > MAX_HOPS) return null;
|
|
23
|
+
if (raw.route.some((x) => !NODE_NAME_RE.test(x)) || new Set(raw.route).size !== raw.route.length) return null;
|
|
24
|
+
if (raw.name !== raw.route[raw.route.length - 1]) return null;
|
|
25
|
+
if (!Number.isInteger(raw.lastSeen) || raw.lastSeen < 0) return null;
|
|
26
|
+
return { instanceId: raw.instanceId, name: raw.name, route: [...raw.route], lastSeen: raw.lastSeen };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseCache(raw) {
|
|
30
|
+
let value = raw;
|
|
31
|
+
if (typeof value === 'string') {
|
|
32
|
+
try { value = JSON.parse(value); } catch (_) { return null; }
|
|
33
|
+
}
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
35
|
+
if (value.schemaVersion !== SCHEMA_VERSION || !Array.isArray(value.nodes) || value.nodes.length > MAX_ENTRIES) return null;
|
|
36
|
+
if (Object.keys(value).some((k) => !['schemaVersion', 'nodes'].includes(k))) return null;
|
|
37
|
+
const nodes = value.nodes.map(parseEntry);
|
|
38
|
+
if (nodes.some((x) => !x)) return null;
|
|
39
|
+
if (new Set(nodes.map((x) => x.instanceId)).size !== nodes.length) return null;
|
|
40
|
+
if (new Set(nodes.map((x) => x.route.join('/'))).size !== nodes.length) return null;
|
|
41
|
+
return { schemaVersion: SCHEMA_VERSION, nodes };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function emptyCache() { return { schemaVersion: SCHEMA_VERSION, nodes: [] }; }
|
|
45
|
+
|
|
46
|
+
function loadCache(file = defaultPath()) {
|
|
47
|
+
try {
|
|
48
|
+
const st = fs.lstatSync(file);
|
|
49
|
+
if (!st.isFile() || st.isSymbolicLink()) return null;
|
|
50
|
+
return parseCache(fs.readFileSync(file, 'utf8'));
|
|
51
|
+
} catch (e) {
|
|
52
|
+
return e.code === 'ENOENT' ? emptyCache() : null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function atomicWriteCache(file, value) {
|
|
57
|
+
const parsed = parseCache(value);
|
|
58
|
+
if (!parsed) throw new Error('topology cache non valida');
|
|
59
|
+
try {
|
|
60
|
+
if (fs.lstatSync(file).isSymbolicLink()) throw new Error('refusing symlink topology cache target');
|
|
61
|
+
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
62
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
63
|
+
const tmp = path.join(path.dirname(file), `.${path.basename(file)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
64
|
+
try {
|
|
65
|
+
fs.writeFileSync(tmp, `${JSON.stringify(parsed, null, 2)}\n`, { mode: 0o600 });
|
|
66
|
+
fs.chmodSync(tmp, 0o600);
|
|
67
|
+
fs.renameSync(tmp, file);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
try { fs.unlinkSync(tmp); } catch (_) {}
|
|
70
|
+
throw e;
|
|
71
|
+
}
|
|
72
|
+
return parsed;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { SCHEMA_VERSION, MAX_ENTRIES, MAX_HOPS, defaultPath, parseEntry, parseCache, emptyCache, loadCache, atomicWriteCache };
|
package/lib/proxy/federation.js
CHANGED
|
@@ -4,6 +4,7 @@ const http = require('node:http');
|
|
|
4
4
|
const net = require('node:net');
|
|
5
5
|
const express = require('express');
|
|
6
6
|
const store = require('../nodes/store.js');
|
|
7
|
+
const topologyCache = require('../nodes/topology-cache.js');
|
|
7
8
|
const { bearerFrom } = require('../auth/middleware.js');
|
|
8
9
|
const { safeEqual } = require('../nodes/peering.js');
|
|
9
10
|
const {
|
|
@@ -53,7 +54,8 @@ function knownResource(resource) {
|
|
|
53
54
|
|| resource === '/files'
|
|
54
55
|
|| resource === '/files/download'
|
|
55
56
|
|| resource === '/files/upload'
|
|
56
|
-
|| resource === '/ws'
|
|
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);
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
function allowedResource(resource, method = 'GET') {
|
|
@@ -65,6 +67,8 @@ function allowedResource(resource, method = 'GET') {
|
|
|
65
67
|
if (resource === '/files/download') return method === 'GET';
|
|
66
68
|
if (resource === '/files/upload') return method === 'POST';
|
|
67
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';
|
|
68
72
|
return false;
|
|
69
73
|
}
|
|
70
74
|
|
|
@@ -111,6 +115,62 @@ function queryOf(url) {
|
|
|
111
115
|
return i < 0 ? '' : stripLocalTokenQuery(String(url).slice(i));
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
// probeHealth: probe federato di un peer verso la sua porta forward locale
|
|
119
|
+
// (127.0.0.1:port) autenticato con il token del nodo (Bearer accettato dal
|
|
120
|
+
// peerRouter via acceptToken). Modella 3 dimensioni invece di un boolean "up":
|
|
121
|
+
// transport — la porta TCP risponde (qualcuno e' in ascolto)
|
|
122
|
+
// auth — la federation accetta la credenziale (200 vs 401)
|
|
123
|
+
// reachability — l'API risponde con payload comprensibile (200 vs 5xx)
|
|
124
|
+
// Mai lancia: ogni guasto (refused/timeout/abort) -> {transport:'down',...}.
|
|
125
|
+
// Questo e' il cuore del fix "peer localhost risponde in porta ma federation 401":
|
|
126
|
+
// il 401 emerge come auth:'failed' con diagnostica esplicita invece di essere
|
|
127
|
+
// mascherato da uno stato verde hardcoded.
|
|
128
|
+
async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl = fetch, timeoutMs = 1500, now = Date.now() }) {
|
|
129
|
+
const out = { transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'unknown', detail: '', httpStatus: null, at: now };
|
|
130
|
+
let r;
|
|
131
|
+
let timer;
|
|
132
|
+
try {
|
|
133
|
+
const ctrl = new AbortController();
|
|
134
|
+
timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
135
|
+
r = await fetchImpl(`http://127.0.0.1:${port}/federation/health`, {
|
|
136
|
+
headers: { authorization: `Bearer ${token}` }, signal: ctrl.signal,
|
|
137
|
+
});
|
|
138
|
+
} catch (e) {
|
|
139
|
+
out.transport = 'down';
|
|
140
|
+
out.status = 'down';
|
|
141
|
+
out.detail = (e && (e.name === 'AbortError' || e.code === 'ETIMEDOUT'))
|
|
142
|
+
? `peer non raggiungibile (timeout ${timeoutMs}ms)` : 'peer non raggiungibile (tcp refused/down)';
|
|
143
|
+
return out;
|
|
144
|
+
} finally {
|
|
145
|
+
if (timer) clearTimeout(timer);
|
|
146
|
+
}
|
|
147
|
+
out.transport = 'up';
|
|
148
|
+
out.httpStatus = r.status;
|
|
149
|
+
if (r.status === 200) {
|
|
150
|
+
out.auth = 'ok';
|
|
151
|
+
let body;
|
|
152
|
+
try { body = await r.json(); } catch (_) { body = null; }
|
|
153
|
+
if (!body || body.ok !== true || typeof body.instanceId !== 'string') {
|
|
154
|
+
out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'health payload non valido';
|
|
155
|
+
} else if (expectedInstanceId && body.instanceId !== expectedInstanceId) {
|
|
156
|
+
out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'peer instanceId inatteso — tunnel/porta punta al nodo sbagliato';
|
|
157
|
+
} else {
|
|
158
|
+
out.reachability = 'ok'; out.status = 'healthy'; out.detail = 'ok';
|
|
159
|
+
}
|
|
160
|
+
} else if (r.status === 401) {
|
|
161
|
+
out.auth = 'failed'; out.reachability = 'ok'; out.status = 'degraded';
|
|
162
|
+
out.detail = 'federation 401 — acceptToken non valido, re-pair richiesto';
|
|
163
|
+
} else if (r.status === 403) {
|
|
164
|
+
out.auth = 'ok'; out.reachability = 'ok'; out.status = 'degraded';
|
|
165
|
+
out.detail = 'peer in READONLY o transito negato (403)';
|
|
166
|
+
} else if (r.status >= 500) {
|
|
167
|
+
out.reachability = 'failed'; out.status = 'degraded'; out.detail = `peer HTTP ${r.status}`;
|
|
168
|
+
} else {
|
|
169
|
+
out.reachability = 'failed'; out.status = 'degraded'; out.detail = `peer HTTP ${r.status}`;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
114
174
|
function controlledVisited(req, ingress, instanceId) {
|
|
115
175
|
const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
|
|
116
176
|
const seen = raw ? raw.split(',').filter(Boolean) : [];
|
|
@@ -118,12 +178,13 @@ function controlledVisited(req, ingress, instanceId) {
|
|
|
118
178
|
return [...seen, instanceId];
|
|
119
179
|
}
|
|
120
180
|
|
|
121
|
-
async function
|
|
181
|
+
async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch }) {
|
|
122
182
|
const st = store.loadStore(nodesPath);
|
|
123
|
-
if (!st) return { instanceId: null, nodes: [] };
|
|
183
|
+
if (!st) return { instanceId: null, nodes: [], authoritative: [] };
|
|
124
184
|
const seen = new Set(visited.filter((x) => store.NODE_ID_RE.test(x)));
|
|
125
185
|
seen.add(st.nodeId);
|
|
126
186
|
const out = [];
|
|
187
|
+
const authoritative = [];
|
|
127
188
|
for (const n of st.nodes) {
|
|
128
189
|
if (!n.nodeId || seen.has(n.nodeId) || (ingress && !canTransit(ingress, n))) continue;
|
|
129
190
|
out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
|
|
@@ -133,6 +194,7 @@ async function collectTopology({ nodesPath, ingress = null, ttl = MAX_HOPS, visi
|
|
|
133
194
|
const r = await fetchImpl(u, { headers: { authorization: `Bearer ${n.token}` } });
|
|
134
195
|
const j = await r.json();
|
|
135
196
|
if (!r.ok || j.instanceId !== n.nodeId || !Array.isArray(j.nodes)) continue;
|
|
197
|
+
authoritative.push(n.name);
|
|
136
198
|
for (const child of j.nodes) {
|
|
137
199
|
if (!child || !store.NODE_ID_RE.test(child.instanceId) || child.instanceId === n.nodeId || seen.has(child.instanceId)
|
|
138
200
|
|| !store.NODE_NAME_RE.test(child.name)
|
|
@@ -151,16 +213,64 @@ async function collectTopology({ nodesPath, ingress = null, ttl = MAX_HOPS, visi
|
|
|
151
213
|
if (ids.has(n.instanceId) || routes.has(routeKey)) continue;
|
|
152
214
|
ids.add(n.instanceId); routes.add(routeKey); unique.push(n);
|
|
153
215
|
}
|
|
154
|
-
return { instanceId: st.nodeId, nodes: unique };
|
|
216
|
+
return { instanceId: st.nodeId, nodes: unique, authoritative };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function collectTopology(opts) {
|
|
220
|
+
const out = await collectTopologyDetailed(opts);
|
|
221
|
+
return { instanceId: out.instanceId, nodes: out.nodes };
|
|
155
222
|
}
|
|
156
223
|
|
|
157
|
-
|
|
224
|
+
// Local roster: live topology plus a credential-free cache of previously seen
|
|
225
|
+
// transitive nodes. Stale entries are never returned by the peer endpoint.
|
|
226
|
+
async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000) }) {
|
|
227
|
+
const live = await collectTopologyDetailed({ nodesPath, fetchImpl });
|
|
228
|
+
const st = store.loadStore(nodesPath);
|
|
229
|
+
const directNames = new Set(((st && st.nodes) || []).map((n) => n.name));
|
|
230
|
+
const authoritative = new Set(live.authoritative);
|
|
231
|
+
const liveIds = new Set(live.nodes.map((n) => n.instanceId));
|
|
232
|
+
const liveRoutes = new Set(live.nodes.map((n) => n.route.join('/')));
|
|
233
|
+
const cacheFile = cachePath || topologyCache.defaultPath();
|
|
234
|
+
const old = topologyCache.loadCache(cacheFile) || topologyCache.emptyCache();
|
|
235
|
+
const next = new Map();
|
|
236
|
+
|
|
237
|
+
for (const entry of old.nodes) {
|
|
238
|
+
const first = entry.route[0];
|
|
239
|
+
if (!directNames.has(first)) continue;
|
|
240
|
+
if (authoritative.has(first) && !liveIds.has(entry.instanceId) && !liveRoutes.has(entry.route.join('/'))) continue;
|
|
241
|
+
next.set(entry.instanceId, entry);
|
|
242
|
+
}
|
|
243
|
+
for (const n of live.nodes) {
|
|
244
|
+
if (n.route.length > 1) next.set(n.instanceId, { instanceId: n.instanceId, name: n.name, route: [...n.route], lastSeen: now });
|
|
245
|
+
}
|
|
246
|
+
const cached = [...next.values()].sort((a, b) => a.route.join('/').localeCompare(b.route.join('/'))).slice(0, topologyCache.MAX_ENTRIES);
|
|
247
|
+
const serialized = { schemaVersion: topologyCache.SCHEMA_VERSION, nodes: cached };
|
|
248
|
+
if (JSON.stringify(serialized) !== JSON.stringify(old)) {
|
|
249
|
+
try { topologyCache.atomicWriteCache(cacheFile, serialized); } catch (_) {}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const nodes = live.nodes.map((n) => ({ ...n, stale: false, lastSeen: now }));
|
|
253
|
+
for (const n of cached) {
|
|
254
|
+
if (!liveIds.has(n.instanceId) && !liveRoutes.has(n.route.join('/'))) nodes.push({ ...n, direct: false, stale: true });
|
|
255
|
+
}
|
|
256
|
+
return { instanceId: live.instanceId, nodes };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly, version = null }) {
|
|
158
260
|
const r = express.Router();
|
|
159
261
|
r.use((req, res, next) => {
|
|
160
262
|
const peer = peerFromToken(nodesPath, bearerFrom(req));
|
|
161
263
|
if (!peer) return res.status(401).json({ error: 'unauthorized peer' });
|
|
162
264
|
req.peer = peer; next();
|
|
163
265
|
});
|
|
266
|
+
// Health federato: il peer autenticato (acceptToken matchato sopra) ottiene un
|
|
267
|
+
// 200 esplicito con instanceId/version. Serve da target di probeHealth() lato
|
|
268
|
+
// Initiator: distingue transport (porta aperta) da auth (200 vs 401) da
|
|
269
|
+
// reachability (payload). Nessun segreto in risposta.
|
|
270
|
+
r.get('/health', (_req, res) => {
|
|
271
|
+
const st = store.loadStore(nodesPath);
|
|
272
|
+
res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly() });
|
|
273
|
+
});
|
|
164
274
|
r.get('/topology', async (req, res) => {
|
|
165
275
|
const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
|
|
166
276
|
const visited = String(req.query.visited || '').split(',');
|
|
@@ -213,5 +323,6 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
|
|
|
213
323
|
|
|
214
324
|
module.exports = {
|
|
215
325
|
MAX_HOPS, ROUTE_DELIMITER, peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
|
|
216
|
-
collectTopology, peerRouter, localRouter, forwardUpgrade,
|
|
326
|
+
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
327
|
+
probeHealth,
|
|
217
328
|
};
|
package/lib/pty/provider.js
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
// Ritorna un oggetto { spawn(file, args, opts) } con un PTY REALE.
|
|
3
|
-
// Termux/Android
|
|
3
|
+
// Termux/Android e desktop usano esclusivamente pacchetti prebuilt scriptless.
|
|
4
4
|
// `tmux attach` ESIGE un vero tty: il fallback child_process NON è accettabile qui.
|
|
5
5
|
let _cached = null;
|
|
6
6
|
|
|
7
7
|
function providerCandidates({ platform = process.platform, arch = process.arch, env = process.env } = {}) {
|
|
8
8
|
const isTermux = platform === 'android'
|
|
9
9
|
|| (env.PREFIX || '').includes('com.termux');
|
|
10
|
-
if (isTermux) return ['@mmmbuto/node-pty-android-arm64'
|
|
10
|
+
if (isTermux) return ['@mmmbuto/node-pty-android-arm64'];
|
|
11
11
|
if (platform === 'darwin') {
|
|
12
12
|
return arch === 'arm64'
|
|
13
|
-
? ['@lydell/node-pty-darwin-arm64'
|
|
14
|
-
: ['@lydell/node-pty-darwin-x64'
|
|
13
|
+
? ['@lydell/node-pty-darwin-arm64']
|
|
14
|
+
: ['@lydell/node-pty-darwin-x64'];
|
|
15
15
|
}
|
|
16
16
|
if (platform === 'linux' && arch === 'arm64') {
|
|
17
|
-
return ['@lydell/node-pty-linux-arm64'
|
|
17
|
+
return ['@lydell/node-pty-linux-arm64'];
|
|
18
18
|
}
|
|
19
|
-
return ['@lydell/node-pty-linux-x64'
|
|
19
|
+
return ['@lydell/node-pty-linux-x64'];
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
function loadPty() {
|
|
@@ -43,6 +43,6 @@ function loadPty() {
|
|
|
43
43
|
}
|
|
44
44
|
} catch (_) { /* prova il prossimo */ }
|
|
45
45
|
}
|
|
46
|
-
throw new Error('no real PTY provider available (
|
|
46
|
+
throw new Error('no real PTY provider available (platform prebuilt missing)');
|
|
47
47
|
}
|
|
48
48
|
module.exports = { loadPty, providerCandidates };
|
package/lib/server.js
CHANGED
|
@@ -25,6 +25,7 @@ const { fleetRoutes } = require('./fleet/routes.js');
|
|
|
25
25
|
const { fsRoutes } = require('./fs/routes.js');
|
|
26
26
|
const nodesStore = require('./nodes/store.js');
|
|
27
27
|
const nodesTunnel = require('./nodes/tunnel.js');
|
|
28
|
+
const nodesHealth = require('./nodes/health.js');
|
|
28
29
|
const { createNodeProxy, handleNodeUpgrade } = require('./proxy/node-proxy.js');
|
|
29
30
|
const federation = require('./proxy/federation.js');
|
|
30
31
|
const { settingsRoutes, publicPeeringRoutes } = require('./settings/routes.js');
|
|
@@ -73,6 +74,13 @@ function createServer(opts = {}) {
|
|
|
73
74
|
proxySockets.clear();
|
|
74
75
|
};
|
|
75
76
|
const watcher = createOutboxWatcher({ root: cfg.filesRoot });
|
|
77
|
+
// server e' creato piu' sotto (http.createServer); lo dichiariamo qui perche' le
|
|
78
|
+
// closure localPort/localCredential dei router federation lo riferiscono lazy, al
|
|
79
|
+
// request time. localPort DEVE leggere la porta effettiva (server.address().port)
|
|
80
|
+
// e non cfg.port: in test (listen(0) su porta random) o dopo fallback EADDRINUSE,
|
|
81
|
+
// cfg.port non coincide con la porta reale e il proxy federation locale puntava
|
|
82
|
+
// alla porta sbagliata (bug reale: 401/"unauthorized" dal servizio su cfg.port).
|
|
83
|
+
let server = null;
|
|
76
84
|
const previews = createPreviewSampler(cfg.tmuxBin);
|
|
77
85
|
// MCP bridge (notify/ask/push): lo stato vive accanto al token (dirname del
|
|
78
86
|
// tokenPath = ~/.nexuscrew di default) cosi' le istanze isolate via opts/env
|
|
@@ -100,6 +108,9 @@ function createServer(opts = {}) {
|
|
|
100
108
|
// token / add-remove nodi visibili senza restart). token MAI redatto qui: e'
|
|
101
109
|
// il valore che il proxy inietta upstream, non esce mai verso il browser.
|
|
102
110
|
const nodesPath = cfg.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
|
|
111
|
+
// fetch usata dai probe di salute federati (iniettabile nei test); default globale.
|
|
112
|
+
const healthFetch = cfg.fetchImpl || fetch;
|
|
113
|
+
const topologyCachePath = cfg.topologyCachePath || require('./nodes/topology-cache.js').defaultPath(cfg.home || os.homedir());
|
|
103
114
|
const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
|
|
104
115
|
const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
105
116
|
function resolveNode(name) {
|
|
@@ -215,16 +226,23 @@ function createServer(opts = {}) {
|
|
|
215
226
|
api.use('/decks', decksRoutes({ cfg, decksPath }));
|
|
216
227
|
api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
|
|
217
228
|
// /nodes (read-only, per la settings UI B2): stesso formato di `nodes list --json`
|
|
218
|
-
// (token SEMPRE redatti via redactStore) +
|
|
219
|
-
|
|
229
|
+
// (token SEMPRE redatti via redactStore) + health federato per-nodo. Il campo
|
|
230
|
+
// `tunnel` resta per retro-compatibilita' col frontend (derivato dal health).
|
|
231
|
+
// health = { transport, auth, reachability, status, detail, managed, at }
|
|
232
|
+
// inbound: non probeable -> health unknown (NON "up" fittizio). outbound: probe
|
|
233
|
+
// reale /federation/health (cache TTL) -> distingue tcp down / 401 / 200.
|
|
234
|
+
api.get('/nodes', async (_req, res) => {
|
|
220
235
|
try {
|
|
221
236
|
const st = nodesStore.loadStore(nodesPath);
|
|
222
237
|
if (!st) return res.json({ nodeId: null, nodes: [] });
|
|
223
238
|
const view = nodesStore.redactStore(st);
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
239
|
+
const healths = await nodesHealth.nodesHealth({
|
|
240
|
+
nodes: st.nodes, home: cfg.home || os.homedir(), fetchImpl: healthFetch, now: Date.now(),
|
|
241
|
+
});
|
|
242
|
+
const nodes = view.nodes.map((n, i) => {
|
|
243
|
+
const h = healths[i] || null;
|
|
244
|
+
return { ...n, tunnel: nodesHealth.tunnelFromHealth(h), health: h };
|
|
245
|
+
});
|
|
228
246
|
const out = { nodeId: view.nodeId, nodes };
|
|
229
247
|
if (view.rendezvous) out.rendezvous = view.rendezvous;
|
|
230
248
|
res.json(out);
|
|
@@ -235,11 +253,11 @@ function createServer(opts = {}) {
|
|
|
235
253
|
// READONLY route-level e la redazione token vivono dentro settingsRoutes.
|
|
236
254
|
api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
|
|
237
255
|
api.get('/topology', async (_req, res) => {
|
|
238
|
-
try { res.json(await federation.
|
|
256
|
+
try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
|
|
239
257
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
240
258
|
});
|
|
241
259
|
api.use('/route', federation.localRouter({
|
|
242
|
-
nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
260
|
+
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
243
261
|
}));
|
|
244
262
|
api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
|
|
245
263
|
api.post('/voice/transcribe',
|
|
@@ -264,7 +282,7 @@ function createServer(opts = {}) {
|
|
|
264
282
|
|
|
265
283
|
app.use('/api', api);
|
|
266
284
|
app.use('/federation', federation.peerRouter({
|
|
267
|
-
nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, readonly: proxyReadonly,
|
|
285
|
+
nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
|
|
268
286
|
}));
|
|
269
287
|
|
|
270
288
|
// Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
|
|
@@ -292,7 +310,7 @@ function createServer(opts = {}) {
|
|
|
292
310
|
});
|
|
293
311
|
app.get('*', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
|
|
294
312
|
|
|
295
|
-
|
|
313
|
+
server = http.createServer(app);
|
|
296
314
|
// Close the watcher when the HTTP server closes. Registered HERE (inside
|
|
297
315
|
// createServer) — not in start() — so every createServer consumer is covered,
|
|
298
316
|
// not only the start() path. watcher.close() is idempotent.
|
package/lib/settings/routes.js
CHANGED
|
@@ -49,16 +49,26 @@ const peering = require('../nodes/peering.js');
|
|
|
49
49
|
const { rotateToken } = require('../auth/token.js');
|
|
50
50
|
const { generateService, installService, installPath: svcInstallPath } = require('../cli/service.js');
|
|
51
51
|
const { detectPlatform, nodeBin, repoRoot, uid } = require('../cli/platform.js');
|
|
52
|
-
const { isServiceRunning, readRoles } = require('../cli/commands.js');
|
|
52
|
+
const { isServiceRunning, readRoles, bootState } = require('../cli/commands.js');
|
|
53
53
|
const { configJsonPath } = require('../config.js');
|
|
54
54
|
const VERSION = require('../../package.json').version;
|
|
55
55
|
|
|
56
56
|
// Whitelist chiavi scrivibili di config.json via API (lista chiusa dal task B2).
|
|
57
57
|
const CONFIG_KEYS = new Set(['roles', 'port', 'wizardDone']);
|
|
58
58
|
const ROLE_KEYS = new Set(['client', 'node']);
|
|
59
|
-
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath']);
|
|
59
|
+
const ADD_KEYS = new Set(['name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'label']);
|
|
60
60
|
const NODE_ROLE_KEYS = new Set(['enabled', 'rendezvousSsh', 'publishedPort', 'keyPath']);
|
|
61
61
|
|
|
62
|
+
// Default sensato per il "nome dispositivo" proposto nei form (pairing/invite).
|
|
63
|
+
// NON usa ciecamente hostname: se l'hostname e' vuoto o 'localhost' (tipico di
|
|
64
|
+
// chiavi di test / host dietro tunnel) propone un fallback neutro. L'utente puo'
|
|
65
|
+
// sempre sovrascriverlo: questo e' solo il valore iniziale del campo.
|
|
66
|
+
function defaultDeviceName() {
|
|
67
|
+
const h = String(os.hostname() || '').trim();
|
|
68
|
+
if (!h || /^localhost$/i.test(h)) return 'NexusCrew';
|
|
69
|
+
return h.slice(0, nodesStore.LABEL_MAX);
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
// Cintura §4b(3): rimozione ricorsiva di ogni chiave `token` da QUALUNQUE payload
|
|
63
73
|
// di risposta (la vista redatta espone hasToken, mai il valore). Difesa in
|
|
64
74
|
// profondita' oltre a redactStore: anche un bug a monte non fa uscire il segreto.
|
|
@@ -178,6 +188,7 @@ function settingsRoutes(deps = {}) {
|
|
|
178
188
|
const service = {
|
|
179
189
|
installed: fs.existsSync(svcPath),
|
|
180
190
|
active: isServiceRunning({ platform, execImpl: seams.execImpl, uid: seams.uid, home }),
|
|
191
|
+
boot: bootState({ platform, execImpl: seams.execImpl, uid: seams.uid, home }).enabled,
|
|
181
192
|
};
|
|
182
193
|
const out = {
|
|
183
194
|
roles: readRoles(configPath),
|
|
@@ -186,6 +197,9 @@ function settingsRoutes(deps = {}) {
|
|
|
186
197
|
platform,
|
|
187
198
|
service,
|
|
188
199
|
version: VERSION,
|
|
200
|
+
// Nome dispositivo proposto per i form di pairing (etichetta umana, non
|
|
201
|
+
// lo slug). La UI lo precompila e lascia editing libero.
|
|
202
|
+
deviceName: defaultDeviceName(),
|
|
189
203
|
};
|
|
190
204
|
// rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
|
|
191
205
|
// la si prende comunque SOLO dalla vista redatta.
|
|
@@ -272,12 +286,15 @@ function settingsRoutes(deps = {}) {
|
|
|
272
286
|
});
|
|
273
287
|
|
|
274
288
|
// One-time PWA pairing capability. It is not the UI bearer token and is
|
|
275
|
-
// persisted hashed, 0600, for ten minutes only.
|
|
276
|
-
|
|
289
|
+
// persisted hashed, 0600, for ten minutes only. Il `label` nel payload e'
|
|
290
|
+
// l'etichetta umana con cui il peer vedra' questo dispositivo: default sensato
|
|
291
|
+
// (mai 'localhost' crudo), sovrascrivibile dal form.
|
|
292
|
+
r.post('/peering/invite', mutGate, (req, res) => {
|
|
277
293
|
try {
|
|
294
|
+
const label = nodesStore.sanitizeLabel(req.body && req.body.label, defaultDeviceName());
|
|
278
295
|
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
279
296
|
send(res, 200, peering.createInvite({
|
|
280
|
-
invitesPath, instanceId: st.nodeId, port: cfg.port, label
|
|
297
|
+
invitesPath, instanceId: st.nodeId, port: cfg.port, label,
|
|
281
298
|
}));
|
|
282
299
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
283
300
|
});
|
|
@@ -293,6 +310,12 @@ function settingsRoutes(deps = {}) {
|
|
|
293
310
|
if (!pair) return send(res, 400, { error: 'link di pairing non valido' });
|
|
294
311
|
if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return send(res, 400, { error: 'sshPort non valida' });
|
|
295
312
|
if (b.identityFile !== undefined && !nodesStore.isAbsPath(b.identityFile)) return send(res, 400, { error: 'identityFile non valido' });
|
|
313
|
+
if (b.label !== undefined && !nodesStore.validLabel(b.label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
|
|
314
|
+
if (b.localLabel !== undefined && !nodesStore.validLabel(b.localLabel)) return send(res, 400, { error: 'localLabel non valida (max 64 char, niente a capo)' });
|
|
315
|
+
// label umana del peer come lo vedro' io (display); se assente usa lo slug.
|
|
316
|
+
const peerLabel = nodesStore.sanitizeLabel(b.label, b.name);
|
|
317
|
+
// etichetta umana con cui il peer vedra' questo dispositivo; default sensato.
|
|
318
|
+
const localLabel = nodesStore.sanitizeLabel(b.localLabel, defaultDeviceName());
|
|
296
319
|
let provisionalPort = null;
|
|
297
320
|
let rollbackCredential = null;
|
|
298
321
|
let created = false;
|
|
@@ -305,7 +328,7 @@ function settingsRoutes(deps = {}) {
|
|
|
305
328
|
name: b.name, ssh: b.ssh, sshPort: b.sshPort,
|
|
306
329
|
remotePort: pair.port, localPort, identityFile: b.identityFile,
|
|
307
330
|
transport: 'auto', autostart: true, visibility: 'network',
|
|
308
|
-
direction: 'outbound', acceptToken,
|
|
331
|
+
direction: 'outbound', acceptToken, label: peerLabel,
|
|
309
332
|
});
|
|
310
333
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
311
334
|
created = true;
|
|
@@ -325,6 +348,7 @@ function settingsRoutes(deps = {}) {
|
|
|
325
348
|
invite: pair.invite,
|
|
326
349
|
instanceId: st.nodeId,
|
|
327
350
|
name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
|
|
351
|
+
label: localLabel,
|
|
328
352
|
port: cfg.port,
|
|
329
353
|
acceptToken,
|
|
330
354
|
}),
|
|
@@ -409,6 +433,9 @@ function settingsRoutes(deps = {}) {
|
|
|
409
433
|
if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
|
|
410
434
|
return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
|
|
411
435
|
}
|
|
436
|
+
if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
|
|
437
|
+
return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
|
|
438
|
+
}
|
|
412
439
|
|
|
413
440
|
const cap = capture();
|
|
414
441
|
const out = nodesCmds.nodesAdd({
|
|
@@ -417,6 +444,16 @@ function settingsRoutes(deps = {}) {
|
|
|
417
444
|
remotePort: b.remotePort, localPort: b.localPort, key: b.keyPath,
|
|
418
445
|
});
|
|
419
446
|
if (out.code === 0) {
|
|
447
|
+
// label (display) opzionale: la persistiamo dopo l'add come rename che NON
|
|
448
|
+
// tocca il name (route stabile). Best-effort: una label malformata qui e'
|
|
449
|
+
// impossibile (validata sopra), ma non facciamo mai fallire l'add per lei.
|
|
450
|
+
if (b.label !== undefined) {
|
|
451
|
+
try {
|
|
452
|
+
let st = nodesStore.loadOrInitStore(nodesPath);
|
|
453
|
+
st = nodesStore.updateNode(st, out.name, { label: nodesStore.sanitizeLabel(b.label, out.name) });
|
|
454
|
+
nodesStore.atomicWriteStore(nodesPath, st);
|
|
455
|
+
} catch (_) { /* best-effort: il nodo e' gia' creato */ }
|
|
456
|
+
}
|
|
420
457
|
return send(res, 200, {
|
|
421
458
|
added: true,
|
|
422
459
|
name: out.name,
|
|
@@ -512,6 +549,20 @@ function settingsRoutes(deps = {}) {
|
|
|
512
549
|
send(res, 200, { saved: true, name, visibility });
|
|
513
550
|
} catch (e) { send(res, 400, { error: String(e.message || e) }); }
|
|
514
551
|
});
|
|
552
|
+
// Rinomina la label umana di un nodo SENZA toccare il name (route/URL stabili).
|
|
553
|
+
r.patch('/nodes/:name/label', mutGate, (req, res) => {
|
|
554
|
+
try {
|
|
555
|
+
const name = String(req.params.name || '');
|
|
556
|
+
const label = req.body && req.body.label;
|
|
557
|
+
if (!validName(name)) return send(res, 400, { error: 'name non valido (^[a-z0-9-]{1,32}$)' });
|
|
558
|
+
if (!nodesStore.validLabel(label)) return send(res, 400, { error: 'label non valida (max 64 char, niente a capo)' });
|
|
559
|
+
let st = nodesStore.loadOrInitStore(nodesPath);
|
|
560
|
+
if (!nodesStore.getNode(st, name)) return send(res, 404, { error: `nodo sconosciuto "${name}"` });
|
|
561
|
+
st = nodesStore.updateNode(st, name, { label: nodesStore.sanitizeLabel(label, name) });
|
|
562
|
+
nodesStore.atomicWriteStore(nodesPath, st);
|
|
563
|
+
send(res, 200, { saved: true, name, label: nodesStore.nodeLabel(nodesStore.getNode(st, name)) });
|
|
564
|
+
} catch (e) { send(res, 400, { error: String(e.message || e) }); }
|
|
565
|
+
});
|
|
515
566
|
|
|
516
567
|
// --- POST /node-role — node on/off (rendezvous config) ---------------------
|
|
517
568
|
r.post('/node-role', mutGate, (req, res) => {
|
|
@@ -568,6 +619,9 @@ function settingsRoutes(deps = {}) {
|
|
|
568
619
|
// dall'API (la UI avvisa di riavviare a mano).
|
|
569
620
|
r.post('/service/regenerate', mutGate, (_req, res) => {
|
|
570
621
|
try {
|
|
622
|
+
if (!bootState({ platform, execImpl: seams.execImpl, uid: seams.uid, home }).enabled) {
|
|
623
|
+
return send(res, 409, { error: 'boot non abilitato: usa `nexuscrew boot`' });
|
|
624
|
+
}
|
|
571
625
|
const { cfg: fileCfg } = readConfigFile(configPath);
|
|
572
626
|
const port = nodesStore.isPort(fileCfg.port) ? fileCfg.port : cfg.port;
|
|
573
627
|
const ctx = {
|
|
@@ -628,6 +682,9 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
628
682
|
|| !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)) {
|
|
629
683
|
return res.status(400).json({ error: 'pairing request non valida' });
|
|
630
684
|
}
|
|
685
|
+
if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
|
|
686
|
+
return res.status(400).json({ error: 'label non valida' });
|
|
687
|
+
}
|
|
631
688
|
if (!peering.consumeInvite({ invitesPath, invite: b.invite })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
|
|
632
689
|
try {
|
|
633
690
|
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
@@ -637,6 +694,7 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
637
694
|
const reversePort = peering.allocateReversePort(st.nodes);
|
|
638
695
|
const credential = peering.createPending({ pendingPath, data: {
|
|
639
696
|
name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
|
|
697
|
+
label: nodesStore.sanitizeLabel(b.label, name),
|
|
640
698
|
} });
|
|
641
699
|
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential });
|
|
642
700
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
@@ -658,6 +716,7 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
658
716
|
direction: 'inbound', transport: 'inbound', autostart: true,
|
|
659
717
|
visibility: 'network', nodeId: pending.instanceId,
|
|
660
718
|
token: pending.acceptToken, acceptToken: b.credential,
|
|
719
|
+
...(pending.label ? { label: pending.label } : {}),
|
|
661
720
|
});
|
|
662
721
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
663
722
|
res.json({ confirmed: true });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mmmbuto/nexuscrew",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.4",
|
|
4
4
|
"description": "Faithful browser tmux client — attach to live sessions over a real PTY, localhost-only, mobile-easy",
|
|
5
5
|
"main": "lib/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,6 @@
|
|
|
23
23
|
"test": "node --test tests/*.test.js"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@mmmbuto/pty-termux-utils": "^1.1.4",
|
|
27
26
|
"express": "^4.21.0",
|
|
28
27
|
"multer": "^2.2.0",
|
|
29
28
|
"qrcode-terminal": "^0.12.0",
|
|
@@ -31,11 +30,11 @@
|
|
|
31
30
|
"ws": "^8.18.0"
|
|
32
31
|
},
|
|
33
32
|
"optionalDependencies": {
|
|
33
|
+
"@mmmbuto/node-pty-android-arm64": "^1.1.2",
|
|
34
34
|
"@lydell/node-pty-darwin-arm64": "^1.2.0-beta.14",
|
|
35
35
|
"@lydell/node-pty-darwin-x64": "^1.2.0-beta.14",
|
|
36
36
|
"@lydell/node-pty-linux-arm64": "^1.2.0-beta.12",
|
|
37
|
-
"@lydell/node-pty-linux-x64": "^1.2.0-beta.12"
|
|
38
|
-
"node-pty": "^1.1.0"
|
|
37
|
+
"@lydell/node-pty-linux-x64": "^1.2.0-beta.12"
|
|
39
38
|
},
|
|
40
39
|
"engines": {
|
|
41
40
|
"node": ">=18"
|
|
@@ -64,8 +63,5 @@
|
|
|
64
63
|
"url": "git+https://github.com/DioNanos/nexuscrew.git"
|
|
65
64
|
},
|
|
66
65
|
"author": "DioNanos",
|
|
67
|
-
"license": "Apache-2.0"
|
|
68
|
-
"allowScripts": {
|
|
69
|
-
"@mmmbuto/node-pty-android-arm64@1.1.0": true
|
|
70
|
-
}
|
|
66
|
+
"license": "Apache-2.0"
|
|
71
67
|
}
|