@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.
@@ -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-BFyPeZsL.js"></script>
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.3"}
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 };
@@ -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
  };
@@ -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
  };
@@ -1,22 +1,22 @@
1
1
  'use strict';
2
2
  // Ritorna un oggetto { spawn(file, args, opts) } con un PTY REALE.
3
- // Termux/Android @mmmbuto/node-pty-android-arm64 ; altrove node-pty upstream.
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', 'node-pty'];
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', 'node-pty']
14
- : ['@lydell/node-pty-darwin-x64', 'node-pty'];
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', 'node-pty'];
17
+ return ['@lydell/node-pty-linux-arm64'];
18
18
  }
19
- return ['@lydell/node-pty-linux-x64', 'node-pty'];
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 (install a platform prebuilt or node-pty build prerequisites)');
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) + stato tunnel per-nodo.
220
- api.get('/nodes', (_req, res) => {
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 nodes = view.nodes.map((n) => ({
226
- ...n,
227
- tunnel: n.direction === 'inbound' ? { status: 'up', managed: false } : nodesTunnel.readTunnelState(os.homedir(), n.name),
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
- const server = http.createServer(app);
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.
@@ -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
- r.post('/peering/invite', mutGate, (_req, res) => {
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: os.hostname(),
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",
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
  }