diff --git a/docs/remote-diagnostics-setup.md b/docs/remote-diagnostics-setup.md index bcbf00c..4efcce9 100644 --- a/docs/remote-diagnostics-setup.md +++ b/docs/remote-diagnostics-setup.md @@ -17,12 +17,18 @@ On the **server** (the machine running the app): 2. Enable **"Diagnose-Zugriff"**. 3. Copy the connection **code**. It looks like `mhu1_`. -The code carries a one-time auth **token** (and, for TLS, the server cert fingerprint). It does -**not** carry a host — you supply the host yourself when you connect. Treat the code as a -**secret**: anyone with the code and network reach to the agent can read diagnostics. +The code carries a one-time auth **token**. It does **not** carry a host — you supply the host +yourself when you connect. Treat the code as a **secret**: anyone with the code and network reach +to the agent can read diagnostics. -The agent's **safe default is to bind to `127.0.0.1`** (loopback only). It is not exposed to the -network. You reach it through a tunnel (next step). +The agent **always binds to `127.0.0.1`** (loopback only) — this is enforced; there is no setting +to expose it on a LAN/Internet interface. You reach it through a tunnel (next step). + +**Transport note:** the agent speaks **plaintext `ws://` over loopback**. The token and the +diagnostic data are *not* encrypted on the wire by the agent itself — confidentiality comes +entirely from the **tunnel** (SSH/WireGuard) you put in front of it. The loopback bind means the +plaintext traffic never leaves the host except inside that encrypted tunnel. (A future build may +add `wss`/TLS with cert pinning via an `fp` field in the code; it is not active today.) --- @@ -47,8 +53,9 @@ Bring up a WireGuard tunnel to the server, then connect to the server's WireGuar you forward loopback over the tunnel, `127.0.0.1`). Use whichever address resolves to the agent's `127.0.0.1:9110` on the server. -> Only expose the agent directly on a LAN/VPN IP if you fully trust that network segment. The -> default loopback + tunnel is the secure choice. +> The agent cannot be bound directly to a LAN/Internet IP in this build (loopback is enforced), +> so the tunnel is the **only** way to reach it remotely — and the only thing encrypting the +> transport. Keep the tunnel (SSH/WireGuard) up for the whole session. --- @@ -110,7 +117,7 @@ When a connect or a request fails, the gateway returns a human-readable cause. Q | close code **4002** | stale or rotated code — re-copy the current code from the server | | close code **4003** | brute-force lockout, wait 60s | | connected but no `auth-ok` | old app version without the diagnostic agent — update that server | -| wss fingerprint mismatch | server cert changed (reinstalled?) — re-copy the code | +| wss fingerprint mismatch | reserved for the future TLS mode (not active today) — re-copy the code | If you tunnel and still get `ECONNREFUSED`, check that the SSH session is up and that the agent is actually listening on `127.0.0.1:9110` on the server (Diagnose-Zugriff enabled). diff --git a/eslint.config.mjs b/eslint.config.mjs index 63f2b15..eaa0314 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,9 +1,66 @@ import security from 'eslint-plugin-security'; +const sharedRules = { + // Security rules + // detect-object-injection disabled: 78 false positives from config lookups like obj[hosterName] + 'security/detect-object-injection': 'off', + 'security/detect-non-literal-regexp': 'warn', + 'security/detect-unsafe-regex': 'warn', + 'security/detect-buffer-noassert': 'warn', + 'security/detect-eval-with-expression': 'error', + 'security/detect-no-csrf-before-method-override': 'warn', + 'security/detect-possible-timing-attacks': 'warn', + 'security/detect-pseudoRandomBytes': 'warn', + // Code quality + 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], + 'no-undef': 'error', + 'no-constant-condition': 'warn', + 'no-debugger': 'error', + 'no-duplicate-case': 'error', + 'no-empty': ['warn', { allowEmptyCatch: true }], + 'no-ex-assign': 'error', + 'no-extra-boolean-cast': 'warn', + 'no-func-assign': 'error', + 'no-inner-declarations': 'error', + 'no-irregular-whitespace': 'error', + 'no-unreachable': 'error', + 'use-isnan': 'error', + 'valid-typeof': 'error', + 'eqeqeq': ['warn', 'always'], + 'no-caller': 'error', + 'no-eval': 'error', + 'no-implied-eval': 'error', + 'no-new-func': 'error', + 'no-throw-literal': 'warn', + 'no-self-assign': 'error', + 'no-self-compare': 'error', + 'no-loss-of-precision': 'error', + 'no-dupe-keys': 'error', + 'no-unsafe-finally': 'error', + 'no-unmodified-loop-condition': 'warn', + 'no-template-curly-in-string': 'warn', +}; + +const nodeGlobals = { + process: 'readonly', + console: 'readonly', + setTimeout: 'readonly', + clearTimeout: 'readonly', + setInterval: 'readonly', + clearInterval: 'readonly', + setImmediate: 'readonly', + Buffer: 'readonly', + URL: 'readonly', + URLSearchParams: 'readonly', + fetch: 'readonly', + crypto: 'readonly', +}; + export default [ + { ignores: ['**/node_modules/**', 'release/**', 'tests/**'] }, { files: ['**/*.js'], - ignores: ['node_modules/**', 'release/**', 'tests/**'], + ignores: ['gateway/**'], plugins: { security }, languageOptions: { ecmaVersion: 2022, @@ -14,16 +71,7 @@ export default [ exports: 'readonly', __dirname: 'readonly', __filename: 'readonly', - process: 'readonly', - console: 'readonly', - setTimeout: 'readonly', - clearTimeout: 'readonly', - setInterval: 'readonly', - clearInterval: 'readonly', - setImmediate: 'readonly', - Buffer: 'readonly', - URL: 'readonly', - fetch: 'readonly', + ...nodeGlobals, AbortController: 'readonly', AbortSignal: 'readonly', navigator: 'readonly', @@ -36,50 +84,20 @@ export default [ requestAnimationFrame: 'readonly', queueMicrotask: 'readonly', Intl: 'readonly', - crypto: 'readonly', - URLSearchParams: 'readonly', EventSource: 'readonly', } }, - rules: { - // Security rules - // detect-object-injection disabled: 78 false positives from config lookups like obj[hosterName] - 'security/detect-object-injection': 'off', - 'security/detect-non-literal-regexp': 'warn', - 'security/detect-unsafe-regex': 'warn', - 'security/detect-buffer-noassert': 'warn', - 'security/detect-eval-with-expression': 'error', - 'security/detect-no-csrf-before-method-override': 'warn', - 'security/detect-possible-timing-attacks': 'warn', - 'security/detect-pseudoRandomBytes': 'warn', - // Code quality - 'no-unused-vars': ['warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }], - 'no-undef': 'error', - 'no-constant-condition': 'warn', - 'no-debugger': 'error', - 'no-duplicate-case': 'error', - 'no-empty': ['warn', { allowEmptyCatch: true }], - 'no-ex-assign': 'error', - 'no-extra-boolean-cast': 'warn', - 'no-func-assign': 'error', - 'no-inner-declarations': 'error', - 'no-irregular-whitespace': 'error', - 'no-unreachable': 'error', - 'use-isnan': 'error', - 'valid-typeof': 'error', - 'eqeqeq': ['warn', 'always'], - 'no-caller': 'error', - 'no-eval': 'error', - 'no-implied-eval': 'error', - 'no-new-func': 'error', - 'no-throw-literal': 'warn', - 'no-self-assign': 'error', - 'no-self-compare': 'error', - 'no-loss-of-precision': 'error', - 'no-dupe-keys': 'error', - 'no-unsafe-finally': 'error', - 'no-unmodified-loop-condition': 'warn', - 'no-template-curly-in-string': 'warn', - } + rules: sharedRules + }, + { + files: ['gateway/**/*.js', 'gateway/**/*.mjs'], + ignores: ['gateway/node_modules/**'], + plugins: { security }, + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: nodeGlobals + }, + rules: sharedRules } ]; diff --git a/gateway/index.js b/gateway/index.js index 29b94f7..6d09f06 100644 --- a/gateway/index.js +++ b/gateway/index.js @@ -48,7 +48,7 @@ const DIAGNOSTIC_TOOLS = [ { name: 'read_log', title: 'Read a log file', - description: 'Read a tail of one of the app log files, optionally grep-filtered, optionally a rotated backup.', + description: 'Read a tail of one of the app log files, optionally a rotated backup. grep is a case-insensitive substring filter; separate alternatives with "|" (e.g. "error|timeout|502") to keep any line matching at least one term. Not a regular expression.', op: 'read_log', inputSchema: { name: z.enum(['debug', 'fileuploader', 'accountRotation', 'crash']), @@ -172,7 +172,8 @@ async function doConnect({ code, host, port, label }) { let version; const info = await client.request('get_system_info', {}); if (info && info.ok && info.data) { - version = info.data.version ?? info.data.appVersion ?? undefined; + const d = info.data; + version = d.version ?? d.appVersion ?? (d.app && d.app.version) ?? (d.agent && d.agent.version) ?? undefined; } const id = `${target.label}@${target.host}:${target.port}`; diff --git a/gateway/package.json b/gateway/package.json index 5b2fcf8..4fb9950 100644 --- a/gateway/package.json +++ b/gateway/package.json @@ -10,6 +10,10 @@ "engines": { "node": ">=18" }, + "scripts": { + "test": "node --test \"test/**/*.test.js\"", + "verify": "node verify/e2e-verify.mjs && node verify/integration-mcp.mjs && node verify/adversarial-probe.mjs" + }, "dependencies": { "@modelcontextprotocol/sdk": "~1.29.0", "ws": "^8", diff --git a/gateway/registry.js b/gateway/registry.js index 70df937..8e49145 100644 --- a/gateway/registry.js +++ b/gateway/registry.js @@ -1,6 +1,8 @@ import { readFile, writeFile, chmod } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import { execFile } from 'node:child_process'; +import { userInfo } from 'node:os'; const HERE = dirname(fileURLToPath(import.meta.url)); const REGISTRY_PATH = join(HERE, 'registry.json'); @@ -18,10 +20,20 @@ export async function loadRegistry() { } } +function tightenWindowsAcl(path) { + return new Promise((resolve) => { + let user; + try { user = userInfo().username; } catch { resolve(); return; } + if (!user) { resolve(); return; } + execFile('icacls', [path, '/inheritance:r', '/grant:r', `${user}:F`], { windowsHide: true }, () => resolve()); + }); +} + export async function saveRegistry(registry) { const data = registry && typeof registry === 'object' ? registry : {}; await writeFile(REGISTRY_PATH, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 }); try { await chmod(REGISTRY_PATH, 0o600); } catch {} + if (process.platform === 'win32') { try { await tightenWindowsAcl(REGISTRY_PATH); } catch {} } } export async function upsertEntry(entry) { diff --git a/gateway/verify/adversarial-probe.mjs b/gateway/verify/adversarial-probe.mjs new file mode 100644 index 0000000..b50616b --- /dev/null +++ b/gateway/verify/adversarial-probe.mjs @@ -0,0 +1,137 @@ +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { writeFileSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import WebSocket from 'ws'; + +const require = createRequire(import.meta.url); +const appRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const support = require(join(appRoot, 'lib', 'support-bundle.js')); +const stats = require(join(appRoot, 'lib', 'stats.js')); +const RemoteServer = require(join(appRoot, 'lib', 'remote-server.js')); +const { createCollectors } = require(join(appRoot, 'lib', 'diagnostics-collectors.js')); +const { createAgent } = require(join(appRoot, 'lib', 'diagnostics-agent.js')); + +const findings = []; +function leakCheck(label, text, needle, { realistic }) { + if (text.includes(needle)) findings.push({ label, needle: needle.slice(0, 24), realistic }); +} + +console.log('=== A. redactLogText pattern-scrub battery (no config secrets; pure shape detection) ==='); +const battery = [ + { line: 'Authorization: Bearer abcDEF123456ghiJKL789', needle: 'abcDEF123456ghiJKL789', realistic: true }, + { line: 'using Bearer eyJhbGciOiJIUzI1Nib-longtokenvalue-0099', needle: 'eyJhbGciOiJIUzI1Nib-longtokenvalue-0099', realistic: true }, + { line: 'resp token=sess_9f8e7d6c5b4a3210ffee', needle: 'sess_9f8e7d6c5b4a3210ffee', realistic: true }, + { line: 'access_token: ya29.A0ARrdaM-longgoogletoken-123', needle: 'ya29.A0ARrdaM-longgoogletoken-123', realistic: true }, + { line: 'refresh_token = 1//0ggLongRefreshToken_abcdef', needle: '1//0ggLongRefreshToken_abcdef', realistic: true }, + { line: 'x-api-key: SuperSecretApiKeyValue99', needle: 'SuperSecretApiKeyValue99', realistic: true }, + { line: 'api_key=AKIAIOSFODNN7EXAMPLEKEY', needle: 'AKIAIOSFODNN7EXAMPLEKEY', realistic: true }, + { line: 'GET /up?key=querykeysecret12345 HTTP/1.1', needle: 'querykeysecret12345', realistic: true }, + { line: 'POST https://discord.com/api/webhooks/123456789012345678/WEBHOOKTOKENsecretvalue', needle: 'WEBHOOKTOKENsecretvalue', realistic: true }, + { line: 'set-cookie: session=COOKIEsecretvalue9988; HttpOnly', needle: 'COOKIEsecretvalue9988', realistic: true }, + { line: 'Cookie: sess_id=ABCcookievalue12345', needle: 'ABCcookievalue12345', realistic: true }, + { line: 'sessionId: SESSIONsecret009988aa', needle: 'SESSIONsecret009988aa', realistic: true }, + { line: 'two leaks: token=firsttok12345678 and api_key=secondkey87654321', needle: 'secondkey87654321', realistic: true }, + { line: 'proxy https://admin:Sup3rProxyPass@proxy.internal:8080/path', needle: 'Sup3rProxyPass', realistic: true }, + { line: 'Authorization: Basic dXNlcjpwYXNzd29yZF9zZWNyZXQ=', needle: 'dXNlcjpwYXNzd29yZF9zZWNyZXQ', realistic: true }, + { line: 'jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhYmMxMjMifQ.SflKxwRJSMeKKF2QT4fwpMabc', needle: 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhYmMxMjMifQ', realistic: true }, + { line: 'session=BareSessionSecret998877', needle: 'BareSessionSecret998877', realistic: true }, + { line: 'password: PlainTextPassword123 was used', needle: 'PlainTextPassword123', realistic: true }, + { line: 'random high-entropy blob 9f8e7d6c5b4a3210ffeeddccbbaa with no key context', needle: '9f8e7d6c5b4a3210ffeeddccbbaa', realistic: false }, +]; +for (const t of battery) { + const out = support.redactLogText(t.line, []); + leakCheck('redactLogText: ' + t.line.slice(0, 40), out, t.needle, t); + console.log(` ${out.includes(t.needle) ? 'LEAK ' : 'scrub'} ${t.line.slice(0, 52)}`); +} + +console.log('\n=== B. value-scrub: config secret in odd encodings (deepRedact via collectors) ==='); +const SECRET = 'CFGsecret_aabbccddeeff'; +const tmp = mkdtempSync(join(tmpdir(), 'mhu-adv-')); +writeFileSync(join(tmp, 'debug.log'), `plain ${SECRET}\nurlenc CFGsecret_aabbccddeeff also\n`); +const cfg = { + hosters: { doodstream: [{ accountId: 'a', apiKey: SECRET }] }, hosterSettings: {}, + globalSettings: { diagnostics: { enabled: true, token: 'd'.repeat(64) }, + pendingQueue: { savedAt: 1, selectedUploadHosters: [], selectedFiles: [], queueJobs: [ + { file: 'x', fileName: 'x', hoster: 'doodstream', status: 'error', error: `failed ${SECRET}` }] } }, + history: [{ timestamp: 't', files: [{ name: 'x', results: [{ hoster: 'doodstream', status: 'error', error: `e ${SECRET}` }] }] }], + rotationCursors: {}, +}; +const cols = createCollectors({ + loadConfig: () => JSON.parse(JSON.stringify(cfg)), + getAllLogPaths: () => ({ fileuploader: join(tmp, 'f.log'), debug: join(tmp, 'debug.log'), accountRotation: join(tmp, 'r.log'), doodstreamDebug: join(tmp, 'doodstream-debug.log'), crashLog: join(tmp, 'c.log'), logDir: tmp }), + support, stats, appInfo: () => ({ version: '3.3.84' }), systemInfo: () => ({}), agentInfo: () => ({}), +}); +for (const [name, fn] of [ + ['getConfigRedacted(all)', () => cols.getConfigRedacted({ section: 'all' })], + ['getQueueState(includeJobs)', () => cols.getQueueState({ includeJobs: true })], + ['getQueueState(default)', () => cols.getQueueState({})], + ['listErrors', () => cols.listErrors({})], + ['getHistory(files)', () => cols.getHistory({ includeFiles: true })], + ['serverHealth', () => cols.serverHealth({})], + ['readLog(debug)', () => cols.readLog({ name: 'debug' })], +]) { + const text = JSON.stringify(fn()); + leakCheck('collector:' + name, text, SECRET, { realistic: true }); + console.log(` ${text.includes(SECRET) ? 'LEAK ' : 'scrub'} ${name}`); +} + +console.log('\n=== C. abuse / DoS: ReDoS grep, oversized tailKb, malformed args (must not hang/crash) ==='); +const t0 = Date.now(); +writeFileSync(join(tmp, 'debug.log'), 'a'.repeat(80) + '! catastrophic-bait line\n' + `plain ${SECRET}\nurlenc CFGsecret_aabbccddeeff also\n`); +const redos = cols.readLog({ name: 'debug', grep: '(a+)+$', tailKb: 1 }); +const redosMs = Date.now() - t0; +if (redosMs > 1500) findings.push({ label: 'grep ReDoS hang (' + redosMs + 'ms) on 80-a line', needle: '(a+)+$', realistic: true }); +console.log(` grep "(a+)+$" vs 80-a line returned in ${redosMs}ms (must be <1500: ${redosMs < 1500})`); +const longGrep = cols.readLog({ name: 'debug', grep: 'a'.repeat(5000) }); +console.log(` grep 5000-char pattern: ${longGrep && (longGrep.matchedLines !== undefined || longGrep.content !== undefined) ? 'handled' : 'handled'}`); +const bigTail = cols.readLog({ name: 'debug', tailKb: 9999999 }); +console.log(` tailKb 9999999 clamped to: ${bigTail.tailKb} (<=1024:${bigTail.tailKb <= 1024})`); +let crashed = false; +for (const bad of [null, undefined, 42, [], { name: 123 }, { name: ['debug'] }, { name: 'debug', backup: 'evil' }, { name: 'debug', tailKb: -5 }, { limit: 'NaN' }]) { + try { cols.readLog(bad); cols.listErrors(bad); cols.getQueueState(bad); cols.getHistory(bad); cols.getAppEvents(bad); } + catch (e) { crashed = true; findings.push({ label: 'collector THREW on malformed args: ' + JSON.stringify(bad), needle: String(e.message), realistic: true }); } +} +console.log(` malformed-args battery: ${crashed ? 'THREW (bad)' : 'no throw (good)'}`); + +console.log('\n=== D. agent whitelist: write/exec/unknown ops rejected, never throws ==='); +const agent = createAgent(cols); +let agentThrew = false; +for (const op of ['save_config', 'run_health_check', 'exec', 'eval', 'delete_log', '__proto__', 'constructor', 'getConfigRedacted', '', null, 'get_config_redacted; drop']) { + try { const r = agent.handle(op, {}); if (r && r.ok === true && !['get_config_redacted'].includes(op)) findings.push({ label: 'agent ACCEPTED non-whitelisted op: ' + op, needle: op, realistic: true }); } + catch (e) { agentThrew = true; findings.push({ label: 'agent THREW on op ' + op, needle: String(e.message), realistic: true }); } +} +console.log(` non-whitelisted ops: ${agentThrew ? 'THREW (bad)' : 'all returned {ok:false} (good)'}`); + +console.log('\n=== E. transport abuse: brute-force lockout + concurrent clients (live RemoteServer) ==='); +const TOKEN = 'z'.repeat(64); +const srv = new RemoteServer(); +await srv.start({ port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, onDiagnosticRequest: (m, _c, reply) => reply(agent.handle(m.op, m.args)) }); +const port = srv.getPort(); +function wsOnce(sendToken) { + return new Promise((resolve) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + let authed = false; + ws.on('open', () => ws.send(JSON.stringify({ type: 'auth', token: sendToken, role: 'diagnostic' }))); + ws.on('message', (raw) => { try { const m = JSON.parse(raw); if (m.type === 'auth-ok') { authed = true; ws.close(); resolve({ authed: true }); } } catch {} }); + ws.on('close', (code) => resolve({ authed, code })); + ws.on('error', () => {}); + }); +} +const okClients = await Promise.all([wsOnce(TOKEN), wsOnce(TOKEN), wsOnce(TOKEN)]); +console.log(` 3 concurrent valid clients all authed: ${okClients.every(c => c.authed)}`); +let lastCode = null; +for (let i = 0; i < 6; i++) lastCode = (await wsOnce('wrongtoken')).code; +console.log(` after 6 bad-token attempts, close code = ${lastCode} (4003 lockout expected: ${lastCode === 4003})`); +const afterLock = await wsOnce(TOKEN); +console.log(` valid token DURING lockout window: authed=${afterLock.authed} closeCode=${afterLock.code} (locked out even with right token: ${!afterLock.authed})`); +srv.stop(); + +console.log('\n=== SUMMARY ==='); +const real = findings.filter(f => f.realistic); +const theo = findings.filter(f => !f.realistic); +if (theo.length) console.log(` ${theo.length} THEORETICAL (acknowledged denylist limit): ${theo.map(f => f.label).join(' | ')}`); +if (real.length) { console.log(` ${real.length} REAL finding(s):`); for (const f of real) console.log(` - ${f.label} :: ${f.needle}`); process.exit(2); } +console.log(' No REAL leaks/crashes found. (Theoretical = standalone secret with zero key/Bearer/URL context — inherent to denylist.)'); +process.exit(0); diff --git a/gateway/verify/integration-mcp.mjs b/gateway/verify/integration-mcp.mjs new file mode 100644 index 0000000..5b4ea0f --- /dev/null +++ b/gateway/verify/integration-mcp.mjs @@ -0,0 +1,217 @@ +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import { writeFileSync, mkdtempSync, readFileSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import assert from 'node:assert'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { encode } from '../code.js'; + +const require = createRequire(import.meta.url); +const here = dirname(fileURLToPath(import.meta.url)); +const gwRoot = join(here, '..'); +const appRoot = join(gwRoot, '..'); +const indexPath = join(gwRoot, 'index.js'); +const registryPath = join(gwRoot, 'registry.json'); + +const RemoteServer = require(join(appRoot, 'lib', 'remote-server.js')); +const support = require(join(appRoot, 'lib', 'support-bundle.js')); +const stats = require(join(appRoot, 'lib', 'stats.js')); +const { createCollectors } = require(join(appRoot, 'lib', 'diagnostics-collectors.js')); +const { createAgent } = require(join(appRoot, 'lib', 'diagnostics-agent.js')); + +const SECRET_API = 'APIKEY_live_77ffee0011aabb'; +const SECRET_PW = 'pw_S3cr3t_zzqqww'; +const SECRET_DIAGTOK = 'd'.repeat(64); +const OPAQUE_TOK = 'OPAQUE_session_tok_5a4b3c2d1e'; +const WEBHOOK = 'https://discord.com/api/webhooks/987654321098765432/IntegrationWebhookSecretXyz'; +const SECRETS = [SECRET_API, SECRET_PW, SECRET_DIAGTOK, OPAQUE_TOK, 'IntegrationWebhookSecretXyz']; + +const tmp = mkdtempSync(join(tmpdir(), 'mhu-int-')); +const debugLog = join(tmp, 'debug.log'); +const rotLog = join(tmp, 'account-rotation.log'); +const dood = join(tmp, 'doodstream-debug.log'); +writeFileSync(debugLog, [ + `[2026-06-19T10:00:00Z] boot ok`, + `[2026-06-19T10:00:01Z] upload with apiKey=${SECRET_API}`, + `[2026-06-19T10:00:02Z] Authorization: Bearer ${OPAQUE_TOK}`, + `[2026-06-19T10:00:03Z] grepneedle marker line`, +].join('\n')); +writeFileSync(rotLog, `[2026-06-19T10:00:00Z] rotating to acc2\n`); +writeFileSync(dood, `[dood] api_key=${SECRET_API}\n`); + +const config = { + hosters: { doodstream: [{ accountId: 'acc1', apiKey: SECRET_API, password: SECRET_PW }] }, + hosterSettings: {}, + globalSettings: { + webhookUrl: WEBHOOK, + diagnostics: { enabled: true, port: 9110, token: SECRET_DIAGTOK }, + pendingQueue: { savedAt: '2026-06-19T09:00:00Z', selectedUploadHosters: ['doodstream'], selectedFiles: ['a.mp4', 'b.mp4'], queueJobs: [ + { file: 'a.mp4', fileName: 'a.mp4', hoster: 'doodstream', status: 'error', error: `rejected token=${OPAQUE_TOK}` }, + { file: 'b.mp4', fileName: 'b.mp4', hoster: 'doodstream', status: 'uploading', error: null }, + ] }, + }, + rotationCursors: { doodstream: 1 }, + history: [{ timestamp: '2026-06-19T08:00:00Z', files: [{ name: 'a.mp4', results: [ + { hoster: 'doodstream', status: 'error', error: `boom apiKey=${SECRET_API}` }, + { hoster: 'voe.sx', status: 'done', url: 'https://voe.sx/x' }, + ] }] }], +}; + +function getAllLogPaths() { + return { fileuploader: join(tmp, 'fileuploader.log'), debug: debugLog, accountRotation: rotLog, doodstreamDebug: dood, crashLog: join(tmp, 'crash.log'), logDir: tmp }; +} + +const collectors = createCollectors({ + loadConfig: () => JSON.parse(JSON.stringify(config)), + getAllLogPaths, support, stats, + appInfo: () => ({ name: 'mhu', version: '3.3.84' }), + systemInfo: () => ({ platform: 'win32', hostname: 'INT-HOST', cpuCount: 8 }), + agentInfo: () => ({ version: '3.3.84', port: 9110, clientCount: 1, lastAccess: null }), +}); +const agent = createAgent(collectors); + +const TOKEN = 't'.repeat(64); +const failures = []; +function check(name, cond, detail) { + if (cond) { console.log(` PASS ${name}`); } + else { console.log(` FAIL ${name}${detail ? ' :: ' + detail : ''}`); failures.push(name); } +} +function noLeak(name, payloadText) { + for (const s of SECRETS) { + if (payloadText.includes(s)) { check(`${name} (no-leak:${s.slice(0, 10)})`, false, 'secret present'); return; } + } + check(`${name} (no-leak)`, true); +} + +const srv = new RemoteServer(); +let client, transport; +const savedRegistry = existsSync(registryPath) ? readFileSync(registryPath, 'utf8') : null; + +async function callJSON(name, args) { + try { + const r = await client.callTool({ name, arguments: args || {} }); + const text = r.content ? r.content.map(c => c.text || '').join('') : ''; + let data; try { data = JSON.parse(text); } catch { data = null; } + return { text, data, isError: !!r.isError, threw: false }; + } catch (e) { + return { text: String(e && e.message || e), data: null, isError: true, threw: true }; + } +} +function rejected(r) { return r.threw || r.isError || (r.data && r.data.ok === false); } + +(async () => { + await srv.start({ + port: 0, host: '127.0.0.1', token: TOKEN, diagnosticMode: true, + onDiagnosticRequest: (msg, _c, reply) => { + let res; try { res = agent.handle(msg.op, msg.args); } catch (e) { res = { ok: false, error: String(e && e.message || e) }; } + reply(res); + }, + }); + const port = srv.getPort(); + const code = encode({ v: 1, port, token: TOKEN, label: 'integration' }); + + transport = new StdioClientTransport({ command: process.execPath, args: [indexPath] }); + client = new Client({ name: 'mhu-int-test', version: '1.0.0' }); + await client.connect(transport); + + const toolList = await client.listTools(); + const names = toolList.tools.map(t => t.name).sort(); + const expected = ['connect_server', 'current_server', 'disconnect_server', 'get_app_events', 'get_config_redacted', 'get_history', 'get_queue_state', 'get_rotation_state', 'get_system_info', 'list_errors', 'list_logs', 'list_servers', 'read_log', 'server_health'].sort(); + check('listTools returns all 14 tools', JSON.stringify(names) === JSON.stringify(expected), names.join(',')); + + let r = await callJSON('connect_server', { code, host: '127.0.0.1' }); + check('connect_server ok', r.data && r.data.ok === true, r.text.slice(0, 120)); + check('connect_server reports version 3.3.84', r.data && r.data.server && r.data.server.version === '3.3.84'); + + r = await callJSON('current_server'); + check('current_server shows host 127.0.0.1', r.data && r.data.current && r.data.current.host === '127.0.0.1'); + + r = await callJSON('server_health', { errorLimit: 10 }); + check('server_health ok + sections', r.data && r.data.ok && r.data.data.errors && r.data.data.queue && r.data.data.logs); + noLeak('server_health', r.text); + + r = await callJSON('read_log', { name: 'debug', tailKb: 64 }); + check('read_log debug ok + benign content kept', r.data && r.data.ok && r.data.data.content.includes('grepneedle marker line')); + noLeak('read_log:debug', r.text); + + r = await callJSON('read_log', { name: 'debug', grep: 'grepneedle' }); + check('read_log grep filters to matching line', r.data && r.data.ok && r.data.data.content.includes('grepneedle') && !r.data.data.content.includes('boot ok')); + + r = await callJSON('read_log', { name: 'doodstream' }); + check('read_log doodstream REJECTED (live keys)', rejected(r), r.text.slice(0, 100)); + noLeak('read_log:doodstream-reject', r.text); + + r = await callJSON('read_log', { name: '../../../etc/passwd' }); + check('read_log path traversal REJECTED', rejected(r), r.text.slice(0, 100)); + noLeak('read_log:traversal-reject', r.text); + + r = await callJSON('list_logs'); + check('list_logs lists debug+fileuploader+accountRotation+crash', r.data && r.data.ok && r.data.data.files.length === 4); + check('list_logs marks doodstream NOT readable (not in files)', r.data && !r.data.data.files.some(f => f.name === 'doodstream')); + + r = await callJSON('list_errors', { limit: 50 }); + check('list_errors finds the 1 history error', r.data && r.data.ok && r.data.data.total === 1); + noLeak('list_errors', r.text); + + r = await callJSON('get_queue_state', { includeJobs: true }); + check('get_queue_state jobs present (2)', r.data && r.data.ok && Array.isArray(r.data.data.jobs) && r.data.data.jobs.length === 2); + check('get_queue_state stale flag set', r.data && r.data.data.stale === true); + noLeak('get_queue_state:includeJobs', r.text); + + r = await callJSON('get_queue_state', {}); + noLeak('get_queue_state:default-args', r.text); + + r = await callJSON('get_history', { limit: 10, includeFiles: true, includeUrls: true }); + check('get_history returns batches + perHoster', r.data && r.data.ok && Array.isArray(r.data.data.batches) && r.data.data.perHoster); + noLeak('get_history', r.text); + + r = await callJSON('get_config_redacted', { section: 'all' }); + check('get_config_redacted ok + history omitted', r.data && r.data.ok && r.data.data.config && r.data.data.config.history === undefined); + noLeak('get_config_redacted:all', r.text); + + r = await callJSON('get_config_redacted', { section: 'hosters' }); + noLeak('get_config_redacted:hosters', r.text); + + r = await callJSON('get_rotation_state'); + check('get_rotation_state returns cursors', r.data && r.data.ok && r.data.data.rotationCursors); + noLeak('get_rotation_state', r.text); + + r = await callJSON('get_system_info'); + check('get_system_info returns app+system+agent', r.data && r.data.ok && r.data.data.app && r.data.data.system); + noLeak('get_system_info', r.text); + + r = await callJSON('get_app_events', { limit: 20 }); + check('get_app_events ok', r.data && r.data.ok); + noLeak('get_app_events', r.text); + + r = await callJSON('list_servers'); + check('list_servers includes the connected one', r.data && r.data.ok && r.data.current && r.data.current.includes('integration')); + + r = await callJSON('disconnect_server'); + check('disconnect_server ok', r.data && r.data.ok && r.data.disconnected === true); + + r = await callJSON('server_health'); + check('tool after disconnect returns guidance (no server connected)', r.data && r.data.ok === false && /connect_server/.test(r.data.error || '')); + + r = await callJSON('connect_server', { code: 'mhu1_not_valid_base64!!', host: '127.0.0.1' }); + check('connect with garbage code REJECTED', r.data && r.data.ok === false); + + r = await callJSON('connect_server', { code, host: '127.0.0.1', port: 1 }); + check('connect to dead port REJECTED with guidance', r.data && r.data.ok === false && /not running|refused|firewall|closed|timeout/i.test(r.data.error || '')); + + await client.close(); + srv.stop(); + if (savedRegistry !== null) writeFileSync(registryPath, savedRegistry, 'utf8'); + + console.log(''); + if (failures.length) { console.log(`INTEGRATION FAIL: ${failures.length} check(s) failed: ${failures.join(' | ')}`); process.exit(1); } + console.log('INTEGRATION PASS: full gateway-MCP <-> live agent stack verified, every tool, no leaks, error paths correct.'); + process.exit(0); +})().catch((e) => { + console.error('INTEGRATION ERROR:', e && e.stack || e); + try { srv.stop(); } catch {} + try { if (savedRegistry !== null) writeFileSync(registryPath, savedRegistry, 'utf8'); } catch {} + process.exit(1); +});