@mmmbuto/nexuscrew 0.8.3 → 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 +15 -8
- 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/nodes/health.js +89 -0
- package/lib/nodes/store.js +78 -1
- package/lib/proxy/federation.js +66 -1
- package/lib/pty/provider.js +7 -7
- package/lib/server.js +26 -9
- package/lib/settings/routes.js +60 -5
- package/package.json +4 -8
- package/frontend/dist/assets/index-BFyPeZsL.js +0 -90
package/frontend/dist/index.html
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-C1AFIaRR.js"></script>
|
|
15
15
|
<link rel="stylesheet" crossorigin href="/assets/index-COsoJxXQ.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.4"}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// lib/nodes/health.js — modello di salute federato a 3 dimensioni per i nodi.
|
|
3
|
+
//
|
|
4
|
+
// Sostituisce il vecchio "inbound sempre up" (lib/server.js) con una valutazione
|
|
5
|
+
// onesta che NON mente sul verde:
|
|
6
|
+
//
|
|
7
|
+
// transport — la porta forward locale (127.0.0.1:localPort) risponde.
|
|
8
|
+
// Per outbound: pidfile del tunnel + probe TCP via /federation/health.
|
|
9
|
+
// auth — la federation accetta la credenziale del nodo (200 vs 401).
|
|
10
|
+
// reachability — l'API remota risponde con payload (200 vs 5xx/network error).
|
|
11
|
+
//
|
|
12
|
+
// Inbound: il nodo ricevente NON possiede il lifecycle del peer, ma il reverse
|
|
13
|
+
// tunnel espone comunque il peer su localPort. Quindi la salute e' probeable;
|
|
14
|
+
// resta managed:false e la UI NON offre il power.
|
|
15
|
+
//
|
|
16
|
+
// Il probe federation e' costoso (fetch): cache per-processo con TTL, keyed by
|
|
17
|
+
// node name, cosi' il poll frequente della UI non re-probe la stessa porta.
|
|
18
|
+
|
|
19
|
+
const nodesTunnel = require('./tunnel.js');
|
|
20
|
+
const { probeHealth } = require('../proxy/federation.js');
|
|
21
|
+
|
|
22
|
+
const TTL_MS = 5000;
|
|
23
|
+
const cache = new Map(); // name -> { health, at }
|
|
24
|
+
|
|
25
|
+
function clearHealthCache() { cache.clear(); }
|
|
26
|
+
|
|
27
|
+
// Compatibilita' tunnel per il frontend attuale (che legge tunnel.status): deriva
|
|
28
|
+
// uno {status, managed} retro-compatibile dal model health, senza reintrodurre il
|
|
29
|
+
// verde hardcoded. inbound -> 'unknown' (la UI lo trattava come up prima: ora e'
|
|
30
|
+
// onesto). outbound down/401 -> 'down'/'degraded'.
|
|
31
|
+
function tunnelFromHealth(h) {
|
|
32
|
+
if (!h) return { status: 'unknown', managed: false };
|
|
33
|
+
if (h.transport === 'up' && h.auth === 'ok') return { status: 'up', managed: h.managed !== false };
|
|
34
|
+
if (h.transport === 'up' && h.auth === 'failed') return { status: 'degraded', managed: h.managed !== false };
|
|
35
|
+
if (h.transport === 'up') return { status: 'degraded', managed: h.managed !== false };
|
|
36
|
+
if (h.transport === 'down') return { status: 'down', managed: h.managed !== false };
|
|
37
|
+
return { status: 'unknown', managed: false }; // inbound / unknown
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function nodeHealth({ node, home, fetchImpl, now = Date.now(), force = false }) {
|
|
41
|
+
if (!node || typeof node !== 'object') return null;
|
|
42
|
+
const cacheKey = `${home || ''}\0${node.direction || 'outbound'}\0${node.name}\0${node.localPort}\0${node.nodeId || ''}`;
|
|
43
|
+
const cached = !force && cache.get(cacheKey);
|
|
44
|
+
if (cached && (now - cached.at) < TTL_MS) return cached.health;
|
|
45
|
+
|
|
46
|
+
let health;
|
|
47
|
+
if (node.direction === 'inbound') {
|
|
48
|
+
if (!node.token) {
|
|
49
|
+
health = {
|
|
50
|
+
transport: 'unknown', auth: 'unknown', reachability: 'unknown', status: 'degraded',
|
|
51
|
+
detail: 'peer inbound senza credenziale federation — re-pair', managed: false, at: now,
|
|
52
|
+
};
|
|
53
|
+
} else {
|
|
54
|
+
const probed = await probeHealth({
|
|
55
|
+
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
|
|
56
|
+
});
|
|
57
|
+
health = { ...probed, managed: false };
|
|
58
|
+
}
|
|
59
|
+
} else {
|
|
60
|
+
const ts = nodesTunnel.readTunnelState(home, node.name);
|
|
61
|
+
if (ts.status !== 'up') {
|
|
62
|
+
health = {
|
|
63
|
+
transport: 'down', auth: 'unknown', reachability: 'unknown', status: 'down',
|
|
64
|
+
detail: ts.reason ? `tunnel down (${ts.reason})` : 'tunnel down',
|
|
65
|
+
managed: ts.managed !== false, at: now,
|
|
66
|
+
};
|
|
67
|
+
} else if (!node.token) {
|
|
68
|
+
health = {
|
|
69
|
+
transport: 'up', auth: 'unknown', reachability: 'unknown', status: 'degraded',
|
|
70
|
+
detail: 'tunnel up, credenziali federation assenti (token mancante) — re-pair',
|
|
71
|
+
managed: true, at: now,
|
|
72
|
+
};
|
|
73
|
+
} else {
|
|
74
|
+
const probed = await probeHealth({
|
|
75
|
+
port: node.localPort, token: node.token, expectedInstanceId: node.nodeId || null, fetchImpl, now,
|
|
76
|
+
});
|
|
77
|
+
health = { ...probed, managed: true };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
cache.set(cacheKey, { health, at: now });
|
|
81
|
+
return health;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function nodesHealth({ nodes, home, fetchImpl, now = Date.now() }) {
|
|
85
|
+
if (!Array.isArray(nodes) || nodes.length === 0) return [];
|
|
86
|
+
return Promise.all(nodes.map((node) => nodeHealth({ node, home, fetchImpl, now })));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { nodeHealth, nodesHealth, tunnelFromHealth, clearHealthCache, TTL_MS };
|
package/lib/nodes/store.js
CHANGED
|
@@ -93,12 +93,18 @@ function parseRoles(r) {
|
|
|
93
93
|
return { client, node };
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
// label: etichetta umana opzionale per il display (1-64 char visibili, no control).
|
|
97
|
+
// NON e' lo slug di routing: puo' contenere maiuscole, spazi, punteggiatura. Il name
|
|
98
|
+
// resta l'identita' tecnica (segmento path/route); la label e' solo come l'utente la
|
|
99
|
+
// vede. Backward-compatible: record esistenti senza label -> fallback a `name`.
|
|
100
|
+
const LABEL_MAX = 64;
|
|
101
|
+
|
|
96
102
|
// parseNode(n) -> nodo normalizzato | null (fail-closed). Nessun campo extra
|
|
97
103
|
// tollerato oltre a quelli noti (schema chiuso: garbage -> null, non guess).
|
|
98
104
|
const NODE_KEYS = new Set([
|
|
99
105
|
'name', 'ssh', 'sshPort', 'remotePort', 'localPort', 'keyPath', 'identityFile',
|
|
100
106
|
'roles', 'token', 'acceptToken', 'nodeId', 'transport', 'autostart', 'visibility', 'selected',
|
|
101
|
-
'direction', 'reversePort',
|
|
107
|
+
'direction', 'reversePort', 'label',
|
|
102
108
|
]);
|
|
103
109
|
function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
104
110
|
if (!n || typeof n !== 'object' || Array.isArray(n)) return null;
|
|
@@ -117,6 +123,16 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
117
123
|
if (schemaVersion === LEGACY_SCHEMA_VERSION && !identityFile) return null;
|
|
118
124
|
const roles = parseRoles(n.roles);
|
|
119
125
|
if (!roles) return null;
|
|
126
|
+
// label (display) opzionale ma, se presente, strict: stringa 1..LABEL_MAX, no
|
|
127
|
+
// control char (newline/tab inclusi: una label su piu' righe non ha senso in
|
|
128
|
+
// UI), no solo-spazi. Garbage -> null (schema chiuso), mai guess/truncate.
|
|
129
|
+
let label = null;
|
|
130
|
+
if (n.label !== undefined) {
|
|
131
|
+
if (typeof n.label !== 'string') return null;
|
|
132
|
+
label = n.label.trim();
|
|
133
|
+
if (!label || label.length > LABEL_MAX) return null;
|
|
134
|
+
if (/[\x00-\x1f\x7f]/.test(label)) return null;
|
|
135
|
+
}
|
|
120
136
|
|
|
121
137
|
const out = {
|
|
122
138
|
name: n.name,
|
|
@@ -163,6 +179,7 @@ function parseNode(n, schemaVersion = SCHEMA_VERSION) {
|
|
|
163
179
|
if (typeof n.nodeId !== 'string' || !NODE_ID_RE.test(n.nodeId)) return null;
|
|
164
180
|
out.nodeId = n.nodeId;
|
|
165
181
|
}
|
|
182
|
+
if (label) out.label = label;
|
|
166
183
|
return out;
|
|
167
184
|
}
|
|
168
185
|
|
|
@@ -357,6 +374,7 @@ function clearRendezvous(store) {
|
|
|
357
374
|
function redactNode(n) {
|
|
358
375
|
const out = {
|
|
359
376
|
name: n.name,
|
|
377
|
+
...(n.label ? { label: n.label } : {}),
|
|
360
378
|
...(n.ssh ? { ssh: n.ssh } : {}),
|
|
361
379
|
remotePort: n.remotePort,
|
|
362
380
|
localPort: n.localPort,
|
|
@@ -409,6 +427,63 @@ function migrateLegacyNodes(configPath, nodesPath) {
|
|
|
409
427
|
return { migrated: true, count: written.nodes.length, reason: 'migrato da config.json' };
|
|
410
428
|
}
|
|
411
429
|
|
|
430
|
+
// --- Label / slug helpers (display vs routing identity) ---------------------
|
|
431
|
+
// nodeLabel: etichetta umana di un nodo (display). Mai vuota: fallback a `name`.
|
|
432
|
+
function nodeLabel(n) {
|
|
433
|
+
if (n && typeof n.label === 'string' && n.label.trim()) return n.label.trim();
|
|
434
|
+
return (n && n.name) || '';
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// validLabel: true se `v` e' una label display accettabile (stringa 1..LABEL_MAX,
|
|
438
|
+
// no control char, no solo-spazi). Riutilizzi la stessa logica strict di parseNode
|
|
439
|
+
// per validare input esterno (form UI, payload pairing) senza ricostruire un nodo.
|
|
440
|
+
function validLabel(v) {
|
|
441
|
+
if (typeof v !== 'string') return false;
|
|
442
|
+
const t = v.trim();
|
|
443
|
+
if (!t || t.length > LABEL_MAX) return false;
|
|
444
|
+
return !/[\x00-\x1f\x7f]/.test(t);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// sanitizeLabel: normalizza un input libero in una label display valida. Trim,
|
|
448
|
+
// collapse spazi, tronca a LABEL_MAX. Mai throw, mai ritorna vuoto (fallback
|
|
449
|
+
// `fallback`). Usata dove si vuole proporre una label senza fallire.
|
|
450
|
+
function sanitizeLabel(v, fallback = 'NexusCrew') {
|
|
451
|
+
const t = String(v == null ? '' : v).replace(/[\x00-\x1f\x7f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, LABEL_MAX);
|
|
452
|
+
return t || fallback;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// toSlug: deriva uno slug routing-safe (^[a-z0-9-]{1,32}$) da un input libero.
|
|
456
|
+
// Normalizza (NFKD + strip segni diacritici), lowercase, sostituisce run non
|
|
457
|
+
// alfanumerici con '-', trim dei bordi. Mai solleva: input povero -> 'node'.
|
|
458
|
+
// Usata dal form "Nuovo nodo" per suggerire lo slug dalla label umana senza
|
|
459
|
+
// obbligare l'utente a scrivere a mano caratteri tecnici.
|
|
460
|
+
function toSlug(input) {
|
|
461
|
+
const s = String(input == null ? '' : input)
|
|
462
|
+
.normalize('NFKD')
|
|
463
|
+
.replace(/[̀-ͯ]/g, '') // segni diacritici staccati (combining) -> ASCII
|
|
464
|
+
.toLowerCase()
|
|
465
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
466
|
+
.replace(/^-+|-+$/g, '')
|
|
467
|
+
.slice(0, 32)
|
|
468
|
+
.replace(/-+$/g, ''); // trim finale dopo il slice
|
|
469
|
+
return s || 'node';
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// suggestNodeName: slug univoco dato un input libero e l'elenco dei name gia'
|
|
473
|
+
// usati. Disambigua con suffisso -2/-3/... Rende la creazione di un nodo non
|
|
474
|
+
// fallibile per collisione di slug (l'utente scrive "VPS3" e ottiene "vps3",
|
|
475
|
+
// oppure "vps3-2" se esiste gia').
|
|
476
|
+
function suggestNodeName(input, existing = []) {
|
|
477
|
+
const used = new Set(Array.isArray(existing) ? existing : []);
|
|
478
|
+
const base = toSlug(input);
|
|
479
|
+
if (!used.has(base)) return base;
|
|
480
|
+
for (let i = 2; i < 100; i += 1) {
|
|
481
|
+
const candidate = `${base.slice(0, Math.max(1, 32 - String(i).length - 1))}-${i}`;
|
|
482
|
+
if (!used.has(candidate)) return candidate;
|
|
483
|
+
}
|
|
484
|
+
return base; // fallback estremo
|
|
485
|
+
}
|
|
486
|
+
|
|
412
487
|
module.exports = {
|
|
413
488
|
// parse/validate
|
|
414
489
|
parseStore, parseNode, parseRendezvous, parseSsh, parseSshTarget, isPort, isAbsPath, validToken,
|
|
@@ -420,6 +495,8 @@ module.exports = {
|
|
|
420
495
|
redactNode, redactStore,
|
|
421
496
|
// migrazione
|
|
422
497
|
migrateLegacyNodes,
|
|
498
|
+
// label / slug
|
|
499
|
+
nodeLabel, validLabel, sanitizeLabel, toSlug, suggestNodeName, LABEL_MAX,
|
|
423
500
|
// costanti
|
|
424
501
|
SCHEMA_VERSION, LEGACY_SCHEMA_VERSION, MAX_NODES, MAX_TOKEN_LEN, NODE_NAME_RE, NODE_ID_RE,
|
|
425
502
|
};
|
package/lib/proxy/federation.js
CHANGED
|
@@ -115,6 +115,62 @@ function queryOf(url) {
|
|
|
115
115
|
return i < 0 ? '' : stripLocalTokenQuery(String(url).slice(i));
|
|
116
116
|
}
|
|
117
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
|
+
|
|
118
174
|
function controlledVisited(req, ingress, instanceId) {
|
|
119
175
|
const raw = ingress ? String(req.headers['x-nexuscrew-visited'] || '') : '';
|
|
120
176
|
const seen = raw ? raw.split(',').filter(Boolean) : [];
|
|
@@ -200,13 +256,21 @@ async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, n
|
|
|
200
256
|
return { instanceId: live.instanceId, nodes };
|
|
201
257
|
}
|
|
202
258
|
|
|
203
|
-
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly }) {
|
|
259
|
+
function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly, version = null }) {
|
|
204
260
|
const r = express.Router();
|
|
205
261
|
r.use((req, res, next) => {
|
|
206
262
|
const peer = peerFromToken(nodesPath, bearerFrom(req));
|
|
207
263
|
if (!peer) return res.status(401).json({ error: 'unauthorized peer' });
|
|
208
264
|
req.peer = peer; next();
|
|
209
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
|
+
});
|
|
210
274
|
r.get('/topology', async (req, res) => {
|
|
211
275
|
const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
|
|
212
276
|
const visited = String(req.query.visited || '').split(',');
|
|
@@ -260,4 +324,5 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
|
|
|
260
324
|
module.exports = {
|
|
261
325
|
MAX_HOPS, ROUTE_DELIMITER, peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
|
|
262
326
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
327
|
+
probeHealth,
|
|
263
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,8 @@ 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;
|
|
103
113
|
const topologyCachePath = cfg.topologyCachePath || require('./nodes/topology-cache.js').defaultPath(cfg.home || os.homedir());
|
|
104
114
|
const decksPath = cfg.decksPath || decksStore.defaultDecksPath(cfg.home || os.homedir());
|
|
105
115
|
const proxyReadonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
@@ -216,16 +226,23 @@ function createServer(opts = {}) {
|
|
|
216
226
|
api.use('/decks', decksRoutes({ cfg, decksPath }));
|
|
217
227
|
api.use('/fs', fsRoutes({ home: os.homedir() })); // folder-picker del dialog new session
|
|
218
228
|
// /nodes (read-only, per la settings UI B2): stesso formato di `nodes list --json`
|
|
219
|
-
// (token SEMPRE redatti via redactStore) +
|
|
220
|
-
|
|
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) => {
|
|
221
235
|
try {
|
|
222
236
|
const st = nodesStore.loadStore(nodesPath);
|
|
223
237
|
if (!st) return res.json({ nodeId: null, nodes: [] });
|
|
224
238
|
const view = nodesStore.redactStore(st);
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
});
|
|
229
246
|
const out = { nodeId: view.nodeId, nodes };
|
|
230
247
|
if (view.rendezvous) out.rendezvous = view.rendezvous;
|
|
231
248
|
res.json(out);
|
|
@@ -240,7 +257,7 @@ function createServer(opts = {}) {
|
|
|
240
257
|
catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
241
258
|
});
|
|
242
259
|
api.use('/route', federation.localRouter({
|
|
243
|
-
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,
|
|
244
261
|
}));
|
|
245
262
|
api.get('/voice/status', (_req, res) => res.json({ serverSttConfigured: !!cfg.voiceUrl }));
|
|
246
263
|
api.post('/voice/transcribe',
|
|
@@ -265,7 +282,7 @@ function createServer(opts = {}) {
|
|
|
265
282
|
|
|
266
283
|
app.use('/api', api);
|
|
267
284
|
app.use('/federation', federation.peerRouter({
|
|
268
|
-
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,
|
|
269
286
|
}));
|
|
270
287
|
|
|
271
288
|
// Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
|
|
@@ -293,7 +310,7 @@ function createServer(opts = {}) {
|
|
|
293
310
|
});
|
|
294
311
|
app.get('*', (_req, res) => res.sendFile(path.join(distDir, 'index.html')));
|
|
295
312
|
|
|
296
|
-
|
|
313
|
+
server = http.createServer(app);
|
|
297
314
|
// Close the watcher when the HTTP server closes. Registered HERE (inside
|
|
298
315
|
// createServer) — not in start() — so every createServer consumer is covered,
|
|
299
316
|
// not only the start() path. watcher.close() is idempotent.
|
package/lib/settings/routes.js
CHANGED
|
@@ -56,9 +56,19 @@ const VERSION = require('../../package.json').version;
|
|
|
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.
|
|
@@ -187,6 +197,9 @@ function settingsRoutes(deps = {}) {
|
|
|
187
197
|
platform,
|
|
188
198
|
service,
|
|
189
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(),
|
|
190
203
|
};
|
|
191
204
|
// rendezvous via redactStore (view sicura §4b(4)): non contiene token, ma
|
|
192
205
|
// la si prende comunque SOLO dalla vista redatta.
|
|
@@ -273,12 +286,15 @@ function settingsRoutes(deps = {}) {
|
|
|
273
286
|
});
|
|
274
287
|
|
|
275
288
|
// One-time PWA pairing capability. It is not the UI bearer token and is
|
|
276
|
-
// persisted hashed, 0600, for ten minutes only.
|
|
277
|
-
|
|
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) => {
|
|
278
293
|
try {
|
|
294
|
+
const label = nodesStore.sanitizeLabel(req.body && req.body.label, defaultDeviceName());
|
|
279
295
|
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
280
296
|
send(res, 200, peering.createInvite({
|
|
281
|
-
invitesPath, instanceId: st.nodeId, port: cfg.port, label
|
|
297
|
+
invitesPath, instanceId: st.nodeId, port: cfg.port, label,
|
|
282
298
|
}));
|
|
283
299
|
} catch (e) { send(res, 500, { error: String(e.message || e) }); }
|
|
284
300
|
});
|
|
@@ -294,6 +310,12 @@ function settingsRoutes(deps = {}) {
|
|
|
294
310
|
if (!pair) return send(res, 400, { error: 'link di pairing non valido' });
|
|
295
311
|
if (b.sshPort !== undefined && !nodesStore.isPort(b.sshPort)) return send(res, 400, { error: 'sshPort non valida' });
|
|
296
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());
|
|
297
319
|
let provisionalPort = null;
|
|
298
320
|
let rollbackCredential = null;
|
|
299
321
|
let created = false;
|
|
@@ -306,7 +328,7 @@ function settingsRoutes(deps = {}) {
|
|
|
306
328
|
name: b.name, ssh: b.ssh, sshPort: b.sshPort,
|
|
307
329
|
remotePort: pair.port, localPort, identityFile: b.identityFile,
|
|
308
330
|
transport: 'auto', autostart: true, visibility: 'network',
|
|
309
|
-
direction: 'outbound', acceptToken,
|
|
331
|
+
direction: 'outbound', acceptToken, label: peerLabel,
|
|
310
332
|
});
|
|
311
333
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
312
334
|
created = true;
|
|
@@ -326,6 +348,7 @@ function settingsRoutes(deps = {}) {
|
|
|
326
348
|
invite: pair.invite,
|
|
327
349
|
instanceId: st.nodeId,
|
|
328
350
|
name: String(b.localName || os.hostname()).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-|-$/g, '').slice(0, 32) || 'node',
|
|
351
|
+
label: localLabel,
|
|
329
352
|
port: cfg.port,
|
|
330
353
|
acceptToken,
|
|
331
354
|
}),
|
|
@@ -410,6 +433,9 @@ function settingsRoutes(deps = {}) {
|
|
|
410
433
|
if (b.keyPath !== undefined && !nodesStore.isAbsPath(b.keyPath)) {
|
|
411
434
|
return send(res, 400, { error: 'keyPath deve essere un path assoluto' });
|
|
412
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
|
+
}
|
|
413
439
|
|
|
414
440
|
const cap = capture();
|
|
415
441
|
const out = nodesCmds.nodesAdd({
|
|
@@ -418,6 +444,16 @@ function settingsRoutes(deps = {}) {
|
|
|
418
444
|
remotePort: b.remotePort, localPort: b.localPort, key: b.keyPath,
|
|
419
445
|
});
|
|
420
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
|
+
}
|
|
421
457
|
return send(res, 200, {
|
|
422
458
|
added: true,
|
|
423
459
|
name: out.name,
|
|
@@ -513,6 +549,20 @@ function settingsRoutes(deps = {}) {
|
|
|
513
549
|
send(res, 200, { saved: true, name, visibility });
|
|
514
550
|
} catch (e) { send(res, 400, { error: String(e.message || e) }); }
|
|
515
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
|
+
});
|
|
516
566
|
|
|
517
567
|
// --- POST /node-role — node on/off (rendezvous config) ---------------------
|
|
518
568
|
r.post('/node-role', mutGate, (req, res) => {
|
|
@@ -632,6 +682,9 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
632
682
|
|| !validPeerName(b.name) || !nodesStore.isPort(b.port) || !nodesStore.validToken(b.acceptToken)) {
|
|
633
683
|
return res.status(400).json({ error: 'pairing request non valida' });
|
|
634
684
|
}
|
|
685
|
+
if (b.label !== undefined && !nodesStore.validLabel(b.label)) {
|
|
686
|
+
return res.status(400).json({ error: 'label non valida' });
|
|
687
|
+
}
|
|
635
688
|
if (!peering.consumeInvite({ invitesPath, invite: b.invite })) return res.status(410).json({ error: 'invito scaduto o gia usato' });
|
|
636
689
|
try {
|
|
637
690
|
const st = nodesStore.loadOrInitStore(nodesPath);
|
|
@@ -641,6 +694,7 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
641
694
|
const reversePort = peering.allocateReversePort(st.nodes);
|
|
642
695
|
const credential = peering.createPending({ pendingPath, data: {
|
|
643
696
|
name, remotePort: b.port, reversePort, instanceId: b.instanceId, acceptToken: b.acceptToken,
|
|
697
|
+
label: nodesStore.sanitizeLabel(b.label, name),
|
|
644
698
|
} });
|
|
645
699
|
res.json({ paired: true, instanceId: st.nodeId, reversePort, credential });
|
|
646
700
|
} catch (e) { res.status(500).json({ error: String(e.message || e) }); }
|
|
@@ -662,6 +716,7 @@ function publicPeeringRoutes(deps = {}) {
|
|
|
662
716
|
direction: 'inbound', transport: 'inbound', autostart: true,
|
|
663
717
|
visibility: 'network', nodeId: pending.instanceId,
|
|
664
718
|
token: pending.acceptToken, acceptToken: b.credential,
|
|
719
|
+
...(pending.label ? { label: pending.label } : {}),
|
|
665
720
|
});
|
|
666
721
|
nodesStore.atomicWriteStore(nodesPath, st);
|
|
667
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
|
}
|