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:
Sucukdeluxe
2026-06-19 17:05:28 +02:00
parent 838bd2ee7a
commit c8beaf96ed
23 changed files with 2836 additions and 49 deletions
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { encodeConnectionCode } from "../src/main/connection-code";
import { decodeConnectionCode } from "../tools/rd-diagnostics-mcp/src/code.mjs";
describe("connection-code", () => {
it("round-trips through the bridge decoder", () => {
const code = encodeConnectionCode({ host: "203.0.113.5", port: 9868, token: "deadbeef", name: "server-1" });
expect(code.startsWith("rddiag:v1:")).toBe(true);
const decoded = decodeConnectionCode(code);
expect(decoded.host).toBe("203.0.113.5");
expect(decoded.port).toBe(9868);
expect(decoded.token).toBe("deadbeef");
expect(decoded.name).toBe("server-1");
expect(decoded.scheme).toBe("http");
});
it("carries https scheme and fingerprint when set", () => {
const code = encodeConnectionCode({
host: "diag.example.com",
port: 8443,
token: "abc",
scheme: "https",
fingerprint: "AA:BB:CC"
});
const decoded = decodeConnectionCode(code);
expect(decoded.scheme).toBe("https");
expect(decoded.fingerprint).toBe("AA:BB:CC");
});
it("omits scheme key for plain http (default)", () => {
const code = encodeConnectionCode({ host: "10.0.0.2", port: 9868, token: "t" });
const json = JSON.parse(Buffer.from(code.slice("rddiag:v1:".length).replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8"));
expect(json.s).toBeUndefined();
expect(json).toMatchObject({ v: 1, h: "10.0.0.2", p: 9868, t: "t" });
});
it("rejects invalid input", () => {
expect(() => encodeConnectionCode({ host: "", port: 9868, token: "t" })).toThrow();
expect(() => encodeConnectionCode({ host: "h", port: 0, token: "t" })).toThrow();
expect(() => encodeConnectionCode({ host: "h", port: 9868, token: "" })).toThrow();
});
});
+139
View File
@@ -0,0 +1,139 @@
import fs from "node:fs";
import http from "node:http";
import os from "node:os";
import path from "node:path";
import { once } from "node:events";
import { afterEach, describe, expect, it } from "vitest";
import {
startDebugServer,
stopDebugServer,
restartDebugServer,
writeDebugServerConfig,
getDebugServerRuntimeStatus
} from "../src/main/debug-server";
import type { DownloadManager } from "../src/main/download-manager";
const tempDirs: string[] = [];
const TOKEN = "allowlist-secret";
async function getFreePort(): Promise<number> {
const probe = http.createServer();
probe.listen(0, "127.0.0.1");
await once(probe, "listening");
const address = probe.address();
if (!address || typeof address === "string") {
throw new Error("port probe failed");
}
probe.close();
await once(probe, "close");
return address.port;
}
async function waitForReady(url: string): Promise<void> {
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
try {
const res = await fetch(url);
if (res.ok) {
return;
}
} catch {
}
await new Promise((resolve) => setTimeout(resolve, 40));
}
throw new Error(`debug server not ready: ${url}`);
}
async function startWithAllowlist(allowlist: string[]): Promise<{ baseUrl: string }> {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-"));
tempDirs.push(baseDir);
const port = await getFreePort();
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "0.0.0.0", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8");
const manager = {} as unknown as DownloadManager;
startDebugServer(manager, baseDir);
const baseUrl = `http://127.0.0.1:${port}`;
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
return { baseUrl };
}
function getAs(baseUrl: string, clientIp: string | null): Promise<Response> {
const headers: Record<string, string> = {};
if (clientIp) {
headers["X-Forwarded-For"] = clientIp;
}
return fetch(`${baseUrl}/health?token=${TOKEN}`, { headers });
}
afterEach(() => {
stopDebugServer();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
continue;
}
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
}
}
});
describe("debug-server allowlist enforcement", () => {
it("always allows loopback regardless of allowlist", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const res = await getAs(baseUrl, null);
expect(res.status).toBe(200);
});
it("allows an exact allowlisted client IP", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const res = await getAs(baseUrl, "8.8.8.8");
expect(res.status).toBe(200);
});
it("allows a client inside an allowlisted CIDR and blocks outside it", async () => {
const { baseUrl } = await startWithAllowlist(["10.0.0.0/24"]);
const inside = await getAs(baseUrl, "10.0.0.42");
expect(inside.status).toBe(200);
const outside = await getAs(baseUrl, "10.0.1.42");
expect(outside.status).toBe(403);
});
it("blocks a non-allowlisted remote client with 403 before auth", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const res = await fetch(`${baseUrl}/health`, { headers: { "X-Forwarded-For": "9.9.9.9" } });
expect(res.status).toBe(403);
const body = await res.json() as Record<string, unknown>;
expect(body.reason).toContain("Allowlist");
});
it("fail-closed: empty allowlist on 0.0.0.0 blocks every non-loopback client", async () => {
const { baseUrl } = await startWithAllowlist([]);
expect(getDebugServerRuntimeStatus().allowlistCount).toBe(0);
const loopback = await getAs(baseUrl, null);
expect(loopback.status).toBe(200);
const remote = await getAs(baseUrl, "203.0.113.7");
expect(remote.status).toBe(403);
});
it("still enforces auth for allowlisted clients", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
const res = await fetch(`${baseUrl}/health`, { headers: { "X-Forwarded-For": "8.8.8.8" } });
expect(res.status).toBe(401);
});
it("applies a new allowlist live via restartDebugServer", async () => {
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
expect((await getAs(baseUrl, "9.9.9.9")).status).toBe(403);
writeDebugServerConfig({ allowlist: ["9.9.9.9"] });
const status = await restartDebugServer();
expect(status.running).toBe(true);
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
expect((await getAs(baseUrl, "9.9.9.9")).status).toBe(200);
expect((await getAs(baseUrl, "8.8.8.8")).status).toBe(403);
});
});
+1
View File
@@ -202,6 +202,7 @@ async function createFixture() {
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "0.0.0.0", "utf8");
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "159.195.63.46\n", "utf8");
const debridLinkApiKeys = "key-a\nkey-b";
const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys);