Matches the Real-Debrid-Downloader's rd-diagnostics-mcp model so the read-only
diagnostics agent is reachable over Tailscale (or any private tunnel) the same way
the downloader is, instead of requiring an SSH local-forward.
- lib/ip-allowlist.js (NEW): fail-closed IP allowlist — normalizeIp strips
::ffff:, loopback is always allowed, an empty allowlist accepts loopback ONLY
(fail-closed), exact IP + CIDR (incl. the Tailscale CGNAT range 100.64.0.0/10) +
wildcard rules. The real socket peer IP is the authority (never a forwarded header).
- remote-server.js: rejects non-allowlisted peers at connection (close 4005). Opt-in
via config.allowlist (the existing remote-control server, which passes none, is
unaffected). Loopback always passes, so local + SSH-forward use keeps working.
- Two bind modes (config diagnostics.bindMode): "local" -> 127.0.0.1 (default),
"network" -> 0.0.0.0 but ONLY when a non-empty allowlist is set (else it stays
loopback, fail-closed). The allowlist + token gate access; the tunnel
(Tailscale/WireGuard) is the confidentiality layer (transport is still plaintext ws://).
- The connection code now carries the host: mhu1_<base64url{v,h,p,t,n,fp?,s?}>. The
gateway decode is tolerant of the legacy {port,token,label} keys; connect_server
takes the host from the code (host arg is an optional override). Proven end-to-end:
the integration harness now connects with NO host arg and resolves it from the code.
- Renderer: Sichtbarkeit selector (local/network), public-host input with
suggested-host chips (os.networkInterfaces — the Tailscale IP shows up there),
allowlist textarea (network mode), and network-requires-allowlist validation.
- main.js: bindMode->host, getSuggestedRemoteHosts, host-in-code, allowlist plumbed
into startDiagnosticAgent + the diagnostics IPC (get/save/status).
- docs: rewritten for Tailscale (set the allowlist to your tailnet, put the Tailscale
IP/MagicDNS in the code address — no SSH forward needed).
This supersedes the v3.3.85 hard loopback-lock with the downloader's allowlist model.
Tests: lib/ip-allowlist (8) + remote-server allowlist wiring/loopback (2) + gateway
decode (host short-key + legacy tolerance). 393 app tests + 9 gateway tests + e2e +
host-in-code integration + adversarial all green; lint 0 errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
219 lines
11 KiB
JavaScript
219 lines
11 KiB
JavaScript
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, h: '127.0.0.1', p: port, t: TOKEN, n: '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 });
|
|
check('connect_server ok (host taken from the code, no host arg)', r.data && r.data.ok === true, r.text.slice(0, 120));
|
|
check('connect_server resolved host from code', r.data && r.data.server && r.data.server.host === '127.0.0.1');
|
|
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);
|
|
});
|