Ferndiagnose (MCP): Verbindungscode + abgesicherter Fernzugriff + stdio-Bridge
Neue Funktion, um Diagnose eines laufenden Servers aus der Ferne zu ermoeglichen:
Hilfe -> Remote-Support -> "Ferndiagnose (MCP)". Erzeugt einen Verbindungscode,
den der Assistent nutzt, um Status, Logs, Fehler und Accounts read-only zu lesen.
App-Seite:
- Live (re)startbarer Debug-Server ohne App-Neustart (restartDebugServer wartet auf
'close' + closeAllConnections, behandelt EADDRINUSE).
- IP-Allowlist (debug_allowlist.txt, exakte IP + CIDR), erzwungen VOR der Auth.
Fail-closed: Netzwerk-Bind (0.0.0.0) ohne Allowlist akzeptiert nur Loopback.
- One-Click Aktivieren/Aktualisieren/Deaktivieren + Token-Rotation (alter Code sofort
ungueltig). Sichtbarkeit waehlbar: "Nur lokal" (Tunnel-Empfehlung) vs "Im Netzwerk".
- Verbindungscode rddiag:v1:base64url({v,h,p,t,n?,fp?,s?}); oeffentlicher Host frei
waehlbar, Netzwerk-IPs als Schnellauswahl.
- Neue IPC: get/enable/disable/rotate Remote-Diagnostics; Controller-Methoden; Typen.
Bridge (tools/rd-diagnostics-mcp, standalone, KEINE App-Dependency):
- stdio MCP-Server (@modelcontextprotocol/sdk) mit 14 Tools, proxyt die bestehende
HTTP-Debug-API. Multi-Server ueber code/server/RDDIAG_CODE/RDDIAG_SERVERS.
- TLS-Fingerprint-Pinning auf secureConnect (vor Token-Versand), falls https genutzt.
- test/harness.mjs: faehrt einen Fake-Debug-Server hoch und treibt die Bridge als
echten stdio-Child per JSON-RPC -> voller Protokollpfad gruen.
Sicherheit (Audit): keine persistenten Secrets in den Logs der Debug-API (Passwoerter
redigiert, keine Debrid-Keys/aufgeloesten Download-URLs geloggt; settings/accounts
redigiert). Empfohlener Transport: Loopback + privater Tunnel; Direkt-Bind nur mit
Allowlist in vertrauenswuerdigen Netzen.
Tests: connection-code-Cross-Check (App-Encoder <-> Bridge-Decoder), Allowlist-Matrix
(Loopback, exakt, CIDR, fail-closed, Live-Restart). Volle Suite 905 gruen, tsc=6.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import http from "node:http";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { encodeConnectionCode } from "../src/code.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const BRIDGE = join(__dirname, "..", "src", "bridge.mjs");
|
||||
const TOKEN = "test-token-abc123";
|
||||
|
||||
const failures = [];
|
||||
function check(name, cond, detail) {
|
||||
if (cond) {
|
||||
process.stdout.write(` PASS ${name}\n`);
|
||||
} else {
|
||||
failures.push(name);
|
||||
process.stdout.write(` FAIL ${name}${detail ? " — " + detail : ""}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function startFakeServer() {
|
||||
return new Promise((resolve) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const auth = req.headers.authorization || "";
|
||||
const tokenOk = auth === `Bearer ${TOKEN}` || url.searchParams.get("token") === TOKEN;
|
||||
if (!tokenOk) {
|
||||
res.writeHead(401, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Unauthorized" }));
|
||||
return;
|
||||
}
|
||||
const p = url.pathname;
|
||||
const q = Object.fromEntries(url.searchParams.entries());
|
||||
const send = (obj) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify(obj));
|
||||
};
|
||||
if (p === "/health") return send({ status: "ok", appVersion: "1.7.222", uptime: 42 });
|
||||
if (p === "/diagnostics") return send({ meta: { appVersion: "1.7.222" }, status: { active: 1 }, query: q });
|
||||
if (p === "/errors") return send({ errors: [{ level: "ERROR", message: "boom" }], query: q });
|
||||
if (p === "/logs/main") return send({ lines: ["line1", "line2"], count: 2, query: q });
|
||||
if (p === "/status") return send({ active: 1, queued: 3 });
|
||||
if (p === "/items") return send({ items: [], query: q });
|
||||
if (p === "/accounts") return send({ accounts: [{ name: "acc1", premium: true }] });
|
||||
if (p === "/meta") return send({ appVersion: "1.7.222", endpoints: ["/health", "/diagnostics"] });
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found", path: p }));
|
||||
});
|
||||
server.listen(0, "127.0.0.1", () => resolve(server));
|
||||
});
|
||||
}
|
||||
|
||||
function startBridge() {
|
||||
const child = spawn(process.execPath, [BRIDGE], { stdio: ["pipe", "pipe", "pipe"] });
|
||||
child.stderr.on("data", (d) => process.stderr.write(`[bridge] ${d}`));
|
||||
const pending = new Map();
|
||||
let buf = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buf += chunk.toString("utf8");
|
||||
let idx;
|
||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
||||
const line = buf.slice(0, idx).trim();
|
||||
buf = buf.slice(idx + 1);
|
||||
if (!line) continue;
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(line);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (msg.id !== undefined && pending.has(msg.id)) {
|
||||
pending.get(msg.id)(msg);
|
||||
pending.delete(msg.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
let nextId = 1;
|
||||
function rpc(method, params) {
|
||||
const id = nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pending.set(id, resolve);
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
|
||||
setTimeout(() => {
|
||||
if (pending.has(id)) {
|
||||
pending.delete(id);
|
||||
reject(new Error(`RPC timeout: ${method}`));
|
||||
}
|
||||
}, 15000);
|
||||
});
|
||||
}
|
||||
function notify(method, params) {
|
||||
child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
|
||||
}
|
||||
return { child, rpc, notify };
|
||||
}
|
||||
|
||||
function textOf(callResult) {
|
||||
const c = callResult && callResult.result && callResult.result.content;
|
||||
if (!Array.isArray(c)) return "";
|
||||
return c.map((x) => x.text || "").join("\n");
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const fake = await startFakeServer();
|
||||
const port = fake.address().port;
|
||||
const code = encodeConnectionCode({ host: "127.0.0.1", port, token: TOKEN, name: "testserver" });
|
||||
const badCode = encodeConnectionCode({ host: "127.0.0.1", port, token: "WRONG", name: "testserver" });
|
||||
|
||||
const bridge = startBridge();
|
||||
try {
|
||||
const init = await bridge.rpc("initialize", {
|
||||
protocolVersion: "2024-11-05",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "harness", version: "1.0.0" }
|
||||
});
|
||||
check("initialize handshake", !!(init.result && init.result.serverInfo), JSON.stringify(init.error || {}));
|
||||
check("server name reported", init.result && init.result.serverInfo && init.result.serverInfo.name === "rd-diagnostics-mcp");
|
||||
bridge.notify("notifications/initialized", {});
|
||||
|
||||
const tools = await bridge.rpc("tools/list", {});
|
||||
const names = (tools.result && tools.result.tools || []).map((t) => t.name);
|
||||
check("tools/list returns tools", names.length >= 10, `got ${names.length}`);
|
||||
for (const expected of ["rd_ping", "rd_diagnostics", "rd_errors", "rd_logs", "rd_get", "rd_servers"]) {
|
||||
check(`tool present: ${expected}`, names.includes(expected));
|
||||
}
|
||||
|
||||
const ping = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code } });
|
||||
const pingText = textOf(ping);
|
||||
check("rd_ping reaches server", /HTTP 200/.test(pingText) && /"status": "ok"/.test(pingText), pingText.slice(0, 200));
|
||||
check("rd_ping shows server label", /testserver \(127\.0\.0\.1:/.test(pingText));
|
||||
|
||||
const diag = await bridge.rpc("tools/call", { name: "rd_diagnostics", arguments: { code, lines: 50, grep: "err" } });
|
||||
const diagText = textOf(diag);
|
||||
check("rd_diagnostics returns aggregate", /"appVersion": "1\.7\.222"/.test(diagText));
|
||||
check("rd_diagnostics passes query params", /"lines": "50"/.test(diagText) && /"grep": "err"/.test(diagText), diagText.slice(0, 300));
|
||||
|
||||
const logs = await bridge.rpc("tools/call", { name: "rd_logs", arguments: { code, name: "main", lines: 5 } });
|
||||
const logsText = textOf(logs);
|
||||
check("rd_logs maps name→path + lines", /logs\/main\?lines=5/.test(logsText) && /"count": 2/.test(logsText), logsText.slice(0, 200));
|
||||
|
||||
const errs = await bridge.rpc("tools/call", { name: "rd_errors", arguments: { code, level: "ERROR" } });
|
||||
check("rd_errors returns ring", /"message": "boom"/.test(textOf(errs)));
|
||||
|
||||
const raw = await bridge.rpc("tools/call", { name: "rd_get", arguments: { code, path: "/meta" } });
|
||||
check("rd_get escape hatch hits arbitrary path", /"endpoints"/.test(textOf(raw)));
|
||||
|
||||
const unauthorized = await bridge.rpc("tools/call", { name: "rd_ping", arguments: { code: badCode } });
|
||||
check("bad token → HTTP 401 + isError", /HTTP 401/.test(textOf(unauthorized)) && unauthorized.result.isError === true);
|
||||
|
||||
const noCode = await bridge.rpc("tools/call", { name: "rd_ping", arguments: {} });
|
||||
check("missing code → graceful isError", noCode.result && noCode.result.isError === true && /Kein Verbindungscode/.test(textOf(noCode)));
|
||||
|
||||
const unreachable = await bridge.rpc("tools/call", {
|
||||
name: "rd_ping",
|
||||
arguments: { code: encodeConnectionCode({ host: "127.0.0.1", port: 1, token: TOKEN }) }
|
||||
});
|
||||
check("unreachable → isError + hint", unreachable.result.isError === true && /nicht erreichbar|abgebrochen|FEHLER/.test(textOf(unreachable)), textOf(unreachable).slice(0, 160));
|
||||
} finally {
|
||||
bridge.child.kill();
|
||||
fake.close();
|
||||
}
|
||||
|
||||
process.stdout.write("\n");
|
||||
if (failures.length) {
|
||||
process.stdout.write(`RESULT: ${failures.length} FAIL\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write("RESULT: ALL PASS\n");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
process.stderr.write(`harness error: ${err && err.stack ? err.stack : err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user