@mmmbuto/nexuscrew 0.8.26 → 0.8.27
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/CHANGELOG.md +23 -0
- package/README.md +22 -2
- package/frontend/dist/assets/index-CWKyv6KS.js +93 -0
- package/frontend/dist/assets/{index-DqG-mDnV.css → index-xxfypte7.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/doctor.js +79 -1
- package/lib/cli/service.js +3 -1
- package/lib/diagnostics/routes.js +43 -0
- package/lib/diagnostics/store.js +139 -0
- package/lib/fleet/cell-exec.js +17 -2
- package/lib/fleet/managed.js +11 -3
- package/lib/fleet/routes.js +29 -4
- package/lib/nodes/aliases.js +122 -0
- package/lib/proxy/federation.js +26 -2
- package/lib/runtime/env.js +60 -1
- package/lib/server.js +28 -5
- package/lib/settings/routes.js +32 -0
- package/lib/update/manager.js +8 -0
- package/package.json +1 -1
- package/frontend/dist/assets/index-DkIPrmX6.js +0 -91
package/lib/fleet/routes.js
CHANGED
|
@@ -29,6 +29,14 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
29
29
|
r.use((req, res, next) => (req.path === '/restore-cells' || req.path === '/restore-engines' ? restoreJson : smallJson)(req, res, next));
|
|
30
30
|
|
|
31
31
|
const readonly = () => (cfg.readonlyDefault === true || process.env.NEXUSCREW_READONLY === '1');
|
|
32
|
+
const diagnostics = cfg.diagnostics || null;
|
|
33
|
+
const safeCell = (body) => {
|
|
34
|
+
const value = body && typeof body.cell === 'string' ? body.cell : '';
|
|
35
|
+
return /^[A-Za-z0-9._-]{1,32}$/.test(value) ? value : '';
|
|
36
|
+
};
|
|
37
|
+
const emit = (level, code, message, meta) => {
|
|
38
|
+
if (diagnostics && typeof diagnostics.record === 'function') diagnostics.record(level, 'fleet', code, message, meta);
|
|
39
|
+
};
|
|
32
40
|
|
|
33
41
|
const guard = (fn, opts = {}) => async (req, res) => {
|
|
34
42
|
try {
|
|
@@ -36,8 +44,25 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
36
44
|
if (opts.mutate && readonly()) return res.status(403).json({ error: 'READONLY: mutazione fleet bloccata' });
|
|
37
45
|
const fleet = await fleetP;
|
|
38
46
|
if (!fleet.available) return res.status(404).json({ error: 'fleet non disponibile' });
|
|
39
|
-
|
|
47
|
+
const body = req.body || {};
|
|
48
|
+
if (opts.action) emit('info', 'FLEET_ACTION_STARTED', 'Fleet action started', {
|
|
49
|
+
action: opts.action, cell: safeCell(body), state: 'starting',
|
|
50
|
+
});
|
|
51
|
+
const result = await fn(fleet, body);
|
|
52
|
+
if (opts.action) emit('info', 'FLEET_ACTION_COMPLETED', 'Fleet action completed', {
|
|
53
|
+
action: opts.action, cell: safeCell(body), state: opts.action === 'down' ? 'stopped' : 'ready',
|
|
54
|
+
});
|
|
55
|
+
res.json(result);
|
|
40
56
|
} catch (e) {
|
|
57
|
+
if (opts.action) {
|
|
58
|
+
const match = String(e && e.message || '').match(/cell spawn failed:\s+([A-Z][A-Z0-9_]{0,31})\s+([A-Za-z0-9._+-]{1,128})/);
|
|
59
|
+
if (match) emit('error', 'CELL_SPAWN_FAILED', 'Cell client spawn failed', {
|
|
60
|
+
action: opts.action, cell: safeCell(req.body), errno: match[1], client: match[2], status: e.status || 500,
|
|
61
|
+
});
|
|
62
|
+
else emit('warn', 'FLEET_ACTION_FAILED', 'Fleet action failed', {
|
|
63
|
+
action: opts.action, cell: safeCell(req.body), state: 'failed', status: e.status || 500,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
41
66
|
res.status(e.status || 500).json({ error: String(e.message || e), ...(e.data || {}) });
|
|
42
67
|
}
|
|
43
68
|
};
|
|
@@ -82,13 +107,13 @@ function fleetRoutes(fleetP, cfg = {}) {
|
|
|
82
107
|
await f.boot(cell, b.boot === true);
|
|
83
108
|
}
|
|
84
109
|
return f.up(cell, { engine: b.engine, boot: !!b.boot });
|
|
85
|
-
}, { mutate: true }));
|
|
110
|
+
}, { mutate: true, action: 'up' }));
|
|
86
111
|
r.post('/down', guard(async (f, b) => {
|
|
87
112
|
const cell = String(b.cell || '');
|
|
88
113
|
if (b.boot === true && capList(f).includes('edit') && capList(f).includes('boot')) await f.boot(cell, false);
|
|
89
114
|
return f.down(cell, { boot: !!b.boot });
|
|
90
|
-
}, { mutate: true }));
|
|
91
|
-
r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true }));
|
|
115
|
+
}, { mutate: true, action: 'down' }));
|
|
116
|
+
r.post('/restart', guard((f, b) => { requireCap(f, 'restart'); return f.restart(String(b.cell || '')); }, { mutate: true, action: 'restart' }));
|
|
92
117
|
r.post('/engine', guard((f, b) => {
|
|
93
118
|
const opts = { model: typeof b.model === 'string' ? b.model : '' };
|
|
94
119
|
if (typeof b.permissionPolicy === 'string') opts.permissionPolicy = b.permissionPolicy;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Alias locali del viewer per nodi routed. Questo store non partecipa mai a
|
|
3
|
+
// topology, routing, ACL o federation: associa soltanto uno stable instanceId a
|
|
4
|
+
// un'etichetta di display scelta sul dispositivo che ospita la PWA.
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const crypto = require('node:crypto');
|
|
9
|
+
|
|
10
|
+
const SCHEMA_VERSION = 1;
|
|
11
|
+
const INSTANCE_ID_RE = /^[a-f0-9]{16,64}$/;
|
|
12
|
+
const ALIAS_MAX = 64;
|
|
13
|
+
const MAX_ALIASES = 128;
|
|
14
|
+
const MAX_FILE_BYTES = 16 * 1024;
|
|
15
|
+
|
|
16
|
+
function defaultAliasesPath(home = os.homedir()) {
|
|
17
|
+
return path.join(home, '.nexuscrew', 'node-aliases.json');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeAlias(value) {
|
|
21
|
+
if (typeof value !== 'string') return null;
|
|
22
|
+
const alias = value.normalize('NFC').trim();
|
|
23
|
+
if (!alias || alias.length > ALIAS_MAX) return null;
|
|
24
|
+
// Cc/Cf includes newlines, NUL, bidi controls and invisible format chars.
|
|
25
|
+
if (/[\p{Cc}\p{Cf}]/u.test(alias)) return null;
|
|
26
|
+
return alias;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function emptyStore() {
|
|
30
|
+
return { version: SCHEMA_VERSION, aliasesByInstanceId: {} };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseStore(raw) {
|
|
34
|
+
let value;
|
|
35
|
+
try { value = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (_) { return null; }
|
|
36
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
37
|
+
if (Object.keys(value).some((key) => !['version', 'aliasesByInstanceId'].includes(key))) return null;
|
|
38
|
+
if (value.version !== SCHEMA_VERSION) return null;
|
|
39
|
+
const aliases = value.aliasesByInstanceId;
|
|
40
|
+
if (!aliases || typeof aliases !== 'object' || Array.isArray(aliases)) return null;
|
|
41
|
+
const entries = Object.entries(aliases);
|
|
42
|
+
if (entries.length > MAX_ALIASES) return null;
|
|
43
|
+
const out = {};
|
|
44
|
+
for (const [instanceId, rawAlias] of entries) {
|
|
45
|
+
const alias = normalizeAlias(rawAlias);
|
|
46
|
+
if (!INSTANCE_ID_RE.test(instanceId) || alias === null || alias !== rawAlias) return null;
|
|
47
|
+
out[instanceId] = alias;
|
|
48
|
+
}
|
|
49
|
+
return { version: SCHEMA_VERSION, aliasesByInstanceId: out };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertSafeTarget(filePath) {
|
|
53
|
+
try {
|
|
54
|
+
const stat = fs.lstatSync(filePath);
|
|
55
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error('node alias store must be a regular file');
|
|
56
|
+
if ((stat.mode & 0o077) !== 0) throw new Error('node alias store permissions must be 0600');
|
|
57
|
+
if (stat.size > MAX_FILE_BYTES) throw new Error('node alias store exceeds size limit');
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error && error.code === 'ENOENT') return false;
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadStore(filePath = defaultAliasesPath()) {
|
|
66
|
+
if (!assertSafeTarget(filePath)) return emptyStore();
|
|
67
|
+
const raw = fs.readFileSync(filePath, { encoding: 'utf8', flag: 'r' });
|
|
68
|
+
if (Buffer.byteLength(raw) > MAX_FILE_BYTES) throw new Error('node alias store exceeds size limit');
|
|
69
|
+
const parsed = parseStore(raw);
|
|
70
|
+
if (!parsed) throw new Error('invalid node alias store');
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function atomicWriteStore(filePath, store) {
|
|
75
|
+
const parsed = parseStore(store);
|
|
76
|
+
if (!parsed) throw new Error('invalid node alias store');
|
|
77
|
+
const payload = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
78
|
+
if (Buffer.byteLength(payload) > MAX_FILE_BYTES) throw new Error('node alias store exceeds size limit');
|
|
79
|
+
assertSafeTarget(filePath);
|
|
80
|
+
const dir = path.dirname(filePath);
|
|
81
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
82
|
+
const dirStat = fs.lstatSync(dir);
|
|
83
|
+
if (dirStat.isSymbolicLink() || !dirStat.isDirectory()) throw new Error('node alias directory must be a regular directory');
|
|
84
|
+
fs.chmodSync(dir, 0o700);
|
|
85
|
+
const tmp = path.join(dir, `.${path.basename(filePath)}.${crypto.randomBytes(8).toString('hex')}.tmp`);
|
|
86
|
+
try {
|
|
87
|
+
fs.writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
|
|
88
|
+
fs.chmodSync(tmp, 0o600);
|
|
89
|
+
fs.renameSync(tmp, filePath);
|
|
90
|
+
fs.chmodSync(filePath, 0o600);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
try { fs.unlinkSync(tmp); } catch (_) { /* best effort */ }
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
return parsed;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function setAlias(store, instanceId, value) {
|
|
99
|
+
if (!INSTANCE_ID_RE.test(String(instanceId || ''))) throw new Error('instanceId non valido');
|
|
100
|
+
const alias = normalizeAlias(value);
|
|
101
|
+
if (alias === null) throw new Error('alias non valido (max 64 char, niente controlli)');
|
|
102
|
+
const current = parseStore(store);
|
|
103
|
+
if (!current) throw new Error('invalid node alias store');
|
|
104
|
+
const aliasesByInstanceId = { ...current.aliasesByInstanceId, [instanceId]: alias };
|
|
105
|
+
if (Object.keys(aliasesByInstanceId).length > MAX_ALIASES) throw new Error('troppi alias nodo');
|
|
106
|
+
return { version: SCHEMA_VERSION, aliasesByInstanceId };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function deleteAlias(store, instanceId) {
|
|
110
|
+
if (!INSTANCE_ID_RE.test(String(instanceId || ''))) throw new Error('instanceId non valido');
|
|
111
|
+
const current = parseStore(store);
|
|
112
|
+
if (!current) throw new Error('invalid node alias store');
|
|
113
|
+
const aliasesByInstanceId = { ...current.aliasesByInstanceId };
|
|
114
|
+
delete aliasesByInstanceId[instanceId];
|
|
115
|
+
return { version: SCHEMA_VERSION, aliasesByInstanceId };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = {
|
|
119
|
+
SCHEMA_VERSION, INSTANCE_ID_RE, ALIAS_MAX, MAX_ALIASES, MAX_FILE_BYTES,
|
|
120
|
+
defaultAliasesPath, normalizeAlias, emptyStore, parseStore, loadStore,
|
|
121
|
+
atomicWriteStore, setAlias, deleteAlias,
|
|
122
|
+
};
|
package/lib/proxy/federation.js
CHANGED
|
@@ -63,6 +63,9 @@ function knownResource(resource) {
|
|
|
63
63
|
|| resource === '/decks'
|
|
64
64
|
|| /^\/decks\/[a-z0-9-]{1,32}$/.test(resource)
|
|
65
65
|
|| resource === '/topology'
|
|
66
|
+
|| resource === '/diagnostics/status'
|
|
67
|
+
|| resource === '/diagnostics/logs'
|
|
68
|
+
|| resource === '/diagnostics/verbose'
|
|
66
69
|
// A connected client may ask its hub to mint a hub-owned, one-time
|
|
67
70
|
// pairing invite. This is the only settings mutation exposed through
|
|
68
71
|
// Hydra: the rest of /settings stays unreachable.
|
|
@@ -87,6 +90,9 @@ function allowedResource(resource, method = 'GET') {
|
|
|
87
90
|
return method === 'PUT' || method === 'PATCH' || method === 'DELETE';
|
|
88
91
|
}
|
|
89
92
|
if (resource === '/topology') return method === 'GET';
|
|
93
|
+
if (resource === '/diagnostics/status') return method === 'GET';
|
|
94
|
+
if (resource === '/diagnostics/logs') return method === 'GET' || method === 'DELETE';
|
|
95
|
+
if (resource === '/diagnostics/verbose') return method === 'PATCH';
|
|
90
96
|
if (resource === '/settings/peering/invite') return method === 'POST';
|
|
91
97
|
if (resource === '/ws') return method === 'GET';
|
|
92
98
|
if (/^\/fleet\/(status|schema|definitions|credentials\/status)$/.test(resource)) return method === 'GET';
|
|
@@ -94,6 +100,23 @@ function allowedResource(resource, method = 'GET') {
|
|
|
94
100
|
return false;
|
|
95
101
|
}
|
|
96
102
|
|
|
103
|
+
function allowedQuery(resource, method, rawUrl) {
|
|
104
|
+
if (!resource.startsWith('/diagnostics/')) return true;
|
|
105
|
+
const index = String(rawUrl || '').indexOf('?');
|
|
106
|
+
if (index < 0) return true;
|
|
107
|
+
const raw = String(rawUrl).slice(index + 1);
|
|
108
|
+
if (!raw) return true;
|
|
109
|
+
if (resource !== '/diagnostics/logs' || method !== 'GET') return false;
|
|
110
|
+
const params = new URLSearchParams(raw);
|
|
111
|
+
const keys = [...params.keys()];
|
|
112
|
+
if (keys.some((key) => !['after', 'limit'].includes(key))) return false;
|
|
113
|
+
if (params.getAll('after').length > 1 || params.getAll('limit').length > 1) return false;
|
|
114
|
+
const after = params.get('after'); const limit = params.get('limit');
|
|
115
|
+
if (after !== null && (!/^\d{1,16}$/.test(after) || !Number.isSafeInteger(Number(after)))) return false;
|
|
116
|
+
if (limit !== null && (!/^\d{1,3}$/.test(limit) || Number(limit) < 1 || Number(limit) > 200)) return false;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
97
120
|
function cleanHeaders(headers, credential, visited = null) {
|
|
98
121
|
const out = sanitizeRequestHeaders(headers, credential);
|
|
99
122
|
for (const key of Object.keys(out)) {
|
|
@@ -115,7 +138,8 @@ function proxyHttp(req, res, { port, path, credential, visited = null }) {
|
|
|
115
138
|
function routeHandler({ nodesPath, localPort, localCredential, ingress = null, readonly = () => false }) {
|
|
116
139
|
return (req, res) => {
|
|
117
140
|
const parsed = parseRoute(req.url);
|
|
118
|
-
if (!parsed || !allowedResource(parsed.resource, req.method)
|
|
141
|
+
if (!parsed || !allowedResource(parsed.resource, req.method)
|
|
142
|
+
|| !allowedQuery(parsed.resource, req.method, req.url)) return res.status(404).json({ error: 'not found' });
|
|
119
143
|
if (readonly() && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(req.method)) return res.status(403).json({ error: 'READONLY: federated mutation blocked' });
|
|
120
144
|
const st = store.loadStore(nodesPath);
|
|
121
145
|
if (!st) return res.status(503).json({ error: 'node store unavailable' });
|
|
@@ -539,7 +563,7 @@ function reject(socket, code) { try { socket.end(`HTTP/1.1 ${code} Error\r\nConn
|
|
|
539
563
|
|
|
540
564
|
module.exports = {
|
|
541
565
|
MAX_HOPS, ROUTE_DELIMITER, TOPOLOGY_PEER_TIMEOUT_MS,
|
|
542
|
-
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource,
|
|
566
|
+
peerFromToken, peerAllows, canTransit, parseRoute, knownResource, allowedResource, allowedQuery,
|
|
543
567
|
collectTopology, collectTopologyDetailed, collectLocalTopology, peerRouter, localRouter, forwardUpgrade,
|
|
544
568
|
probeHealth, waitForHealthyPeer, notifyHubShare, reconcilePeerShare,
|
|
545
569
|
};
|
package/lib/runtime/env.js
CHANGED
|
@@ -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
|
-
|
|
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', () => {
|
|
402
|
-
|
|
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 = {}) {
|
package/lib/settings/routes.js
CHANGED
|
@@ -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
|
package/lib/update/manager.js
CHANGED
|
@@ -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
|
|