diff --git a/lib/config-store.js b/lib/config-store.js index 416f0d1..55b7dbd 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -100,6 +100,14 @@ const DEFAULTS = { port: 9100, token: '', allowInput: true + }, + diagnostics: { + enabled: false, + port: 9110, + token: '', + label: '', + codeIssuedAt: 0, + bindAddress: '127.0.0.1' } }, history: [], diff --git a/lib/diagnostics-agent.js b/lib/diagnostics-agent.js new file mode 100644 index 0000000..a14c466 --- /dev/null +++ b/lib/diagnostics-agent.js @@ -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 }; diff --git a/lib/diagnostics-collectors.js b/lib/diagnostics-collectors.js new file mode 100644 index 0000000..a7649e9 --- /dev/null +++ b/lib/diagnostics-collectors.js @@ -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 }; diff --git a/lib/remote-server.js b/lib/remote-server.js index dd16ee6..26af0b9 100644 --- a/lib/remote-server.js +++ b/lib/remote-server.js @@ -1,19 +1,28 @@ const { WebSocketServer } = require('ws'); 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 { constructor() { this._wss = null; this._clients = new Map(); // ws -> { id, role, authenticated } this._config = null; this._failedAttempts = new Map(); // ip -> { count, blockedUntil } + this._lastAccess = null; } start(opts) { return new Promise((resolve, reject) => { 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(); }); @@ -83,12 +92,13 @@ class RemoteServer { authReceived = true; 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.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 })); - if (this.getClientCount() === 1) { + if (!this._config.diagnosticMode && this.getClientCount() === 1) { this._config.onCreateCaptureWindow(); } } else { @@ -99,6 +109,16 @@ class RemoteServer { 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') { msg.clientId = client.id; msg.role = client.role; @@ -112,7 +132,7 @@ class RemoteServer { const wasAuthenticated = client && client.authenticated; this._clients.delete(ws); - if (wasAuthenticated) { + if (wasAuthenticated && !this._config.diagnosticMode) { this._config.onSignalingToCapture({ type: 'client-disconnected', clientId: client.id @@ -130,7 +150,7 @@ class RemoteServer { const wasAuthenticated = client && client.authenticated; this._clients.delete(ws); - if (wasAuthenticated) { + if (wasAuthenticated && !this._config.diagnosticMode) { this._config.onSignalingToCapture({ type: 'client-disconnected', clientId: client.id @@ -142,6 +162,10 @@ class RemoteServer { }); } + getLastAccess() { + return this._lastAccess; + } + sendToClient(clientId, data) { for (const [ws, client] of this._clients) { if (client.id === clientId && client.authenticated) { diff --git a/lib/support-bundle.js b/lib/support-bundle.js index 4dd3bad..bc84c1b 100644 --- a/lib/support-bundle.js +++ b/lib/support-bundle.js @@ -1,6 +1,6 @@ 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 = ''; function sanitizeConfig(config) { @@ -18,6 +18,51 @@ function sanitizeConfig(config) { 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) { if (!filePath) return `=== ${label} ===\n\n\n`; let stat; @@ -61,4 +106,4 @@ function buildSupportBundleText({ header, sanitizedConfig, files }) { return parts.join(''); } -module.exports = { sanitizeConfig, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED }; +module.exports = { sanitizeConfig, collectSecretValues, redactLogText, valueScrub, collectFile, buildSupportBundleText, CRED_KEYS, REDACTED }; diff --git a/main.js b/main.js index 8f23886..a0f27d6 100644 --- a/main.js +++ b/main.js @@ -19,8 +19,11 @@ const { maybeRotateLogFile } = require('./lib/log-rotation'); const { hosterLogToFileEnabled } = require('./lib/log-policy'); const { formatUploadLogLine, parseUploadLogLine } = require('./lib/upload-log'); 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 stats = require('./lib/stats'); +const { createCollectors } = require('./lib/diagnostics-collectors'); +const { createAgent } = require('./lib/diagnostics-agent'); let mainWindow; let _lastImportPath = null; @@ -28,6 +31,21 @@ let dropTargetWindow = null; let tray = null; const configStore = new ConfigStore(app); 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 // same app session. Without this, clicking "Retry failed" after a batch // ended would burn the full retry budget on accounts we already know are @@ -1160,6 +1178,7 @@ function updateTrayTooltip(text) { } app.whenReady().then(() => { + if (!_hasSingleInstanceLock) return; try { const _bootCfg = configStore.load(); setLogVerbose(!!(_bootCfg.globalSettings && _bootCfg.globalSettings.logVerbose)); @@ -1209,6 +1228,12 @@ app.whenReady().then(() => { 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) { debugLog(`remote-server auto-start failed: ${err.message}`); } @@ -1251,6 +1276,7 @@ app.on('before-quit', () => { if (remoteServer) { remoteServer.stop(); remoteServer = null; } destroyCaptureWindow(); } catch {} + try { stopDiagnosticAgent(); } catch {} try { destroyDropTargetWindow(); } catch {} try { if (tray && !tray.isDestroyed()) { tray.destroy(); tray = null; } } catch {} // Flush pending log buffers synchronously so no lines are lost. @@ -2246,7 +2272,19 @@ ipcMain.handle('get-global-settings', () => { 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) => { + globalSettings = _preserveDiagSubtree(globalSettings); await configStore.save({ globalSettings }); if (uploadManager) uploadManager.updateSettings(null, globalSettings); return true; @@ -2287,7 +2325,9 @@ ipcMain.on('save-global-settings-sync', (event, globalSettings) => { const tmpPath = configStore.filePath + '.' + process.pid + '.tmp'; try { const current = configStore.load(); + const _diskDiag = current.globalSettings && current.globalSettings.diagnostics; current.globalSettings = globalSettings; + if (_diskDiag) current.globalSettings.diagnostics = _diskDiag; const data = configStore._serializeForDisk(current); const backupPath = configStore.filePath + '.bak'; fs.writeFileSync(tmpPath, data, 'utf-8'); @@ -2378,6 +2418,164 @@ function generateToken() { 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() { if (captureWindow && !captureWindow.isDestroyed()) return; captureWindowReady = false; diff --git a/preload.js b/preload.js index 398acba..9c0f0d4 100644 --- a/preload.js +++ b/preload.js @@ -139,6 +139,12 @@ contextBridge.exposeInMainWorld('api', { 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) getPathForFile: (file) => webUtils.getPathForFile(file), removeAllListeners: () => { diff --git a/renderer/app.js b/renderer/app.js index cab8573..c37fcb3 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3005,12 +3005,13 @@ function renderSettings() { + `; container.appendChild(subtabBar); const pages = {}; - ['allgemein', 'automatik', 'logs', 'remote', 'backup'].forEach((id) => { + ['allgemein', 'automatik', 'logs', 'remote', 'diagnose', 'backup'].forEach((id) => { const page = document.createElement('div'); page.className = id === 'allgemein' ? 'settings-subpage active' : 'settings-subpage'; page.dataset.subpage = id; @@ -3199,6 +3200,41 @@ function renderSettings() { `; + pages.diagnose.innerHTML = ` + +

+ Erlaubt Claude nur lesenden Zugriff auf Logs, Queue-Status und sanitierte Config (Passwörter/API-Keys/Token werden maskiert). Kein Bildschirm, keine Eingabe-Steuerung. Der Verbindungs-Code ist ein Zugangsschlüssel — nur mit vertrauenswürdigen Stellen teilen; bei Verdacht „Neu" klicken. Standard-Bindung ist 127.0.0.1 (nur über SSH-/VPN-Tunnel erreichbar). +

+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + + + +
+
+ +
+ Prüfe… +
+ `; + pages.backup.innerHTML = `

Alle Accounts und Einstellungen exportieren oder importieren. Der Upload-Verlauf bleibt lokal und wird nicht übertragen; nach einem Import ist der Verlauf-Tab leer.

@@ -3321,6 +3357,75 @@ function renderSettings() { } }).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('importBackupBtn').addEventListener('click', () => doBackupImport()); diff --git a/tests/diagnostics-agent.test.js b/tests/diagnostics-agent.test.js new file mode 100644 index 0000000..500163c --- /dev/null +++ b/tests/diagnostics-agent.test.js @@ -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/); +}); diff --git a/tests/diagnostics-collectors.test.js b/tests/diagnostics-collectors.test.js new file mode 100644 index 0000000..ae88ef8 --- /dev/null +++ b/tests/diagnostics-collectors.test.js @@ -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'); +}); diff --git a/tests/diagnostics-protocol.test.js b/tests/diagnostics-protocol.test.js new file mode 100644 index 0000000..fe88ee9 --- /dev/null +++ b/tests/diagnostics-protocol.test.js @@ -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(); +});