feat(diagnostics): read-only remote diagnostics agent over the existing WS transport (app side)

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>
This commit is contained in:
Administrator 2026-06-19 17:24:32 +02:00
parent 26f34b4966
commit ab7313f32c
11 changed files with 894 additions and 10 deletions

View File

@ -100,6 +100,14 @@ const DEFAULTS = {
port: 9100, port: 9100,
token: '', token: '',
allowInput: true allowInput: true
},
diagnostics: {
enabled: false,
port: 9110,
token: '',
label: '',
codeIssuedAt: 0,
bindAddress: '127.0.0.1'
} }
}, },
history: [], history: [],

32
lib/diagnostics-agent.js Normal file
View File

@ -0,0 +1,32 @@
function createAgent(collectors) {
const OPS = {
get_system_info: (a) => collectors.getSystemInfo(a),
server_health: (a) => collectors.serverHealth(a),
get_config_redacted: (a) => collectors.getConfigRedacted(a),
list_logs: () => collectors.listLogs(),
read_log: (a) => collectors.readLog(a),
tail_log: (a) => collectors.readLog(a),
get_app_events: (a) => collectors.getAppEvents(a),
list_errors: (a) => collectors.listErrors(a),
get_queue_state: (a) => collectors.getQueueState(a),
get_history: (a) => collectors.getHistory(a),
get_rotation_state: () => collectors.getRotationState(),
get_health: () => collectors.getHealth()
};
function handle(op, args) {
const fn = OPS[op];
if (typeof fn !== 'function') return { ok: false, error: `unknown or non-readonly op: ${op}` };
try {
const data = fn(args || {});
if (data && data.ok === false) return data;
return { ok: true, data };
} catch (e) {
return { ok: false, error: String((e && e.message) || e) };
}
}
return { handle, ops: Object.keys(OPS) };
}
module.exports = { createAgent };

View File

