@mmmbuto/nexuscrew 0.8.9 → 0.8.11

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/lib/server.js CHANGED
@@ -129,30 +129,19 @@ function createServer(opts = {}) {
129
129
  return { localPort: node.localPort, token: node.token || null };
130
130
  }
131
131
 
132
- // A node-role installation must republish itself after service/reboot. The
133
- // detached supervisor is idempotent and owns retry/backoff independently.
134
- if (cfg.roles && cfg.roles.node === true) {
135
- const st = nodesStore.loadStore(nodesPath);
136
- if (st && st.rendezvous) {
137
- const tr = nodesTunnel.startReverse({
138
- home: cfg.home || os.homedir(), rendezvous: st.rendezvous,
139
- spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
140
- sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
141
- });
142
- if (!tr.started && tr.reason !== 'already running') {
143
- process.stderr.write(`[nexuscrew] reverse tunnel autostart failed: ${tr.reason || 'unknown'}\n`);
144
- }
145
- }
146
- }
147
-
148
- // New Hydra peers are ordinary local+remote nodes. Only the side that owns
149
- // the outbound SSH alias dials; inbound records are reached through its -R.
150
- {
132
+ const runtimePort = () => (server && server.address() ? server.address().port : cfg.port);
133
+ let tunnelsStarted = false;
134
+ function startManagedTunnels() {
135
+ if (tunnelsStarted) return;
136
+ tunnelsStarted = true;
151
137
  const st = nodesStore.loadStore(nodesPath);
138
+ // Exactly one connection per configured hub. Legacy `roles.node` /
139
+ // rendezvous data is migration-only and never starts a second hidden SSH
140
+ // process. Publishing this device is the optional -R on its outbound link.
152
141
  for (const node of (st && st.nodes) || []) {
153
142
  if (node.direction !== 'inbound' && node.autostart === true) {
154
143
  const tr = nodesTunnel.startForward({
155
- home: cfg.home || os.homedir(), node, localAppPort: cfg.port,
144
+ home: cfg.home || os.homedir(), node, localAppPort: runtimePort(),
156
145
  spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
157
146
  sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
158
147
  });
@@ -251,15 +240,13 @@ function createServer(opts = {}) {
251
240
  const h = healths[i] || null;
252
241
  return { ...n, tunnel: nodesHealth.tunnelFromHealth(h), health: h };
253
242
  });
254
- const out = { nodeId: view.nodeId, nodes };
255
- if (view.rendezvous) out.rendezvous = view.rendezvous;
256
- res.json(out);
243
+ res.json({ nodeId: view.nodeId, nodes });
257
244
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
258
245
  });
259
246
  // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
260
247
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
261
248
  // READONLY route-level e la redazione token vivono dentro settingsRoutes.
262
- api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions, updater }));
249
+ api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort }));
263
250
  api.get('/topology', async (_req, res) => {
264
251
  try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
265
252
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
@@ -291,6 +278,7 @@ function createServer(opts = {}) {
291
278
  app.use('/api', api);
292
279
  app.use('/federation', federation.peerRouter({
293
280
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
281
+ fetchImpl: healthFetch,
294
282
  roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
295
283
  }));
296
284
 
@@ -305,14 +293,18 @@ function createServer(opts = {}) {
305
293
  // per costruire path: validazione ^[a-z0-9-]{1,32}$, nome invalido → 404 secco
306
294
  // (niente traversal, niente fallback silenzioso alla SPA su nomi sporchi).
307
295
  const DECK_NAME_RE = /^[a-z0-9-]{1,32}$/;
296
+ const DECK_OWNER_RE = /^[a-f0-9]{16,64}$/;
308
297
  // Cattura TUTTO cio' che sta sotto /deck/ (anche slash/segmenti extra o encoded)
309
298
  // e valida il remainder: qualunque cosa non sia un nome deck strict → 404,
310
299
  // senza mai cadere nel catch-all SPA con un nome sporco.
311
300
  app.get(/^\/deck\/(.*)$/, (req, res) => {
312
301
  // parita' col client: deckFromPath accetta UN trailing slash (/deck/main/),
313
302
  // il server deve fare lo stesso; piu' di uno resta 404 (regex strict).
314
- const name = req.params[0].replace(/\/$/, '');
315
- if (!DECK_NAME_RE.test(name)) {
303
+ const value = req.params[0].replace(/\/$/, '');
304
+ const parts = value.split('/');
305
+ const valid = (parts.length === 1 && DECK_NAME_RE.test(parts[0]))
306
+ || (parts.length === 2 && DECK_OWNER_RE.test(parts[0]) && DECK_NAME_RE.test(parts[1]));
307
+ if (!valid) {
316
308
  return res.status(404).type('text/plain').send('invalid deck name');
317
309
  }
318
310
  return res.sendFile(path.join(distDir, 'index.html'));
@@ -323,7 +315,7 @@ function createServer(opts = {}) {
323
315
  // Close the watcher when the HTTP server closes. Registered HERE (inside
324
316
  // createServer) — not in start() — so every createServer consumer is covered,
325
317
  // not only the start() path. watcher.close() is idempotent.
326
- server.on('listening', () => updater.start());
318
+ server.on('listening', () => { updater.start(); startManagedTunnels(); });
327
319
  server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); });
328
320
  // noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
329
321
  // (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
@@ -353,13 +345,13 @@ function createServer(opts = {}) {
353
345
  let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
354
346
  const given = bearerFrom(req) || u.searchParams.get('token') || '';
355
347
  if (!verify(tokenHolder.value, given)) return socket.destroy();
356
- federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets });
348
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress: null, readonly: proxyReadonly, activeSockets: proxySockets });
357
349
  return;
358
350
  }
359
351
  if (pathname.startsWith('/federation/route/')) {
360
352
  const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
361
353
  if (!ingress) return socket.destroy();
362
- federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: () => cfg.port, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets });
354
+ federation.forwardUpgrade({ req, socket, head, nodesPath, localPort: runtimePort, localCredential: () => tokenHolder.value, ingress, readonly: proxyReadonly, activeSockets: proxySockets });
363
355
  return;
