Adds the server/app half of a remote-diagnostics system so Claude (via a local MCP gateway, added separately) can read a server's full state to diagnose problems: logs, errors, queue/app state, redacted config, system health. All read-only; security review folded in as hard requirements. Architecture (design-verified): - A SECOND, independent RemoteServer instance (diagnosticMode) on its own port (default 9110, bind 127.0.0.1) with NO capture/input callbacks, so screen capture and sendInputEvent are structurally unreachable from the diagnostic path. The existing screen-share server (port 9100) is byte-for-byte unchanged. - lib/remote-server.js: a `host` bind option, a post-auth `diag-request` branch that delegates to onDiagnosticRequest and replies with a reqId-correlated `diag-response`, a `diagnosticMode` guard (capture never spawns), a timing-safe token compare (length-guarded), and getLastAccess(). - lib/diagnostics-agent.js: handle(op,args) enforces a HARDCODED read-only op whitelist as the sole authority — no write/exec op, no run_health_check. - lib/diagnostics-collectors.js: pure, dependency-injected collectors (server_health one-shot hub, read_log, list_errors, get_queue_state, get_history, get_config_redacted, get_system_info, list_logs, get_app_events, get_rotation_state, get_health) reusing support-bundle + stats. Security (all mandatory, implemented): - Redaction gates every off-box payload. support-bundle now adds webhookUrl + diagToken to CRED_KEYS, and exports redactLogText (value-scrub of live secret strings + pattern-scrub of Discord webhooks / Bearer / api_key= / cookies / sess ids) + valueScrub + collectSecretValues. read_log takes a logical NAME (no path traversal); doodstream-debug.log is excluded from the readable set (it logs live api-key-bearing HTML). grep is length-capped (ReDoS guard). - diagnostics config subtree (enabled/port/token/label/codeIssuedAt/bindAddress) defaults OFF, bind 127.0.0.1; deep-merge makes it migration-free. The server-owned token is preserved against renderer clobber in both save-global-settings handlers; the diagnostics:* IPC is the only mutator. - app.requestSingleInstanceLock() (also the approved fix #6) so a relaunch can't EADDRINUSE-kill the agent and two instances can't clobber the config. main.js: buildDiagnosticCode (mhu1_ base64url, no host embedded), start/stop + auto-start-on-launch + stop-on-quit, the four diagnostics:* IPC handlers. renderer: a "Diagnose-Zugriff" settings subtab (toggle, port, bind-address, copyable connection code + regenerate, status, read-only security note). 382 tests pass (incl. 11 new: collectors+redaction, agent whitelist, and a live RemoteServer protocol test proving auth->diag-response correlation, that a diagnostic client never triggers the capture window, and brute-force lockout). Lint clean (0 errors). Smoke boots identically to baseline. The standalone MCP gateway package + operator setup docs land in a follow-up commit. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
88 lines
4.6 KiB
JavaScript
88 lines
4.6 KiB
JavaScript
const { test } = require('node:test');
|
|
const assert = require('node:assert');
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const support = require('../lib/support-bundle');
|
|
const stats = require('../lib/stats');
|
|
const { createCollectors } = require('../lib/diagnostics-collectors');
|
|
const { createAgent } = require('../lib/diagnostics-agent');
|
|
|
|
function makeFixture() {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-diag-'));
|
|
const paths = {
|
|
fileuploader: path.join(dir, 'fileuploader.log'),
|
|
debug: path.join(dir, 'debug.log'),
|
|
accountRotation: path.join(dir, 'account-rotation.log'),
|
|
doodstreamDebug: path.join(dir, 'doodstream-debug.log'),
|
|
crashLog: path.join(dir, 'crash.log'),
|
|
logDir: dir
|
|
};
|
|
fs.writeFileSync(paths.debug, 'boot ok\nuploading file with token SECRETTOKEN123456 inline\nAuthorization: Bearer abcdef123456\n');
|
|
fs.writeFileSync(paths.doodstreamDebug, 'api_key=LIVEKEY99999 sess=abc\n');
|
|
fs.writeFileSync(paths.crashLog, 'CRASH at 12:00\n');
|
|
const config = {
|
|
hosters: { 'voe.sx': [{ id: 'a1', username: 'u', password: 'HUNTER2SECRET' }], 'byse.sx': [{ id: 'b1', apiKey: 'BYSEKEY1234567' }] },
|
|
hosterSettings: {},
|
|
globalSettings: {
|
|
webhookUrl: 'https://discord.com/api/webhooks/12345/WBHOOKSECRETTOKEN',
|
|
diagnostics: { enabled: true, port: 9110, token: 'SECRETTOKEN123456', bindAddress: '127.0.0.1' },
|
|
pendingQueue: { savedAt: 1, selectedUploadHosters: ['voe.sx'], selectedFiles: [{ path: 'C:/a.mkv' }], queueJobs: [{ file: 'C:/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'error', error: 'timeout' }] }
|
|
},
|
|
history: [{ timestamp: new Date(2026, 0, 1).toISOString(), files: [{ name: 'x.mkv', results: [{ hoster: 'voe.sx', status: 'error', error: 'Not video file format' }, { hoster: 'byse.sx', status: 'done', url: 'https://byse.sx/x' }] }] }],
|
|
rotationCursors: { 'voe.sx': 1 }
|
|
};
|
|
const collectors = createCollectors({
|
|
loadConfig: () => JSON.parse(JSON.stringify(config)),
|
|
getAllLogPaths: () => paths,
|
|
support, stats,
|
|
appInfo: () => ({ name: 'mhu', version: '9.9.9' }),
|
|
systemInfo: () => ({ platform: 'win32', hostname: 'srv' }),
|
|
agentInfo: () => ({ version: '9.9.9', port: 9110, clientCount: 0, lastAccess: null })
|
|
});
|
|
return { dir, paths, config, collectors };
|
|
}
|
|
|
|
test('getConfigRedacted strips password/apiKey/token/webhookUrl and value-scrubs the token mid-string', () => {
|
|
const { collectors } = makeFixture();
|
|
const out = collectors.getConfigRedacted({ section: 'all' });
|
|
const json = JSON.stringify(out);
|
|
assert.ok(!json.includes('HUNTER2SECRET'), 'password must be redacted');
|
|
assert.ok(!json.includes('BYSEKEY1234567'), 'apiKey must be redacted');
|
|
assert.ok(!json.includes('SECRETTOKEN123456'), 'diag token must be redacted');
|
|
assert.ok(!json.includes('WBHOOKSECRETTOKEN'), 'webhook secret must be redacted');
|
|
});
|
|
|
|
test('readLog redacts a planted token and a Bearer line; doodstream is NOT readable; unknown name rejected', () => {
|
|
const { collectors } = makeFixture();
|
|
const dbg = collectors.readLog({ name: 'debug', tailKb: 64 });
|
|
assert.ok(!dbg.content.includes('SECRETTOKEN123456'), 'value-scrub removes the live diag token from logs');
|
|
assert.ok(!/Bearer abcdef123456/.test(dbg.content), 'pattern-scrub removes Authorization Bearer');
|
|
assert.equal(collectors.readLog({ name: 'doodstreamDebug' }).ok, false, 'doodstream-debug.log is not in the readable allowlist');
|
|
assert.equal(collectors.readLog({ name: '../../etc/passwd' }).ok, false, 'arbitrary names are rejected (no path traversal)');
|
|
assert.equal(collectors.readLog({ name: 'crash' }).name, 'crash');
|
|
});
|
|
|
|
test('getQueueState flags stale=true for the persisted snapshot and counts by status', () => {
|
|
const { collectors } = makeFixture();
|
|
const q = collectors.getQueueState({});
|
|
assert.equal(q.source, 'persisted');
|
|
assert.equal(q.stale, true);
|
|
assert.equal(q.counts.error, 1);
|
|
});
|
|
|
|
test('listErrors classifies via stats.classifyErrorCategory and redacts error text', () => {
|
|
const { collectors } = makeFixture();
|
|
const e = collectors.listErrors({});
|
|
assert.equal(e.total, 1, 'only the non-done result is an error');
|
|
assert.equal(e.byCategory['file-rejected'], 1, '"Not video file format" -> file-rejected');
|
|
});
|
|
|
|
test('serverHealth assembles the one-shot hub without leaking secrets', () => {
|
|
const { collectors } = makeFixture();
|
|
const h = collectors.serverHealth({});
|
|
const json = JSON.stringify(h);
|
|
assert.ok(h.server && h.queue && h.errors && h.logs, 'hub has all sections');
|
|
assert.ok(!json.includes('HUNTER2SECRET') && !json.includes('SECRETTOKEN123456') && !json.includes('WBHOOKSECRETTOKEN'), 'no secret leaks in server_health');
|
|
});
|