Ferndiagnose (MCP): Verbindungscode + abgesicherter Fernzugriff + stdio-Bridge
Neue Funktion, um Diagnose eines laufenden Servers aus der Ferne zu ermoeglichen:
Hilfe -> Remote-Support -> "Ferndiagnose (MCP)". Erzeugt einen Verbindungscode,
den der Assistent nutzt, um Status, Logs, Fehler und Accounts read-only zu lesen.
App-Seite:
- Live (re)startbarer Debug-Server ohne App-Neustart (restartDebugServer wartet auf
'close' + closeAllConnections, behandelt EADDRINUSE).
- IP-Allowlist (debug_allowlist.txt, exakte IP + CIDR), erzwungen VOR der Auth.
Fail-closed: Netzwerk-Bind (0.0.0.0) ohne Allowlist akzeptiert nur Loopback.
- One-Click Aktivieren/Aktualisieren/Deaktivieren + Token-Rotation (alter Code sofort
ungueltig). Sichtbarkeit waehlbar: "Nur lokal" (Tunnel-Empfehlung) vs "Im Netzwerk".
- Verbindungscode rddiag:v1:base64url({v,h,p,t,n?,fp?,s?}); oeffentlicher Host frei
waehlbar, Netzwerk-IPs als Schnellauswahl.
- Neue IPC: get/enable/disable/rotate Remote-Diagnostics; Controller-Methoden; Typen.
Bridge (tools/rd-diagnostics-mcp, standalone, KEINE App-Dependency):
- stdio MCP-Server (@modelcontextprotocol/sdk) mit 14 Tools, proxyt die bestehende
HTTP-Debug-API. Multi-Server ueber code/server/RDDIAG_CODE/RDDIAG_SERVERS.
- TLS-Fingerprint-Pinning auf secureConnect (vor Token-Versand), falls https genutzt.
- test/harness.mjs: faehrt einen Fake-Debug-Server hoch und treibt die Bridge als
echten stdio-Child per JSON-RPC -> voller Protokollpfad gruen.
Sicherheit (Audit): keine persistenten Secrets in den Logs der Debug-API (Passwoerter
redigiert, keine Debrid-Keys/aufgeloesten Download-URLs geloggt; settings/accounts
redigiert). Empfohlener Transport: Loopback + privater Tunnel; Direkt-Bind nur mit
Allowlist in vertrauenswuerdigen Netzen.
Tests: connection-code-Cross-Check (App-Encoder <-> Bridge-Decoder), Allowlist-Matrix
(Loopback, exakt, CIDR, fail-closed, Live-Restart). Volle Suite 905 gruen, tsc=6.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import v8 from "node:v8";
|
||||
import { app } from "electron";
|
||||
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
|
||||
@@ -9,9 +10,11 @@ import {
|
||||
DebridAccountStatus,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
EnableRemoteDiagnosticsInput,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
ParsedPackageInput,
|
||||
RemoteDiagnosticsInfo,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
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 { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
|
||||
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 { buildBackupPayload, planBackupImport } from "./backup-payload";
|
||||
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
|
||||
@@ -291,6 +295,83 @@ export class AppController {
|
||||
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 {
|
||||
return getDebugSetupCheck(this.storagePaths.baseDir);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+212
-16
@@ -65,6 +65,16 @@ let authToken = "";
|
||||
let bindHost = DEFAULT_HOST;
|
||||
let bindPort = DEFAULT_PORT;
|
||||
let runtimeBaseDir = "";
|
||||
let allowlist: string[] = [];
|
||||
|
||||
export interface DebugServerRuntimeStatus {
|
||||
running: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
hasToken: boolean;
|
||||
localOnly: boolean;
|
||||
allowlistCount: number;
|
||||
}
|
||||
|
||||
function getStoragePaths() {
|
||||
return createStoragePaths(runtimeBaseDir);
|
||||
@@ -140,6 +150,86 @@ function getHost(baseDir: string): string {
|
||||
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;
|
||||
}
|
||||
|
||||
function isClientAllowed(clientIp: string): boolean {
|
||||
const client = normalizeIp(clientIp);
|
||||
if (isLoopbackIp(client) || client === "") {
|
||||
return true;
|
||||
}
|
||||
if (allowlist.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return allowlist.some((rule) => matchIpRule(client, rule));
|
||||
}
|
||||
|
||||
function checkAuth(req: http.IncomingMessage): boolean {
|
||||
if (!authToken) {
|
||||
return false;
|
||||
@@ -461,6 +551,18 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
return;
|
||||
}
|
||||
|
||||
const clientIp = extractDebugClientIp(req);
|
||||
if (!isClientAllowed(clientIp)) {
|
||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
||||
logTraceEvent("WARN", "debug-http", "Durch Allowlist blockiert", {
|
||||
clientIp,
|
||||
url: sanitizeRequestUrlForTrace(req.url || "/")
|
||||
});
|
||||
}
|
||||
jsonResponse(res, 403, { error: "Forbidden", reason: "Client-IP nicht in Allowlist", clientIp });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!checkAuth(req)) {
|
||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
||||
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
|
||||
@@ -927,32 +1029,126 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
});
|
||||
}
|
||||
|
||||
function openServerSocket(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
authToken = loadToken(runtimeBaseDir);
|
||||
bindPort = getPort(runtimeBaseDir);
|
||||
bindHost = getHost(runtimeBaseDir);
|
||||
allowlist = loadAllowlist(runtimeBaseDir);
|
||||
writeAiManifest(runtimeBaseDir);
|
||||
if (!authToken) {
|
||||
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (bindHost === "0.0.0.0" && allowlist.length === 0) {
|
||||
logger.warn("Debug-Server: Netzwerk-Bind ohne Allowlist - nur Loopback-Clients werden akzeptiert (fail-closed)");
|
||||
}
|
||||
const srv = http.createServer(handleRequest);
|
||||
let settled = false;
|
||||
const settle = (): void => {
|
||||
if (!settled) {
|
||||
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)}`);
|
||||
}
|
||||
if (server === srv) {
|
||||
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;
|
||||
authToken = loadToken(baseDir);
|
||||
bindPort = getPort(baseDir);
|
||||
bindHost = getHost(baseDir);
|
||||
writeAiManifest(baseDir);
|
||||
if (!authToken) {
|
||||
logger.info("Debug-Server: Kein Token in debug_token.txt, Server wird nicht gestartet");
|
||||
return;
|
||||
}
|
||||
|
||||
manager = mgr;
|
||||
void openServerSocket();
|
||||
}
|
||||
|
||||
server = http.createServer(handleRequest);
|
||||
server.listen(bindPort, bindHost, () => {
|
||||
logger.info(`Debug-Server gestartet auf ${bindHost}:${bindPort}`);
|
||||
});
|
||||
server.on("error", (err) => {
|
||||
logger.warn(`Debug-Server Fehler: ${String(err)}`);
|
||||
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 {
|
||||
if (server) {
|
||||
server.close();
|
||||
try {
|
||||
server.closeAllConnections?.();
|
||||
} catch {
|
||||
}
|
||||
server = null;
|
||||
logger.info("Debug-Server gestoppt");
|
||||
}
|
||||
|
||||
+28
-1
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
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 { IPC_CHANNELS } from "../shared/ipc";
|
||||
import { getLogFilePath, logger } from "./logger";
|
||||
@@ -671,6 +671,33 @@ function registerIpcHandlers(): void {
|
||||
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) => {
|
||||
validateString(itemId, "itemId");
|
||||
const logPath = controller.getItemLogPath(itemId);
|
||||
|
||||
@@ -7,8 +7,10 @@ import {
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
EnableRemoteDiagnosticsInput,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RemoteDiagnosticsInfo,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
@@ -74,6 +76,10 @@ const api: ElectronApi = {
|
||||
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),
|
||||
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),
|
||||
openAllDebridLogin: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ALLDEBRID_LOGIN),
|
||||
importBestDebridCookies: (): Promise<number> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_BESTDEBRID_COOKIES),
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
DuplicatePolicy,
|
||||
HistoryEntry,
|
||||
PackageEntry,
|
||||
RemoteDiagnosticsInfo,
|
||||
StartConflictEntry,
|
||||
UiSnapshot,
|
||||
UpdateCheckResult,
|
||||
@@ -1761,6 +1762,14 @@ export function App(): ReactElement {
|
||||
const [startConflictPrompt, setStartConflictPrompt] = useState<StartConflictPromptState | 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 [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 confirmQueueRef = useRef<Array<{ prompt: ConfirmPromptState; resolve: (confirmed: boolean) => void }>>([]);
|
||||
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 => {
|
||||
closeMenus();
|
||||
void window.rd.restart();
|
||||
@@ -4718,6 +4810,7 @@ export function App(): ReactElement {
|
||||
<button className="menu-submenu-trigger">Remote-Support</button>
|
||||
{openSubmenu === "hilfe-remote" && (
|
||||
<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 onToggleSupportTrace(); }}><span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span></button>
|
||||
</div>
|
||||
@@ -5934,6 +6027,97 @@ export function App(): ReactElement {
|
||||
</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 && (() => {
|
||||
const itemCount = [...deleteConfirm.ids].filter((id) => snapshot.session.items[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-body strong { font-weight: 600; }
|
||||
.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",
|
||||
SET_TRACE_ENABLED: "app:set-trace-enabled",
|
||||
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_ALLDEBRID_LOGIN: "app:open-alldebrid-login",
|
||||
IMPORT_BESTDEBRID_COOKIES: "app:import-bestdebrid-cookies",
|
||||
|
||||
@@ -7,8 +7,10 @@ import type {
|
||||
DebridLinkHostLimitInfo,
|
||||
DebridProvider,
|
||||
DuplicatePolicy,
|
||||
EnableRemoteDiagnosticsInput,
|
||||
HistoryEntry,
|
||||
PackagePriority,
|
||||
RemoteDiagnosticsInfo,
|
||||
RendererErrorReport,
|
||||
SessionStats,
|
||||
StartConflictEntry,
|
||||
@@ -71,6 +73,10 @@ export interface ElectronApi {
|
||||
getTraceConfig: () => Promise<SupportTraceConfig>;
|
||||
setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
|
||||
rotateDebugToken: () => Promise<{ path: string }>;
|
||||
getRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||
enableRemoteDiagnostics: (input: EnableRemoteDiagnosticsInput) => Promise<RemoteDiagnosticsInfo>;
|
||||
disableRemoteDiagnostics: () => Promise<RemoteDiagnosticsInfo>;
|
||||
rotateRemoteDiagnosticsToken: () => Promise<RemoteDiagnosticsInfo>;
|
||||
openRealDebridLogin: () => Promise<void>;
|
||||
openAllDebridLogin: () => Promise<void>;
|
||||
importBestDebridCookies: () => Promise<number>;
|
||||
|
||||
@@ -537,3 +537,30 @@ export interface RendererErrorReport {
|
||||
column?: number;
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user