@mmmbuto/nexuscrew 0.8.26 → 0.8.28

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.
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
3
4
  const os = require('node:os');
4
5
  const path = require('node:path');
5
6
 
@@ -77,11 +78,69 @@ function minimalRuntimeEnv(source = process.env, opts = {}) {
77
78
  if (!selected.PREFIX) selected.PREFIX = termux.prefix;
78
79
  if (!selected.TMPDIR) selected.TMPDIR = termux.tmpdir;
79
80
  if (!selected.TMUX_TMPDIR) selected.TMUX_TMPDIR = termux.tmuxTmpdir;
81
+ // Termux Google Play (targetSdk >= 29) runs under the SELinux `untrusted_app`
82
+ // domain, which forbids execve() of files in the app's data directory unless
83
+ // libtermux-exec is preloaded (it redirects those execs to the system linker).
84
+ // The shared tmux server and every pane are launched with this minimal env,
85
+ // so without the preload every command pane dies at execve. Preserve ONLY the
86
+ // validated trusted preload; every other LD_*/provider/credential value stays
87
+ // dropped as before. Fail-closed: any doubt -> drop, never pass through.
88
+ const trusted = trustedTermuxPreload(source, opts);
89
+ if (trusted) selected.LD_PRELOAD = trusted;
80
90
  }
81
91
  return withUtf8Locale(selected, opts);
82
92
  }
83
93
 
94
+ // Basenames of the trusted termux-exec preload library shipped by Termux.
95
+ // Modern builds ship `libtermux-exec-ld-preload.so`; older ones shipped
96
+ // `libtermux-exec.so`. The optional numeric segment accepts a versioned
97
+ // variant; every other name is rejected. This IS the allowlist: adding a new
98
+ // trusted entry requires extending this regex (after a device-specific proof).
99
+ const TERMUX_EXEC_BASENAME_RE = /^libtermux-exec(?:-ld-preload)?(?:-\d+(?:\.\d+)*)?\.so$/;
100
+
101
+ // Pure validator for the single Termux LD_PRELOAD entry minimalRuntimeEnv may
102
+ // preserve. Returns the canonical realpath of the trusted library, or null when
103
+ // the value is absent, non-Termux, relative, a list/mixed value, outside the
104
+ // active Termux PREFIX/lib, missing, non-regular, world/group-writable, or not
105
+ // owned by the running user (or root). Never throws, never logs.
106
+ //
107
+ // Security model: the preload must come from the already-trusted service env
108
+ // (the attacker who controls that env is already inside the trust domain). We
109
+ // only make sure it cannot escape that domain: a relative path, a foreign
110
+ // library, or a path planted outside PREFIX/lib is dropped as if absent.
111
+ function trustedTermuxPreload(source = process.env, opts = {}) {
112
+ const raw = source && source.LD_PRELOAD;
113
+ if (typeof raw !== 'string' || raw === '') return null;
114
+ // Reject list-shaped / ambiguous / mixed values: LD_PRELOAD accepts a
115
+ // colon-(or space-)separated list; we only ever preserve a single entry.
116
+ if (/[ \t\r\n:]/.test(raw)) return null;
117
+ if (raw.includes('\0')) return null;
118
+ if (!path.isAbsolute(raw)) return null;
119
+ // Only when the runtime is genuinely Termux (PREFIX or files/home layout).
120
+ const termux = termuxRuntimePaths(source, opts);
121
+ if (!termux || !termux.prefix) return null;
122
+ let realPrefix;
123
+ let realRaw;
124
+ try {
125
+ realPrefix = fs.realpathSync(termux.prefix);
126
+ realRaw = fs.realpathSync(raw);
127
+ } catch (_) { return null; }
128
+ // Resolve the canonical trusted directory and require the library to live in
129
+ // it directly (no nested paths, no elsewhere). Both sides are realpath'd so a
130
+ // symlinked PREFIX or a planted symlink cannot escape.
131
+ const trustedDir = path.join(realPrefix, 'lib');
132
+ if (path.dirname(realRaw) !== trustedDir) return null;
133
+ const base = path.basename(realRaw);
134
+ if (!TERMUX_EXEC_BASENAME_RE.test(base)) return null;
135
+ let st;
136
+ try { st = fs.lstatSync(realRaw); } catch (_) { return null; }
137
+ if (!st.isFile()) return null; // regular file only (realpath already resolved symlinks)
138
+ if (st.mode & 0o022) return null; // not group/world-writable
139
+ if (typeof process.getuid === 'function' && st.uid !== process.getuid() && st.uid !== 0) return null;
140
+ return realRaw;
141
+ }
142
+
84
143
  module.exports = {
85
144
  MINIMAL_ENV_KEYS, UTF8_RE, localeDefaults, withUtf8Locale,
86
- termuxRuntimePaths, minimalRuntimeEnv,
145
+ termuxRuntimePaths, minimalRuntimeEnv, trustedTermuxPreload, TERMUX_EXEC_BASENAME_RE,
87
146
  };
