Adds the connect-by-code side of remote diagnostics and closes two real secret-leak vectors that an end-to-end gateway<->agent test surfaced. Gateway (gateway/, local stdio MCP, Claude connects once): - 14 read-only tools (server_health hub, read_log, list_logs, list_errors, get_queue_state, get_history, get_config_redacted, get_system_info, get_rotation_state, get_app_events + connect/disconnect/list/current). - The HOST is always supplied by the operator, never taken from the code. - TLS fingerprint pinning is enforced in the socket 'open' handler BEFORE the token is sent (wss opt-in); plain ws is loopback-only. - registry.json (holds bearer tokens) is gitignored; only an empty example ships. Security hardening (gates every off-box payload): - redactLogText now scrubs opaque bearer/token-family secrets that are NOT stored config credentials (e.g. a session token a hoster returns inside an error string): bare token/auth_token/refresh_token/session_token + standalone "Bearer <opaque>". Benign "token bucket" prose is left intact. - get_config_redacted deep-redacts every string leaf (JSON-safe, per-leaf, so the cookie/sess line patterns can't gobble across a compact-JSON field) and drops the history subtree (served by get_history with its own per-error redaction). This plugs leaks via globalSettings.pendingQueue[].error etc. Bind-address safety: - _safeDiagBindAddress() forces the diagnostic agent to 127.0.0.1/::1; the 0.0.0.0 UI option is removed. Direct LAN/Internet bind stays disabled until encrypted transport (wss) exists — remote access goes through an SSH/VPN tunnel to loopback. (Never plaintext ws:// on all interfaces.) Tests: end-to-end gateway<->agent gate (connect -> server_health/read_log/ get_config_redacted, asserts zero secret leakage, rejects doodstream log, path traversal and write ops); + redaction regression tests in the main suite. 385 app tests + 9 gateway tests pass; lint 0 errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
295 lines
8.3 KiB
JavaScript
295 lines
8.3 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 grep-filtered, optionally a rotated backup.',
|
|
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 plus host' };
|
|
}
|
|
let payload;
|
|
try {
|
|
payload = decode(code);
|
|
} catch (e) {
|
|
return { ok: false, error: String(e.message ?? e) };
|
|
}
|
|
if (!host) {
|
|
return { ok: false, error: 'host is required when connecting with a code' };
|
|
}
|
|
target = {
|
|
host,
|
|
port: typeof port === 'number' ? port : payload.port,
|
|
token: payload.token,
|
|
fp: payload.fp,
|
|
label: label || payload.label,
|
|
};
|
|
}
|
|
|
|
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) {
|
|
version = info.data.version ?? info.data.appVersion ?? 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 code+host for a new one. The host is always supplied by you, never taken from the code.',
|
|
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);
|
|
});
|
|
}
|