@ -0,0 +1,256 @@
const fs = require('fs');
const path = require('path');
const READABLE_LOGS = {
debug: 'debug',
fileuploader: 'fileuploader',
accountRotation: 'accountRotation',
crash: 'crashLog'
};
const QUEUE_STATUSES = ['preview', 'queued', 'getting-server', 'uploading', 'retrying', 'done', 'error', 'aborted', 'skipped'];
function createCollectors(deps) {
const { loadConfig, getAllLogPaths, support, stats, appInfo, systemInfo, agentInfo } = deps;
function _secrets() {
try { return support.collectSecretValues(loadConfig()); } catch { return []; }
}
function _scrub(value, secrets) {
try { return support.valueScrub(value, secrets || _secrets()); } catch { return value; }
}
function _resolveLogPath(name, backup) {
const key = READABLE_LOGS[name];
if (!key) return null;
const paths = getAllLogPaths();
let p = paths[key];
if (!p) return null;
if (backup === 1 || backup === 2) p = `${p}.${backup}`;
return p;
}
function getSystemInfo() {
return { app: appInfo(), system: systemInfo(), agent: agentInfo() };
}
function getConfigRedacted(args) {
const section = (args && args.section) || 'all';
const cfg = loadConfig();
const secrets = support.collectSecretValues(cfg);
const sanitized = support.sanitizeConfig(cfg);
let pick = sanitized;
if (section !== 'all') pick = sanitized[section] !== undefined ? sanitized[section] : null;
return { section, config: _scrub(pick, secrets) };
}
function listLogs() {
const paths = getAllLogPaths();
const dir = paths.logDir;
const files = [];
for (const [name, key] of Object.entries(READABLE_LOGS)) {
const base = paths[key];
if (!base) continue;
const variants = [];
for (const suffix of ['', '.1', '.2']) {
const fp = base + suffix;
try {
const st = fs.statSync(fp);
variants.push({ backup: suffix === '' ? 0 : Number(suffix.slice(1)), sizeBytes: st.size, mtime: st.mtime.toISOString() });
} catch {}
}
files.push({ name, path: base, readable: true, present: variants.length > 0, variants });
}
let siblings = [];
try {
siblings = fs.readdirSync(dir)
.filter(f => /\.log(\.\d+)?$/i.test(f))
.filter(f => !files.some(x => path.basename(x.path) === f || f.startsWith(path.basename(x.path))));
siblings = siblings.map(f => {
let size = 0, mtime = null;
try { const st = fs.statSync(path.join(dir, f)); size = st.size; mtime = st.mtime.toISOString(); } catch {}
return { name: f, readable: false, sizeBytes: size, mtime };
});
} catch {}
return { dir, files, otherLogs: siblings };
}
function readLog(args) {
const a = args || {};
const name = a.name;
const p = _resolveLogPath(name, a.backup);
if (!p) return { ok: false, error: `unknown or non-readable log: ${name}` };
const tailKb = Math.min(Math.max(Number(a.tailKb) || 256, 1), 1024);
const raw = support.collectFile(p, name, tailKb * 1024);
let content = support.redactLogText(raw, _secrets());
let matchedLines;
if (a.grep && typeof a.grep === 'string' && a.grep.length <= 200) {
let re;
try { re = new RegExp(a.grep, 'i'); } catch { re = null; }
if (re) {
const lines = content.split('\n').filter(l => re.test(l));
matchedLines = lines.length;
content = lines.join('\n');
}
}
let sizeBytes = null;
try { sizeBytes = fs.statSync(p).size; } catch {}
return { name, path: p, sizeBytes, returnedBytes: Buffer.byteLength(content), tailKb, matchedLines, content };
}
function getAppEvents(args) {
const limit = Math.min(Math.max(Number(args && args.limit) || 50, 1), 500);
const out = [];
const secrets = _secrets();
for (const name of ['crash', 'debug']) {
const p = _resolveLogPath(name);
if (!p) continue;
const raw = support.redactLogText(support.collectFile(p, name, 256 * 1024), secrets);
const lines = raw.split('\n').filter(l => l.trim() && !l.startsWith('==='));
for (const line of lines.slice(-limit)) out.push({ source: name, text: line });
}
return { events: out.slice(-limit), truncated: out.length > limit };
}
function _historyErrors(history, opts) {
const o = opts || {};
const sinceMs = Number.isFinite(o.sinceMs) ? o.sinceMs : null;
const secrets = _secrets();
const errors = [];
const byCategory = {};
for (const batch of (Array.isArray(history) ? history : [])) {
if (!batch || !Array.isArray(batch.files)) continue;
const ts = batch.timestamp ? Date.parse(batch.timestamp) : null;
if (sinceMs !== null && ts !== null && ts < sinceMs) continue;
for (const file of batch.files) {
if (!file || !Array.isArray(file.results)) continue;
for (const r of file.results) {
if (!r || r.status === 'done') continue;
const category = stats.classifyErrorCategory(r.error);
if (o.category && o.category !== category) continue;
if (o.hoster && o.hoster !== r.hoster) continue;
byCategory[category] = (byCategory[category] || 0) + 1;
errors.push({
ts: batch.timestamp || null,
fileName: file.name || file.fileName || '',
hoster: r.hoster || '',
accountId: r.accountId || undefined,
category,
error: support.redactLogText(String(r.error || ''), secrets)
});
}
}
}
return { errors, byCategory };
}
function listErrors(args) {
const a = args || {};
const cfg = loadConfig();
const { errors, byCategory } = _historyErrors(cfg.history, a);
const limit = Math.min(Math.max(Number(a.limit) || 100, 1), 1000);
const window = Number.isFinite(a.sinceMs) ? `since ${new Date(a.sinceMs).toISOString()}` : 'all history';
return { window, total: errors.length, byCategory, errors: errors.slice(-limit) };
}
function getQueueState(args) {
const a = args || {};
const cfg = loadConfig();
const pending = cfg.globalSettings && cfg.globalSettings.pendingQueue;
if (!pending || typeof pending !== 'object') {
return { source: 'empty', stale: false, counts: {}, selectedHosters: [] };
}
const counts = {};
for (const s of QUEUE_STATUSES) counts[s] = 0;
const jobs = Array.isArray(pending.queueJobs) ? pending.queueJobs : [];
for (const j of jobs) { if (counts[j.status] !== undefined) counts[j.status]++; }
const result = {
source: 'persisted',
stale: true,
savedAt: pending.savedAt || null,
selectedHosters: Array.isArray(pending.selectedUploadHosters) ? pending.selectedUploadHosters : [],
fileCount: Array.isArray(pending.selectedFiles) ? pending.selectedFiles.length : 0,
counts
};
if (a.includeJobs !== false) {
const maxJobs = Math.min(Math.max(Number(a.maxJobs) || 200, 1), 2000);
result.jobs = _scrub(jobs.slice(0, maxJobs).map(j => ({
file: j.file, fileName: j.fileName, hoster: j.hoster, status: j.status, error: j.error || null
})));
result.jobsTruncated = jobs.length > maxJobs;
}
return result;
}
function getHistory(args) {
const a = args || {};
const cfg = loadConfig();
const history = Array.isArray(cfg.history) ? cfg.history : [];
const limit = Math.min(Math.max(Number(a.limit) || 20, 1), 200);
const perHoster = stats.summarizePerHoster(history);
const recent = [...history].slice(-limit).reverse();
const secrets = _secrets();
const batches = recent.map(b => {
const out = { timestamp: b.timestamp || null, fileCount: Array.isArray(b.files) ? b.files.length : 0 };
if (a.includeFiles) {
out.files = (b.files || []).map(f => ({
name: f.name || f.fileName || '',
results: (f.results || []).map(r => {
const rr = { hoster: r.hoster, status: r.status };
if (r.error) rr.error = support.redactLogText(String(r.error), secrets);
if (a.includeUrls && r.url) rr.url = r.url;
return rr;
})
}));
}
return out;
});
return { totalBatches: history.length, returned: batches.length, perHoster, batches };
}
function getRotationState() {
const cfg = loadConfig();
return { rotationCursors: _scrub(cfg.rotationCursors || {}) };
}
function getHealth() {
const cfg = loadConfig();
const hosters = cfg.hosters && typeof cfg.hosters === 'object' ? Object.keys(cfg.hosters).filter(h => Array.isArray(cfg.hosters[h]) && cfg.hosters[h].length > 0) : [];
return {
reachabilityKnown: false,
hint: 'Live hoster probing (run_health_check) is disabled in this build. Configured hosters with at least one account are listed.',
configuredHosters: hosters
};
}
function serverHealth(args) {
const a = args || {};
const errorLimit = Math.min(Math.max(Number(a.errorLimit) || 20, 1), 200);
const errArgs = Number.isFinite(a.errorSinceMs) ? { sinceMs: a.errorSinceMs, limit: errorLimit } : { limit: errorLimit };
const errors = listErrors(errArgs);
const queue = getQueueState({ includeJobs: false });
const history = getHistory({ limit: 5 });
const warnings = [];
if (queue.source === 'persisted' && queue.stale) warnings.push('queue state is from the persisted snapshot (may lag live state; UploadManager not introspected in this build).');
if (errors.total > 0) warnings.push(`${errors.total} non-success result(s) in the error window.`);
return {
server: getSystemInfo(),
queue,
recentBatches: history.batches,
perHoster: history.perHoster,
errors,
hosters: getHealth(),
logs: listLogs(),
warnings
};
}
return {
getSystemInfo, getConfigRedacted, listLogs, readLog, getAppEvents,
listErrors, getQueueState, getHistory, getRotationState, getHealth, serverHealth,
READABLE_LOGS
};
}
module.exports = { createCollectors, READABLE_LOGS };