package/lib/server.js CHANGED
@@ -41,6 +41,8 @@ const { createAsksStore } = require('./notify/asks.js');
41
41
  const { createNotifier } = require('./notify/notifier.js');
42
42
  const { notifyRoutes } = require('./notify/routes.js');
43
43
  const { createNpmUpdater } = require('./update/manager.js');
44
+ const { createDiagnostics } = require('./diagnostics/store.js');
45
+ const { diagnosticsRoutes } = require('./diagnostics/routes.js');
44
46
 
45
47
  function sessionExists(tmuxBin, name) {
46
48
  if (typeof name !== 'string' || !/^[\w.@%:+-]{1,128}$/.test(name)) return false;
@@ -102,11 +104,13 @@ function createServer(opts = {}) {
102
104
  });
103
105
  const asksStore = createAsksStore({ dir: notifyDir });
104
106
  const notifier = createNotifier({ hub: eventsHub, push: pushSvc });
107
+ const diagnostics = opts.diagnostics || createDiagnostics();
105
108
  const updater = opts.updateManager || createNpmUpdater({
106
109
  currentVersion: VERSION,
107
110
  home: cfg.home || os.homedir(),
108
111
  enabled: cfg.autoUpdate !== false,
109
112
  readonly: bridgeReadonly(),
113
+ diagnostics,
110
114
  ...(cfg.updateSeams || {}),
111
115
  });
112
116
  const attachedWs = new Map(); // ws -> session (per il push dei frame files)
@@ -150,6 +154,9 @@ function createServer(opts = {}) {
150
154
  const recovered = reconcile({ home: cfg.home || os.homedir(), configuredNames: configured });
151
155
  for (const failure of recovered.failed || []) {
152
156
  process.stderr.write(`[nexuscrew] orphan tunnel cleanup failed for ${failure.name}: ${failure.reason}\n`);
157
+ diagnostics.record('warn', 'tunnel', 'TUNNEL_CLEANUP_FAILED', 'Tunnel cleanup failed', {
158
+ node: failure.name, state: 'cleanup-failed',
159
+ });
153
160
  }
154
161
  // Exactly one connection per configured hub. Legacy `roles.node` /
155
162
  // rendezvous data is migration-only and never starts a second hidden SSH
@@ -163,7 +170,14 @@ function createServer(opts = {}) {
163
170
  });
164
171
  if (!tr.started && tr.reason !== 'already running') {
165
172
  process.stderr.write(`[nexuscrew] peer ${node.name} autostart failed: ${tr.reason || 'unknown'}\n`);
166
- } else if (node.token && node.acceptToken && node.nodeId) {
173
+ diagnostics.record('warn', 'tunnel', 'TUNNEL_AUTOSTART_FAILED', 'Tunnel autostart failed', {
174
+ node: node.name, state: 'failed', transport: node.transport || 'auto',
175
+ });
176
+ } else {
177
+ diagnostics.record('info', 'tunnel', 'TUNNEL_READY', 'Tunnel is ready', {
178
+ node: node.name, state: tr.reason === 'already running' ? 'existing' : 'started', transport: node.transport || 'auto',
179
+ });
180
+ if (!(node.token && node.acceptToken && node.nodeId)) continue;
167
181
  // `shared` in nodes.json e' lo stato desiderato. Una riconciliazione
168
182
  // asincrona chiude la finestra di crash tra write locale e ACK hub
169
183
  // senza ritardare il listen del server.
@@ -268,7 +282,7 @@ function createServer(opts = {}) {
268
282
  paste: (session, text) => pasteToSession(cfg.tmuxBin, session, text),
269
283
  sessionExists: (name) => sessionExists(cfg.tmuxBin, name),
270
284
  }));