364
356
  }
365
357
  if (pathname === '/node' || pathname.startsWith('/node/')) {
@@ -411,12 +403,18 @@ function start(opts = {}) {
411
403
  const { server, cfg } = createServer(opts);
412
404
  const log = opts.log || console.log;
413
405
  const requestedPort = cfg.port;
406
+ const nodesPath = opts.nodesPath || nodesStore.defaultNodesPath(cfg.home || os.homedir());
407
+ const pairedPeers = nodesStore.hasPairedPeers(nodesStore.loadStore(nodesPath));
408
+ const listenError = (error) => {
409
+ if (typeof opts.onListenError === 'function') return opts.onListenError(error);
410
+ throw error;
411
+ };
414
412
  const onListening = () => {
415
413
  cfg.port = server.address().port;
416
414
  // Il token NON si stampa allo startup: finirebbe nei log del servizio
417
415
  // (journalctl/logfile). L'apertura autenticata passa da `nexuscrew show`.
418
416
  log(`nexuscrew on http://${cfg.bind}:${cfg.port} (open with \`nexuscrew show\`)`);
419
- log('localhost-only — reach it via SSH/autossh/VPN tunnel.');
417
+ log('localhost-only — reach it via a user-controlled SSH or VPN channel.');
420
418
  };
421
419
  const persistFallback = () => {
422
420
  const selected = server.address().port;
@@ -442,10 +440,17 @@ function start(opts = {}) {
442
440
  };
443
441
  server.once('error', (error) => {
444
442
  if (error && error.code === 'EADDRINUSE' && requestedPort !== 0 && opts.autoPort !== false) {
443
+ if (pairedPeers) {
444
+ const refused = new Error(`preferred port ${requestedPort} is busy; paired peers exist, refusing automatic port change`);
445
+ refused.code = 'EADDRINUSE_PAIRED';
446
+ refused.cause = error;
447
+ listenError(refused);
448
+ return;
449
+ }
445
450
  tryFallback(requestedPort >= 65535 ? 41820 : requestedPort + 1, 200);
446
451
  return;
447
452
  }
448
- throw error;
453
+ listenError(error);
449
454
  });
450
455
  server.listen(requestedPort, cfg.bind, onListening);
451
456
  return server;