@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.
@@ -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 };
@@ -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 collectTopology({ nodesPath, ingress = null, ttl = MAX_HOPS, visited = [], fetchImpl = fetch }) {
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
- function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly }) {
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
  };
@@ -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,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) + stato tunnel per-nodo.
219
- 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) => {
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 nodes = view.nodes.map((n) => ({
225
- ...n,
226
- tunnel: n.direction === 'inbound' ? { status: 'up', managed: false } : nodesTunnel.readTunnelState(os.homedir(), n.name),
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.collectTopology({ nodesPath })); }
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
- const server = http.createServer(app);
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.
@@ -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
- 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) => {
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: os.hostname(),
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.2",
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
  }