271
- api.use('/fleet', fleetRoutes(fleetP, cfg));
285
+ api.use('/fleet', fleetRoutes(fleetP, { ...cfg, diagnostics }));
272
286
  api.use('/cells', cellsRoutes({
273
287
  fleetP,
274
288
  instanceId: () => (nodesStore.loadStore(nodesPath) || {}).nodeId || null,
@@ -330,6 +344,7 @@ function createServer(opts = {}) {
330
344
  // wizard/settings UI. Dietro lo stesso requireToken del router /api; il gate
331
345
  // READONLY route-level e la redazione token vivono dentro settingsRoutes.
332
346
  api.use('/settings', settingsRoutes({ cfg, nodesPath, tokenStore, closeSessions, updater, runtimePort }));
347
+ api.use('/diagnostics', diagnosticsRoutes({ diagnostics, readonly: proxyReadonly }));
333
348
  api.get('/topology', async (_req, res) => {
334
349
  try { res.json(await federation.collectLocalTopology({ nodesPath, cachePath: topologyCachePath })); }
335
350
  catch (e) { res.status(500).json({ error: String(e.message || e) }); }
@@ -398,8 +413,16 @@ function createServer(opts = {}) {
398
413
  // Close the watcher when the HTTP server closes. Registered HERE (inside
399
414
  // createServer) — not in start() — so every createServer consumer is covered,
400
415
  // not only the start() path. watcher.close() is idempotent.
401
- server.on('listening', () => { updater.start(); startManagedTunnels(); });
402
- server.on('close', () => { watcher.close(); previews.close(); eventsHub.closeAll(); updater.close(); });
416
+ server.on('listening', () => {
417
+ diagnostics.record('info', 'server', 'SERVER_STARTED', 'NexusCrew server started', {
418
+ port: runtimePort(), platform: cfg.platform || process.platform,
419
+ });
420
+ updater.start(); startManagedTunnels();
421
+ });
422
+ server.on('close', () => {
423
+ diagnostics.record('info', 'server', 'SERVER_STOPPED', 'NexusCrew server stopped', { reason: 'close' });
424
+ watcher.close(); previews.close(); eventsHub.closeAll(); updater.close();
425
+ });
403
426
  // noServer: gestiamo l'upgrade a mano per instradare /ws (locale) e /node/*
404
427
  // (proxy). Il WS locale resta identico; il proxy WS applica gli STESSI check
405
428
  // dell'HTTP (auth locale -> name strict -> inject token) prima del piping.
@@ -483,7 +506,7 @@ function createServer(opts = {}) {
483
506
  fleetP.then((fleet) => (typeof fleet.close === 'function' ? fleet.close() : null)).catch(() => {});
484
507
  });
485
508
 
486
- return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater };
509
+ return { app, server, wss, cfg, token: tokenHolder.value, tokenStore, watcher, fleetP, updater, diagnostics };
487
510
  }
488
511
 
489
512
  function start(opts = {}) {
@@ -44,6 +44,7 @@ const crypto = require('node:crypto');
44
44
  const express = require('express');
45
45
 
46
46
  const nodesStore = require('../nodes/store.js');
47
+ const nodeAliases = require('../nodes/aliases.js');
47
48
  const nodesCmds = require('../nodes/commands.js');
48
49
  const nodesTunnel = require('../nodes/tunnel.js');
49
50
  const peering = require('../nodes/peering.js');
@@ -158,6 +159,7 @@ function settingsRoutes(deps = {}) {
158
159
  // isolate via env (bug trovato in audit: smoke test che scrivono la config reale).
159
160
  const configPath = cfg.configPath || configJsonPath();
160
161
  const nodesPath = deps.nodesPath || cfg.nodesPath || nodesStore.defaultNodesPath(home);
162
+ const aliasesPath = deps.aliasesPath || cfg.aliasesPath || nodeAliases.defaultAliasesPath(home);
161
163
  const tokenPath = cfg.tokenPath || path.join(configDir, 'token');
162
164
  const invitesPath = cfg.invitesPath || peering.defaultInvitesPath(home);
163
165
  const pendingPath = cfg.pendingPairingsPath || peering.defaultPendingPath(home);
@@ -661,6 +663,36 @@ function settingsRoutes(deps = {}) {
661
663
  return applyNodeEdit(res, name, { label });
662
664
  });
663
665
 
666
+ // Viewer-local aliases for routed nodes. These routes are local-only and do
667
+ // not mutate the peer identity, route, label, or federated topology.
668
+ r.get('/node-aliases', (_req, res) => {
669
+ try { send(res, 200, nodeAliases.loadStore(aliasesPath)); }
670
+ catch (e) { send(res, 500, { error: String(e.message || e) }); }
671
+ });
672
+
673
+ r.patch('/node-aliases/:instanceId', mutGate, (req, res) => {
674
+ try {
675
+ const instanceId = String(req.params.instanceId || '');
676
+ const raw = req.body && req.body.alias;
677
+ let store = nodeAliases.loadStore(aliasesPath);
678
+ store = typeof raw === 'string' && raw.trim() === ''
679
+ ? nodeAliases.deleteAlias(store, instanceId)
680
+ : nodeAliases.setAlias(store, instanceId, raw);
681
+ store = nodeAliases.atomicWriteStore(aliasesPath, store);
682
+ send(res, 200, { saved: true, instanceId, alias: store.aliasesByInstanceId[instanceId] || null });
683
+ } catch (e) { send(res, 400, { error: String(e.message || e) }); }
684
+ });
685
+
686
+ r.delete('/node-aliases/:instanceId', mutGate, (req, res) => {
687
+ try {
688
+ const instanceId = String(req.params.instanceId || '');
689
+ let store = nodeAliases.loadStore(aliasesPath);
690
+ store = nodeAliases.deleteAlias(store, instanceId);
691
+ nodeAliases.atomicWriteStore(aliasesPath, store);
692
+ send(res, 200, { saved: true, instanceId, alias: null });
693
+ } catch (e) { send(res, 400, { error: String(e.message || e) }); }
694
+ });
695
+
664
696
  // Legacy compatibility endpoint: the old node-role/rendezvous flow opened a
665
697
  // second SSH process and is intentionally retired. Existing data is kept for
666
698
  // migration, but all new publication goes through pairing + Share on the
@@ -54,6 +54,7 @@ function createNpmUpdater(opts = {}) {
54
54
  const initialDelayMs = opts.initialDelayMs === undefined ? DEFAULT_INITIAL_DELAY_MS : opts.initialDelayMs;
55
55
  const intervalMs = opts.intervalMs === undefined ? DEFAULT_INTERVAL_MS : opts.intervalMs;
56
56
  const maxLogBytes = opts.maxLogBytes === undefined ? DEFAULT_MAX_LOG_BYTES : opts.maxLogBytes;
57
+ const diagnostics = opts.diagnostics || null;
57
58
  let enabled = opts.enabled !== false && !readonly;
58
59
  let checking = null;
59
60
  let timer = null;
@@ -81,8 +82,15 @@ function createNpmUpdater(opts = {}) {
81
82
  }
82
83
 
83
84
  const persist = (patch) => {
85
+ const previousPhase = state.phase || 'idle';
84
86
  state = { ...state, ...patch };
85
87
  writeState(statusPath, state);
88
+ const phase = state.phase || 'idle';
89
+ if (diagnostics && typeof diagnostics.record === 'function' && phase !== previousPhase) {
90
+ diagnostics.record(phase === 'error' ? 'warn' : 'info', 'updater', 'UPDATE_PHASE', 'Updater phase changed', {
91
+ phase, version: state.targetVersion || state.latest || currentVersion,
92
+ });
93
+ }
86
94
  return status();
87
95
  };
88
96
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mmmbuto/nexuscrew",
3
- "version": "0.8.26",
3
+ "version": "0.8.28",
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": {