@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.
- package/CHANGELOG.md +46 -0
- package/README.md +44 -9
- package/frontend/dist/assets/{index-DqG-mDnV.css → index-BAzlX2nP.css} +1 -1
- package/frontend/dist/assets/index-DiKMLCvW.js +93 -0
- package/frontend/dist/index.html +2 -2
- package/frontend/dist/version.json +1 -1
- package/lib/cli/commands.js +19 -2
- package/lib/cli/doctor.js +166 -1
- package/lib/cli/service.js +10 -5
- package/lib/diagnostics/routes.js +43 -0
- package/lib/diagnostics/store.js +139 -0
- package/lib/fleet/builtin.js +201 -16
- package/lib/fleet/causes.js +68 -0
- package/lib/fleet/cell-exec.js +17 -2
- package/lib/fleet/definitions.js +86 -4
- package/lib/fleet/launch.js +19 -1
- package/lib/fleet/managed.js +73 -12
- package/lib/fleet/routes.js +44 -5
- package/lib/fleet/runtime.js +69 -33
- 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/frontend/dist/index.html
CHANGED
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
<meta name="apple-mobile-web-app-title" content="NexusCrew" />
|
|
12
12
|
<link rel="manifest" href="/manifest.json" />
|
|
13
13
|
<title>NexusCrew</title>
|
|
14
|
-
<script type="module" crossorigin src="/assets/index-
|
|
15
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
14
|
+
<script type="module" crossorigin src="/assets/index-DiKMLCvW.js"></script>
|
|
15
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BAzlX2nP.css">
|
|
16
16
|
</head>
|
|
17
17
|
<body>
|
|
18
18
|
<div id="root"></div>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.8.
|
|
1
|
+
{"version":"0.8.28"}
|
package/lib/cli/commands.js
CHANGED
|
@@ -14,7 +14,7 @@ const pidf = require('./pidfile.js');
|
|
|
14
14
|
const { runInit, ensureFleetDefaults } = require('./init.js');
|
|
15
15
|
const { rotateToken } = require('../auth/token.js');
|
|
16
16
|
const urlmod = require('./url.js');
|
|
17
|
-
const { doctor } = require('./doctor.js');
|
|
17
|
+
const { doctor, checkServiceWorkingDirectory } = require('./doctor.js');
|
|
18
18
|
const nodesStore = require('../nodes/store.js');
|
|
19
19
|
const nodesTunnel = require('../nodes/tunnel.js');
|
|
20
20
|
const nodesCmds = require('../nodes/commands.js');
|
|
@@ -346,6 +346,11 @@ function servicePinsLegacyPort(platform, home, installPath) {
|
|
|
346
346
|
try { return /NEXUSCREW_PORT/.test(fs.readFileSync(target, 'utf8')); } catch (_) { return false; }
|
|
347
347
|
}
|
|
348
348
|
|
|
349
|
+
function serviceDefinitionNeedsRefresh(platform, home, installPath) {
|
|
350
|
+
if (servicePinsLegacyPort(platform, home, installPath)) return true;
|
|
351
|
+
return !checkServiceWorkingDirectory(platform, home, installPath).ok;
|
|
352
|
+
}
|
|
353
|
+
|
|
349
354
|
function portAvailable(port, host = '127.0.0.1') {
|
|
350
355
|
return new Promise((resolve) => {
|
|
351
356
|
const s = net.createServer();
|
|
@@ -465,8 +470,19 @@ async function smartUp(opts = {}) {
|
|
|
465
470
|
// config.json forever. Regenerate once so config.json becomes authoritative.
|
|
466
471
|
const home = opts.home || require('node:os').homedir();
|
|
467
472
|
const persistent = bootState({ ...opts, platform }).enabled;
|
|
468
|
-
|
|
473
|
+
let serviceRefreshed = false;
|
|
474
|
+
if (persistent && serviceDefinitionNeedsRefresh(platform, home, opts.installPath)) {
|
|
469
475
|
runInitImpl({ ...opts, log: quiet, platform, installBoot: true, printUrl: false });
|
|
476
|
+
serviceRefreshed = checkServiceWorkingDirectory(platform, home, opts.installPath).ok;
|
|
477
|
+
// Termux:Boot has no service manager, so replacing the script alone cannot
|
|
478
|
+
// change the cwd of the already-running pidfile owner. Restart exactly once
|
|
479
|
+
// after a verified migration; Linux/macOS are restarted by installService.
|
|
480
|
+
if (serviceRefreshed && platform === 'termux') {
|
|
481
|
+
const migrated = restartImpl({ ...opts, platform, log: quiet });
|
|
482
|
+
if (!migrated || migrated.restarted !== true) {
|
|
483
|
+
throw new Error(`service cwd migration restart failed: ${(migrated && migrated.reason) || 'unknown'}`);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
470
486
|
}
|
|
471
487
|
|
|
472
488
|
if (!port) port = urlmod.loadPort(opts);
|
|
@@ -972,6 +988,7 @@ function dispatch(argv, opts = {}) {
|
|
|
972
988
|
module.exports = {
|
|
973
989
|
dispatch, dispatchNodes, serve, start, stop, status, parseFlags, HELP, NODES_HELP,
|
|
974
990
|
smartUp, url, tokenRotate, logs, doctor, update, restart,
|
|
991
|
+
serviceDefinitionNeedsRefresh,
|
|
975
992
|
portAvailable, findAvailablePort, probeNexusCrew, waitForNexusCrew, openPwa, startPortable, wizardComplete,
|
|
976
993
|
servicePinsLegacyPort,
|
|
977
994
|
isServiceRunning, readRoles,
|
package/lib/cli/doctor.js
CHANGED
|
@@ -14,6 +14,9 @@ const { installPath } = require('./service.js');
|
|
|
14
14
|
const { resolvePaths } = require('./url.js');
|
|
15
15
|
const { commandExists } = require('./path.js');
|
|
16
16
|
const { loadDefinitions } = require('../fleet/definitions.js');
|
|
17
|
+
const {
|
|
18
|
+
termuxRuntimePaths, trustedTermuxPreload, TERMUX_EXEC_BASENAME_RE,
|
|
19
|
+
} = require('../runtime/env.js');
|
|
17
20
|
|
|
18
21
|
function nodeMajor() {
|
|
19
22
|
return parseInt(String(process.versions.node).split('.')[0], 10);
|
|
@@ -64,6 +67,65 @@ function checkService(platform, home, execImpl, uidVal, installPathOverride) {
|
|
|
64
67
|
};
|
|
65
68
|
}
|
|
66
69
|
|
|
70
|
+
function decodeXmlText(value) {
|
|
71
|
+
return String(value)
|
|
72
|
+
.replace(/"/g, '"')
|
|
73
|
+
.replace(/>/g, '>')
|
|
74
|
+
.replace(/</g, '<')
|
|
75
|
+
.replace(/&/g, '&');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function checkMacServiceWorkingDirectory(platform, home, installPathOverride) {
|
|
79
|
+
if (platform !== 'mac') {
|
|
80
|
+
return { name: 'launchd cwd stabile', ok: true, detail: `${platform}: non applicabile` };
|
|
81
|
+
}
|
|
82
|
+
return checkServiceWorkingDirectory(platform, home, installPathOverride);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The service cwd is inherited by a shared tmux server and every future pane.
|
|
86
|
+
// It must therefore be HOME, never the replaceable runtime directory. This
|
|
87
|
+
// check reads only the installed definition and fails closed on missing,
|
|
88
|
+
// symlinked, malformed, or legacy service files.
|
|
89
|
+
function checkServiceWorkingDirectory(platform, home, installPathOverride) {
|
|
90
|
+
if (!['linux', 'mac', 'termux'].includes(platform)) {
|
|
91
|
+
return { name: 'service cwd stabile', ok: true, detail: `${platform}: non applicabile` };
|
|
92
|
+
}
|
|
93
|
+
const target = installPathOverride || installPath(platform, home);
|
|
94
|
+
let raw;
|
|
95
|
+
try {
|
|
96
|
+
const st = fs.lstatSync(target);
|
|
97
|
+
if (st.isSymbolicLink() || !st.isFile()) throw Object.assign(new Error('definizione service non regolare'), { code: 'UNSAFE' });
|
|
98
|
+
raw = fs.readFileSync(target, 'utf8');
|
|
99
|
+
}
|
|
100
|
+
catch (error) {
|
|
101
|
+
return {
|
|
102
|
+
name: 'service cwd stabile', ok: false,
|
|
103
|
+
detail: error.code === 'ENOENT' ? `service non installato (${target})` : error.message,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
let actual = '';
|
|
107
|
+
let ok = false;
|
|
108
|
+
if (platform === 'mac') {
|
|
109
|
+
const match = raw.match(/<key>WorkingDirectory<\/key>\s*<string>([\s\S]*?)<\/string>/);
|
|
110
|
+
if (!match) return { name: 'service cwd stabile', ok: false, detail: 'WorkingDirectory assente dal plist' };
|
|
111
|
+
actual = decodeXmlText(match[1]);
|
|
112
|
+
ok = actual === String(home);
|
|
113
|
+
} else if (platform === 'linux') {
|
|
114
|
+
const match = raw.match(/^WorkingDirectory=(.+)$/m);
|
|
115
|
+
if (!match) return { name: 'service cwd stabile', ok: false, detail: 'WorkingDirectory assente dalla unit' };
|
|
116
|
+
actual = match[1].replace(/%%/g, '%');
|
|
117
|
+
ok = actual === String(home);
|
|
118
|
+
} else {
|
|
119
|
+
const stable = /^\s*cd -- "\$HOME"\s*$/m.test(raw);
|
|
120
|
+
actual = stable ? '$HOME' : (/^\s*cd\s+--\s+(.+)$/m.exec(raw)?.[1] || 'cd assente');
|
|
121
|
+
ok = stable;
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
name: 'service cwd stabile', ok,
|
|
125
|
+
detail: ok ? (platform === 'termux' ? '$HOME' : String(home)) : `${actual} (atteso HOME stabile)`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
67
129
|
function checkBoot(platform, home, execImpl) {
|
|
68
130
|
if (platform === 'termux') {
|
|
69
131
|
const p = path.join(home, '.termux', 'boot', 'nexuscrew.sh');
|
|
@@ -186,6 +248,102 @@ function checkTokenPerms(tokenPath) {
|
|
|
186
248
|
}
|
|
187
249
|
}
|
|
188
250
|
|
|
251
|
+
// Presence-only Termux preload check. On the Google Play build of Termux
|
|
252
|
+
// (targetSdk >= 29, SELinux `untrusted_app` domain) every command pane spawned
|
|
253
|
+
// by the shared tmux server dies at execve() unless libtermux-exec is
|
|
254
|
+
// preloaded. The validated preload is preserved by minimalRuntimeEnv; this
|
|
255
|
+
// check tells the user, BEFORE launching a cell, whether the trusted library
|
|
256
|
+
// exists under PREFIX/lib and whether the current process carries a trusted
|
|
257
|
+
// LD_PRELOAD. It is strictly read-only: no tmux socket is touched, no service
|
|
258
|
+
// or device state is mutated, no command is spawned.
|
|
259
|
+
function checkTermuxExec(runtimeEnv, opts = {}) {
|
|
260
|
+
const env = runtimeEnv || process.env;
|
|
261
|
+
const platform = opts.platform || detectPlatform();
|
|
262
|
+
const termux = termuxRuntimePaths(env, { platform, home: opts.home });
|
|
263
|
+
if (!termux) {
|
|
264
|
+
return { name: 'termux-exec preload', ok: true, detail: `${platform}: non applicabile` };
|
|
265
|
+
}
|
|
266
|
+
const trusted = trustedTermuxPreload(env, { platform, home: opts.home });
|
|
267
|
+
const libDir = path.join(termux.prefix, 'lib');
|
|
268
|
+
let present = '';
|
|
269
|
+
let candidates = [];
|
|
270
|
+
try {
|
|
271
|
+
candidates = fs.readdirSync(libDir).filter((name) => TERMUX_EXEC_BASENAME_RE.test(name)).sort();
|
|
272
|
+
} catch (_) { /* absent/unreadable */ }
|
|
273
|
+
for (const name of candidates) {
|
|
274
|
+
const candidate = path.join(libDir, name);
|
|
275
|
+
try { if (fs.statSync(candidate).isFile()) { present = candidate; break; } } catch (_) { /* next */ }
|
|
276
|
+
}
|
|
277
|
+
if (trusted) {
|
|
278
|
+
return { name: 'termux-exec preload', ok: true, detail: `preload trusted: ${path.basename(trusted)}` };
|
|
279
|
+
}
|
|
280
|
+
if (present) {
|
|
281
|
+
return {
|
|
282
|
+
name: 'termux-exec preload', ok: true, warn: true,
|
|
283
|
+
detail: `libreria presente (${path.basename(present)}) ma LD_PRELOAD non valido nell'env del doctor: avvia il servizio da una shell Termux di login o via termux-exec preload`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
return {
|
|
287
|
+
name: 'termux-exec preload', ok: false,
|
|
288
|
+
detail: 'libtermux-exec non trovata sotto PREFIX/lib: sulla build Google Play celle e shell non possono eseguire comandi',
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Read-only probe of the long-lived tmux server cwd. A server that retained an
|
|
293
|
+
// unlinked runtime directory keeps accepting clients but makes later children
|
|
294
|
+
// fail getcwd(3). Never reports the path itself; only stable state.
|
|
295
|
+
function checkTmuxServerCwd(platform, execImpl, opts = {}) {
|
|
296
|
+
if (!['linux', 'termux'].includes(platform)) {
|
|
297
|
+
return { name: 'tmux server cwd', ok: true, detail: `${platform}: non applicabile` };
|
|
298
|
+
}
|
|
299
|
+
let rawPid = '';
|
|
300
|
+
try {
|
|
301
|
+
rawPid = String(execImpl(opts.tmuxBin || 'tmux', ['display-message', '-p', '#{pid}'], { encoding: 'utf8' }) || '').trim();
|
|
302
|
+
} catch (_) {
|
|
303
|
+
return { name: 'tmux server cwd', ok: true, warn: true, detail: 'server tmux non attivo; probe rinviato al primo avvio' };
|
|
304
|
+
}
|
|
305
|
+
if (!/^\d+$/.test(rawPid)) {
|
|
306
|
+
return { name: 'tmux server cwd', ok: true, warn: true, detail: 'server tmux non rilevato' };
|
|
307
|
+
}
|
|
308
|
+
try {
|
|
309
|
+
const resolveImpl = opts.procCwdImpl || ((pid) => fs.realpathSync(`/proc/${pid}/cwd`));
|
|
310
|
+
const cwd = resolveImpl(Number(rawPid));
|
|
311
|
+
if (typeof cwd !== 'string' || !cwd) throw new Error('cwd vuota');
|
|
312
|
+
return { name: 'tmux server cwd', ok: true, detail: 'cwd risolvibile' };
|
|
313
|
+
} catch (_) {
|
|
314
|
+
return {
|
|
315
|
+
name: 'tmux server cwd', ok: false,
|
|
316
|
+
detail: 'cwd del server tmux non risolvibile (directory sostituita); termina le sessioni in modo esplicito e riavvia tmux',
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Inspect only the single tmux global environment key required by
|
|
322
|
+
// termux-exec. The value is validated and then discarded; it is never logged.
|
|
323
|
+
// This distinguishes a healthy server from RC-2 (stale/no preload) and RC-7
|
|
324
|
+
// (present but rejected by the trust boundary) without killing any session.
|
|
325
|
+
function checkTmuxServerTermuxPreload(runtimeEnv, execImpl, opts = {}) {
|
|
326
|
+
const env = runtimeEnv || process.env;
|
|
327
|
+
const platform = opts.platform || detectPlatform();
|
|
328
|
+
if (!termuxRuntimePaths(env, { platform, home: opts.home })) {
|
|
329
|
+
return { name: 'tmux server termux-exec', ok: true, detail: `${platform}: non applicabile` };
|
|
330
|
+
}
|
|
331
|
+
let raw = '';
|
|
332
|
+
try {
|
|
333
|
+
raw = String(execImpl(opts.tmuxBin || 'tmux', ['show-environment', '-g', 'LD_PRELOAD'], { encoding: 'utf8' }) || '').trim();
|
|
334
|
+
} catch (_) {
|
|
335
|
+
return { name: 'tmux server termux-exec', ok: true, warn: true, detail: 'server tmux non attivo; probe rinviato al primo avvio' };
|
|
336
|
+
}
|
|
337
|
+
const match = raw.match(/^LD_PRELOAD=(.+)$/);
|
|
338
|
+
const trusted = match && trustedTermuxPreload({ ...env, LD_PRELOAD: match[1] }, { platform, home: opts.home });
|
|
339
|
+
return trusted
|
|
340
|
+
? { name: 'tmux server termux-exec', ok: true, detail: 'preload trusted presente nel server tmux' }
|
|
341
|
+
: {
|
|
342
|
+
name: 'tmux server termux-exec', ok: false,
|
|
343
|
+
detail: 'LD_PRELOAD assente o non trusted nel server tmux; server stale o formato non compatibile (nessun kill automatico)',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
189
347
|
// Check MCP identity (P0). NON-FAILING per costrutto: `ok` è sempre true, così
|
|
190
348
|
// chi usa NexusCrew solo come PWA (nessuna integrazione MCP) non vede mai il
|
|
191
349
|
// doctor andare in FAIL solo per env MCP assente. Il check osserva SOLO la
|
|
@@ -266,15 +424,19 @@ function doctor(opts = {}) {
|
|
|
266
424
|
checkTmux(existsImpl, opts.tmuxBin),
|
|
267
425
|
checkPty(ptyLoad),
|
|
268
426
|
checkService(platform, home, execImpl, uidVal, opts.installPath),
|
|
427
|
+
checkServiceWorkingDirectory(platform, home, opts.installPath),
|
|
269
428
|
checkBoot(platform, home, execImpl),
|
|
270
429
|
checkUserLinger(platform, execImpl, uidVal),
|
|
271
430
|
checkTmuxSurvival(platform, execImpl),
|
|
272
431
|
checkTokenPerms(tokenPath),
|
|
273
432
|
checkFleetDefinitions(home, opts.fleetDefsPath, fleetEnabled),
|
|
433
|
+
checkTermuxExec(opts.env, { platform, home }),
|
|
274
434
|
checkSshClient(existsImpl),
|
|
275
435
|
checkAutossh(existsImpl),
|
|
276
436
|
checkSshPermitlisten(opts.sshVersion),
|
|
277
437
|
checkMcpIdentity(opts.env),
|
|
438
|
+
checkTmuxServerCwd(platform, execImpl, { tmuxBin: opts.tmuxBin, procCwdImpl: opts.procCwdImpl }),
|
|
439
|
+
checkTmuxServerTermuxPreload(opts.env, execImpl, { platform, home, tmuxBin: opts.tmuxBin }),
|
|
278
440
|
];
|
|
279
441
|
|
|
280
442
|
for (const c of checks) {
|
|
@@ -289,8 +451,11 @@ function doctor(opts = {}) {
|
|
|
289
451
|
module.exports = {
|
|
290
452
|
doctor, nodeMajor,
|
|
291
453
|
checkNode, checkTmux, checkPty, checkService, checkBoot, checkTokenPerms,
|
|
454
|
+
checkMacServiceWorkingDirectory,
|
|
292
455
|
checkFleetDefinitions,
|
|
456
|
+
checkServiceWorkingDirectory,
|
|
293
457
|
checkTmuxSurvival, checkUserLinger,
|
|
294
458
|
checkSshClient, checkAutossh, checkSshPermitlisten,
|
|
295
|
-
checkMcpIdentity,
|
|
459
|
+
checkMcpIdentity, checkTermuxExec,
|
|
460
|
+
checkTmuxServerCwd, checkTmuxServerTermuxPreload,
|
|
296
461
|
};
|
package/lib/cli/service.js
CHANGED
|
@@ -59,9 +59,12 @@ function generateService(platform, ctx) {
|
|
|
59
59
|
function generateLinux(ctx) {
|
|
60
60
|
assertSystemdSafe('repoRoot', ctx.repoRoot); // [M3] reject char non gestibili
|
|
61
61
|
assertSystemdSafe('nodeBin', ctx.nodeBin);
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
// HOME is stable across init/repair/update. The runtime directory may be
|
|
63
|
+
// atomically replaced; keeping it as the service cwd leaves the HTTP process
|
|
64
|
+
// and a shared tmux server holding an orphaned inode, so every later pane can
|
|
65
|
+
// fail getcwd(3). Runtime state still lives in runtimeDir; only cwd changes.
|
|
66
|
+
assertSystemdSafe('home', ctx.home);
|
|
67
|
+
const runtime = escapeSystemdPath(ctx.home);
|
|
65
68
|
const node = escapeSystemdExec(ctx.nodeBin);
|
|
66
69
|
const repoBin = escapeSystemdExec(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
|
|
67
70
|
const nodeDir = escapeSystemdPath(path.dirname(ctx.nodeBin));
|
|
@@ -90,7 +93,9 @@ WantedBy=default.target
|
|
|
90
93
|
function generateMac(ctx) {
|
|
91
94
|
const nodeXml = escapeXml(ctx.nodeBin);
|
|
92
95
|
const repoBinXml = escapeXml(path.join(ctx.repoRoot, 'bin', 'nexuscrew.js'));
|
|
93
|
-
|
|
96
|
+
// HOME remains stable when init or repair atomically replaces the runtime
|
|
97
|
+
// directory; launchd must not retain an unlinked runtime-directory cwd.
|
|
98
|
+
const runtimeXml = escapeXml(ctx.home);
|
|
94
99
|
const homeXml = escapeXml(ctx.home);
|
|
95
100
|
const launchPath = [...new Set([
|
|
96
101
|
path.dirname(ctx.nodeBin), '/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin',
|
|
@@ -149,7 +154,7 @@ export LANG=\${LANG:-en_US.UTF-8}
|
|
|
149
154
|
export LC_CTYPE=\${LC_CTYPE:-en_US.UTF-8}
|
|
150
155
|
termux-wake-lock 2>/dev/null || true
|
|
151
156
|
mkdir -p "$HOME/.nexuscrew" "$TMPDIR" "$TMUX_TMPDIR"
|
|
152
|
-
cd -- "$HOME
|
|
157
|
+
cd -- "$HOME"
|
|
153
158
|
exec ${nodeQ} ${repoBinQ} serve --pidfile >> "$HOME/.nexuscrew/nexuscrew.log" 2>&1
|
|
154
159
|
`;
|
|
155
160
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const express = require('express');
|
|
3
|
+
|
|
4
|
+
function diagnosticsRoutes({ diagnostics, readonly = () => false } = {}) {
|
|
5
|
+
if (!diagnostics) throw new Error('diagnostics store required');
|
|
6
|
+
const router = express.Router();
|
|
7
|
+
router.use(express.json({ limit: '2kb' }));
|
|
8
|
+
const mutGate = (_req, res, next) => {
|
|
9
|
+
if (readonly()) return res.status(403).json({ error: 'READONLY: mutazione diagnostica bloccata' });
|
|
10
|
+
next();
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
router.get('/status', (_req, res) => res.json(diagnostics.status()));
|
|
14
|
+
router.get('/logs', (req, res) => {
|
|
15
|
+
try {
|
|
16
|
+
const keys = Object.keys(req.query || {});
|
|
17
|
+
if (keys.some((key) => !['after', 'limit'].includes(key))) return res.status(400).json({ error: 'query non valida' });
|
|
18
|
+
const after = req.query.after === undefined ? 0 : Number(req.query.after);
|
|
19
|
+
const limit = req.query.limit === undefined ? 200 : Number(req.query.limit);
|
|
20
|
+
res.json(diagnostics.logs({ after, limit }));
|
|
21
|
+
} catch (error) { res.status(400).json({ error: String(error.message || error) }); }
|
|
22
|
+
});
|
|
23
|
+
router.patch('/verbose', mutGate, (req, res) => {
|
|
24
|
+
try {
|
|
25
|
+
const body = req.body;
|
|
26
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)
|
|
27
|
+
|| Object.keys(body).some((key) => !['enabled', 'durationSeconds'].includes(key))) {
|
|
28
|
+
return res.status(400).json({ error: 'body non valido' });
|
|
29
|
+
}
|
|
30
|
+
res.json(diagnostics.setVerbose(body.enabled, body.durationSeconds === undefined ? 900 : body.durationSeconds));
|
|
31
|
+
} catch (error) { res.status(400).json({ error: String(error.message || error) }); }
|
|
32
|
+
});
|
|
33
|
+
router.delete('/logs', mutGate, (_req, res) => res.json(diagnostics.clear()));
|
|
34
|
+
|
|
35
|
+
router.use((error, _req, res, _next) => {
|
|
36
|
+
if (error && error.type === 'entity.too.large') return res.status(413).json({ error: 'body troppo grande' });
|
|
37
|
+
if (error instanceof SyntaxError) return res.status(400).json({ error: 'JSON non valido' });
|
|
38
|
+
return res.status(400).json({ error: String(error.message || error) });
|
|
39
|
+
});
|
|
40
|
+
return router;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { diagnosticsRoutes };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_MAX_RECORDS = 500;
|
|
4
|
+
const DEFAULT_MAX_BYTES = 256 * 1024;
|
|
5
|
+
const DEFAULT_MAX_ENTRY_BYTES = 4096;
|
|
6
|
+
const ALLOWED_DURATIONS = new Set([300, 900, 1800, 3600]);
|
|
7
|
+
const LEVELS = new Set(['debug', 'info', 'warn', 'error']);
|
|
8
|
+
const META_KEYS = new Set([
|
|
9
|
+
'port', 'platform', 'reason', 'durationSeconds', 'count', 'errno', 'client',
|
|
10
|
+
'cell', 'engine', 'action', 'state', 'transport', 'phase', 'version', 'status', 'node', 'code',
|
|
11
|
+
]);
|
|
12
|
+
const DENIED_KEY = /(authorization|cookie|token|secret|credential|password|prompt|terminal|argv|command|env|content|payload|file|path|endpoint|url)/i;
|
|
13
|
+
|
|
14
|
+
function redactText(value, max = 160) {
|
|
15
|
+
let text = String(value == null ? '' : value).normalize('NFC');
|
|
16
|
+
text = text.replace(/[\p{Cc}\p{Cf}]/gu, ' ').replace(/\s+/g, ' ').trim();
|
|
17
|
+
text = text
|
|
18
|
+
.replace(/\bBearer\s+\S+/gi, 'Bearer [redacted]')
|
|
19
|
+
.replace(/\b(?:sk|xox[baprs]|gh[pousr]|npm)_[A-Za-z0-9_-]{8,}\b/g, '[redacted]')
|
|
20
|
+
.replace(/\b(?:token|password|secret|api[_-]?key)\s*[=:]\s*\S+/gi, '[redacted]')
|
|
21
|
+
.replace(/(?:\/home\/[^/\s]+|\/data\/(?:data|user\/\d+)\/[^/\s]+)(?:\/[^\s]*)?/g, '[path]')
|
|
22
|
+
.replace(/\b[A-Z][A-Z0-9_]{2,}\s*=\s*\S+/g, '[env]');
|
|
23
|
+
return text.slice(0, max);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sanitizeMeta(meta) {
|
|
27
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {};
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
30
|
+
if (!META_KEYS.has(key) || DENIED_KEY.test(key)) continue;
|
|
31
|
+
if (typeof value === 'boolean') out[key] = value;
|
|
32
|
+
else if (typeof value === 'number' && Number.isFinite(value)) out[key] = value;
|
|
33
|
+
else if (typeof value === 'string') out[key] = redactText(value, 96);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function createDiagnostics(opts = {}) {
|
|
39
|
+
const now = typeof opts.now === 'function' ? opts.now : Date.now;
|
|
40
|
+
const maxRecords = opts.maxRecords || DEFAULT_MAX_RECORDS;
|
|
41
|
+
const maxBytes = opts.maxBytes || DEFAULT_MAX_BYTES;
|
|
42
|
+
const maxEntryBytes = opts.maxEntryBytes || DEFAULT_MAX_ENTRY_BYTES;
|
|
43
|
+
let entries = [];
|
|
44
|
+
let bytes = 0;
|
|
45
|
+
let seq = 0;
|
|
46
|
+
let enabledUntil = 0;
|
|
47
|
+
let expiryRecorded = false;
|
|
48
|
+
|
|
49
|
+
function push(level, component, code, message, meta = {}) {
|
|
50
|
+
const record = {
|
|
51
|
+
seq: ++seq,
|
|
52
|
+
ts: new Date(now()).toISOString(),
|
|
53
|
+
level,
|
|
54
|
+
component: redactText(component, 48) || 'runtime',
|
|
55
|
+
code: /^[A-Z][A-Z0-9_]{0,63}$/.test(String(code || '')) ? String(code) : 'DIAGNOSTIC_EVENT',
|
|
56
|
+
message: redactText(message, 240) || 'Diagnostic event',
|
|
57
|
+
meta: sanitizeMeta(meta),
|
|
58
|
+
};
|
|
59
|
+
let size = Buffer.byteLength(JSON.stringify(record));
|
|
60
|
+
if (size > maxEntryBytes) {
|
|
61
|
+
record.meta = {};
|
|
62
|
+
record.message = record.message.slice(0, 96);
|
|
63
|
+
size = Buffer.byteLength(JSON.stringify(record));
|
|
64
|
+
}
|
|
65
|
+
if (size > maxEntryBytes) return null;
|
|
66
|
+
entries.push({ record, size }); bytes += size;
|
|
67
|
+
while (entries.length > maxRecords || bytes > maxBytes) {
|
|
68
|
+
const removed = entries.shift(); bytes -= removed.size;
|
|
69
|
+
}
|
|
70
|
+
return record;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function refreshExpiry() {
|
|
74
|
+
if (enabledUntil > 0 && now() >= enabledUntil) {
|
|
75
|
+
enabledUntil = 0;
|
|
76
|
+
if (!expiryRecorded) {
|
|
77
|
+
expiryRecorded = true;
|
|
78
|
+
push('info', 'diagnostics', 'VERBOSE_EXPIRED', 'Verbose diagnostics expired', { reason: 'timeout' });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function record(level, component, code, message, meta = {}) {
|
|
84
|
+
if (!LEVELS.has(level)) return null;
|
|
85
|
+
refreshExpiry();
|
|
86
|
+
if ((level === 'debug' || level === 'info') && enabledUntil === 0) return null;
|
|
87
|
+
return push(level, component, code, message, meta);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function setVerbose(enabled, durationSeconds = 900) {
|
|
91
|
+
if (typeof enabled !== 'boolean') throw new Error('enabled deve essere boolean');
|
|
92
|
+
if (enabled) {
|
|
93
|
+
if (!ALLOWED_DURATIONS.has(durationSeconds)) throw new Error('durationSeconds deve essere 300, 900, 1800 o 3600');
|
|
94
|
+
enabledUntil = now() + durationSeconds * 1000;
|
|
95
|
+
expiryRecorded = false;
|
|
96
|
+
push('info', 'diagnostics', 'VERBOSE_ENABLED', 'Verbose diagnostics enabled', { durationSeconds });
|
|
97
|
+
} else {
|
|
98
|
+
enabledUntil = 0;
|
|
99
|
+
expiryRecorded = true;
|
|
100
|
+
push('info', 'diagnostics', 'VERBOSE_DISABLED', 'Verbose diagnostics disabled', { reason: 'manual' });
|
|
101
|
+
}
|
|
102
|
+
return status();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function clear() {
|
|
106
|
+
refreshExpiry();
|
|
107
|
+
const count = entries.length;
|
|
108
|
+
entries = []; bytes = 0;
|
|
109
|
+
push('info', 'diagnostics', 'LOGS_CLEARED', 'Diagnostic buffer cleared', { count });
|
|
110
|
+
return { cleared: count, ...status() };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function status() {
|
|
114
|
+
refreshExpiry();
|
|
115
|
+
return {
|
|
116
|
+
verbose: enabledUntil > 0,
|
|
117
|
+
expiresAt: enabledUntil > 0 ? new Date(enabledUntil).toISOString() : null,
|
|
118
|
+
nextSeq: seq + 1,
|
|
119
|
+
retained: entries.length,
|
|
120
|
+
bytes,
|
|
121
|
+
limits: { records: maxRecords, bytes: maxBytes, entryBytes: maxEntryBytes },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function logs({ after = 0, limit = 200 } = {}) {
|
|
126
|
+
refreshExpiry();
|
|
127
|
+
if (!Number.isSafeInteger(after) || after < 0) throw new Error('after non valido');
|
|
128
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 200) throw new Error('limit non valido (1..200)');
|
|
129
|
+
const records = entries.map((item) => item.record).filter((entry) => entry.seq > after).slice(0, limit);
|
|
130
|
+
return { records, cursor: records.length ? records[records.length - 1].seq : after, ...status() };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { record, setVerbose, clear, status, logs };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = {
|
|
137
|
+
DEFAULT_MAX_RECORDS, DEFAULT_MAX_BYTES, DEFAULT_MAX_ENTRY_BYTES,
|
|
138
|
+
ALLOWED_DURATIONS, redactText, sanitizeMeta, createDiagnostics,
|
|
139
|
+
};
|