Multi-Hoster-Upload/gateway/index.js
Administrator 0c6c502aab feat(diagnostics): network bind + fail-closed IP allowlist + host-in-code (Tailscale, like rd-diagnostics-mcp)
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>
2026-06-19 19:32:29 +02:00

297 lines
8.7 KiB
JavaScript

#!/usr/bin/env node
import { fileURLToPath } from 'node:url';
import { z } from 'zod';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { decode } from './code.js';
import { AgentClient } from './agent-client.js';
import { loadRegistry, upsertEntry } from './registry.js';
const state = {
current: null,
servers: new Map(),
};
function result(obj) {
return { content: [{ type: 'text', text: JSON.stringify(obj, null, 2) }] };
}
function guard(handler) {
return async (args) => {
try {
return result(await handler(args ?? {}));
} catch (e) {
return result({ ok: false, error: String(e && e.message ? e.message : e) });
}
};
}
async function requireCurrent(op, args) {
if (!state.current || !state.current.client || !state.current.client.connected) {
return { ok: false, error: 'no server connected — call connect_server first' };
}
return state.current.client.request(op, args ?? {});
}
const DIAGNOSTIC_TOOLS = [
{
name: 'server_health',
title: 'Server health (one-shot hub)',
description:
'THE one-shot diagnostic hub: answers "what is wrong" in a single call by aggregating recent errors, queue state, rotation and system info.',
op: 'server_health',
inputSchema: {
errorLimit: z.number().optional(),
errorSinceMs: z.number().optional(),
},
},
{
name: 'read_log',
title: 'Read a log file',
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']),
tailKb: z.number().optional(),
grep: z.string().optional(),
backup: z.number().optional(),
},
},
{
name: 'list_logs',
title: 'List available logs',
description: 'List the available log files and their sizes.',
op: 'list_logs',
inputSchema: {},
},
{
name: 'list_errors',
title: 'List recent errors',
description: 'List recent structured errors, filterable by time window, category and hoster.',
op: 'list_errors',
inputSchema: {
sinceMs: z.number().optional(),
category: z.string().optional(),
hoster: z.string().optional(),
limit: z.number().optional(),
},
},
{
name: 'get_queue_state',
title: 'Get queue state',
description: 'Get the upload queue state, optionally including individual jobs.',
op: 'get_queue_state',
inputSchema: {
includeJobs: z.boolean().optional(),
maxJobs: z.number().optional(),
},
},
{
name: 'get_history',
title: 'Get upload history',
description: 'Get recent upload history, optionally including file names and URLs.',
op: 'get_history',
inputSchema: {
limit: z.number().optional(),
includeFiles: z.boolean().optional(),
includeUrls: z.boolean().optional(),
},
},
{
name: 'get_config_redacted',
title: 'Get redacted config',
description: 'Get the app configuration with secrets redacted. Choose a section to narrow the output.',
op: 'get_config_redacted',
inputSchema: {
section: z.enum(['all', 'hosters', 'hosterSettings', 'globalSettings', 'rotationCursors']).optional(),
},
},
{
name: 'get_system_info',
title: 'Get system info',
description: 'Get system and app version info (OS, app version, uptime).',
op: 'get_system_info',
inputSchema: {},
},
{
name: 'get_rotation_state',
title: 'Get rotation state',
description: 'Get the per-hoster account rotation state and round-robin cursors.',
op: 'get_rotation_state',
inputSchema: {},
},
{
name: 'get_app_events',
title: 'Get app events',
description: 'Get recent in-app lifecycle events.',
op: 'get_app_events',
inputSchema: {
limit: z.number().optional(),
},
},
];
async function doConnect({ code, host, port, label }) {
const registry = await loadRegistry();
let target;
if (label && registry[label]) {
const e = registry[label];
target = { host: e.host, port: e.port, token: e.token, fp: e.fp, label: e.label };
} else {
if (!code) {
return { ok: false, error: 'provide a known label, or a code (the host is taken from the code; pass host only to override)' };
}
let payload;
try {
payload = decode(code);
} catch (e) {
return { ok: false, error: String(e.message ?? e) };
}
const effHost = host || payload.host;
if (!effHost) {
return { ok: false, error: 'no host in the code and none provided — pass host (e.g. the Tailscale IP/MagicDNS name)' };
}
target = {
host: effHost,
port: typeof port === 'number' ? port : payload.port,
token: payload.token,
fp: payload.fp,
label: label || payload.label || effHost,
};
}
if (typeof port === 'number') target.port = port;
const client = new AgentClient(target);
try {
await client.connect();
} catch (e) {
return { ok: false, error: String(e.message ?? e) };
}
let version;
const info = await client.request('get_system_info', {});
if (info && info.ok && info.data) {
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}`;
if (state.current && state.current.client && state.current.client !== client) {
state.current.client.close();
}
state.servers.set(id, { id, client, target });
state.current = { id, client, target };
await upsertEntry({
host: target.host,
port: target.port,
token: target.token,
fp: target.fp,
label: target.label,
version,
lastConnectedAt: new Date().toISOString(),
});
return {
ok: true,
server: { label: target.label, host: target.host, port: target.port, version },
};
}
export function buildServer() {
const server = new McpServer({ name: 'mhu-diagnostics-gateway', version: '1.0.0' });
server.registerTool(
'list_servers',
{
title: 'List known servers',
description: 'List the registry of known diagnostic servers and which one is currently connected.',
inputSchema: {},
},
guard(async () => {
const registry = await loadRegistry();
return {
ok: true,
servers: Object.values(registry),
current: state.current ? state.current.id : null,
};
}),
);
server.registerTool(
'connect_server',
{
title: 'Connect to a diagnostic server',
description:
'Connect to a remote diagnostic agent. Use label for a known server, or a code for a new one — the host (e.g. a Tailscale IP/MagicDNS name) is taken from the code. Pass host only to override what the code carries.',
inputSchema: {
code: z.string().optional(),
host: z.string().optional(),
port: z.number().optional(),
label: z.string().optional(),
},
},
guard((args) => doConnect(args)),
);
server.registerTool(
'disconnect_server',
{
title: 'Disconnect the current server',
description: 'Close the connection to the currently connected diagnostic server.',
inputSchema: {},
},
guard(async () => {
if (!state.current) return { ok: true, disconnected: false };
const id = state.current.id;
try { state.current.client.close(); } catch {}
state.servers.delete(id);
state.current = null;
return { ok: true, disconnected: true, id };
}),
);
server.registerTool(
'current_server',
{
title: 'Show the current server',
description: 'Show the currently connected diagnostic target, or null if none.',
inputSchema: {},
},
guard(async () => {
if (!state.current) return { ok: true, current: null };
const t = state.current.target;
return {
ok: true,
current: { id: state.current.id, label: t.label, host: t.host, port: t.port },
};
}),
);
for (const tool of DIAGNOSTIC_TOOLS) {
server.registerTool(
tool.name,
{ title: tool.title, description: tool.description, inputSchema: tool.inputSchema },
guard((args) => requireCurrent(tool.op, args)),
);
}
return server;
}
async function main() {
const server = buildServer();
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[mhu-diag] gateway ready (stdio MCP)');
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
main().catch((e) => {
console.error('[mhu-diag] fatal:', e);
process.exit(1);
});
}