Compare commits
3 Commits
838bd2ee7a
...
41f2925b13
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41f2925b13 | ||
|
|
95d284f687 | ||
|
|
c8beaf96ed |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "real-debrid-downloader",
|
"name": "real-debrid-downloader",
|
||||||
"version": "1.7.222",
|
"version": "1.7.223",
|
||||||
"description": "Desktop downloader",
|
"description": "Desktop downloader",
|
||||||
"main": "build/main/main/main.js",
|
"main": "build/main/main/main.js",
|
||||||
"author": "Sucukdeluxe",
|
"author": "Sucukdeluxe",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import os from "node:os";
|
||||||
import v8 from "node:v8";
|
import v8 from "node:v8";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||||
@ -9,9 +10,11 @@ import {
|
|||||||
DebridAccountStatus,
|
DebridAccountStatus,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
|
EnableRemoteDiagnosticsInput,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
ParsedPackageInput,
|
ParsedPackageInput,
|
||||||
|
RemoteDiagnosticsInfo,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
StartConflictResolutionResult,
|
StartConflictResolutionResult,
|
||||||
@ -39,7 +42,8 @@ import { MegaWebFallback } from "./mega-web-fallback";
|
|||||||
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
import { addHistoryEntry, addHistoryEntryForRetention, cancelPendingAsyncSaves, clearHistory, createStoragePaths, loadHistory, loadHistoryForRetention, loadSession, loadSettings, normalizeHistoryEntry, normalizeLoadedSession, normalizeLoadedSessionTransientFields, normalizeSettings, removeHistoryEntry, resetHistoryForRetention, saveHistory, saveSession, saveSettings } from "./storage";
|
||||||
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||||
import { runInstallWithResume } from "./update-install-flow";
|
import { runInstallWithResume } from "./update-install-flow";
|
||||||
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
|
import { rotateDebugToken, startDebugServer, stopDebugServer, restartDebugServer, getDebugServerRuntimeStatus, getActiveDebugToken, getDebugAllowlist, writeDebugServerConfig, clearDebugToken } from "./debug-server";
|
||||||
|
import { encodeConnectionCode, loadRemoteMeta, saveRemoteMeta } from "./connection-code";
|
||||||
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
import { encryptBackup, decryptBackup } from "./backup-crypto";
|
||||||
import { buildBackupPayload, planBackupImport } from "./backup-payload";
|
import { buildBackupPayload, planBackupImport } from "./backup-payload";
|
||||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
||||||
@ -291,6 +295,83 @@ export class AppController {
|
|||||||
return rotated;
|
return rotated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSuggestedRemoteHosts(): string[] {
|
||||||
|
const hosts: string[] = [];
|
||||||
|
try {
|
||||||
|
const interfaces = os.networkInterfaces();
|
||||||
|
for (const entry of Object.values(interfaces)) {
|
||||||
|
for (const net of entry || []) {
|
||||||
|
if (net.family === "IPv4" && !net.internal && net.address) {
|
||||||
|
hosts.push(net.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
return [...new Set(hosts)];
|
||||||
|
}
|
||||||
|
|
||||||
|
public getRemoteDiagnostics(): RemoteDiagnosticsInfo {
|
||||||
|
const status = getDebugServerRuntimeStatus();
|
||||||
|
const meta = loadRemoteMeta(this.storagePaths.baseDir);
|
||||||
|
const token = getActiveDebugToken();
|
||||||
|
const allowlist = getDebugAllowlist();
|
||||||
|
const suggestedHosts = this.getSuggestedRemoteHosts();
|
||||||
|
const host = meta.publicHost
|
||||||
|
|| (status.localOnly ? "127.0.0.1" : (suggestedHosts[0] || status.host));
|
||||||
|
const code = (status.hasToken && token && host)
|
||||||
|
? encodeConnectionCode({ host, port: status.port, token, name: meta.name || undefined })
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
code,
|
||||||
|
publicHost: meta.publicHost,
|
||||||
|
name: meta.name,
|
||||||
|
allowlist,
|
||||||
|
suggestedHosts
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async enableRemoteDiagnostics(input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> {
|
||||||
|
const baseDir = this.storagePaths.baseDir;
|
||||||
|
const port = input.port && Number.isInteger(input.port) && input.port >= 1024 && input.port <= 65535
|
||||||
|
? input.port
|
||||||
|
: 9868;
|
||||||
|
const bindHost = input.hostMode === "network" ? "0.0.0.0" : "127.0.0.1";
|
||||||
|
const allowlist = (input.allowlist || []).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
||||||
|
if (input.hostMode === "network" && allowlist.length === 0) {
|
||||||
|
throw new Error("Netzwerk-Freigabe erfordert mindestens eine erlaubte IP oder CIDR in der Allowlist.");
|
||||||
|
}
|
||||||
|
let token = getActiveDebugToken();
|
||||||
|
if (!token || input.rotateToken) {
|
||||||
|
token = rotateDebugToken(baseDir).token;
|
||||||
|
}
|
||||||
|
writeDebugServerConfig({ host: bindHost, port, allowlist });
|
||||||
|
saveRemoteMeta(baseDir, { publicHost: (input.publicHost || "").trim(), name: (input.name || "").trim() });
|
||||||
|
await restartDebugServer();
|
||||||
|
this.audit("WARN", "Ferndiagnose aktiviert", {
|
||||||
|
host: bindHost,
|
||||||
|
port,
|
||||||
|
allowlistCount: allowlist.length,
|
||||||
|
localOnly: input.hostMode === "local"
|
||||||
|
});
|
||||||
|
return this.getRemoteDiagnostics();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async disableRemoteDiagnostics(): Promise<RemoteDiagnosticsInfo> {
|
||||||
|
clearDebugToken();
|
||||||
|
await restartDebugServer();
|
||||||
|
this.audit("WARN", "Ferndiagnose deaktiviert (Token entfernt)");
|
||||||
|
return this.getRemoteDiagnostics();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async rotateRemoteDiagnosticsToken(): Promise<RemoteDiagnosticsInfo> {
|
||||||
|
rotateDebugToken(this.storagePaths.baseDir);
|
||||||
|
await restartDebugServer();
|
||||||
|
this.audit("WARN", "Ferndiagnose-Token rotiert");
|
||||||
|
return this.getRemoteDiagnostics();
|
||||||
|
}
|
||||||
|
|
||||||
public getDebugSetupCheck(): DebugSetupCheckResult {
|
public getDebugSetupCheck(): DebugSetupCheckResult {
|
||||||
return getDebugSetupCheck(this.storagePaths.baseDir);
|
return getDebugSetupCheck(this.storagePaths.baseDir);
|
||||||
}
|
}
|
||||||
|
|||||||
59
src/main/connection-code.ts
Normal file
59
src/main/connection-code.ts
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const PREFIX = "rddiag:v1:";
|
||||||
|
|
||||||
|
function base64urlEncode(value: string): string {
|
||||||
|
return Buffer.from(value, "utf8")
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionCodeInput {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
token: string;
|
||||||
|
name?: string;
|
||||||
|
scheme?: "http" | "https";
|
||||||
|
fingerprint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeConnectionCode(input: ConnectionCodeInput): string {
|
||||||
|
const host = String(input.host || "").trim();
|
||||||
|
if (!host) throw new Error("Host fehlt fuer Verbindungscode");
|
||||||
|
const port = Number(input.port);
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Port ungueltig fuer Verbindungscode");
|
||||||
|
if (!input.token) throw new Error("Token fehlt fuer Verbindungscode");
|
||||||
|
const payload: Record<string, unknown> = { v: 1, h: host, p: port, t: input.token };
|
||||||
|
if (input.name) payload.n = String(input.name);
|
||||||
|
if (input.fingerprint) payload.fp = String(input.fingerprint);
|
||||||
|
if (input.scheme && input.scheme !== "http") payload.s = String(input.scheme);
|
||||||
|
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteMeta {
|
||||||
|
publicHost: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function remoteMetaPath(baseDir: string): string {
|
||||||
|
return path.join(baseDir, "debug_remote.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadRemoteMeta(baseDir: string): RemoteMeta {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(fs.readFileSync(remoteMetaPath(baseDir), "utf8"));
|
||||||
|
return {
|
||||||
|
publicHost: String(parsed.publicHost || ""),
|
||||||
|
name: String(parsed.name || "")
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { publicHost: "", name: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveRemoteMeta(baseDir: string, meta: RemoteMeta): void {
|
||||||
|
fs.writeFileSync(remoteMetaPath(baseDir), JSON.stringify({ publicHost: meta.publicHost, name: meta.name }, null, 2), "utf8");
|
||||||
|
}
|
||||||
@ -65,6 +65,16 @@ let authToken = "";
|
|||||||
let bindHost = DEFAULT_HOST;
|
let bindHost = DEFAULT_HOST;
|
||||||
let bindPort = DEFAULT_PORT;
|
let bindPort = DEFAULT_PORT;
|
||||||
let runtimeBaseDir = "";
|
let runtimeBaseDir = "";
|
||||||
|
let allowlist: string[] = [];
|
||||||
|
|
||||||
|
export interface DebugServerRuntimeStatus {
|
||||||
|
running: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
hasToken: boolean;
|
||||||
|
localOnly: boolean;
|
||||||
|
allowlistCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
function getStoragePaths() {
|
function getStoragePaths() {
|
||||||
return createStoragePaths(runtimeBaseDir);
|
return createStoragePaths(runtimeBaseDir);
|
||||||
@ -140,6 +150,94 @@ function getHost(baseDir: string): string {
|
|||||||
return DEFAULT_HOST;
|
return DEFAULT_HOST;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAllowlistPath(baseDir: string = runtimeBaseDir): string {
|
||||||
|
return path.join(baseDir, "debug_allowlist.txt");
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAllowlist(baseDir: string): string[] {
|
||||||
|
try {
|
||||||
|
return fs.readFileSync(getAllowlistPath(baseDir), "utf8")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIp(ip: string): string {
|
||||||
|
return String(ip || "").trim().replace(/^::ffff:/i, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackIp(ip: string): boolean {
|
||||||
|
const x = normalizeIp(ip);
|
||||||
|
return x === "::1" || x === "localhost" || x.startsWith("127.");
|
||||||
|
}
|
||||||
|
|
||||||
|
function ipv4ToInt(ip: string): number | null {
|
||||||
|
const parts = ip.split(".");
|
||||||
|
if (parts.length !== 4) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
let result = 0;
|
||||||
|
for (const part of parts) {
|
||||||
|
const value = Number(part);
|
||||||
|
if (!Number.isInteger(value) || value < 0 || value > 255) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
result = (result * 256) + value;
|
||||||
|
}
|
||||||
|
return result >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchIpRule(clientIp: string, rule: string): boolean {
|
||||||
|
const client = normalizeIp(clientIp);
|
||||||
|
const r = rule.trim().toLowerCase();
|
||||||
|
if (!r) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (r === "*" || r === "0.0.0.0/0") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (r === client) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const slash = r.indexOf("/");
|
||||||
|
if (slash > 0) {
|
||||||
|
const baseInt = ipv4ToInt(r.slice(0, slash));
|
||||||
|
const clientInt = ipv4ToInt(client);
|
||||||
|
const bits = Number(r.slice(slash + 1));
|
||||||
|
if (baseInt === null || clientInt === null || !Number.isInteger(bits) || bits < 0 || bits > 32) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (bits === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const mask = bits === 32 ? 0xffffffff : (~((1 << (32 - bits)) - 1)) >>> 0;
|
||||||
|
return (clientInt & mask) === (baseInt & mask);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function evaluateClientAllowed(clientIp: string, rules: string[]): boolean {
|
||||||
|
const client = normalizeIp(clientIp);
|
||||||
|
if (isLoopbackIp(client) || client === "") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (rules.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return rules.some((rule) => matchIpRule(client, rule));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPeerIp(req: http.IncomingMessage): string {
|
||||||
|
return normalizeIp(req.socket?.remoteAddress || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClientAllowed(clientIp: string): boolean {
|
||||||
|
return evaluateClientAllowed(clientIp, allowlist);
|
||||||
|
}
|
||||||
|
|
||||||
function checkAuth(req: http.IncomingMessage): boolean {
|
function checkAuth(req: http.IncomingMessage): boolean {
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
return false;
|
return false;
|
||||||
@ -461,6 +559,19 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const peerIp = getPeerIp(req);
|
||||||
|
if (!isClientAllowed(peerIp)) {
|
||||||
|
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
||||||
|
logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", {
|
||||||
|
peerIp,
|
||||||
|
forwardedFor: extractDebugClientIp(req),
|
||||||
|
url: sanitizeRequestUrlForTrace(req.url || "/")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp: peerIp });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!checkAuth(req)) {
|
if (!checkAuth(req)) {
|
||||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
||||||
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
|
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
|
||||||
@ -927,32 +1038,126 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
|
function openServerSocket(): Promise<void> {
|
||||||
runtimeBaseDir = baseDir;
|
return new Promise((resolve) => {
|
||||||
authToken = loadToken(baseDir);
|
authToken = loadToken(runtimeBaseDir);
|
||||||
bindPort = getPort(baseDir);
|
bindPort = getPort(runtimeBaseDir);
|
||||||
bindHost = getHost(baseDir);
|
bindHost = getHost(runtimeBaseDir);
|
||||||
writeAiManifest(baseDir);
|
allowlist = loadAllowlist(runtimeBaseDir);
|
||||||
|
writeAiManifest(runtimeBaseDir);
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
||||||
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (bindHost === "0.0.0.0" && allowlist.length === 0) {
|
||||||
manager = mgr;
|
logger.warn("Debug-Server: Netzwerk-Bind ohne Allowlist - nur Loopback-Clients werden akzeptiert (fail-closed)");
|
||||||
|
}
|
||||||
server = http.createServer(handleRequest);
|
const srv = http.createServer(handleRequest);
|
||||||
server.listen(bindPort, bindHost, () => {
|
let settled = false;
|
||||||
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`);
|
const settle = (): void => {
|
||||||
});
|
if (!settled) {
|
||||||
server.on("error", (err) => {
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
srv.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === "EADDRINUSE") {
|
||||||
|
logger.warn(`Debug-Server: Port ${bindPort} belegt (EADDRINUSE) - Server nicht gestartet`);
|
||||||
|
} else {
|
||||||
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
||||||
|
}
|
||||||
|
if (server === srv) {
|
||||||
server = null;
|
server = null;
|
||||||
|
}
|
||||||
|
settle();
|
||||||
});
|
});
|
||||||
|
srv.listen(bindPort, bindHost, () => {
|
||||||
|
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort} (Allowlist: ${allowlist.length})`);
|
||||||
|
settle();
|
||||||
|
});
|
||||||
|
server = srv;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startDebugServer(mgr: DownloadManager, baseDir: string): void {
|
||||||
|
runtimeBaseDir = baseDir;
|
||||||
|
manager = mgr;
|
||||||
|
void openServerSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restartDebugServer(): Promise<DebugServerRuntimeStatus> {
|
||||||
|
const old = server;
|
||||||
|
if (old) {
|
||||||
|
server = null;
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const done = (): void => {
|
||||||
|
if (!settled) {
|
||||||
|
settled = true;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
old.close(() => done());
|
||||||
|
try {
|
||||||
|
old.closeAllConnections?.();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
setTimeout(done, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await openServerSocket();
|
||||||
|
return getDebugServerRuntimeStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDebugServerRuntimeStatus(): DebugServerRuntimeStatus {
|
||||||
|
return {
|
||||||
|
running: Boolean(server && server.listening),
|
||||||
|
host: bindHost,
|
||||||
|
port: bindPort,
|
||||||
|
hasToken: Boolean(authToken),
|
||||||
|
localOnly: isLoopbackIp(bindHost) || bindHost === "127.0.0.1",
|
||||||
|
allowlistCount: allowlist.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveDebugToken(): string {
|
||||||
|
return authToken || loadToken(runtimeBaseDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDebugAllowlist(): string[] {
|
||||||
|
return [...allowlist];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeDebugServerConfig(opts: { host?: string; port?: number; allowlist?: string[] }): void {
|
||||||
|
if (opts.host !== undefined) {
|
||||||
|
fs.writeFileSync(path.join(runtimeBaseDir, "debug_host.txt"), `${opts.host}\n`, "utf8");
|
||||||
|
}
|
||||||
|
if (opts.port !== undefined) {
|
||||||
|
fs.writeFileSync(path.join(runtimeBaseDir, "debug_port.txt"), `${opts.port}\n`, "utf8");
|
||||||
|
}
|
||||||
|
if (opts.allowlist !== undefined) {
|
||||||
|
const body = opts.allowlist.length > 0 ? opts.allowlist.join("\n") + "\n" : "";
|
||||||
|
fs.writeFileSync(getAllowlistPath(), body, "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearDebugToken(): void {
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(getDebugTokenPath());
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
|
authToken = "";
|
||||||
|
writeAiManifest(runtimeBaseDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopDebugServer(): void {
|
export function stopDebugServer(): void {
|
||||||
if (server) {
|
if (server) {
|
||||||
server.close();
|
server.close();
|
||||||
|
try {
|
||||||
|
server.closeAllConnections?.();
|
||||||
|
} catch {
|
||||||
|
}
|
||||||
server = null;
|
server = null;
|
||||||
logger.info("Debug-Server gestoppt");
|
logger.info("Debug-Server gestoppt");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
import { app, BrowserWindow, clipboard, dialog, ipcMain, IpcMainInvokeEvent, Menu, shell, Tray } from "electron";
|
||||||
import { AddLinksPayload, AppSettings, DebridProvider, UpdateInstallProgress } from "../shared/types";
|
import { AddLinksPayload, AppSettings, DebridProvider, EnableRemoteDiagnosticsInput, UpdateInstallProgress } from "../shared/types";
|
||||||
import { AppController } from "./app-controller";
|
import { AppController } from "./app-controller";
|
||||||
import { IPC_CHANNELS } from "../shared/ipc";
|
import { IPC_CHANNELS } from "../shared/ipc";
|
||||||
import { getLogFilePath, logger } from "./logger";
|
import { getLogFilePath, logger } from "./logger";
|
||||||
@ -671,6 +671,33 @@ function registerIpcHandlers(): void {
|
|||||||
return { path: rotated.path };
|
return { path: rotated.path };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS, async () => {
|
||||||
|
return controller.getRemoteDiagnostics();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, async (_event: IpcMainInvokeEvent, input: EnableRemoteDiagnosticsInput) => {
|
||||||
|
if (!input || (input.hostMode !== "local" && input.hostMode !== "network")) {
|
||||||
|
throw new Error("hostMode muss 'local' oder 'network' sein");
|
||||||
|
}
|
||||||
|
const allowlist = Array.isArray(input.allowlist) ? input.allowlist.map((entry) => String(entry)) : [];
|
||||||
|
return controller.enableRemoteDiagnostics({
|
||||||
|
hostMode: input.hostMode,
|
||||||
|
publicHost: String(input.publicHost || ""),
|
||||||
|
port: input.port ? Number(input.port) : undefined,
|
||||||
|
allowlist,
|
||||||
|
name: input.name ? String(input.name) : undefined,
|
||||||
|
rotateToken: Boolean(input.rotateToken)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS, async () => {
|
||||||
|
return controller.disableRemoteDiagnostics();
|
||||||
|
});
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN, async () => {
|
||||||
|
return controller.rotateRemoteDiagnosticsToken();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
|
ipcMain.handle(IPC_CHANNELS.OPEN_ITEM_LOG, async (_event: IpcMainInvokeEvent, itemId: string) => {
|
||||||
validateString(itemId, "itemId");
|
validateString(itemId, "itemId");
|
||||||
const logPath = controller.getItemLogPath(itemId);
|
const logPath = controller.getItemLogPath(itemId);
|
||||||
|
|||||||
@ -7,8 +7,10 @@ import {
|
|||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
|
EnableRemoteDiagnosticsInput,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
|
RemoteDiagnosticsInfo,
|
||||||
RendererErrorReport,
|
RendererErrorReport,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
@ -74,6 +76,10 @@ const api: ElectronApi = {
|
|||||||
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
|
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
|
||||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
|
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note, durationMinutes),
|
||||||
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
|
rotateDebugToken: (): Promise<{ path: string }> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_DEBUG_TOKEN),
|
||||||
|
getRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.GET_REMOTE_DIAGNOSTICS),
|
||||||
|
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ENABLE_REMOTE_DIAGNOSTICS, input),
|
||||||
|
disableRemoteDiagnostics: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.DISABLE_REMOTE_DIAGNOSTICS),
|
||||||
|
rotateRemoteDiagnosticsToken: (): Promise<RemoteDiagnosticsInfo> => ipcRenderer.invoke(IPC_CHANNELS.ROTATE_REMOTE_DIAGNOSTICS_TOKEN),
|
||||||
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
openRealDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_REALDEBRID_LOGIN),
|
||||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import type {
|
|||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackageEntry,
|
PackageEntry,
|
||||||
|
RemoteDiagnosticsInfo,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
UiSnapshot,
|
UiSnapshot,
|
||||||
UpdateCheckResult,
|
UpdateCheckResult,
|
||||||
@ -1761,6 +1762,14 @@ export function App(): ReactElement {
|
|||||||
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | null>(null);
|
||||||
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
const startConflictResolverRef = useRef<((result: { policy: Extract<DuplicatePolicy, "skip" | "overwrite">; applyToAll: boolean } | null) => void) | null>(null);
|
||||||
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
const [confirmPrompt, setConfirmPrompt] = useState<ConfirmPromptState | null>(null);
|
||||||
|
const [remoteDiag, setRemoteDiag] = useState<RemoteDiagnosticsInfo | null>(null);
|
||||||
|
const [remoteDiagOpen, setRemoteDiagOpen] = useState(false);
|
||||||
|
const [remoteDiagBusy, setRemoteDiagBusy] = useState(false);
|
||||||
|
const [rdHostMode, setRdHostMode] = useState<"local" | "network">("local");
|
||||||
|
const [rdPublicHost, setRdPublicHost] = useState("");
|
||||||
|
const [rdPort, setRdPort] = useState("9868");
|
||||||
|
const [rdAllowlist, setRdAllowlist] = useState("");
|
||||||
|
const [rdName, setRdName] = useState("");
|
||||||
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
|
const confirmResolverRef = useRef<((confirmed: boolean) => void) | null>(null);
|
||||||
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
|
const confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
|
||||||
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
|
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
|
||||||
@ -4328,6 +4337,89 @@ export function App(): ReactElement {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const applyRemoteDiagInfo = (info: RemoteDiagnosticsInfo): void => {
|
||||||
|
setRemoteDiag(info);
|
||||||
|
setRdHostMode(info.status.localOnly ? "local" : "network");
|
||||||
|
setRdPublicHost(info.publicHost || (info.status.localOnly ? "" : (info.suggestedHosts[0] || "")));
|
||||||
|
setRdPort(String(info.status.port || 9868));
|
||||||
|
setRdAllowlist(info.allowlist.join("\n"));
|
||||||
|
setRdName(info.name || "");
|
||||||
|
};
|
||||||
|
|
||||||
|
const onOpenRemoteDiagnostics = async (): Promise<void> => {
|
||||||
|
closeMenus();
|
||||||
|
try {
|
||||||
|
const info = await window.rd.getRemoteDiagnostics();
|
||||||
|
applyRemoteDiagInfo(info);
|
||||||
|
setRemoteDiagOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Ferndiagnose-Status fehlgeschlagen: ${String(error)}`, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmitRemoteDiagnostics = async (): Promise<void> => {
|
||||||
|
const port = Number(rdPort) || 9868;
|
||||||
|
const allowlist = rdAllowlist.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
||||||
|
if (rdHostMode === "network" && allowlist.length === 0) {
|
||||||
|
showToast("Netzwerkmodus braucht mindestens eine IP oder CIDR in der Allowlist", 3600);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRemoteDiagBusy(true);
|
||||||
|
try {
|
||||||
|
const info = await window.rd.enableRemoteDiagnostics({
|
||||||
|
hostMode: rdHostMode,
|
||||||
|
publicHost: rdPublicHost,
|
||||||
|
port,
|
||||||
|
allowlist,
|
||||||
|
name: rdName
|
||||||
|
});
|
||||||
|
applyRemoteDiagInfo(info);
|
||||||
|
showToast(info.status.running ? "Ferndiagnose aktiv" : "Ferndiagnose konfiguriert", 2600);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Aktivieren fehlgeschlagen: ${String(error)}`, 3600);
|
||||||
|
} finally {
|
||||||
|
setRemoteDiagBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDisableRemoteDiagnostics = async (): Promise<void> => {
|
||||||
|
setRemoteDiagBusy(true);
|
||||||
|
try {
|
||||||
|
const info = await window.rd.disableRemoteDiagnostics();
|
||||||
|
applyRemoteDiagInfo(info);
|
||||||
|
showToast("Ferndiagnose deaktiviert", 2400);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Deaktivieren fehlgeschlagen: ${String(error)}`, 3000);
|
||||||
|
} finally {
|
||||||
|
setRemoteDiagBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRotateRemoteDiagnosticsToken = async (): Promise<void> => {
|
||||||
|
setRemoteDiagBusy(true);
|
||||||
|
try {
|
||||||
|
const info = await window.rd.rotateRemoteDiagnosticsToken();
|
||||||
|
applyRemoteDiagInfo(info);
|
||||||
|
showToast("Neues Token - alter Verbindungscode ist ungueltig", 3000);
|
||||||
|
} catch (error) {
|
||||||
|
showToast(`Token-Rotation fehlgeschlagen: ${String(error)}`, 3000);
|
||||||
|
} finally {
|
||||||
|
setRemoteDiagBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCopyRemoteDiagnosticsCode = async (): Promise<void> => {
|
||||||
|
if (!remoteDiag?.code) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(remoteDiag.code);
|
||||||
|
showToast("Verbindungscode kopiert", 2200);
|
||||||
|
} catch {
|
||||||
|
showToast("Kopieren fehlgeschlagen", 2200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onMenuRestart = (): void => {
|
const onMenuRestart = (): void => {
|
||||||
closeMenus();
|
closeMenus();
|
||||||
void window.rd.restart();
|
void window.rd.restart();
|
||||||
@ -4718,6 +4810,7 @@ export function App(): ReactElement {
|
|||||||
<button className="menu-submenu-trigger">Remote-Support</button>
|
<button className="menu-submenu-trigger">Remote-Support</button>
|
||||||
{openSubmenu === "hilfe-remote" && (
|
{openSubmenu === "hilfe-remote" && (
|
||||||
<div className="menu-submenu-dropdown">
|
<div className="menu-submenu-dropdown">
|
||||||
|
<button className="menu-dropdown-item" onClick={() => { void onOpenRemoteDiagnostics(); }}><span>Ferndiagnose (MCP) …</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onExportSupportBundle(); }}><span>Support-Bundle exportieren</span></button>
|
||||||
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
||||||
</div>
|
</div>
|
||||||
@ -5934,6 +6027,97 @@ export function App(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{remoteDiagOpen && (
|
||||||
|
<div className="modal-backdrop" onClick={() => setRemoteDiagOpen(false)}>
|
||||||
|
<div className="modal-card" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<h3>Ferndiagnose (MCP)</h3>
|
||||||
|
<p>Aktiviert einen abgesicherten Lesezugriff auf Status, Logs und Fehler dieses Servers. Den Verbindungscode dem Assistenten geben - er verbindet sich, sieht alles und behebt Probleme.</p>
|
||||||
|
<div className="rd-status-line">
|
||||||
|
<span className={`rd-dot${remoteDiag?.status.running ? " on" : ""}`} />
|
||||||
|
<span>
|
||||||
|
{remoteDiag?.status.running
|
||||||
|
? `Aktiv auf ${remoteDiag.status.host}:${remoteDiag.status.port}${remoteDiag.status.localOnly ? " (nur lokal)" : ` (Allowlist: ${remoteDiag.status.allowlistCount})`}`
|
||||||
|
: "Inaktiv"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rd-field">
|
||||||
|
<label>Sichtbarkeit</label>
|
||||||
|
<div className="rd-seg">
|
||||||
|
<button className={rdHostMode === "local" ? "active" : ""} onClick={() => setRdHostMode("local")}>Nur lokal</button>
|
||||||
|
<button className={rdHostMode === "network" ? "active" : ""} onClick={() => setRdHostMode("network")}>Im Netzwerk</button>
|
||||||
|
</div>
|
||||||
|
<span className="rd-hint">
|
||||||
|
{rdHostMode === "local"
|
||||||
|
? "Bindet nur an 127.0.0.1. Fernzugriff nur ueber einen Tunnel (z.B. Tailscale/SSH) - die sicherste Variante."
|
||||||
|
: "Bindet an 0.0.0.0. Erreichbar im Netzwerk, erfordert eine Allowlist. Nur in vertrauenswuerdigen Netzen/VPN nutzen."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rd-field">
|
||||||
|
<label>Oeffentliche Adresse (fuer den Verbindungscode)</label>
|
||||||
|
<input
|
||||||
|
value={rdPublicHost}
|
||||||
|
placeholder={rdHostMode === "local" ? "127.0.0.1 oder Tunnel-Adresse" : "Server-IP oder DNS-Name"}
|
||||||
|
onChange={(event) => setRdPublicHost(event.target.value)}
|
||||||
|
/>
|
||||||
|
{remoteDiag && remoteDiag.suggestedHosts.length > 0 && (
|
||||||
|
<div className="rd-chips">
|
||||||
|
{remoteDiag.suggestedHosts.map((host) => (
|
||||||
|
<button key={host} className="rd-chip" onClick={() => setRdPublicHost(host)}>{host}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rd-field rd-field-inline">
|
||||||
|
<div className="rd-field">
|
||||||
|
<label>Port</label>
|
||||||
|
<input value={rdPort} onChange={(event) => setRdPort(event.target.value.replace(/[^0-9]/g, ""))} />
|
||||||
|
</div>
|
||||||
|
<div className="rd-field">
|
||||||
|
<label>Name (optional)</label>
|
||||||
|
<input value={rdName} placeholder="z.B. Server-Berlin" onChange={(event) => setRdName(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{rdHostMode === "network" && (
|
||||||
|
<div className="rd-field">
|
||||||
|
<label>Allowlist - erlaubte IPs/CIDR (eine pro Zeile)</label>
|
||||||
|
<textarea
|
||||||
|
value={rdAllowlist}
|
||||||
|
placeholder={"203.0.113.5\n10.0.0.0/24"}
|
||||||
|
onChange={(event) => setRdAllowlist(event.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="rd-hint">Pflicht im Netzwerkmodus. Nur diese Quell-IPs duerfen verbinden (zusaetzlich zum Token). Loopback ist immer erlaubt.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{remoteDiag?.status.running && remoteDiag.code && (
|
||||||
|
<div className="rd-field">
|
||||||
|
<label>Verbindungscode</label>
|
||||||
|
<div className="rd-code">{remoteDiag.code}</div>
|
||||||
|
<div className="rd-chips">
|
||||||
|
<button className="btn" onClick={() => { void onCopyRemoteDiagnosticsCode(); }}>Kopieren</button>
|
||||||
|
<button className="btn" onClick={() => { void onRotateRemoteDiagnosticsToken(); }} disabled={remoteDiagBusy}>Token neu (Code ungueltig machen)</button>
|
||||||
|
</div>
|
||||||
|
<span className="rd-hint">Enthaelt das Zugriffstoken - wie ein Passwort behandeln. Token neu = alter Code wird sofort ungueltig.</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
<button className="btn" onClick={() => setRemoteDiagOpen(false)}>Schliessen</button>
|
||||||
|
{remoteDiag?.status.running && (
|
||||||
|
<button className="btn danger" onClick={() => { void onDisableRemoteDiagnostics(); }} disabled={remoteDiagBusy}>Deaktivieren</button>
|
||||||
|
)}
|
||||||
|
<button className="btn accent" onClick={() => { void onSubmitRemoteDiagnostics(); }} disabled={remoteDiagBusy}>
|
||||||
|
{remoteDiag?.status.running ? "Aktualisieren" : "Aktivieren"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{deleteConfirm && (() => {
|
{deleteConfirm && (() => {
|
||||||
const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length;
|
const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[id]).length;
|
||||||
const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length;
|
const pkgCount = [...deleteConfirm.ids].filter((id) => snapshot.session.packages[id]).length;
|
||||||
|
|||||||
@ -3360,3 +3360,110 @@ td {
|
|||||||
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
|
.rotation-event .rotation-time { color: var(--muted, #a59c8e); font-variant-numeric: tabular-nums; }
|
||||||
.rotation-event .rotation-body strong { font-weight: 600; }
|
.rotation-event .rotation-body strong { font-weight: 600; }
|
||||||
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); }
|
.rotation-event .rotation-reason { color: var(--muted, #a59c8e); }
|
||||||
|
|
||||||
|
.rd-status-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.rd-dot {
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--muted);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
.rd-dot.on {
|
||||||
|
background: #3fb950;
|
||||||
|
}
|
||||||
|
.rd-field {
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.rd-field > label {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.rd-field input,
|
||||||
|
.rd-field textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.rd-field input:focus,
|
||||||
|
.rd-field textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.rd-field textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 62px;
|
||||||
|
}
|
||||||
|
.rd-field-inline {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.rd-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.rd-seg {
|
||||||
|
display: flex;
|
||||||
|
width: fit-content;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.rd-seg button {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: none;
|
||||||
|
padding: 7px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.rd-seg button.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #1a1206;
|
||||||
|
}
|
||||||
|
.rd-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.rd-chip {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 9px;
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.rd-chip:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.rd-code {
|
||||||
|
font-family: ui-monospace, "Cascadia Code", monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-all;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|||||||
@ -52,6 +52,10 @@ export const IPC_CHANNELS = {
|
|||||||
GET_TRACE_CONFIG: "app:get-trace-config",
|
GET_TRACE_CONFIG: "app:get-trace-config",
|
||||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||||
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
ROTATE_DEBUG_TOKEN: "app:rotate-debug-token",
|
||||||
|
GET_REMOTE_DIAGNOSTICS: "app:get-remote-diagnostics",
|
||||||
|
ENABLE_REMOTE_DIAGNOSTICS: "app:enable-remote-diagnostics",
|
||||||
|
DISABLE_REMOTE_DIAGNOSTICS: "app:disable-remote-diagnostics",
|
||||||
|
ROTATE_REMOTE_DIAGNOSTICS_TOKEN: "app:rotate-remote-diagnostics-token",
|
||||||
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
OPEN_REALDEBRID_LOGIN: "app:open-realdebrid-login",
|
||||||
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
OPEN_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||||
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
||||||
|
|||||||
@ -7,8 +7,10 @@ import type {
|
|||||||
DebridLinkHostLimitInfo,
|
DebridLinkHostLimitInfo,
|
||||||
DebridProvider,
|
DebridProvider,
|
||||||
DuplicatePolicy,
|
DuplicatePolicy,
|
||||||
|
EnableRemoteDiagnosticsInput,
|
||||||
HistoryEntry,
|
HistoryEntry,
|
||||||
PackagePriority,
|
PackagePriority,
|
||||||
|
RemoteDiagnosticsInfo,
|
||||||
RendererErrorReport,
|
RendererErrorReport,
|
||||||
SessionStats,
|
SessionStats,
|
||||||
StartConflictEntry,
|
StartConflictEntry,
|
||||||
@ -71,6 +73,10 @@ export interface ElectronApi {
|
|||||||
getTraceConfig: () => Promise<SupportTraceConfig>;
|
getTraceConfig: () => Promise<SupportTraceConfig>;
|
||||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
|
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
|
||||||
rotateDebugToken: () => Promise<{ path: string }>;
|
rotateDebugToken: () => Promise<{ path: string }>;
|
||||||
|
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||||
|
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
|
||||||
|
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||||
|
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
|
||||||
openRealDebridLogin: () => Promise<void>;
|
openRealDebridLogin: () => Promise<void>;
|
||||||
openAllDebridLogin: () => Promise<void>;
|
openAllDebridLogin: () => Promise<void>;
|
||||||
importBestDebridCookies: () => Promise<number>;
|
importBestDebridCookies: () => Promise<number>;
|
||||||
|
|||||||
@ -537,3 +537,30 @@ export interface RendererErrorReport {
|
|||||||
column?: number;
|
column?: number;
|
||||||
componentStack?: string;
|
componentStack?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RemoteDiagnosticsStatus {
|
||||||
|
running: boolean;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
hasToken: boolean;
|
||||||
|
localOnly: boolean;
|
||||||
|
allowlistCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoteDiagnosticsInfo {
|
||||||
|
status: RemoteDiagnosticsStatus;
|
||||||
|
code: string | null;
|
||||||
|
publicHost: string;
|
||||||
|
name: string;
|
||||||
|
allowlist: string[];
|
||||||
|
suggestedHosts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnableRemoteDiagnosticsInput {
|
||||||
|
hostMode: "local" | "network";
|
||||||
|
publicHost: string;
|
||||||
|
port?: number;
|
||||||
|
allowlist: string[];
|
||||||
|
name?: string;
|
||||||
|
rotateToken?: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@ -1,37 +1,38 @@
|
|||||||
# Massives Conversion-Logging (v1.7.213) + Failover-Fix
|
# MCP-Ferndiagnose (Goal, ultracode) — v1.7.223
|
||||||
|
|
||||||
## Problem (live belegt, 2026-06-17)
|
## Ziel (Nutzer)
|
||||||
- Links haengen mit "Unrestrict Timeout nach 60s", R53/R64, halten Download-Slot → Stop-and-Go.
|
"Baue massive Diagnose-Funktionen ein (MCP), so dass ich MCP auf nem Windows-Server aktivieren kann und du
|
||||||
- Web-first: 1 globaler 60s-Timeout um die GANZE Provider-Kette → Web frisst das Budget,
|
auf den Server zugreifst und WIRKLICH ALLES siehst (State, Fehler, Logs, Probleme). 5-6 Server, ueber
|
||||||
API-Failover wird NIE versucht (debrid.ts 3801 `signal.aborted` → throw). Retry startet wieder bei Web.
|
Verbindungscode. Du verbindest dich → liest alles → behebst Probleme direkt."
|
||||||
- API-first laeuft "um einiges fluessiger" (User bestaetigt: API resolved UND downloaded auf dem Server).
|
|
||||||
- ABER: API-Token-Fehler ("Token error, please log-in") + "Login oder Unrestrict fehlgeschlagen"
|
|
||||||
→ beide Accounts kriegen Cooldown → Doom-Loop. connectApi single-flightet Logins schon (pendingConnects),
|
|
||||||
also ist die Token-Ursache NICHT trivial → braucht Token-Lifecycle-Logging.
|
|
||||||
|
|
||||||
## Kern-Blindstelle
|
## Architektur (Advisor-bestaetigt v1)
|
||||||
Bestehende Logs (account-rotation.log) zeigen `elapsedMs` aber NICHT:
|
- Standalone **stdio MCP-Bridge** auf MEINER (Claude-Code-)Maschine, proxyt zur bestehenden HTTP `debug-server.ts`
|
||||||
- queue-wait vs aktive Arbeit (war der 60s-Timeout Warten in der Queue oder echtes Arbeiten?)
|
jedes Servers via **Verbindungscode**. Eine Bridge bedient alle 5-6 Server.
|
||||||
- Token-Lifecycle (cacheHit/freshLogin/invalidation) → woher "Token error"?
|
- KEIN eingebettetes MCP-over-HTTP (verworfen: hand-rolled Protokoll auf Internet-Oberflaeche = mehr Risiko).
|
||||||
- Provider-Ketten-Entscheidung pro Item (welche Provider, welches Budget, warum Stopp)
|
- Bridge-Code identisch fuer Direkt-IP vs spaeterer Tunnel (proxyt host:port).
|
||||||
- Was war in-flight als der Caller-60s-Timeout feuerte (Provider/Account/Phase) + Slot-Belegung
|
- Verbindungscode: `rddiag:v1:<base64url(JSON {v,h:host,p:port,t:token,n?:name,fp?:certFp})>`. Nutzer liefert
|
||||||
|
oeffentlichen Host (nicht auto-detecten).
|
||||||
|
|
||||||
## Plan (Release 213 = NUR Logging, bewusst KEINE Verhaltensaenderung — damit das naechste Bundle das ECHTE aktuelle Verhalten zeigt)
|
## Sicherheitsmodell (Advisor, first-class)
|
||||||
- [x] `src/main/conversion-trace.ts`: AsyncLocalStorage-Trace + dedizierte `conversion.log`. EIN strukturierter Block pro Unrestrict-Versuch.
|
Plain HTTP + Bearer ueber Internet = sniffbares Token mit Lesezugriff auf sensible Logs. Mitigations:
|
||||||
- [x] Wiring: init in app-controller, shutdown, support-bundle.
|
- App-seitige IP-Allowlist (extractDebugClientIp existiert schon).
|
||||||
- [x] Instrumentiert (nur tracePhase-Calls, additiv, no-op ohne aktiven Trace):
|
- `/trace/config` MUTIERT → "read-only"-Claim auditieren: Writes von Remote-Oberflaeche gaten oder umlabeln.
|
||||||
- download-manager unrestrict-Boundary: runWithConversionTrace + Caller-Timeout-Attribution + describeSlotOccupancy.
|
- Opt-in + sofort widerrufbar (Token-Rotation killt Zugang).
|
||||||
- debrid.ts Provider-Kette: chain-try/chain-ok/chain-failed/chain-aborted (zeigt ob Failover feuert).
|
- debug_token.txt in userData bestaetigen (ueberlebt Auto-Update).
|
||||||
- debrid.ts Mega-Rotation: mega-account workMs + outcome (ok/failed/fatal/aborted) + cooldown.
|
- Optional self-signed Cert + Fingerprint im Code gepinnt (NICHT v1-blockierend).
|
||||||
- MegaDebridClient connectApi/doConnectApi/unrestrictViaApi: token cached/pending-join/fresh-login + connectMs + getLink response_code/text (DAS klaert "Token error").
|
|
||||||
- mega-web-fallback runExclusive: web-queue queueWaitMs + workMs (DAS klaert ob 60s = Warten oder Arbeit).
|
|
||||||
- [x] Test: conversion-trace.test.ts (Formatter + ALS-Kontext). tsc unveraendert 6.
|
|
||||||
- [ ] Build + Suite gruen. Release 213 (Gitea + Mirror).
|
|
||||||
|
|
||||||
## DEFERRED auf 214 (erst NACH Logs, kein Blind-Fix mehr)
|
## Phasen
|
||||||
- 60s global → per-Provider-Budget (Failover feuert) — proven, aber erst messen: tritt der 60s ueberhaupt bei API-first auf, und ist es Queue oder Arbeit?
|
- [x] **P0 Vertical Slice (Diskriminator) — ERLEDIGT, harness ALL PASS:** Bridge → debug-server via stdio JSON-RPC, echte Daten zurueck.
|
||||||
- API Token-Error Doom-Loop ("Token error, please log-in") — Mechanismus per conversion.log verifizieren, DANN fixen (evtl. per-Account-Serialisierung fuer API wie bei Web).
|
- [x] MCP SDK API holen (context7) → @modelcontextprotocol/sdk 1.29.0, registerTool(name,{inputSchema:zodShape},cb)
|
||||||
- Config-Realitaet an User: 2. Account (xe) lief abgelaufen/deaktiviert → 212-Parallelitaet griff nicht; API-first ist der schnelle Pfad.
|
- [x] Bridge in `tools/rd-diagnostics-mcp/` (eigenes package.json, NICHT in App-Bundle): code.mjs/http.mjs/bridge.mjs/gen-code.mjs
|
||||||
|
- [x] 14 Tools: rd_servers/rd_ping/rd_diagnostics/rd_status/rd_items/rd_packages/rd_errors/rd_logs/rd_history/rd_accounts/rd_host/rd_self_check/rd_get + Multi-Server (code|server|RDDIAG_CODE|RDDIAG_SERVERS)
|
||||||
|
- [x] Verbindungscode-Codec rddiag:v1:base64url({v,h,p,t,n?,fp?,s?})
|
||||||
|
- [x] Test-Harness (test/harness.mjs): fake debug-server (auth+routes+query-echo) + Bridge als stdio-Child → 19 Checks gruen (handshake, tools/list, ping, diagnostics+query-passthrough, logs-mapping, errors, escape-hatch, 401, missing-code, unreachable+hint)
|
||||||
|
- [ ] **P1 Security-Hardening debug-server:** IP-Allowlist app-seitig; /trace/config-Mutation gaten/umlabeln; opt-in+revoke; userData-Pfad verifizieren.
|
||||||
|
- [ ] **P2 One-Click-Enable + Code (App):** debug-server live (re)startbar ohne App-Neustart; IPC + flache UI (Anti-KI-Taste); Verbindungscode mit Copy + Revoke.
|
||||||
|
- [ ] **P3 Diagnose-Luecken:** Provider-Cooldown/Rotation Live-State, "Was ist JETZT kaputt"-Triage, evtl. Live-Log-Tail.
|
||||||
|
- [ ] **P4 Verify:** Tests gruen + tsc=6, Bridge end-to-end, Security-Review, Release v1.7.223 (Gitea+Mirror, 4 .exe). Bridge zu Claude Code (claude mcp add).
|
||||||
|
- [ ] **P5 Reachability-Acceptance (Nutzer):** debug-server auf 1 Server an → curl von Claude-Maschine. Direkt-IP vs Tunnel-Entscheid.
|
||||||
|
|
||||||
## Review
|
## Review
|
||||||
Logging-Release: pure Diagnose, null Verhaltensrisiko. Naechster Schritt: User reproduziert, schickt Bundle, conversion.log zeigt Queue-vs-Arbeit + Token-Lifecycle eindeutig → praeziser Fix in 214.
|
(folgt)
|
||||||
|
|||||||
42
tests/connection-code.test.ts
Normal file
42
tests/connection-code.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
146
tests/debug-server-allowlist.test.ts
Normal file
146
tests/debug-server-allowlist.test.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
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,
|
||||||
|
evaluateClientAllowed,
|
||||||
|
getPeerIp
|
||||||
|
} 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[], host = "0.0.0.0"): 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"), host, "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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 matcher (pure)", () => {
|
||||||
|
it("always allows loopback regardless of rules", () => {
|
||||||
|
expect(evaluateClientAllowed("127.0.0.1", [])).toBe(true);
|
||||||
|
expect(evaluateClientAllowed("::1", [])).toBe(true);
|
||||||
|
expect(evaluateClientAllowed("::ffff:127.0.0.1", ["8.8.8.8"])).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches an exact allowlisted IP and rejects others", () => {
|
||||||
|
expect(evaluateClientAllowed("8.8.8.8", ["8.8.8.8"])).toBe(true);
|
||||||
|
expect(evaluateClientAllowed("9.9.9.9", ["8.8.8.8"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("matches inside a CIDR and rejects outside it", () => {
|
||||||
|
expect(evaluateClientAllowed("10.0.0.42", ["10.0.0.0/24"])).toBe(true);
|
||||||
|
expect(evaluateClientAllowed("10.0.1.42", ["10.0.0.0/24"])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fail-closed: empty rules reject every non-loopback client", () => {
|
||||||
|
expect(evaluateClientAllowed("203.0.113.7", [])).toBe(false);
|
||||||
|
expect(evaluateClientAllowed("8.8.8.8", [])).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives the client IP from the socket peer, never from X-Forwarded-For", () => {
|
||||||
|
const forgedLoopback = {
|
||||||
|
socket: { remoteAddress: "8.8.8.8" },
|
||||||
|
headers: { "x-forwarded-for": "127.0.0.1" }
|
||||||
|
} as unknown as http.IncomingMessage;
|
||||||
|
expect(getPeerIp(forgedLoopback)).toBe("8.8.8.8");
|
||||||
|
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), [])).toBe(false);
|
||||||
|
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["9.9.9.9"])).toBe(false);
|
||||||
|
expect(evaluateClientAllowed(getPeerIp(forgedLoopback), ["8.8.8.8"])).toBe(true);
|
||||||
|
|
||||||
|
const ipv6Mapped = {
|
||||||
|
socket: { remoteAddress: "::ffff:10.0.0.5" },
|
||||||
|
headers: {}
|
||||||
|
} as unknown as http.IncomingMessage;
|
||||||
|
expect(getPeerIp(ipv6Mapped)).toBe("10.0.0.5");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("debug-server allowlist enforcement (wired)", () => {
|
||||||
|
it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => {
|
||||||
|
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
|
||||||
|
const plain = await fetch(`${baseUrl}/health?token=${TOKEN}`);
|
||||||
|
expect(plain.status).toBe(200);
|
||||||
|
const spoofed = await fetch(`${baseUrl}/health?token=${TOKEN}`, {
|
||||||
|
headers: { "X-Forwarded-For": "203.0.113.9" }
|
||||||
|
});
|
||||||
|
expect(spoofed.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still enforces the token for loopback clients", async () => {
|
||||||
|
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
|
||||||
|
const res = await fetch(`${baseUrl}/health`);
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads the allowlist live via restartDebugServer", async () => {
|
||||||
|
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
|
||||||
|
expect(getDebugServerRuntimeStatus().allowlistCount).toBe(1);
|
||||||
|
writeDebugServerConfig({ allowlist: ["9.9.9.9", "10.0.0.0/24"] });
|
||||||
|
const status = await restartDebugServer();
|
||||||
|
expect(status.running).toBe(true);
|
||||||
|
expect(status.allowlistCount).toBe(2);
|
||||||
|
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
|
||||||
|
expect((await fetch(`${baseUrl}/health?token=${TOKEN}`)).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
60
tools/rd-diagnostics-mcp/README.md
Normal file
60
tools/rd-diagnostics-mcp/README.md
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# rd-diagnostics-mcp
|
||||||
|
|
||||||
|
Standalone **stdio MCP bridge** to the Real-Debrid-Downloader debug-server. It runs on the machine where the
|
||||||
|
AI assistant (Claude Code) runs, takes a **connection code** for a downloader server, and exposes that server's
|
||||||
|
read-only HTTP diagnostics API (`/diagnostics`, `/status`, `/errors`, `/logs/*`, `/accounts`, …) as MCP tools.
|
||||||
|
One bridge serves all 5–6 servers; you pass a `code` (or a configured `server` name) per call.
|
||||||
|
|
||||||
|
This bridge is **not** bundled into the Electron app and adds **no** dependencies to it.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/rd-diagnostics-mcp
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Register it with Claude Code (single default server):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
claude mcp add rd-diag -- node "<repo>/tools/rd-diagnostics-mcp/src/bridge.mjs"
|
||||||
|
```
|
||||||
|
|
||||||
|
Provide servers via environment variables (codes contain a token — treat like passwords):
|
||||||
|
|
||||||
|
- `RDDIAG_CODE` — a single default connection code (`rddiag:v1:...`)
|
||||||
|
- `RDDIAG_SERVERS` — JSON map of name → code, e.g. `{"berlin":"rddiag:v1:...","fra":"rddiag:v1:..."}`
|
||||||
|
|
||||||
|
Without env config, every tool simply takes a `code` argument.
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
`rd_servers`, `rd_ping`, `rd_diagnostics`, `rd_status`, `rd_items`, `rd_packages`, `rd_errors`, `rd_logs`,
|
||||||
|
`rd_history`, `rd_accounts`, `rd_host`, `rd_self_check`, `rd_get` (raw escape-hatch, any read-only path).
|
||||||
|
|
||||||
|
Each tool accepts `code` or `server` to pick the target.
|
||||||
|
|
||||||
|
## Connection code
|
||||||
|
|
||||||
|
Format: `rddiag:v1:<base64url(JSON)>` with `{ v:1, h:host, p:port, t:token, n?:name, fp?:certFingerprint, s?:scheme }`.
|
||||||
|
Generated by the app (Hilfe → Remote-Support → Ferndiagnose (MCP)) or via `node src/gen-code.mjs --host H --port P --token T`.
|
||||||
|
|
||||||
|
## Security model (read before exposing a server)
|
||||||
|
|
||||||
|
- The debug surface is **read-only** for state/logs; the one control endpoint is `/trace/config` (toggles the
|
||||||
|
optional, time-bounded support trace). No persistent secrets are written into the logs it serves: passwords are
|
||||||
|
redacted, debrid API keys/tokens and resolved download URLs are never logged; `/settings` and `/accounts` are redacted.
|
||||||
|
- Auth is a bearer token (24 random bytes). Over plain HTTP on a public network the token is sniffable, so:
|
||||||
|
- **Preferred:** keep the server bound to `127.0.0.1` ("Nur lokal") and reach it through a private tunnel
|
||||||
|
(Tailscale / SSH / Cloudflare Tunnel). The tunnel encrypts and authenticates; no public exposure.
|
||||||
|
- **Direct network bind (`0.0.0.0`)** requires a non-empty **IP allowlist** (enforced fail-closed: with an empty
|
||||||
|
allowlist only loopback is accepted). Use only inside a trusted LAN/VPN.
|
||||||
|
- Revoke instantly from the app ("Token neu" or "Deaktivieren") — the old code stops working immediately.
|
||||||
|
- `fp` pins a self-signed cert fingerprint and is verified on `secureConnect` (before the token is sent). HTTPS is
|
||||||
|
not the v1 default; plain HTTP behind a tunnel is the recommended transport.
|
||||||
|
|
||||||
|
## Test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # spins a fake debug-server, runs the bridge as a stdio child, asserts the full protocol path
|
||||||
|
```
|
||||||
1172
tools/rd-diagnostics-mcp/package-lock.json
generated
Normal file
1172
tools/rd-diagnostics-mcp/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
tools/rd-diagnostics-mcp/package.json
Normal file
18
tools/rd-diagnostics-mcp/package.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "rd-diagnostics-mcp",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "Standalone stdio MCP bridge to the Real-Debrid-Downloader debug-server. Connects via connection code, proxies the read-only HTTP diagnostics API as MCP tools.",
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"rd-diagnostics-mcp": "src/bridge.mjs"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/bridge.mjs",
|
||||||
|
"test": "node test/harness.mjs"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
||||||
|
"zod": "^3.23.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
321
tools/rd-diagnostics-mcp/src/bridge.mjs
Normal file
321
tools/rd-diagnostics-mcp/src/bridge.mjs
Normal file
@ -0,0 +1,321 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { decodeConnectionCode } from "./code.mjs";
|
||||||
|
import { debugGet } from "./http.mjs";
|
||||||
|
|
||||||
|
function loadServerMap() {
|
||||||
|
const map = new Map();
|
||||||
|
const raw = process.env.RDDIAG_SERVERS;
|
||||||
|
if (raw) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
for (const [name, code] of Object.entries(parsed || {})) {
|
||||||
|
map.set(String(name), String(code));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
process.stderr.write("rd-diagnostics-mcp: RDDIAG_SERVERS ist kein gueltiges JSON, wird ignoriert\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SERVER_MAP = loadServerMap();
|
||||||
|
const DEFAULT_CODE = process.env.RDDIAG_CODE ? String(process.env.RDDIAG_CODE) : "";
|
||||||
|
|
||||||
|
function listAvailableServers() {
|
||||||
|
const names = [...SERVER_MAP.keys()];
|
||||||
|
if (DEFAULT_CODE) names.push("(RDDIAG_CODE-Default)");
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTarget(args) {
|
||||||
|
let code = "";
|
||||||
|
if (args && args.code) {
|
||||||
|
code = String(args.code);
|
||||||
|
} else if (args && args.server) {
|
||||||
|
const found = SERVER_MAP.get(String(args.server));
|
||||||
|
if (!found) {
|
||||||
|
throw new Error(
|
||||||
|
`Server "${args.server}" nicht konfiguriert. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
code = found;
|
||||||
|
} else if (DEFAULT_CODE) {
|
||||||
|
code = DEFAULT_CODE;
|
||||||
|
} else if (SERVER_MAP.size === 1) {
|
||||||
|
code = [...SERVER_MAP.values()][0];
|
||||||
|
} else {
|
||||||
|
throw new Error(
|
||||||
|
`Kein Verbindungscode. Uebergib "code" oder "server", oder setze RDDIAG_CODE/RDDIAG_SERVERS. Bekannt: ${listAvailableServers().join(", ") || "(keine)"}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return decodeConnectionCode(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetLabel(target) {
|
||||||
|
return target.name ? `${target.name} (${target.host}:${target.port})` : `${target.host}:${target.port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuery(params) {
|
||||||
|
const usable = Object.entries(params || {}).filter(
|
||||||
|
([, v]) => v !== undefined && v !== null && String(v).length > 0
|
||||||
|
);
|
||||||
|
if (usable.length === 0) return "";
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
for (const [k, v] of usable) sp.set(k, String(v));
|
||||||
|
return "?" + sp.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyBody(body) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(body), null, 2);
|
||||||
|
} catch {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectionHint(err) {
|
||||||
|
const m = String((err && err.code) || err && err.message || "");
|
||||||
|
if (/ECONNREFUSED/.test(m)) return "Debug-Server nicht erreichbar — auf dem Server aktiviert? Port/Firewall offen?";
|
||||||
|
if (/ENOTFOUND|EAI_AGAIN/.test(m)) return "Host nicht aufloesbar — stimmt die Adresse im Verbindungscode?";
|
||||||
|
if (/ETIMEDOUT|Zeitueberschreitung/.test(m)) return "Zeitueberschreitung — Server/Netz langsam oder Port geblockt.";
|
||||||
|
if (/ECONNRESET|EPIPE/.test(m)) return "Verbindung abgebrochen — falscher Port/Scheme (http vs https)?";
|
||||||
|
if (/Fingerprint/.test(m)) return "TLS-Fingerprint passt nicht — Code stammt evtl. von einem anderen Server.";
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestTool(args, path, params, opts = {}) {
|
||||||
|
let target;
|
||||||
|
try {
|
||||||
|
target = resolveTarget(args);
|
||||||
|
} catch (err) {
|
||||||
|
return { content: [{ type: "text", text: `# Verbindungsfehler\n${err.message}` }], isError: true };
|
||||||
|
}
|
||||||
|
const fullPath = path + buildQuery(params);
|
||||||
|
const label = targetLabel(target);
|
||||||
|
try {
|
||||||
|
const res = await debugGet(target, fullPath, { timeoutMs: opts.timeoutMs || 20000 });
|
||||||
|
const isError = res.status < 200 || res.status >= 300;
|
||||||
|
let extra = "";
|
||||||
|
if (res.status === 401) extra = "\n(401 = Token im Verbindungscode ist abgelaufen/rotiert. Neuen Code anfordern.)";
|
||||||
|
if (res.status === 503) extra = "\n(503 = Download-Manager nicht bereit. App laeuft, aber noch nicht initialisiert?)";
|
||||||
|
const head = `# ${label} ${fullPath} → HTTP ${res.status}${extra}`;
|
||||||
|
return { content: [{ type: "text", text: head + "\n" + prettyBody(res.body) }], isError };
|
||||||
|
} catch (err) {
|
||||||
|
const hint = connectionHint(err);
|
||||||
|
const text = `# ${label} ${fullPath} → FEHLER\n${err.message}${hint ? "\n→ " + hint : ""}`;
|
||||||
|
return { content: [{ type: "text", text }], isError: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CODE_FIELD = {
|
||||||
|
code: z.string().optional().describe("Verbindungscode (rddiag:v1:...). Optional, wenn server/RDDIAG_CODE gesetzt ist."),
|
||||||
|
server: z.string().optional().describe("Name eines via RDDIAG_SERVERS konfigurierten Servers statt eines vollen Codes.")
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = new McpServer({ name: "rd-diagnostics-mcp", version: "1.0.0" });
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_servers",
|
||||||
|
{
|
||||||
|
title: "Konfigurierte Server",
|
||||||
|
description: "Listet die in dieser Bridge konfigurierten Server (RDDIAG_SERVERS / RDDIAG_CODE). Verbindet sich nicht.",
|
||||||
|
inputSchema: {}
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const names = [...SERVER_MAP.keys()];
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`Konfigurierte Server: ${names.length}`);
|
||||||
|
for (const n of names) lines.push(`- ${n}`);
|
||||||
|
lines.push(`Default (RDDIAG_CODE): ${DEFAULT_CODE ? "gesetzt" : "nicht gesetzt"}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Tools akzeptieren entweder code:<rddiag:v1:...> oder server:<name>.");
|
||||||
|
return { content: [{ type: "text", text: lines.join("\n") }] };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_ping",
|
||||||
|
{
|
||||||
|
title: "Erreichbarkeit pruefen",
|
||||||
|
description: "Schneller Health-Check (GET /health): App-Version, Uptime, Speicher. Zuerst aufrufen, um Erreichbarkeit + Token zu pruefen.",
|
||||||
|
inputSchema: { ...CODE_FIELD }
|
||||||
|
},
|
||||||
|
async (args) => requestTool(args, "/health", {}, { timeoutMs: 10000 })
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_diagnostics",
|
||||||
|
{
|
||||||
|
title: "Gesamtdiagnose",
|
||||||
|
description: "Aggregierter Zustand (GET /diagnostics): Meta, Status, Settings, Stats, Accounts, History, Host + die wichtigsten Logs. Der 'alles auf einen Blick'-Endpunkt.",
|
||||||
|
inputSchema: {
|
||||||
|
...CODE_FIELD,
|
||||||
|
lines: z.number().int().positive().optional().describe("Anzahl Log-Zeilen pro Log (Default 150)."),
|
||||||
|
grep: z.string().optional().describe("Filter fuer Log-Zeilen."),
|
||||||
|
package: z.string().optional().describe("Optional auf ein Paket fokussieren.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => requestTool(args, "/diagnostics", { lines: args.lines, grep: args.grep, package: args.package }, { timeoutMs: 30000 })
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_status",
|
||||||
|
{
|
||||||
|
title: "Live-Status",
|
||||||
|
description: "Laufzeit-Status (GET /status): aktive Downloads, Queue, Provider-Zustand.",
|
||||||
|
inputSchema: { ...CODE_FIELD }
|
||||||
|
},
|
||||||
|
async (args) => requestTool(args, "/status", {})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_items",
|
||||||
|
{
|
||||||
|
title: "Download-Items",
|
||||||
|
description: "Einzelne Download-Items (GET /items), optional gefiltert nach Status/Paket.",
|
||||||
|
inputSchema: {
|
||||||
|
...CODE_FIELD,
|
||||||
|
status: z.string().optional().describe("Status-Filter (z.B. downloading, error, done)."),
|
||||||
|
package: z.string().optional().describe("Paket-Filter.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => requestTool(args, "/items", { status: args.status, package: args.package })
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_packages",
|
||||||
|
{
|
||||||
|
title: "Pakete",
|
||||||
|
description: "Pakete (GET /packages), optional mit enthaltenen Items.",
|
||||||
|
inputSchema: {
|
||||||
|
...CODE_FIELD,
|
||||||
|
package: z.string().optional().describe("Bestimmtes Paket."),
|
||||||
|
includeItems: z.boolean().optional().describe("Items mitliefern.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => requestTool(args, "/packages", { package: args.package, includeItems: args.includeItems ? "1" : "" })
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_errors",
|
||||||
|
{
|
||||||
|
title: "Letzte Fehler",
|
||||||
|
description: "Fehler-Ring (GET /errors): die letzten Fehler mit Level/Quelle. 'Was ist schiefgelaufen'.",
|
||||||
|
inputSchema: {
|
||||||
|
...CODE_FIELD,
|
||||||
|
level: z.string().optional().describe("Level-Filter (ERROR, WARN, ...)."),
|
||||||
|
limit: z.number().int().positive().optional().describe("Anzahl (Default 100).")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => requestTool(args, "/errors", { level: args.level, limit: args.limit })
|
||||||
|
);
|
||||||
|
|
||||||
|
const LOG_PATHS = {
|
||||||
|
main: "/logs/main",
|
||||||
|
audit: "/logs/audit",
|
||||||
|
rename: "/logs/rename",
|
||||||
|
trace: "/logs/trace",
|
||||||
|
session: "/logs/session",
|
||||||
|
package: "/logs/package",
|
||||||
|
item: "/logs/item"
|
||||||
|
};
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_logs",
|
||||||
|
{
|
||||||
|
title: "Log lesen",
|
||||||
|
description: "Liest das Ende eines Logs (GET /logs/<name>). name: main|audit|rename|trace|session|package|item. Fuer package/item zusaetzlich package/item angeben.",
|
||||||
|
inputSchema: {
|
||||||
|
...CODE_FIELD,
|
||||||
|
name: z.enum(["main", "audit", "rename", "trace", "session", "package", "item"]).describe("Welches Log."),
|
||||||
|
lines: z.number().int().positive().optional().describe("Anzahl Zeilen vom Ende (Default 100)."),
|
||||||
|
grep: z.string().optional().describe("Filter."),
|
||||||
|
package: z.string().optional().describe("Nur fuer name=package."),
|
||||||
|
item: z.string().optional().describe("Nur fuer name=item.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const path = LOG_PATHS[args.name];
|
||||||
|
return requestTool(args, path, { lines: args.lines, grep: args.grep, package: args.package, item: args.item });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_history",
|
||||||
|
{
|
||||||
|
title: "Verlauf",
|
||||||
|
description: "Abgeschlossener Verlauf (GET /history), optional nach Status/Suchbegriff.",
|
||||||
|
inputSchema: {
|
||||||
|
...CODE_FIELD,
|
||||||
|
limit: z.number().int().positive().optional().describe("Anzahl (Default 50)."),
|
||||||
|
status: z.string().optional().describe("Status-Filter."),
|
||||||
|
grep: z.string().optional().describe("Suchbegriff.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => requestTool(args, "/history", { limit: args.limit, status: args.status, grep: args.grep })
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_accounts",
|
||||||
|
{
|
||||||
|
title: "Accounts",
|
||||||
|
description: "Debrid-Accounts (GET /accounts, Token redigiert): Gueltigkeit, Premium, Cooldown/Rotation.",
|
||||||
|
inputSchema: { ...CODE_FIELD }
|
||||||
|
},
|
||||||
|
async (args) => requestTool(args, "/accounts", {})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_host",
|
||||||
|
{
|
||||||
|
title: "Host-Diagnose",
|
||||||
|
description: "Windows-Host-Diagnose (GET /host/diagnostics): Laufwerke, Speicher, Pfade.",
|
||||||
|
inputSchema: { ...CODE_FIELD }
|
||||||
|
},
|
||||||
|
async (args) => requestTool(args, "/host/diagnostics", {})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_self_check",
|
||||||
|
{
|
||||||
|
title: "Self-Check",
|
||||||
|
description: "Setup/Self-Check (GET /self-check): erkennt Konfigurations-/Pfadprobleme.",
|
||||||
|
inputSchema: { ...CODE_FIELD }
|
||||||
|
},
|
||||||
|
async (args) => requestTool(args, "/self-check", {})
|
||||||
|
);
|
||||||
|
|
||||||
|
server.registerTool(
|
||||||
|
"rd_get",
|
||||||
|
{
|
||||||
|
title: "Roh-Endpunkt (Escape-Hatch)",
|
||||||
|
description: "Beliebigen Debug-Server-Pfad lesen (GET <path>), wenn kein spezialisiertes Tool passt. Pfad inkl. fuehrendem / und optionalem Query-String, z.B. /meta oder /stats.",
|
||||||
|
inputSchema: {
|
||||||
|
...CODE_FIELD,
|
||||||
|
path: z.string().describe("Pfad mit fuehrendem /, optional ?query. Nur GET, read-only.")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async (args) => {
|
||||||
|
const p = String(args.path || "");
|
||||||
|
if (!p.startsWith("/")) {
|
||||||
|
return { content: [{ type: "text", text: "# Fehler\npath muss mit / beginnen" }], isError: true };
|
||||||
|
}
|
||||||
|
return requestTool(args, p, {}, { timeoutMs: 30000 });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const transport = new StdioServerTransport();
|
||||||
|
await server.connect(transport);
|
||||||
|
process.stderr.write(
|
||||||
|
`rd-diagnostics-mcp bereit. Server: ${listAvailableServers().join(", ") || "(keine vorkonfiguriert; code pro Aufruf uebergeben)"}\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
process.stderr.write(`rd-diagnostics-mcp Startfehler: ${err && err.stack ? err.stack : err}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
20
tools/rd-diagnostics-mcp/src/code.d.mts
Normal file
20
tools/rd-diagnostics-mcp/src/code.d.mts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
export interface DecodedConnectionCode {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
token: string;
|
||||||
|
scheme: string;
|
||||||
|
name: string;
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EncodeConnectionCodeInput {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
token: string;
|
||||||
|
name?: string;
|
||||||
|
fingerprint?: string;
|
||||||
|
scheme?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeConnectionCode(input: EncodeConnectionCodeInput): string;
|
||||||
|
export function decodeConnectionCode(code: string): DecodedConnectionCode;
|
||||||
56
tools/rd-diagnostics-mcp/src/code.mjs
Normal file
56
tools/rd-diagnostics-mcp/src/code.mjs
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
const PREFIX = "rddiag:v1:";
|
||||||
|
|
||||||
|
function base64urlEncode(str) {
|
||||||
|
return Buffer.from(str, "utf8")
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64urlDecode(str) {
|
||||||
|
const pad = str.length % 4 === 0 ? "" : "=".repeat(4 - (str.length % 4));
|
||||||
|
const b64 = str.replace(/-/g, "+").replace(/_/g, "/") + pad;
|
||||||
|
return Buffer.from(b64, "base64").toString("utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encodeConnectionCode({ host, port, token, name, fingerprint, scheme }) {
|
||||||
|
if (!host || typeof host !== "string") throw new Error("host fehlt");
|
||||||
|
const p = Number(port);
|
||||||
|
if (!Number.isInteger(p) || p < 1 || p > 65535) throw new Error("port ungueltig");
|
||||||
|
if (!token || typeof token !== "string") throw new Error("token fehlt");
|
||||||
|
const payload = { v: 1, h: host, p, t: token };
|
||||||
|
if (name) payload.n = String(name);
|
||||||
|
if (fingerprint) payload.fp = String(fingerprint);
|
||||||
|
if (scheme && scheme !== "http") payload.s = String(scheme);
|
||||||
|
return PREFIX + base64urlEncode(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeConnectionCode(code) {
|
||||||
|
const raw = String(code || "").trim();
|
||||||
|
if (!raw.startsWith(PREFIX)) {
|
||||||
|
throw new Error(`Verbindungscode muss mit "${PREFIX}" beginnen`);
|
||||||
|
}
|
||||||
|
let json;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(base64urlDecode(raw.slice(PREFIX.length)));
|
||||||
|
} catch {
|
||||||
|
throw new Error("Verbindungscode ist beschaedigt (kein gueltiges base64url/JSON)");
|
||||||
|
}
|
||||||
|
if (!json || typeof json !== "object") throw new Error("Verbindungscode-Inhalt ungueltig");
|
||||||
|
const host = String(json.h || "").trim();
|
||||||
|
const port = Number(json.p);
|
||||||
|
const token = String(json.t || "");
|
||||||
|
if (!host) throw new Error("Verbindungscode ohne Host");
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("Verbindungscode mit ungueltigem Port");
|
||||||
|
if (!token) throw new Error("Verbindungscode ohne Token");
|
||||||
|
const scheme = json.s === "https" ? "https" : "http";
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
token,
|
||||||
|
scheme,
|
||||||
|
name: json.n ? String(json.n) : "",
|
||||||
|
fingerprint: json.fp ? String(json.fp) : ""
|
||||||
|
};
|
||||||
|
}
|
||||||
22
tools/rd-diagnostics-mcp/src/gen-code.mjs
Normal file
22
tools/rd-diagnostics-mcp/src/gen-code.mjs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { encodeConnectionCode } from "./code.mjs";
|
||||||
|
|
||||||
|
function arg(name, fallback) {
|
||||||
|
const i = process.argv.indexOf("--" + name);
|
||||||
|
if (i >= 0 && i + 1 < process.argv.length) return process.argv[i + 1];
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = arg("host");
|
||||||
|
const port = arg("port");
|
||||||
|
const token = arg("token");
|
||||||
|
const name = arg("name");
|
||||||
|
const scheme = arg("scheme");
|
||||||
|
const fingerprint = arg("fp");
|
||||||
|
|
||||||
|
if (!host || !port || !token) {
|
||||||
|
process.stderr.write("Usage: node src/gen-code.mjs --host <h> --port <p> --token <t> [--name <n>] [--scheme https] [--fp <sha256>]\n");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.stdout.write(encodeConnectionCode({ host, port, token, name, scheme, fingerprint }) + "\n");
|
||||||
63
tools/rd-diagnostics-mcp/src/http.mjs
Normal file
63
tools/rd-diagnostics-mcp/src/http.mjs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
|
||||||
|
function normalizeFp(fp) {
|
||||||
|
return String(fp || "").replace(/:/g, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debugGet(target, path, { timeoutMs = 20000 } = {}) {
|
||||||
|
const scheme = target.scheme === "https" ? "https" : "http";
|
||||||
|
const lib = scheme === "https" ? https : http;
|
||||||
|
const rel = path.startsWith("/") ? path : "/" + path;
|
||||||
|
const url = new URL(rel, `${scheme}://${target.host}:${target.port}`);
|
||||||
|
const pinning = scheme === "https" && !!target.fingerprint;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const options = {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${target.token}`,
|
||||||
|
Accept: "application/json"
|
||||||
|
},
|
||||||
|
timeout: timeoutMs
|
||||||
|
};
|
||||||
|
if (scheme === "https") {
|
||||||
|
options.rejectUnauthorized = !target.fingerprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = lib.request(url, options, (res) => {
|
||||||
|
let data = "";
|
||||||
|
res.setEncoding("utf8");
|
||||||
|
res.on("data", (chunk) => {
|
||||||
|
data += chunk;
|
||||||
|
});
|
||||||
|
res.on("end", () => {
|
||||||
|
resolve({ status: res.statusCode || 0, body: data, headers: res.headers });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("timeout", () => {
|
||||||
|
req.destroy(new Error(`Zeitueberschreitung nach ${timeoutMs}ms`));
|
||||||
|
});
|
||||||
|
req.on("error", (err) => {
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pinning) {
|
||||||
|
req.on("socket", (socket) => {
|
||||||
|
socket.on("secureConnect", () => {
|
||||||
|
const cert = typeof socket.getPeerCertificate === "function" ? socket.getPeerCertificate() : null;
|
||||||
|
const got = normalizeFp(cert && cert.fingerprint256);
|
||||||
|
const want = normalizeFp(target.fingerprint);
|
||||||
|
if (!got || got !== want) {
|
||||||
|
req.destroy(new Error(`TLS-Fingerprint stimmt nicht (erwartet ${want || "?"}, erhalten ${got || "?"})`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
req.end();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
175
tools/rd-diagnostics-mcp/test/harness.mjs
Normal file
175
tools/rd-diagnostics-mcp/test/harness.mjs
Normal file
@ -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);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user