View File

@ -1,19 +1,28 @@
const { WebSocketServer } = require('ws'); const { WebSocketServer } = require('ws');
const crypto = require('crypto'); const crypto = require('crypto');
function timingSafeEqualStr(a, b) {
const x = Buffer.from(String(a == null ? '' : a));
const y = Buffer.from(String(b == null ? '' : b));
return x.length === y.length && crypto.timingSafeEqual(x, y);
}
class RemoteServer { class RemoteServer {
constructor() { constructor() {
this._wss = null; this._wss = null;
this._clients = new Map(); // ws -> { id, role, authenticated } this._clients = new Map(); // ws -> { id, role, authenticated }
this._config = null; this._config = null;
this._failedAttempts = new Map(); // ip -> { count, blockedUntil } this._failedAttempts = new Map(); // ip -> { count, blockedUntil }
this._lastAccess = null;
} }
start(opts) { start(opts) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this._config = opts; this._config = opts;
this._wss = new WebSocketServer({ port: opts.port }, () => { const wssOpts = { port: opts.port };
if (opts.host) wssOpts.host = opts.host;
this._wss = new WebSocketServer(wssOpts, () => {
resolve(); resolve();
}); });
@ -83,12 +92,13 @@ class RemoteServer {
authReceived = true; authReceived = true;
clearTimeout(authTimeout); clearTimeout(authTimeout);
if (msg.type === 'auth' && msg.token === this._config.token) { if (msg.type === 'auth' && timingSafeEqualStr(msg.token, this._config.token)) {
client.authenticated = true; client.authenticated = true;
client.role = msg.role || 'viewer'; client.role = this._config.diagnosticMode ? 'diagnostic' : (msg.role || 'viewer');
this._lastAccess = Date.now();
ws.send(JSON.stringify({ type: 'auth-ok', clientId })); ws.send(JSON.stringify({ type: 'auth-ok', clientId }));
if (this.getClientCount() === 1) { if (!this._config.diagnosticMode && this.getClientCount() === 1) {
this._config.onCreateCaptureWindow(); this._config.onCreateCaptureWindow();
} }
} else { } else {
@ -99,6 +109,16 @@ class RemoteServer {
return; return;
} }
if (this._config.diagnosticMode) {
if (msg.type === 'diag-request' && typeof this._config.onDiagnosticRequest === 'function') {
this._lastAccess = Date.now();
this._config.onDiagnosticRequest(msg, client, (payload) => {
this.sendToClient(client.id, { type: 'diag-response', reqId: msg.reqId, ...payload });
});
}
return;
}
if (msg.type === 'offer' || msg.type === 'ice-candidate') { if (msg.type === 'offer' || msg.type === 'ice-candidate') {
msg.clientId = client.id; msg.clientId = client.id;
msg.role = client.role; msg.role = client.role;
@ -112,7 +132,7 @@ class RemoteServer {
const wasAuthenticated = client && client.authenticated; const wasAuthenticated = client && client.authenticated;
this._clients.delete(ws); this._clients.delete(ws);
if (wasAuthenticated) { if (wasAuthenticated && !this._config.diagnosticMode) {
this._config.onSignalingToCapture({ this._config.onSignalingToCapture({
type: 'client-disconnected', type: 'client-disconnected',
clientId: client.id clientId: client.id
@ -130,7 +150,7 @@ class RemoteServer {
const wasAuthenticated = client && client.authenticated; const wasAuthenticated = client && client.authenticated;
this._clients.delete(ws); this._clients.delete(ws);
if (wasAuthenticated) { if (wasAuthenticated && !this._config.diagnosticMode) {
this._config.onSignalingToCapture({ this._config.onSignalingToCapture({
type: 'client-disconnected', type: 'client-disconnected',
clientId: client.id clientId: client.id
@ -142,6 +162,10 @@ class RemoteServer {
}); });
} }
getLastAccess() {
return this._lastAccess;
}
sendToClient(clientId, data) { sendToClient(clientId, data) {
for (const [ws, client] of this._clients) { for (const [ws, client] of this._clients) {
if (client.id === clientId && client.authenticated) { if (client.id === clientId && client.authenticated) {

View File

@ -1,6 +1,6 @@
const fs = require('fs'); const fs = require('fs');
const CRED_KEYS = new Set(['password', 'apiKey', 'token', 'cookie', 'sessionId']); const CRED_KEYS = new Set(['password', 'apiKey', 'token', 'cookie', 'sessionId', 'webhookUrl', 'diagToken']);
const REDACTED = '<redacted>'; const REDACTED = '<redacted>';
function sanitizeConfig(config) { function sanitizeConfig(config) {
@ -18,6 +18,51 @@ function sanitizeConfig(config) {
return clone; return clone;
} }
function collectSecretValues(config) {
const out = new Set();
(function walk(o) {
if (!o) return;
if (Array.isArray(o)) { for (const e of o) walk(e); return; }
if (typeof o !== 'object') return;
for (const k of Object.keys(o)) {
const v = o[k];
if (CRED_KEYS.has(k) && typeof v === 'string' && v.length >= 6) out.add(v);
else walk(v);
}
})(config);
return Array.from(out);
}
function redactLogText(text, secrets) {
if (typeof text !== 'string' || !text) return text;
let out = text;
if (Array.isArray(secrets)) {
for (const s of secrets) {
if (typeof s === 'string' && s.length >= 6) out = out.split(s).join(REDACTED);
}
}
out = out
.replace(/https?:\/\/(?:ptb\.|canary\.)?discord(?:app)?\.com\/api\/webhooks\/\d+\/[\w-]+/gi, 'https://discord.com/api/webhooks/' + REDACTED)
.replace(/(authorization:\s*bearer\s+)\S+/gi, '$1' + REDACTED)
.replace(/([?&](?:api_?key|key|token|access_token|password|pass)=)[^\s&"'`]+/gi, '$1' + REDACTED)
.replace(/("?\b(?:api[_-]?key|apikey|password|secret|access_token)"?\s*[:=]\s*"?)[A-Za-z0-9._\-/+]{8,}/gi, '$1' + REDACTED)
.replace(/(\bset-cookie:|\bcookie:)\s*\S[^\n]*/gi, '$1 ' + REDACTED)
.replace(/(\bsess(?:_?id)?\b["'=:\s]+)[A-Za-z0-9._\-]{8,}/gi, '$1' + REDACTED);
return out;
}
function valueScrub(value, secrets) {
if (value == null) return value;
const json = JSON.stringify(value);
let scrubbed = json;
if (Array.isArray(secrets)) {
for (const s of secrets) {
if (typeof s === 'string' && s.length >= 6) scrubbed = scrubbed.split(s).join(REDACTED);
}
}
return JSON.parse(scrubbed);
}
function collectFile(filePath, label, maxBytes) { function collectFile(filePath, label, maxBytes) {
if (!filePath) return `=== ${label} ===\n<no path configured>\n\n`; if (!filePath) return `=== ${label} ===\n<no path configured>\n\n`;
let stat; let stat;
@ -61,4 +106,4 @@ function buildSupportBundleText({ header, sanitizedConfig, files }) {
return parts.join(''); return parts.join('');
} }
module.exports = { sanitizeConfig, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED }; module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED };

200
main.js
View File

@ -19,8 +19,11 @@ const { maybeRotateLogFile } = require('./lib/log-rotation');
const { hosterLogToFileEnabled } = require('./lib/log-policy'); const { hosterLogToFileEnabled } = require('./lib/log-policy');
const { formatUploadLogLine, parseUploadLogLine } = require('./lib/upload-log'); const { formatUploadLogLine, parseUploadLogLine } = require('./lib/upload-log');
const { selectOrphanTmps } = require('./lib/orphan-tmp'); const { selectOrphanTmps } = require('./lib/orphan-tmp');
const { sanitizeConfig, buildSupportBundleText } = require('./lib/support-bundle'); const { sanitizeConfig, buildSupportBundleText, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED } = require('./lib/support-bundle');
const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify'); const { buildWebhookRequest, isAllAborted } = require('./lib/webhook-notify');
const stats = require('./lib/stats');
const { createCollectors } = require('./lib/diagnostics-collectors');
const { createAgent } = require('./lib/diagnostics-agent');
let mainWindow; let mainWindow;
let _lastImportPath = null; let _lastImportPath = null;
@ -28,6 +31,21 @@ let dropTargetWindow = null;
let tray = null; let tray = null;
const configStore = new ConfigStore(app); const configStore = new ConfigStore(app);
let uploadManager = null; let uploadManager = null;
let diagnosticAgent = null;
let _diagHandler = null;
const _hasSingleInstanceLock = app.requestSingleInstanceLock();
if (!_hasSingleInstanceLock) {
app.quit();
} else {
app.on('second-instance', () => {
if (mainWindow) {
if (mainWindow.isMinimized()) mainWindow.restore();
if (!mainWindow.isVisible()) mainWindow.show();
mainWindow.focus();
}
});
}
// Rotation memory that survives batch-done → new UploadManager within the // Rotation memory that survives batch-done → new UploadManager within the
// same app session. Without this, clicking "Retry failed" after a batch // same app session. Without this, clicking "Retry failed" after a batch
// ended would burn the full retry budget on accounts we already know are // ended would burn the full retry budget on accounts we already know are
@ -1160,6 +1178,7 @@ function updateTrayTooltip(text) {
} }
app.whenReady().then(() => { app.whenReady().then(() => {
if (!_hasSingleInstanceLock) return;
try { try {
const _bootCfg = configStore.load(); const _bootCfg = configStore.load();
setLogVerbose(!!(_bootCfg.globalSettings && _bootCfg.globalSettings.logVerbose)); setLogVerbose(!!(_bootCfg.globalSettings && _bootCfg.globalSettings.logVerbose));
@ -1209,6 +1228,12 @@ app.whenReady().then(() => {
debugLog(`remote-server auto-start failed: ${err.message}`); debugLog(`remote-server auto-start failed: ${err.message}`);
}); });
} }
const diagConfig = _remCfg.globalSettings && _remCfg.globalSettings.diagnostics;
if (diagConfig && diagConfig.enabled) {
startDiagnosticAgent().catch(err => {
debugLog(`diagnostics-agent auto-start failed: ${err.message}`);
});
}
} catch (err) { } catch (err) {
debugLog(`remote-server auto-start failed: ${err.message}`); debugLog(`remote-server auto-start failed: ${err.message}`);
} }
@ -1251,6 +1276,7 @@ app.on('before-quit', () => {
if (remoteServer) { remoteServer.stop(); remoteServer = null; } if (remoteServer) { remoteServer.stop(); remoteServer = null; }
destroyCaptureWindow(); destroyCaptureWindow();
} catch {} } catch {}
try { stopDiagnosticAgent(); } catch {}
try { destroyDropTargetWindow(); } catch {} try { destroyDropTargetWindow(); } catch {}
try { if (tray && !tray.isDestroyed()) { tray.destroy(); tray = null; } } catch {} try { if (tray && !tray.isDestroyed()) { tray.destroy(); tray = null; } } catch {}
// Flush pending log buffers synchronously so no lines are lost. // Flush pending log buffers synchronously so no lines are lost.
@ -2246,7 +2272,19 @@ ipcMain.handle('get-global-settings', () => {
return config.globalSettings || {}; return config.globalSettings || {};
}); });
function _preserveDiagSubtree(globalSettings) {
if (!globalSettings || typeof globalSettings !== 'object') return globalSettings;
try {
const cur = configStore.load();
if (cur.globalSettings && cur.globalSettings.diagnostics) {
globalSettings.diagnostics = cur.globalSettings.diagnostics;
}
} catch {}
return globalSettings;
}
ipcMain.handle('save-global-settings', async (_event, globalSettings) => { ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
globalSettings = _preserveDiagSubtree(globalSettings);
await configStore.save({ globalSettings }); await configStore.save({ globalSettings });
if (uploadManager) uploadManager.updateSettings(null, globalSettings); if (uploadManager) uploadManager.updateSettings(null, globalSettings);
return true; return true;
@ -2287,7 +2325,9 @@ ipcMain.on('save-global-settings-sync', (event, globalSettings) => {
const tmpPath = configStore.filePath + '.' + process.pid + '.tmp'; const tmpPath = configStore.filePath + '.' + process.pid + '.tmp';
try { try {
const current = configStore.load(); const current = configStore.load();
const _diskDiag = current.globalSettings && current.globalSettings.diagnostics;
current.globalSettings = globalSettings; current.globalSettings = globalSettings;
if (_diskDiag) current.globalSettings.diagnostics = _diskDiag;
const data = configStore._serializeForDisk(current); const data = configStore._serializeForDisk(current);
const backupPath = configStore.filePath + '.bak'; const backupPath = configStore.filePath + '.bak';
fs.writeFileSync(tmpPath, data, 'utf-8'); fs.writeFileSync(tmpPath, data, 'utf-8');
@ -2378,6 +2418,164 @@ function generateToken() {
return crypto.randomBytes(32).toString('hex'); return crypto.randomBytes(32).toString('hex');
} }
// --- Remote Diagnostics (read-only) ---
function _diagAppInfo() {
return {
name: app.getName(),
version: app.getVersion(),
electron: process.versions.electron,
node: process.versions.node,
chrome: process.versions.chrome,
packaged: app.isPackaged,
pid: process.pid,
uptimeSec: Math.round(process.uptime())
};
}
function _diagSystemInfo() {
const os = require('os');
let disk = null;
try {
const sf = fs.statfsSync(app.getPath('userData'));
disk = { freeBytes: sf.bavail * sf.bsize, totalBytes: sf.blocks * sf.bsize };
} catch {}
return {
platform: process.platform,
arch: process.arch,
osType: os.type(),
osRelease: os.release(),
hostname: os.hostname(),
totalMemBytes: os.totalmem(),
freeMemBytes: os.freemem(),
cpuCount: (os.cpus() || []).length,
osUptimeSec: Math.round(os.uptime()),
disk
};
}
function _diagAgentInfo() {
const cfg = configStore.load();
const diag = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
return {
version: app.getVersion(),
port: diag.port || 9110,
bindAddress: diag.bindAddress || '127.0.0.1',
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
};
}
function _buildDiagnosticHandler() {
const collectors = createCollectors({
loadConfig: () => configStore.load(),
getAllLogPaths,
support: { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, REDACTED },
stats,
appInfo: _diagAppInfo,
systemInfo: _diagSystemInfo,
agentInfo: _diagAgentInfo
});
const agent = createAgent(collectors);
return (msg, _client, reply) => {
let result;
try { result = agent.handle(msg.op, msg.args); }
catch (e) { result = { ok: false, error: String((e && e.message) || e) }; }
reply(result);
};
}
function buildDiagnosticCode(diag, fp) {
const os = require('os');
const payload = { v: 1, port: diag.port || 9110, token: diag.token, label: diag.label || os.hostname() };
if (fp) payload.fp = fp;
return 'mhu1_' + Buffer.from(JSON.stringify(payload)).toString('base64url');
}
async function startDiagnosticAgent() {
if (diagnosticAgent) { try { diagnosticAgent.stop(); } catch {} diagnosticAgent = null; }
const config = configStore.load();
const diag = config.globalSettings && config.globalSettings.diagnostics;
if (!diag || !diag.enabled) return;
let token = diag.token;
if (!token) {
token = generateToken();
const gs = { ...config.globalSettings, diagnostics: { ...diag, token, codeIssuedAt: Date.now() } };
await configStore.save({ globalSettings: gs });
}
if (!_diagHandler) _diagHandler = _buildDiagnosticHandler();
diagnosticAgent = new RemoteServer();
try {
await diagnosticAgent.start({
port: diag.port || 9110,
host: diag.bindAddress || '127.0.0.1',
token,
diagnosticMode: true,
onDiagnosticRequest: _diagHandler
});
debugLog(`diagnostics-agent started on ${diag.bindAddress || '127.0.0.1'}:${diagnosticAgent.getPort()}`);
} catch (e) {
debugLog(`diagnostics-agent start failed: ${e.message}`);
diagnosticAgent = null;
}
}
function stopDiagnosticAgent() {
if (diagnosticAgent) { try { diagnosticAgent.stop(); } catch {} diagnosticAgent = null; }
}
ipcMain.handle('diagnostics:get-settings', () => {
const cfg = configStore.load();
const diag = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
return {
enabled: !!diag.enabled,
port: diag.port || 9110,
bindAddress: diag.bindAddress || '127.0.0.1',
label: diag.label || require('os').hostname(),
codeIssuedAt: diag.codeIssuedAt || 0,
code: diag.token ? buildDiagnosticCode(diag) : ''
};
});
ipcMain.handle('diagnostics:save-settings', async (_e, incoming) => {
const cfg = configStore.load();
const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
const next = {
...cur,
enabled: !!(incoming && incoming.enabled),
port: (incoming && Number(incoming.port)) || cur.port || 9110,
bindAddress: (incoming && incoming.bindAddress) || cur.bindAddress || '127.0.0.1',
label: (incoming && incoming.label != null) ? String(incoming.label) : cur.label
};
const gs = { ...cfg.globalSettings, diagnostics: next };
await configStore.save({ globalSettings: gs });
await startDiagnosticAgent();
return { ok: true };
});
ipcMain.handle('diagnostics:regenerate', async () => {
const cfg = configStore.load();
const cur = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
const next = { ...cur, token: generateToken(), codeIssuedAt: Date.now() };
const gs = { ...cfg.globalSettings, diagnostics: next };
await configStore.save({ globalSettings: gs });
if (next.enabled) await startDiagnosticAgent();
return { ok: true, code: buildDiagnosticCode(next), codeIssuedAt: next.codeIssuedAt };
});
ipcMain.handle('diagnostics:status', () => {
const cfg = configStore.load();
const diag = (cfg.globalSettings && cfg.globalSettings.diagnostics) || {};
return {
running: !!diagnosticAgent,
port: diagnosticAgent ? diagnosticAgent.getPort() : (diag.port || 9110),
bindAddress: diag.bindAddress || '127.0.0.1',
clientCount: diagnosticAgent ? diagnosticAgent.getClientCount() : 0,
lastAccess: diagnosticAgent ? diagnosticAgent.getLastAccess() : null
};
});
function createCaptureWindow() { function createCaptureWindow() {
if (captureWindow && !captureWindow.isDestroyed()) return; if (captureWindow && !captureWindow.isDestroyed()) return;
captureWindowReady = false; captureWindowReady = false;

View File

@ -139,6 +139,12 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('remote:client-count', (_event, count) => callback(count)); ipcRenderer.on('remote:client-count', (_event, count) => callback(count));
}, },
// Remote Diagnostics (read-only)
diagnosticsGetSettings: () => ipcRenderer.invoke('diagnostics:get-settings'),
diagnosticsSaveSettings: (settings) => ipcRenderer.invoke('diagnostics:save-settings', settings),
diagnosticsRegenerate: () => ipcRenderer.invoke('diagnostics:regenerate'),
diagnosticsStatus: () => ipcRenderer.invoke('diagnostics:status'),
// File path from drag & drop (Electron 33+ compatible) // File path from drag & drop (Electron 33+ compatible)
getPathForFile: (file) => webUtils.getPathForFile(file), getPathForFile: (file) => webUtils.getPathForFile(file),
removeAllListeners: () => { removeAllListeners: () => {

View File

@ -3005,12 +3005,13 @@ function renderSettings() {
<button class="settings-subtab" data-subtab="automatik">Automatik</button> <button class="settings-subtab" data-subtab="automatik">Automatik</button>
<button class="settings-subtab" data-subtab="logs">Logs & Diagnose</button> <button class="settings-subtab" data-subtab="logs">Logs & Diagnose</button>
<button class="settings-subtab" data-subtab="remote">Fernsteuerung</button> <button class="settings-subtab" data-subtab="remote">Fernsteuerung</button>
<button class="settings-subtab" data-subtab="diagnose">Diagnose-Zugriff</button>
<button class="settings-subtab" data-subtab="backup">Backup</button> <button class="settings-subtab" data-subtab="backup">Backup</button>
`; `;
container.appendChild(subtabBar); container.appendChild(subtabBar);
const pages = {}; const pages = {};
['allgemein', 'automatik', 'logs', 'remote', 'backup'].forEach((id) => { ['allgemein', 'automatik', 'logs', 'remote', 'diagnose', 'backup'].forEach((id) => {
const page = document.createElement('div'); const page = document.createElement('div');
page.className = id === 'allgemein' ? 'settings-subpage active' : 'settings-subpage'; page.className = id === 'allgemein' ? 'settings-subpage active' : 'settings-subpage';
page.dataset.subpage = id; page.dataset.subpage = id;
@ -3199,6 +3200,41 @@ function renderSettings() {
</div> </div>
`; `;
pages.diagnose.innerHTML = `
<div class="settings-section-label">Diagnose-Zugriff (nur lesen) <span class="panel-status" id="diagStatusBadge"></span></div>
<p class="hint" style="margin:0 0 12px;padding:8px 10px;border-left:3px solid #f59e0b;background:rgba(245,158,11,0.08)">
Erlaubt Claude <strong>nur lesenden</strong> Zugriff auf Logs, Queue-Status und sanitierte Config (Passwörter/API-Keys/Token werden maskiert). <strong>Kein Bildschirm, keine Eingabe-Steuerung.</strong> Der Verbindungs-Code ist ein Zugangsschlüssel nur mit vertrauenswürdigen Stellen teilen; bei Verdacht Neu" klicken. Standard-Bindung ist <code>127.0.0.1</code> (nur über SSH-/VPN-Tunnel erreichbar).
</p>
<div class="settings-grid-mini">
<div class="settings-row checkbox-row">
<label>Aktiviert</label>
<input type="checkbox" id="diagEnabledInput">
</div>
</div>
<div class="settings-row">
<label>Port</label>
<input type="number" class="hs-input" id="diagPortInput" min="1024" max="65535" value="9110" style="width:100px">
</div>
<div class="settings-row">
<label>Bind-Adresse</label>
<select class="hs-input" id="diagBindInput" style="width:auto">
<option value="127.0.0.1">Nur lokal (Tunnel/VPN) empfohlen</option>
<option value="0.0.0.0">Alle Adressen (LAN/Internet)</option>
</select>
</div>
<div class="settings-row">
<label>Verbindungs-Code</label>
<input type="text" class="key-input" id="diagCodeInput" value="" readonly style="flex:1" placeholder="(aktivieren zum Erzeugen)">
<button class="btn btn-xs btn-secondary" id="diagCopyCodeBtn" title="Kopieren">Kopieren</button>
<button class="btn btn-xs btn-secondary" id="diagRegenerateBtn" title="Neu generieren (macht alte Codes ungültig)">Neu</button>
</div>
<div class="settings-row"><span class="hint" id="diagCodeIssued"></span></div>
<div class="settings-section-label">Status</div>
<div class="settings-row">
<span id="diagConnectionStatus" style="color:#94a3b8">Prüfe</span>
</div>
`;
pages.backup.innerHTML = ` pages.backup.innerHTML = `
<p class="hint" style="margin:0 0 10px">Alle Accounts und Einstellungen exportieren oder importieren. Der Upload-Verlauf bleibt lokal und wird nicht übertragen; nach einem Import ist der Verlauf-Tab leer.</p> <p class="hint" style="margin:0 0 10px">Alle Accounts und Einstellungen exportieren oder importieren. Der Upload-Verlauf bleibt lokal und wird nicht übertragen; nach einem Import ist der Verlauf-Tab leer.</p>
<div style="display:flex;gap:8px"> <div style="display:flex;gap:8px">
@ -3321,6 +3357,75 @@ function renderSettings() {
} }
}).catch(() => {}); }).catch(() => {});
(function wireDiagnostics() {
const enabledEl = document.getElementById('diagEnabledInput');
const portEl = document.getElementById('diagPortInput');
const bindEl = document.getElementById('diagBindInput');
const codeEl = document.getElementById('diagCodeInput');
const issuedEl = document.getElementById('diagCodeIssued');
const badgeEl = document.getElementById('diagStatusBadge');
if (!enabledEl) return;
const fmtIssued = (ts) => {
if (!ts) return '';
try { return 'Code erstellt: ' + new Date(ts).toLocaleString('de-DE'); } catch { return ''; }
};
const applySettings = (s) => {
if (!s) return;
enabledEl.checked = !!s.enabled;
portEl.value = s.port || 9110;
bindEl.value = s.bindAddress || '127.0.0.1';
codeEl.value = s.code || '';
issuedEl.textContent = fmtIssued(s.codeIssuedAt);
if (badgeEl) {
badgeEl.textContent = s.enabled ? 'Aktiv' : 'Inaktiv';
badgeEl.className = 'panel-status' + (s.enabled ? ' active' : '');
}
};
const refreshStatus = () => {
window.api.diagnosticsStatus().then((st) => {
const el = document.getElementById('diagConnectionStatus');
if (!el || !st) return;
if (st.running) {
const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString('de-DE') : '—';
el.textContent = `Aktiv auf ${st.bindAddress}:${st.port}${st.clientCount} Client(s) — Letzter Zugriff: ${last}`;
el.style.color = '#10b981';
} else {
el.textContent = 'Nicht aktiv';
el.style.color = '#94a3b8';
}
}).catch(() => {});
};
const save = async () => {
await window.api.diagnosticsSaveSettings({
enabled: enabledEl.checked,
port: parseInt(portEl.value, 10) || 9110,
bindAddress: bindEl.value
});
applySettings(await window.api.diagnosticsGetSettings());
refreshStatus();
};
window.api.diagnosticsGetSettings().then(applySettings).catch(() => {});
refreshStatus();
enabledEl.addEventListener('change', save);
portEl.addEventListener('change', save);
bindEl.addEventListener('change', save);
document.getElementById('diagCopyCodeBtn').addEventListener('click', async () => {
if (!codeEl.value) return;
await window.api.copyToClipboard(codeEl.value);
const b = document.getElementById('diagCopyCodeBtn');
b.textContent = 'Kopiert!';
setTimeout(() => { b.textContent = 'Kopieren'; }, 1500);
});
document.getElementById('diagRegenerateBtn').addEventListener('click', async () => {
const r = await window.api.diagnosticsRegenerate();
if (r && r.code) { codeEl.value = r.code; issuedEl.textContent = fmtIssued(r.codeIssuedAt); }
refreshStatus();
});
})();
document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport()); document.getElementById('exportBackupBtn').addEventListener('click', () => doBackupExport());
document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport()); document.getElementById('importBackupBtn').addEventListener('click', () => doBackupImport());

View File

@ -0,0 +1,51 @@
const { test } = require('node:test');
const assert = require('node:assert');
const { createAgent } = require('../lib/diagnostics-agent');
function stubCollectors() {
const calls = [];
const mk = (name) => (a) => { calls.push([name, a]); return { name, a }; };
return {
calls,
getSystemInfo: mk('getSystemInfo'),
serverHealth: mk('serverHealth'),
getConfigRedacted: mk('getConfigRedacted'),
listLogs: mk('listLogs'),
readLog: mk('readLog'),
getAppEvents: mk('getAppEvents'),
listErrors: mk('listErrors'),
getQueueState: mk('getQueueState'),
getHistory: mk('getHistory'),
getRotationState: mk('getRotationState'),
getHealth: mk('getHealth')
};
}
test('agent rejects unknown ops and any write/exec-shaped op', () => {
const agent = createAgent(stubCollectors());
for (const bad of ['delete_log', 'write_config', 'run_health_check', 'exec', 'eval', '__proto__', 'set_setting', 'restart']) {
const r = agent.handle(bad, {});
assert.equal(r.ok, false, `${bad} must be rejected`);
assert.match(r.error, /unknown or non-readonly/);
}
});
test('agent maps each whitelisted op to its collector and is read-only only', () => {
const stub = stubCollectors();
const agent = createAgent(stub);
assert.equal(agent.handle('server_health', { errorLimit: 5 }).ok, true);
assert.equal(agent.handle('read_log', { name: 'debug' }).ok, true);
assert.equal(agent.handle('tail_log', { name: 'debug' }).ok, true, 'tail_log aliases read_log');
assert.equal(agent.handle('get_config_redacted', {}).ok, true);
const ops = new Set(agent.ops);
assert.ok(!ops.has('run_health_check'), 'no live probe op in this build');
for (const op of agent.ops) assert.ok(!/write|delete|set_|exec|restart|cancel|retry/.test(op), `${op} must be read-only`);
});
test('agent surfaces a collector ok:false verbatim and never throws', () => {
const agent = createAgent({ readLog: () => ({ ok: false, error: 'unknown or non-readable log: x' }), getSystemInfo: () => { throw new Error('boom'); } });
assert.equal(agent.handle('read_log', { name: 'x' }).ok, false);
const thrown = agent.handle('get_system_info', {});
assert.equal(thrown.ok, false);
assert.match(thrown.error, /boom/);
});

View File

@ -0,0 +1,87 @@
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');
});

View File

@ -0,0 +1,72 @@
const { test } = require('node:test');
const assert = require('node:assert');
const WebSocket = require('ws');
const RemoteServer = require('../lib/remote-server');
const TOKEN = 'a'.repeat(64);
function startAgent(onDiagnosticRequest, extra) {
const srv = new RemoteServer();
return srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest, ...(extra || {}) })
.then(() => srv);
}
function connect(port) {
return new WebSocket(`ws://127.0.0.1:${port}`);
}
function once(ws, type) {
return new Promise((resolve, reject) => {
ws.on('message', (raw) => { const m = JSON.parse(raw); if (m.type === type) resolve(m); });
ws.on('close', (code) => reject(new Error('closed ' + code)));
ws.on('error', reject);
});
}
test('diagnostic client: auth -> diag-request -> reqId-correlated diag-response', async () => {
const agent = await startAgent((msg, _client, reply) => {
assert.equal(msg.op, 'server_health');
reply({ ok: true, data: { hello: 'world', echo: msg.args } });
});
const port = agent.getPort();
const ws = connect(port);
await new Promise((r) => ws.on('open', r));
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
const ok = await once(ws, 'auth-ok');
assert.ok(ok.clientId);
ws.send(JSON.stringify({ type: 'diag-request', reqId: 'r1', op: 'server_health', args: { errorLimit: 3 } }));
const resp = await once(ws, 'diag-response');
assert.equal(resp.reqId, 'r1');
assert.equal(resp.ok, true);
assert.equal(resp.data.hello, 'world');
assert.equal(resp.data.echo.errorLimit, 3);
assert.equal(agent.getLastAccess() !== null, true, 'access timestamp recorded');
ws.close(); agent.stop();
});
test('a diagnostic client NEVER triggers the screen-capture window', async () => {
let captureCreated = false;
const agent = await startAgent(() => {}, { onCreateCaptureWindow: () => { captureCreated = true; } });
const ws = connect(agent.getPort());
await new Promise((r) => ws.on('open', r));
ws.send(JSON.stringify({ type: 'auth', token: TOKEN, role: 'diagnostic' }));
await once(ws, 'auth-ok');
await new Promise((r) => setTimeout(r, 50));
assert.equal(captureCreated, false, 'diagnosticMode must not spawn the capture window');
ws.close(); agent.stop();
});
test('wrong token is rejected and the ip is locked out after 5 attempts', async () => {
const agent = await startAgent(() => {});
const port = agent.getPort();
for (let i = 0; i < 5; i++) {
const ws = connect(port);
await new Promise((r) => ws.on('open', r));
ws.send(JSON.stringify({ type: 'auth', token: 'wrong', role: 'diagnostic' }));
await new Promise((r) => ws.on('close', r));
}
const ws = connect(port);
const closeCode = await new Promise((resolve) => ws.on('close', (c) => resolve(c)));
assert.equal(closeCode, 4003, 'locked out after 5 failed attempts');
agent.stop();
});