@mmmbuto/nexuscrew 0.8.7 → 0.8.10

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.
@@ -29,7 +29,10 @@ function peerAllows(peer, otherId) {
29
29
 
30
30
  function canTransit(ingress, egress) {
31
31
  if (!ingress || !egress || ingress.name === egress.name) return !ingress;
32
- return peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
32
+ // `shared` is the explicit publication gate. Visibility remains the hub ACL,
33
+ // but it cannot make a private peer routable on its own.
34
+ return egress.shared === true
35
+ && peerAllows(ingress, egress.nodeId) && peerAllows(egress, ingress.nodeId);
33
36
  }
34
37
 
35
38
  function parseRoute(raw) {
@@ -54,8 +57,15 @@ function knownResource(resource) {
54
57
  || resource === '/files'
55
58
  || resource === '/files/download'
56
59
  || resource === '/files/upload'
60
+ || resource === '/decks'
61
+ || /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
62
+ || resource === '/topology'
63
+ // A connected client may ask its hub to mint a hub-owned, one-time
64
+ // pairing invite. This is the only settings mutation exposed through
65
+ // Hydra: the rest of /settings stays unreachable.
66
+ || resource === '/settings/peering/invite'
57
67
  || 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);
68
+ || /^\/fleet\/(status|schema|definitions|up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource);
59
69
  }
60
70
 
61
71
  function allowedResource(resource, method = 'GET') {
@@ -66,9 +76,15 @@ function allowedResource(resource, method = 'GET') {
66
76
  if (resource === '/files') return method === 'GET' || method === 'DELETE';
67
77
  if (resource === '/files/download') return method === 'GET';
68
78
  if (resource === '/files/upload') return method === 'POST';
79
+ if (resource === '/decks') return method === 'GET' || method === 'POST';
80
+ if (/^\/decks\/[a-z0-9-]{1,32}$/.test(resource)) {
81
+ return method === 'PUT' || method === 'PATCH' || method === 'DELETE';
82
+ }
83
+ if (resource === '/topology') return method === 'GET';
84
+ if (resource === '/settings/peering/invite') return method === 'POST';
69
85
  if (resource === '/ws') return method === 'GET';
70
86
  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';
87
+ if (/^\/fleet\/(up|down|restart|engine|boot|define-engine|edit-engine|remove-engine|define-cell|edit-cell|remove-cell|restore-cells)$/.test(resource)) return method === 'POST';
72
88
  return false;
73
89
  }
74
90
 
@@ -103,7 +119,8 @@ function routeHandler({ nodesPath, localPort, localCredential, ingress = null, r
103
119
  return proxyHttp(req, res, { port: typeof localPort === 'function' ? localPort() : localPort, path: `/api${parsed.resource}${queryOf(req.url)}`, credential: localCredential() });
104
120
  }
105
121
  const next = st && store.getNode(st, parsed.route[0]);
106
- if (!next || !next.token || (ingress && !canTransit(ingress, next))) return res.status(403).json({ error: 'route non consentita' });
122
+ const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
123
+ if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return res.status(403).json({ error: 'route non consentita' });
107
124
  const rest = parsed.route.slice(1);
108
125
  const path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}${parsed.resource}${queryOf(req.url)}`;
109
126
  proxyHttp(req, res, { port: next.localPort, path, credential: next.token, visited });
@@ -155,6 +172,14 @@ async function probeHealth({ port, token, expectedInstanceId = null, fetchImpl =
155
172
  } else if (expectedInstanceId && body.instanceId !== expectedInstanceId) {
156
173
  out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'peer instanceId inatteso — tunnel/porta punta al nodo sbagliato';
157
174
  } else {
175
+ if (body.roles !== undefined) {
176
+ const roles = store.parseRoles(body.roles);
177
+ if (!roles) {
178
+ out.reachability = 'failed'; out.status = 'degraded'; out.detail = 'health roles non validi';
179
+ return out;
180
+ }
181
+ out.roles = roles; out.rolesKnown = true;
182
+ }
158
183
  out.reachability = 'ok'; out.status = 'healthy'; out.detail = 'ok';
159
184
  }
160
185
  } else if (r.status === 401) {
@@ -186,7 +211,11 @@ async function collectTopologyDetailed({ nodesPath, ingress = null, ttl = MAX_HO
186
211
  const out = [];
187
212
  const authoritative = [];
188
213
  for (const n of st.nodes) {
189
- if (!n.nodeId || seen.has(n.nodeId) || (ingress && !canTransit(ingress, n))) continue;
214
+ // The local installation always keeps its outbound hub visible. Inbound
215
+ // clients become part of Hydra only after their explicit Share toggle.
216
+ if (!n.nodeId || seen.has(n.nodeId)
217
+ || (!ingress && n.direction === 'inbound' && n.shared !== true)
218
+ || (ingress && !canTransit(ingress, n))) continue;
190
219
  out.push({ instanceId: n.nodeId, name: n.name, route: [n.name], direct: true });
191
220
  if (ttl <= 1 || !n.token) continue;
192
221
  try {
@@ -226,7 +255,9 @@ async function collectTopology(opts) {
226
255
  async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, now = Math.floor(Date.now() / 1000) }) {
227
256
  const live = await collectTopologyDetailed({ nodesPath, fetchImpl });
228
257
  const st = store.loadStore(nodesPath);
229
- const directNames = new Set(((st && st.nodes) || []).map((n) => n.name));
258
+ const directNames = new Set(((st && st.nodes) || [])
259
+ .filter((n) => n.direction !== 'inbound' || n.shared === true)
260
+ .map((n) => n.name));
230
261
  const authoritative = new Set(live.authoritative);
231
262
  const liveIds = new Set(live.nodes.map((n) => n.instanceId));
232
263
  const liveRoutes = new Set(live.nodes.map((n) => n.route.join('/')));
@@ -256,7 +287,7 @@ async function collectLocalTopology({ nodesPath, cachePath, fetchImpl = fetch, n
256
287
  return { instanceId: live.instanceId, nodes };
257
288
  }
258
289
 
259
- function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly, version = null }) {
290
+ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly = () => false, version = null, roles = null }) {
260
291
  const r = express.Router();
261
292
  r.use((req, res, next) => {
262
293
  const peer = peerFromToken(nodesPath, bearerFrom(req));
@@ -269,13 +300,53 @@ function peerRouter({ nodesPath, localPort, localCredential, fetchImpl, readonly
269
300
  // reachability (payload). Nessun segreto in risposta.
270
301
  r.get('/health', (_req, res) => {
271
302
  const st = store.loadStore(nodesPath);
272
- res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly() });
303
+ const advertisedRoles = typeof roles === 'function' ? roles() : null;
304
+ res.json({ ok: true, instanceId: (st && st.nodeId) || null, version, readonly: !!readonly(),
305
+ ...(advertisedRoles ? { roles: advertisedRoles } : {}) });
273
306
  });
274
307
  r.get('/topology', async (req, res) => {
275
308
  const ttl = Math.max(0, Math.min(MAX_HOPS, Number(req.query.ttl) || MAX_HOPS));
276
309
  const visited = String(req.query.visited || '').split(',');
277
310
  res.json(await collectTopology({ nodesPath, ingress: req.peer, ttl, visited, fetchImpl }));
278
311
  });
312
+ // A connected client publishes itself through the SAME SSH connection by
313
+ // toggling its optional -R channel. The hub records that intent only after a
314
+ // real authenticated health probe succeeds; Share off is immediate/fail-safe.
315
+ r.post('/share', express.json({ limit: '1kb' }), async (req, res) => {
316
+ if (readonly()) return res.status(403).json({ error: 'READONLY: share bloccato' });
317
+ const body = req.body || {};
318
+ if (Object.keys(body).some((k) => k !== 'shared') || typeof body.shared !== 'boolean') {
319
+ return res.status(400).json({ error: 'body non valido: atteso {shared:boolean}' });
320
+ }
321
+ try {
322
+ if (body.shared) {
323
+ const health = await probeHealth({
324
+ port: req.peer.localPort,
325
+ token: req.peer.token,
326
+ expectedInstanceId: req.peer.nodeId || null,
327
+ fetchImpl: fetchImpl || fetch,
328
+ });
329
+ if (health.status !== 'healthy') {
330
+ return res.status(409).json({
331
+ error: 'canale share non raggiungibile',
332
+ detail: health.detail || 'reverse SSH non pronto',
333
+ });
334
+ }
335
+ }
336
+ let st = store.loadOrInitStore(nodesPath);
337
+ const current = store.getNode(st, req.peer.name);
338
+ if (!current) return res.status(404).json({ error: 'peer non trovato' });
339
+ st = store.updateNode(st, current.name, {
340
+ shared: body.shared,
341
+ roles: { ...current.roles, node: body.shared },
342
+ rolesKnown: true,
343
+ });
344
+ store.atomicWriteStore(nodesPath, st);
345
+ return res.json({ shared: body.shared });
346
+ } catch (e) {
347
+ return res.status(500).json({ error: String(e && e.message || e) });
348
+ }
349
+ });
279
350
  r.use('/route', (req, res) => routeHandler({ nodesPath, localPort, localCredential, ingress: req.peer, readonly })(req, res));
280
351
  return r;
281
352
  }
@@ -297,7 +368,8 @@ function forwardUpgrade({ req, socket, head, nodesPath, localPort, localCredenti
297
368
  let port = typeof localPort === 'function' ? localPort() : localPort; let credential = localCredential(); let path = '/ws';
298
369
  if (parsed.route.length) {
299
370
  const next = store.getNode(st, parsed.route[0]);
300
- if (!next || !next.token || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
371
+ const privateInbound = next && next.direction === 'inbound' && next.shared !== true;
372
+ if (!next || !next.token || privateInbound || (ingress && !canTransit(ingress, next))) return reject(socket, 403);
301
373
  port = next.localPort; credential = next.token;
302
374
  const rest = parsed.route.slice(1);
303
375
  path = `/federation/route/${rest.length ? `${rest.join('/')}/` : ''}${ROUTE_DELIMITER}/ws`;
package/lib/server.js CHANGED
@@ -36,6 +36,7 @@ const { createPushService } = require('./notify/push.js');
36
36
  const { createAsksStore } = require('./notify/asks.js');
37
37
  const { createNotifier } = require('./notify/notifier.js');
38
38
  const { notifyRoutes } = require('./notify/routes.js');
39
+ const { createNpmUpdater } = require('./update/manager.js');
39
40
 
40
41
  function sessionExists(tmuxBin, name) {
41
42
  if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
@@ -97,6 +98,13 @@ function createServer(opts = {}) {
97
98
  });
98
99
  const asksStore = createAsksStore({ dir: notifyDir });
99
100
  const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
101
+ const updater = opts.updateManager || createNpmUpdater({
102
+ currentVersion: VERSION,
103
+ home: cfg.home || os.homedir(),
104
+ enabled: cfg.autoUpdate !== false,
105
+ readonly: bridgeReadonly(),
106
+ ...(cfg.updateSeams || {}),
107
+ });
100
108
  const attachedWs = new Map(); // ws -> session (per il push dei frame files)
101
109
  // selectProvider sceglie UNA volta (startup) il provider external|builtin|disabled
102
110
  // (design §4b/§9b/§9g) e ritorna {mode,reason,fleet}; routes consumano il .fleet,
@@ -121,30 +129,19 @@ function createServer(opts = {}) {
121
129
  return { localPort: node.localPort, token: node.token || null };
122
130
  }
123
131
 
124
- // A node-role installation must republish itself after service/reboot. The
125
- // detached supervisor is idempotent and owns retry/backoff independently.
126
- if (cfg.roles && cfg.roles.node === true) {
127
- const st = nodesStore.loadStore(nodesPath);
128
- if (st && st.rendezvous) {
129
- const tr = nodesTunnel.startReverse({
130
- home: cfg.home || os.homedir(), rendezvous: st.rendezvous,
131
- spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
132
- sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
133
- });
134
- if (!tr.started && tr.reason !== 'already running') {
135
- process.stderr.write(`[nexuscrew] reverse tunnel autostart failed: ${tr.reason || 'unknown'}\n`);
136
- }
137
- }
138
- }
139
-
140
- // New Hydra peers are ordinary local+remote nodes. Only the side that owns
141
- // the outbound SSH alias dials; inbound records are reached through its -R.
142
- {
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;
143
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.
144
141
  for (const node of (st && st.nodes) || []) {
145
142
  if (node.direction !== 'inbound' && node.autostart === true) {
146
143
  const tr = nodesTunnel.startForward({
147
- home: cfg.home || os.homedir(), node, localAppPort: cfg.port,
144
+ home: cfg.home || os.homedir(), node, localAppPort: runtimePort(),
148
145
  spawnImpl: cfg.tunnelSpawnImpl, spawnSyncImpl: cfg.tunnelSpawnSyncImpl,
149
146
  sshBin: cfg.sshBin, logFd: cfg.tunnelLogFd,
150
147
  });
@@ -243,15 +240,13 @@ function createServer(opts = {}) {
243
240
  const h = healths[i] || null;
244
241
  return { ...n, tunnel: nodesHealth.tunnelFromHealth(h), health: h };
245
242
  });
246
- const out = { nodeId: view.nodeId, nodes };
247
- if (view.rendezvous) out.rendezvous = view.rendezvous;
248
- res.json(out);
243
+ res.json({ nodeId: view.nodeId, nodes });
249
244
  } catch (e) { res.status(500).json({ error: String(e.message || e) }); }
250
245
  });
251
246
  // Settings API B2 (design §4b(6)): read-only GET + mutanti lista chiusa per il
252
247
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
253
248
  // READONLY route-level e la redazione token vivono dentro settingsRoutes.
254
- api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions }));
249
+ api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort }));
255
250
  api.get('/topology', async (_req, res) => {
256
251
  try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
257
252
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
@@ -283,6 +278,8 @@ function createServer(opts = {}) {
283
278
  app.use('/api', api);
284
279
  app.use('/federation', federation.peerRouter({
285
280
  nodesPath, localPort: () => (server && server.address() ? server.address().port : cfg.port), localCredential: () => tokenHolder.value, readonly: proxyReadonly, version: VERSION,
281
+ fetchImpl: healthFetch,
282
+ roles: () => require('./cli/commands.js').readRoles(cfg.configPath || configJsonPath()),
286
283
  }));
287
284
 
288
285
  // Reverse-proxy single-origin /node/<name>/… (design §4b(2)). Auth locale PRIMA
@@ -296,14 +293,18 @@ function createServer(opts = {}) {
296
293
  // per costruire path: validazione ^[a-z0-9-]{1,32}$, nome invalido → 404 secco
297
294
  // (niente traversal, niente fallback silenzioso alla SPA su nomi sporchi).
298
295
  const DECK_NAME_RE = /^[a-z0-9-]{1,32}$/;
296
+ const DECK_OWNER_RE = /^[a-f0-9]{16,64}$/;
299
297
  // Cattura TUTTO cio' che sta sotto /deck/ (anche slash/segmenti extra o encoded)
300
298
  // e valida il remainder: qualunque cosa non sia un nome deck strict → 404,
301
299
  // senza mai cadere nel catch-all SPA con un nome sporco.
302
300
  app.get(/^\/deck\/(.*)$/, (req, res) => {
303
301
  // parita' col client: deckFromPath accetta UN trailing slash (/deck/main/),
304
302
  // il server deve fare lo stesso; piu' di uno resta 404 (regex strict).
305
- const name = req.params[0].replace(/\/$/, '');
306
- 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) {
307
308
  return res.status(404).type('text/plain').send('invalid deck name');
308
309
  }
309
310
  return res.sendFile(path.join(distDir, 'index.html'));
@@ -314,7 +315,8 @@ function createServer(opts = {}) {
314
315
  // Close the watcher when the HTTP server closes. Registered HERE (inside
315
316
  // createServer) — not in start() — so every createServer consumer is covered,
316
317
  // not only the start() path. watcher.close() is idempotent.
317
- server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); });
318
+ server.on('listening', () => { updater.start(); startManagedTunnels(); });
319
+ server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); });
318
320
  // noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
319
321
  // (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
320
322
  // dell'HTTP (auth locale -> name strict -> inject token) prima del piping.
@@ -343,13 +345,13 @@ function createServer(opts = {}) {
343
345
  let u; try { u = new URL(req.url, 'http://127.0.0.1'); } catch (_) { return socket.destroy(); }
344
346
  const given = bearerFrom(req) || u.searchParams.get('token') || '';
345
347
  if (!verify(tokenHolder.value, given)) return socket.destroy();
346
- 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 });
347
349
  return;
348
350
  }
349
351
  if (pathname.startsWith('/federation/route/')) {
350
352
  const ingress = federation.peerFromToken(nodesPath, bearerFrom(req));
351
353
  if (!ingress) return socket.destroy();
352
- 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 });
353
355
  return;
354
356
  }
355
357
  if (pathname === '/node' || pathname.startsWith('/node/')) {
@@ -394,19 +396,25 @@ function createServer(opts = {}) {
394
396
  }
395
397
  });
396
398
 
397
- return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP };
399
+ return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater };
398
400
  }
399
401
 
400
402
  function start(opts = {}) {
401
403
  const { server, cfg } = createServer(opts);
402
404
  const log = opts.log || console.log;
403
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
+ };
404
412
  const onListening = () => {
405
413
  cfg.port = server.address().port;
406
414
  // Il token NON si stampa allo startup: finirebbe nei log del servizio
407
415
  // (journalctl/logfile). L'apertura autenticata passa da `nexuscrew show`.
408
416
  log(`nexuscrew on http://${cfg.bind}:${cfg.port} (open with \`nexuscrew show\`)`);
409
- log('localhost-only — reach it via SSH/autossh/VPN tunnel.');
417
+ log('localhost-only — reach it via a user-controlled SSH or VPN channel.');
410
418
  };
411
419
  const persistFallback = () => {
412
420
  const selected = server.address().port;
@@ -432,10 +440,17 @@ function start(opts = {}) {
432
440
  };
433
441
  server.once('error', (error) => {
434
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
+ }
435
450
  tryFallback(requestedPort >= 65535 ? 41820 : requestedPort + 1, 200);
436
451
  return;
437
452
  }
438
- throw error;
453
+ listenError(error);
439
454
  });
440
455
  server.listen(requestedPort, cfg.bind, onListening);
441
456
  return server;