Harden support logging and debug setup

This commit is contained in:
Sucukdeluxe 2026-03-09 02:47:49 +01:00
parent af73934b0f
commit fc4fafa0d6
16 changed files with 512 additions and 24 deletions

View File

@ -189,6 +189,8 @@ Runtime files are stored in Electron's `userData` directory, including:
- `package-logs/package_*.txt` - `package-logs/package_*.txt`
- `item-logs/item_*.txt` - `item-logs/item_*.txt`
`audit.log` and `trace.log` are rotated automatically. The current file is kept plus one `.old` backup, and outdated backups are purged automatically.
### Remote debug server ### Remote debug server
For headless or server-style troubleshooting, the app can expose a small authenticated HTTP debug API with live status and log tails. For headless or server-style troubleshooting, the app can expose a small authenticated HTTP debug API with live status and log tails.
@ -204,12 +206,15 @@ Enable it by creating these files in the same runtime folder that contains `rd_d
After startup, the app also writes `debug_ai_manifest.json` into the same runtime folder. This file is meant for support tooling and AI agents: it lists all available endpoints, the auth method, the related runtime files, and the one remaining external value the assistant may still need from you for remote access: the server IP or DNS name. After startup, the app also writes `debug_ai_manifest.json` into the same runtime folder. This file is meant for support tooling and AI agents: it lists all available endpoints, the auth method, the related runtime files, and the one remaining external value the assistant may still need from you for remote access: the server IP or DNS name.
If you want extra support detail during a flaky or hard-to-reproduce issue, the app also maintains a `trace.log` plus `trace_config.json`. You can enable or disable the support trace from the app menu or remotely via the debug API. If you want extra support detail during a flaky or hard-to-reproduce issue, the app also maintains a `trace.log` plus `trace_config.json`. You can enable or disable the support trace from the app menu or remotely via the debug API. By default, the support trace now auto-disables again after 2 hours so it does not stay enabled forever by accident.
The app menu under `Hilfe` also includes a `Debug-Setup prüfen` action. It verifies the current host/port/token/AI-manifest/trace setup locally and shows the exact local and remote URLs that support tooling can use.
Available endpoints after restart: Available endpoints after restart:
- `GET /health` - `GET /health`
- `GET /meta` - `GET /meta`
- `GET /debug/setup`
- `GET /host/diagnostics` - `GET /host/diagnostics`
- `GET /status` - `GET /status`
- `GET /settings` - `GET /settings`
@ -226,7 +231,7 @@ Available endpoints after restart:
- `GET /logs/session?lines=100&grep=keyword` - `GET /logs/session?lines=100&grep=keyword`
- `GET /logs/package?package=Release&lines=100&grep=keyword` - `GET /logs/package?package=Release&lines=100&grep=keyword`
- `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword` - `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword`
- `GET /trace/config?enable=1&note=support` - `GET /trace/config?enable=1&note=support&durationMinutes=120`
- `GET /support/bundle` - `GET /support/bundle`
- `GET /diagnostics?package=Release&lines=150` - `GET /diagnostics?package=Release&lines=150`
@ -243,9 +248,10 @@ Invoke-RestMethod "http://SERVER:9868/settings?token=YOUR_TOKEN"
Invoke-RestMethod "http://SERVER:9868/accounts?token=YOUR_TOKEN" Invoke-RestMethod "http://SERVER:9868/accounts?token=YOUR_TOKEN"
Invoke-RestMethod "http://SERVER:9868/stats?token=YOUR_TOKEN" Invoke-RestMethod "http://SERVER:9868/stats?token=YOUR_TOKEN"
Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20" Invoke-RestMethod "http://SERVER:9868/history?token=YOUR_TOKEN&limit=20"
Invoke-RestMethod "http://SERVER:9868/debug/setup?token=YOUR_TOKEN"
Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200" Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&lines=200"
Invoke-RestMethod "http://SERVER:9868/logs/trace?token=YOUR_TOKEN&lines=200" Invoke-RestMethod "http://SERVER:9868/logs/trace?token=YOUR_TOKEN&lines=200"
Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1&note=support" Invoke-RestMethod "http://SERVER:9868/trace/config?token=YOUR_TOKEN&enable=1&note=support&durationMinutes=120"
Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200" Invoke-RestMethod "http://SERVER:9868/logs/package?token=YOUR_TOKEN&package=Release&lines=200"
Invoke-RestMethod "http://SERVER:9868/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200" Invoke-RestMethod "http://SERVER:9868/logs/item?token=YOUR_TOKEN&item=episode.part2.rar&lines=200"
Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN" Invoke-RestMethod "http://SERVER:9868/host/diagnostics?token=YOUR_TOKEN"

View File

@ -37,10 +37,11 @@ import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } fro
import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server"; import { rotateDebugToken, startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } from "./backup-crypto"; import { encryptBackup, decryptBackup } from "./backup-crypto";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log"; import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup";
import { buildAccountSummary, diffAccountSummary } from "./support-data"; import { buildAccountSummary, diffAccountSummary } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log"; import { getTraceConfig, getTraceLogPath, initTraceLog, logTraceEvent, setTraceEnabled, shutdownTraceLog } from "./trace-log";
import type { SupportTraceConfig } from "../shared/types"; import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> { function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@ -198,13 +199,17 @@ export class AppController {
return rotated; return rotated;
} }
public getDebugSetupCheck(): DebugSetupCheckResult {
return getDebugSetupCheck(this.storagePaths.baseDir);
}
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void { private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
logAuditEvent(level, message, fields); logAuditEvent(level, message, fields);
logTraceEvent(level, "audit", message, fields); logTraceEvent(level, "audit", message, fields);
} }
public setTraceEnabled(enabled: boolean, note = ""): SupportTraceConfig { public setTraceEnabled(enabled: boolean, note = "", durationMs?: number): SupportTraceConfig {
const next = setTraceEnabled(enabled, note); const next = setTraceEnabled(enabled, note, durationMs);
this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note }); this.audit("INFO", enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", { note });
return next; return next;
} }

View File

@ -3,6 +3,9 @@ import path from "node:path";
type AuditLevel = "INFO" | "WARN" | "ERROR"; type AuditLevel = "INFO" | "WARN" | "ERROR";
const AUDIT_LOG_MAX_FILE_BYTES = Number(process.env.RD_AUDIT_LOG_MAX_BYTES || 10 * 1024 * 1024);
const AUDIT_LOG_RETENTION_DAYS = Number(process.env.RD_AUDIT_LOG_RETENTION_DAYS || 30);
let auditLogPath: string | null = null; let auditLogPath: string | null = null;
function sanitizeFieldValue(value: unknown): string { function sanitizeFieldValue(value: unknown): string {
@ -32,10 +35,46 @@ function formatFields(fields?: Record<string, unknown>): string {
return parts.length > 0 ? ` | ${parts.join(" | ")}` : ""; return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
} }
function rotateIfNeeded(filePath: string): void {
try {
const stat = fs.statSync(filePath);
if (stat.size < AUDIT_LOG_MAX_FILE_BYTES) {
return;
}
const backup = `${filePath}.old`;
try {
fs.rmSync(backup, { force: true });
} catch {
// ignore
}
fs.renameSync(filePath, backup);
} catch {
// ignore
}
}
function cleanupOldBackup(filePath: string): void {
const backup = `${filePath}.old`;
try {
const stat = fs.statSync(backup);
const cutoff = Date.now() - AUDIT_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (stat.mtimeMs < cutoff) {
fs.rmSync(backup, { force: true });
}
} catch {
// ignore
}
}
export function initAuditLog(baseDir: string): void { export function initAuditLog(baseDir: string): void {
auditLogPath = path.join(baseDir, "audit.log"); auditLogPath = path.join(baseDir, "audit.log");
try { try {
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true }); fs.mkdirSync(path.dirname(auditLogPath), { recursive: true });
cleanupOldBackup(auditLogPath);
if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8");
}
rotateIfNeeded(auditLogPath);
if (!fs.existsSync(auditLogPath)) { if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8"); fs.writeFileSync(auditLogPath, "", "utf8");
} }
@ -50,6 +89,10 @@ export function logAuditEvent(level: AuditLevel, message: string, fields?: Recor
return; return;
} }
try { try {
rotateIfNeeded(auditLogPath);
if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8");
}
fs.appendFileSync( fs.appendFileSync(
auditLogPath, auditLogPath,
`${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`, `${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`,

View File

@ -4,6 +4,7 @@ import path from "node:path";
import crypto from "node:crypto"; import crypto from "node:crypto";
import { APP_VERSION } from "./constants"; import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log"; import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup";
import { logger, getLogFilePath } from "./logger"; import { logger, getLogFilePath } from "./logger";
import { getItemLogPath as getPersistedItemLogPath } from "./item-log"; import { getItemLogPath as getPersistedItemLogPath } from "./item-log";
import { getSessionLogPath } from "./session-log"; import { getSessionLogPath } from "./session-log";
@ -11,7 +12,7 @@ import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage"; import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data"; import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle"; import { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, updateTraceConfig } from "./trace-log"; import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, setTraceEnabled, updateTraceConfig } from "./trace-log";
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics"; import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager"; import type { DownloadManager } from "./download-manager";
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types"; import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
@ -31,6 +32,7 @@ type DebugEndpointDescriptor = {
const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/health", description: "Basic health, uptime, and memory information." }, { method: "GET", path: "/health", description: "Basic health, uptime, and memory information." },
{ method: "GET", path: "/meta", description: "Lists runtime metadata and all available endpoints." }, { method: "GET", path: "/meta", description: "Lists runtime metadata and all available endpoints." },
{ method: "GET", path: "/debug/setup", description: "Checks whether the local debug setup is configured for support." },
{ method: "GET", path: "/host/diagnostics", description: "Returns Windows host crash and dump diagnostics." }, { method: "GET", path: "/host/diagnostics", description: "Returns Windows host crash and dump diagnostics." },
{ method: "GET", path: "/log", queryExample: "lines=100&grep=keyword", description: "Legacy alias for the main application log tail." }, { method: "GET", path: "/log", queryExample: "lines=100&grep=keyword", description: "Legacy alias for the main application log tail." },
{ method: "GET", path: "/logs/main", queryExample: "lines=100&grep=keyword", description: "Reads the main application log tail." }, { method: "GET", path: "/logs/main", queryExample: "lines=100&grep=keyword", description: "Reads the main application log tail." },
@ -39,7 +41,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." }, { method: "GET", path: "/logs/session", queryExample: "lines=100&grep=keyword", description: "Reads the session log tail." },
{ method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." }, { method: "GET", path: "/logs/package", queryExample: "package=Release&lines=100&grep=keyword", description: "Reads the package log for a specific package name or id." },
{ method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." }, { method: "GET", path: "/logs/item", queryExample: "item=episode.part2.rar&lines=100&grep=keyword", description: "Reads the item log for a specific file name or item id." },
{ method: "GET", path: "/trace/config", queryExample: "enable=1&note=support", description: "Reads or updates the support trace configuration." }, { method: "GET", path: "/trace/config", queryExample: "enable=1&note=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
{ method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." }, { method: "GET", path: "/settings", description: "Returns a redacted settings snapshot without raw secrets." },
{ method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." }, { method: "GET", path: "/accounts", description: "Returns a redacted account/provider configuration summary." },
{ method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." }, { method: "GET", path: "/stats", description: "Returns live session stats plus persisted all-time totals." },
@ -236,6 +238,7 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
"Read debug_token.txt and debug_port.txt from this runtime folder.", "Read debug_token.txt and debug_port.txt from this runtime folder.",
"If remote access is needed, ask the user only for the server IP or DNS name.", "If remote access is needed, ask the user only for the server IP or DNS name.",
"Call /meta first to confirm the server is reachable and to re-read the endpoint list.", "Call /meta first to confirm the server is reachable and to re-read the endpoint list.",
"Use /debug/setup to quickly verify whether token, host, manifest, and trace files are in a good support state.",
"Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.", "Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /status, /packages, /items, /settings, /accounts, /stats, /history, or /logs/trace.",
"If a full handoff is needed, download /support/bundle as a ZIP." "If a full handoff is needed, download /support/bundle as a ZIP."
], ],
@ -270,6 +273,7 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}`, remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}`,
remoteHostHint remoteHostHint
}, },
setupCheckEndpoint: "/debug/setup",
askUserFor: [ askUserFor: [
"Server IP or DNS name, if remote access is required and not already known." "Server IP or DNS name, if remote access is required and not already known."
], ],
@ -470,6 +474,9 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
traceConfig: getTraceConfigPath(), traceConfig: getTraceConfigPath(),
traceLog: getTraceLogPath() traceLog: getTraceLogPath()
}, },
supportChecks: {
setup: "/debug/setup"
},
logPaths: { logPaths: {
main: getLogFilePath(), main: getLogFilePath(),
audit: getAuditLogPath(), audit: getAuditLogPath(),
@ -481,6 +488,11 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return; return;
} }
if (pathname === "/debug/setup") {
jsonResponse(res, 200, getDebugSetupCheck(runtimeBaseDir));
return;
}
if (pathname === "/host/diagnostics") { if (pathname === "/host/diagnostics") {
jsonResponse(res, 200, getWindowsHostDiagnostics()); jsonResponse(res, 200, getWindowsHostDiagnostics());
return; return;
@ -554,11 +566,21 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
patch.logDebugRequests = logDebugRequests; patch.logDebugRequests = logDebugRequests;
} }
const note = String(url.searchParams.get("note") || "").trim(); const note = String(url.searchParams.get("note") || "").trim();
const config = Object.keys(patch).length > 0 const durationMinutesRaw = Number(url.searchParams.get("durationMinutes") || "120");
? updateTraceConfig({ ...patch, ...(note ? { updatedAt: new Date().toISOString() } : {}) }) const durationMinutes = Number.isFinite(durationMinutesRaw) && durationMinutesRaw > 0
: getTraceConfig(); ? Math.min(Math.floor(durationMinutesRaw), 24 * 60)
: 120;
let config = getTraceConfig();
if (enabled !== null) {
config = setTraceEnabled(enabled, note, durationMinutes * 60 * 1000);
}
const configPatch = { ...patch };
delete configPatch.enabled;
if (Object.keys(configPatch).length > 0) {
config = updateTraceConfig(configPatch);
}
if (Object.keys(patch).length > 0) { if (Object.keys(patch).length > 0) {
logTraceEvent("INFO", "support", "Trace-Konfiguration über Debug-Server geändert", { ...patch, note }); logTraceEvent("INFO", "support", "Trace-Konfiguration über Debug-Server geändert", { ...patch, note, durationMinutes });
} }
jsonResponse(res, 200, { jsonResponse(res, 200, {
path: getTraceConfigPath(), path: getTraceConfigPath(),
@ -797,7 +819,8 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
debugServer: { debugServer: {
host: bindHost, host: bindHost,
port: bindPort port: bindPort
} },
setup: getDebugSetupCheck(runtimeBaseDir)
}, },
status: buildStatusPayload(snapshot), status: buildStatusPayload(snapshot),
settings: buildRedactedSettingsPayload(readSupportSettings()), settings: buildRedactedSettingsPayload(readSupportSettings()),

133
src/main/debug-setup.ts Normal file
View File

@ -0,0 +1,133 @@
import fs from "node:fs";
import path from "node:path";
import type { DebugSetupCheckResult, SupportTraceConfig } from "../shared/types";
const DEFAULT_PORT = 9868;
const DEFAULT_HOST = "127.0.0.1";
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
function readToken(baseDir: string): string {
try {
return fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim();
} catch {
return "";
}
}
function readPort(baseDir: string): number {
try {
const raw = Number(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim());
if (Number.isFinite(raw) && raw >= 1024 && raw <= 65535) {
return raw;
}
} catch {
// ignore
}
return DEFAULT_PORT;
}
function readHost(baseDir: string): string {
try {
const raw = fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim();
if (!raw) {
return DEFAULT_HOST;
}
if (/^(localhost|0\.0\.0\.0|127\.0\.0\.1|::1)$/i.test(raw)) {
return raw;
}
if (/^[a-z0-9.-]+$/i.test(raw)) {
return raw;
}
} catch {
// ignore
}
return DEFAULT_HOST;
}
function readTraceConfig(baseDir: string): SupportTraceConfig {
const fallback: SupportTraceConfig = {
enabled: false,
includeMainLog: true,
includeAudit: true,
logDebugRequests: true,
autoDisableAt: null,
updatedAt: new Date(0).toISOString()
};
try {
const filePath = path.join(baseDir, "trace_config.json");
const parsed = JSON.parse(fs.readFileSync(filePath, "utf8")) as Partial<SupportTraceConfig>;
return {
enabled: Boolean(parsed.enabled),
includeMainLog: parsed.includeMainLog === undefined ? true : Boolean(parsed.includeMainLog),
includeAudit: parsed.includeAudit === undefined ? true : Boolean(parsed.includeAudit),
logDebugRequests: parsed.logDebugRequests === undefined ? true : Boolean(parsed.logDebugRequests),
autoDisableAt: typeof parsed.autoDisableAt === "string" && parsed.autoDisableAt.trim() ? parsed.autoDisableAt : null,
updatedAt: typeof parsed.updatedAt === "string" && parsed.updatedAt.trim() ? parsed.updatedAt : fallback.updatedAt
};
} catch {
return fallback;
}
}
export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
const host = readHost(baseDir);
const port = readPort(baseDir);
const token = readToken(baseDir);
const tokenPath = path.join(baseDir, "debug_token.txt");
const aiManifestPath = path.join(baseDir, AI_MANIFEST_FILE);
const traceConfigPath = path.join(baseDir, "trace_config.json");
const traceLogPath = path.join(baseDir, "trace.log");
const traceConfig = readTraceConfig(baseDir);
const localOnly = /^(127\.0\.0\.1|localhost|::1)$/i.test(host);
const warnings: string[] = [];
const notes: string[] = [];
if (!token) {
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
}
if (localOnly) {
warnings.push("Der Debug-Server ist aktuell nur lokal erreichbar. Für Remote-Support debug_host.txt auf 0.0.0.0 setzen.");
} else {
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein.");
}
if (!fs.existsSync(aiManifestPath)) {
warnings.push("debug_ai_manifest.json fehlt. App einmal neu starten, damit die KI-Support-Datei neu geschrieben wird.");
}
if (!fs.existsSync(traceConfigPath)) {
warnings.push("trace_config.json fehlt. Trace-Funktionen sind lokal noch nicht initialisiert.");
}
if (traceConfig.enabled && !traceConfig.autoDisableAt) {
warnings.push("Support-Trace ist aktiv ohne automatische Abschaltzeit. Einmal neu aktivieren, damit die 2-Stunden-Begrenzung gesetzt wird.");
}
if (traceConfig.enabled && traceConfig.autoDisableAt) {
notes.push(`Support-Trace aktiv bis ${traceConfig.autoDisableAt}.`);
}
notes.push("Die App kann Netzwerk-Firewalls oder Provider-Sicherheitsgruppen nicht direkt prüfen.");
return {
enabled: Boolean(token),
host,
port,
localOnly,
tokenConfigured: Boolean(token),
tokenPath,
aiManifestPath,
aiManifestPresent: fs.existsSync(aiManifestPath),
traceConfigPath: fs.existsSync(traceConfigPath) ? traceConfigPath : null,
traceLogPath: fs.existsSync(traceLogPath) ? traceLogPath : null,
traceEnabled: traceConfig.enabled,
traceAutoDisableAt: traceConfig.autoDisableAt,
warnings,
notes,
localUrls: {
health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`,
meta: `http://127.0.0.1:${port}/meta?token=${token || "<TOKEN>"}`,
diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || "<TOKEN>"}`
},
remoteUrlTemplates: {
health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`,
meta: `http://<SERVER_IP_OR_DNS>:${port}/meta?token=${token || "<TOKEN>"}`,
diagnostics: `http://<SERVER_IP_OR_DNS>:${port}/diagnostics?token=${token || "<TOKEN>"}`
}
};
}

View File

@ -538,16 +538,21 @@ function registerIpcHandlers(): void {
} }
}); });
ipcMain.handle(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK, async () => controller.getDebugSetupCheck());
ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig()); ipcMain.handle(IPC_CHANNELS.GET_TRACE_CONFIG, async () => controller.getTraceConfig());
ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string) => { ipcMain.handle(IPC_CHANNELS.SET_TRACE_ENABLED, async (_event: IpcMainInvokeEvent, enabled: boolean, note?: string, durationMinutes?: number) => {
if (typeof enabled !== "boolean") { if (typeof enabled !== "boolean") {
throw new Error("enabled muss ein Boolean sein"); throw new Error("enabled muss ein Boolean sein");
} }
if (note !== undefined) { if (note !== undefined) {
validateString(note, "note"); validateString(note, "note");
} }
return controller.setTraceEnabled(enabled, note); if (durationMinutes !== undefined && (!Number.isFinite(durationMinutes) || durationMinutes <= 0)) {
throw new Error("durationMinutes muss eine positive Zahl sein");
}
return controller.setTraceEnabled(enabled, note, durationMinutes ? durationMinutes * 60 * 1000 : undefined);
}); });
ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => { ipcMain.handle(IPC_CHANNELS.ROTATE_DEBUG_TOKEN, async () => {

View File

@ -3,6 +3,7 @@ import path from "node:path";
import AdmZip from "adm-zip"; import AdmZip from "adm-zip";
import { APP_VERSION } from "./constants"; import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log"; import { getAuditLogPath } from "./audit-log";
import { getDebugSetupCheck } from "./debug-setup";
import { getLogFilePath } from "./logger"; import { getLogFilePath } from "./logger";
import { getPackageLogPath } from "./package-log"; import { getPackageLogPath } from "./package-log";
import { getSessionLogPath } from "./session-log"; import { getSessionLogPath } from "./session-log";
@ -89,6 +90,7 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime
} }
}); });
addJson(zip, "overview/debug-setup.json", getDebugSetupCheck(baseDir));
addJson(zip, "overview/history.json", { addJson(zip, "overview/history.json", {
total: history.length, total: history.length,
entries: history.map((entry) => summarizeHistoryEntry(entry)) entries: history.map((entry) => summarizeHistoryEntry(entry))
@ -115,8 +117,10 @@ export function buildSupportBundle(manager: DownloadManager, baseDir: string): B
addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log"); addFileIfExists(zip, getLogFilePath(), "logs/rd_downloader.log");
addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old"); addFileIfExists(zip, `${getLogFilePath()}.old`, "logs/rd_downloader.log.old");
addFileIfExists(zip, getAuditLogPath(), "logs/audit.log"); addFileIfExists(zip, getAuditLogPath(), "logs/audit.log");
addFileIfExists(zip, getAuditLogPath() ? `${getAuditLogPath()}.old` : null, "logs/audit.log.old");
addFileIfExists(zip, getSessionLogPath(), "logs/session.log"); addFileIfExists(zip, getSessionLogPath(), "logs/session.log");
addFileIfExists(zip, getTraceLogPath(), "logs/trace.log"); addFileIfExists(zip, getTraceLogPath(), "logs/trace.log");
addFileIfExists(zip, getTraceLogPath() ? `${getTraceLogPath()}.old` : null, "logs/trace.log.old");
addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs"); addDirectoryIfExists(zip, path.join(baseDir, "session-logs"), "logs/session-logs");
addDirectoryIfExists(zip, path.join(baseDir, "package-logs"), "logs/package-logs"); addDirectoryIfExists(zip, path.join(baseDir, "package-logs"), "logs/package-logs");

View File

@ -7,12 +7,16 @@ type TraceLevel = "INFO" | "WARN" | "ERROR";
const TRACE_LOG_FLUSH_INTERVAL_MS = 200; const TRACE_LOG_FLUSH_INTERVAL_MS = 200;
const TRACE_CONFIG_FILE = "trace_config.json"; const TRACE_CONFIG_FILE = "trace_config.json";
const TRACE_LOG_MAX_FILE_BYTES = Number(process.env.RD_TRACE_LOG_MAX_BYTES || 10 * 1024 * 1024);
const TRACE_LOG_RETENTION_DAYS = Number(process.env.RD_TRACE_LOG_RETENTION_DAYS || 30);
const TRACE_DEFAULT_AUTO_DISABLE_MS = Number(process.env.RD_TRACE_AUTO_DISABLE_MS || 2 * 60 * 60 * 1000);
const DEFAULT_TRACE_CONFIG: SupportTraceConfig = { const DEFAULT_TRACE_CONFIG: SupportTraceConfig = {
enabled: false, enabled: false,
includeMainLog: true, includeMainLog: true,
includeAudit: true, includeAudit: true,
logDebugRequests: true, logDebugRequests: true,
autoDisableAt: null,
updatedAt: new Date(0).toISOString() updatedAt: new Date(0).toISOString()
}; };
@ -21,6 +25,7 @@ let traceConfigPath: string | null = null;
let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG }; let traceConfig: SupportTraceConfig = { ...DEFAULT_TRACE_CONFIG };
let pendingLines: string[] = []; let pendingLines: string[] = [];
let flushTimer: NodeJS.Timeout | null = null; let flushTimer: NodeJS.Timeout | null = null;
let autoDisableTimer: NodeJS.Timeout | null = null;
function sanitizeFieldValue(value: unknown): string { function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) { if (value === undefined || value === null) {
@ -62,6 +67,37 @@ function flushPending(): void {
} }
} }
function rotateIfNeeded(filePath: string): void {
try {
const stat = fs.statSync(filePath);
if (stat.size < TRACE_LOG_MAX_FILE_BYTES) {
return;
}
const backup = `${filePath}.old`;
try {
fs.rmSync(backup, { force: true });
} catch {
// ignore
}
fs.renameSync(filePath, backup);
} catch {
// ignore
}
}
function cleanupOldBackup(filePath: string): void {
const backup = `${filePath}.old`;
try {
const stat = fs.statSync(backup);
const cutoff = Date.now() - TRACE_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
if (stat.mtimeMs < cutoff) {
fs.rmSync(backup, { force: true });
}
} catch {
// ignore
}
}
function scheduleFlush(): void { function scheduleFlush(): void {
if (flushTimer) { if (flushTimer) {
return; return;
@ -76,6 +112,14 @@ function appendTraceLine(line: string): void {
if (!traceLogPath) { if (!traceLogPath) {
return; return;
} }
rotateIfNeeded(traceLogPath);
if (!fs.existsSync(traceLogPath)) {
try {
fs.writeFileSync(traceLogPath, "", "utf8");
} catch {
return;
}
}
pendingLines.push(line); pendingLines.push(line);
scheduleFlush(); scheduleFlush();
} }
@ -90,6 +134,9 @@ function normalizeTraceConfig(raw: unknown): SupportTraceConfig {
includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog), includeMainLog: value.includeMainLog === undefined ? DEFAULT_TRACE_CONFIG.includeMainLog : Boolean(value.includeMainLog),
includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit), includeAudit: value.includeAudit === undefined ? DEFAULT_TRACE_CONFIG.includeAudit : Boolean(value.includeAudit),
logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests), logDebugRequests: value.logDebugRequests === undefined ? DEFAULT_TRACE_CONFIG.logDebugRequests : Boolean(value.logDebugRequests),
autoDisableAt: typeof value.autoDisableAt === "string" && value.autoDisableAt.trim()
? value.autoDisableAt
: null,
updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim() updatedAt: typeof value.updatedAt === "string" && value.updatedAt.trim()
? value.updatedAt ? value.updatedAt
: DEFAULT_TRACE_CONFIG.updatedAt : DEFAULT_TRACE_CONFIG.updatedAt
@ -126,11 +173,58 @@ const mainLogListener = (line: string): void => {
appendTraceLine(line); appendTraceLine(line);
}; };
function clearAutoDisableTimer(): void {
if (autoDisableTimer) {
clearTimeout(autoDisableTimer);
autoDisableTimer = null;
}
}
function disableTraceDueToExpiry(): void {
clearAutoDisableTimer();
if (!traceConfig.enabled) {
return;
}
traceConfig = normalizeTraceConfig({
...traceConfig,
enabled: false,
autoDisableAt: null,
updatedAt: new Date().toISOString()
});
persistTraceConfig();
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace automatisch deaktiviert | reason=expired\n`);
}
function scheduleAutoDisable(): void {
clearAutoDisableTimer();
if (!traceConfig.enabled || !traceConfig.autoDisableAt) {
return;
}
const until = Date.parse(traceConfig.autoDisableAt);
if (!Number.isFinite(until)) {
return;
}
const remainingMs = until - Date.now();
if (remainingMs <= 0) {
disableTraceDueToExpiry();
return;
}
autoDisableTimer = setTimeout(() => {
autoDisableTimer = null;
disableTraceDueToExpiry();
}, Math.min(remainingMs, 2_147_483_647));
}
export function initTraceLog(baseDir: string): void { export function initTraceLog(baseDir: string): void {
traceLogPath = path.join(baseDir, "trace.log"); traceLogPath = path.join(baseDir, "trace.log");
traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE); traceConfigPath = path.join(baseDir, TRACE_CONFIG_FILE);
try { try {
fs.mkdirSync(baseDir, { recursive: true }); fs.mkdirSync(baseDir, { recursive: true });
cleanupOldBackup(traceLogPath);
if (!fs.existsSync(traceLogPath)) {
fs.writeFileSync(traceLogPath, "", "utf8");
}
rotateIfNeeded(traceLogPath);
if (!fs.existsSync(traceLogPath)) { if (!fs.existsSync(traceLogPath)) {
fs.writeFileSync(traceLogPath, "", "utf8"); fs.writeFileSync(traceLogPath, "", "utf8");
} }
@ -144,6 +238,7 @@ export function initTraceLog(baseDir: string): void {
return; return;
} }
addLogListener(mainLogListener); addLogListener(mainLogListener);
scheduleAutoDisable();
} }
export function getTraceLogPath(): string | null { export function getTraceLogPath(): string | null {
@ -171,13 +266,17 @@ export function updateTraceConfig(patch: Partial<SupportTraceConfig>): SupportTr
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}); });
persistTraceConfig(); persistTraceConfig();
scheduleAutoDisable();
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig)}\n`); appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Konfiguration aktualisiert${formatFields(traceConfig)}\n`);
return getTraceConfig(); return getTraceConfig();
} }
export function setTraceEnabled(enabled: boolean, note = ""): SupportTraceConfig { export function setTraceEnabled(enabled: boolean, note = "", durationMs: number = TRACE_DEFAULT_AUTO_DISABLE_MS): SupportTraceConfig {
const next = updateTraceConfig({ enabled }); const autoDisableAt = enabled && durationMs > 0
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note })}\n`); ? new Date(Date.now() + durationMs).toISOString()
: null;
const next = updateTraceConfig({ enabled, autoDisableAt });
appendTraceLine(`${new Date().toISOString()} [INFO] [trace] Support-Trace ${enabled ? "aktiviert" : "deaktiviert"}${formatFields({ note, autoDisableAt })}\n`);
return next; return next;
} }
@ -198,6 +297,7 @@ export function logTraceEvent(
export function shutdownTraceLog(): void { export function shutdownTraceLog(): void {
removeLogListener(mainLogListener); removeLogListener(mainLogListener);
clearAutoDisableTimer();
if (!traceLogPath) { if (!traceLogPath) {
return; return;
} }

View File

@ -63,8 +63,9 @@ const api: ElectronApi = {
openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG), openTraceLog: (): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_TRACE_LOG),
openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId), openPackageLog: (packageId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_PACKAGE_LOG, packageId),
openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId), openItemLog: (itemId: string): Promise<void> => ipcRenderer.invoke(IPC_CHANNELS.OPEN_ITEM_LOG, itemId),
getDebugSetupCheck: () => ipcRenderer.invoke(IPC_CHANNELS.GET_DEBUG_SETUP_CHECK),
getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG), getTraceConfig: () => ipcRenderer.invoke(IPC_CHANNELS.GET_TRACE_CONFIG),
setTraceEnabled: (enabled: boolean, note?: string) => ipcRenderer.invoke(IPC_CHANNELS.SET_TRACE_ENABLED, enabled, note), 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),
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),

View File

@ -5,6 +5,7 @@ import type {
AppSettings, AppSettings,
AppTheme, AppTheme,
BandwidthScheduleEntry, BandwidthScheduleEntry,
DebugSetupCheckResult,
DebridFallbackProvider, DebridFallbackProvider,
DebridLinkHostLimitInfo, DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
@ -49,8 +50,10 @@ interface ConfirmPromptState {
title: string; title: string;
message: string; message: string;
confirmLabel: string; confirmLabel: string;
cancelLabel?: string;
danger?: boolean; danger?: boolean;
details?: string; details?: string;
detailsLabel?: string;
} }
interface ContextMenuState { interface ContextMenuState {
@ -137,6 +140,44 @@ interface ConfiguredAccountEntry {
debridLinkKeys: DebridLinkAccountKeyEntry[]; debridLinkKeys: DebridLinkAccountKeyEntry[];
} }
function buildDebugSetupDetails(setup: DebugSetupCheckResult): string {
const lines: string[] = [
`Debug-Server aktiv: ${setup.enabled ? "ja" : "nein"}`,
`Host: ${setup.host}`,
`Port: ${setup.port}`,
`Token-Datei: ${setup.tokenPath}`,
`KI-Manifest: ${setup.aiManifestPresent ? "vorhanden" : "fehlt"} (${setup.aiManifestPath})`,
`Trace aktiv: ${setup.traceEnabled ? "ja" : "nein"}`,
`Trace-Auto-Ende: ${setup.traceAutoDisableAt || "nicht gesetzt"}`,
"",
"Lokale URLs:",
setup.localUrls.health,
setup.localUrls.meta,
setup.localUrls.diagnostics,
"",
"Remote-Vorlagen:",
setup.remoteUrlTemplates.health,
setup.remoteUrlTemplates.meta,
setup.remoteUrlTemplates.diagnostics
];
if (setup.warnings.length > 0) {
lines.push("", "Warnungen:");
for (const warning of setup.warnings) {
lines.push(`- ${warning}`);
}
}
if (setup.notes.length > 0) {
lines.push("", "Hinweise:");
for (const note of setup.notes) {
lines.push(`- ${note}`);
}
}
return lines.join("\n");
}
const ACCOUNT_OPTIONS: AccountOption[] = [ const ACCOUNT_OPTIONS: AccountOption[] = [
{ {
kind: "realdebrid-api", kind: "realdebrid-api",
@ -3413,14 +3454,34 @@ export function App(): ReactElement {
closeMenus(); closeMenus();
const nextEnabled = !supportTraceEnabled; const nextEnabled = !supportTraceEnabled;
await performQuickAction(async () => { await performQuickAction(async () => {
const result = await window.rd.setTraceEnabled(nextEnabled, "UI support toggle"); const result = await window.rd.setTraceEnabled(nextEnabled, "UI support toggle", 120);
setSupportTraceEnabled(result.enabled); setSupportTraceEnabled(result.enabled);
showToast(result.enabled ? "Support-Trace aktiviert" : "Support-Trace deaktiviert", 2400); showToast(result.enabled ? "Support-Trace für 2 Stunden aktiviert" : "Support-Trace deaktiviert", 2600);
}, (error) => { }, (error) => {
showToast(`Support-Trace fehlgeschlagen: ${String(error)}`, 2800); showToast(`Support-Trace fehlgeschlagen: ${String(error)}`, 2800);
}); });
}; };
const onRunDebugSetupCheck = async (): Promise<void> => {
closeMenus();
try {
const setup = await window.rd.getDebugSetupCheck();
const warningText = setup.warnings.length > 0 ? `Warnungen: ${setup.warnings.length}` : "Keine akuten Warnungen";
const reachabilityText = setup.localOnly ? "Nur lokal gebunden" : "Remote-fähig konfiguriert";
const details = buildDebugSetupDetails(setup);
await askConfirmPrompt({
title: "Debug-Setup prüfen",
message: `${warningText}\n${reachabilityText}\nHost: ${setup.host}:${setup.port}`,
confirmLabel: "Schließen",
cancelLabel: "Schließen",
details,
detailsLabel: "Details anzeigen"
});
} catch (error) {
showToast(`Debug-Setup-Check fehlgeschlagen: ${String(error)}`, 3000);
}
};
const onRotateDebugToken = async (): Promise<void> => { const onRotateDebugToken = async (): Promise<void> => {
closeMenus(); closeMenus();
const confirmed = await askConfirmPrompt({ const confirmed = await askConfirmPrompt({
@ -3767,6 +3828,9 @@ export function App(): ReactElement {
<button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}> <button className="menu-dropdown-item" onClick={() => { void onToggleSupportTrace(); }}>
<span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span> <span>{supportTraceEnabled ? "Support-Trace deaktivieren" : "Support-Trace aktivieren"}</span>
</button> </button>
<button className="menu-dropdown-item" onClick={() => { void onRunDebugSetupCheck(); }}>
<span>Debug-Setup prüfen</span>
</button>
<button className="menu-dropdown-item" onClick={() => { void onRotateDebugToken(); }}> <button className="menu-dropdown-item" onClick={() => { void onRotateDebugToken(); }}>
<span>Debug-Token rotieren</span> <span>Debug-Token rotieren</span>
</button> </button>
@ -4855,12 +4919,12 @@ export function App(): ReactElement {
<p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p> <p style={{ whiteSpace: "pre-line" }}>{confirmPrompt.message}</p>
{confirmPrompt.details && ( {confirmPrompt.details && (
<details className="modal-details"> <details className="modal-details">
<summary>Changelog anzeigen</summary> <summary>{confirmPrompt.detailsLabel || "Details anzeigen"}</summary>
<pre>{confirmPrompt.details}</pre> <pre>{confirmPrompt.details}</pre>
</details> </details>
)} )}
<div className="modal-actions"> <div className="modal-actions">
<button className="btn" onClick={() => closeConfirmPrompt(false)}>Abbrechen</button> <button className="btn" onClick={() => closeConfirmPrompt(false)}>{confirmPrompt.cancelLabel || "Abbrechen"}</button>
<button <button
className={confirmPrompt.danger ? "btn danger" : "btn"} className={confirmPrompt.danger ? "btn danger" : "btn"}
onClick={() => closeConfirmPrompt(true)} onClick={() => closeConfirmPrompt(true)}

View File

@ -43,6 +43,7 @@ export const IPC_CHANNELS = {
OPEN_TRACE_LOG: "app:open-trace-log", OPEN_TRACE_LOG: "app:open-trace-log",
OPEN_PACKAGE_LOG: "app:open-package-log", OPEN_PACKAGE_LOG: "app:open-package-log",
OPEN_ITEM_LOG: "app:open-item-log", OPEN_ITEM_LOG: "app:open-item-log",
GET_DEBUG_SETUP_CHECK: "app:get-debug-setup-check",
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",

View File

@ -2,6 +2,7 @@ import type {
AddLinksPayload, AddLinksPayload,
AllDebridHostInfo, AllDebridHostInfo,
AppSettings, AppSettings,
DebugSetupCheckResult,
DebridLinkHostLimitInfo, DebridLinkHostLimitInfo,
DebridProvider, DebridProvider,
DuplicatePolicy, DuplicatePolicy,
@ -59,8 +60,9 @@ export interface ElectronApi {
openTraceLog: () => Promise<void>; openTraceLog: () => Promise<void>;
openPackageLog: (packageId: string) => Promise<void>; openPackageLog: (packageId: string) => Promise<void>;
openItemLog: (itemId: string) => Promise<void>; openItemLog: (itemId: string) => Promise<void>;
getDebugSetupCheck: () => Promise<DebugSetupCheckResult>;
getTraceConfig: () => Promise<SupportTraceConfig>; getTraceConfig: () => Promise<SupportTraceConfig>;
setTraceEnabled: (enabled: boolean, note?: string) => Promise<SupportTraceConfig>; setTraceEnabled: (enabled: boolean, note?: string, durationMinutes?: number) => Promise<SupportTraceConfig>;
rotateDebugToken: () => Promise<{ path: string }>; rotateDebugToken: () => Promise<{ path: string }>;
openRealDebridLogin: () => Promise<void>; openRealDebridLogin: () => Promise<void>;
openAllDebridLogin: () => Promise<void>; openAllDebridLogin: () => Promise<void>;

View File

@ -342,9 +342,37 @@ export interface SupportTraceConfig {
includeMainLog: boolean; includeMainLog: boolean;
includeAudit: boolean; includeAudit: boolean;
logDebugRequests: boolean; logDebugRequests: boolean;
autoDisableAt: string | null;
updatedAt: string; updatedAt: string;
} }
export interface DebugSetupCheckResult {
enabled: boolean;
host: string;
port: number;
localOnly: boolean;
tokenConfigured: boolean;
tokenPath: string;
aiManifestPath: string;
aiManifestPresent: boolean;
traceConfigPath: string | null;
traceLogPath: string | null;
traceEnabled: boolean;
traceAutoDisableAt: string | null;
warnings: string[];
notes: string[];
localUrls: {
health: string;
meta: string;
diagnostics: string;
};
remoteUrlTemplates: {
health: string;
meta: string;
diagnostics: string;
};
}
export interface HistoryEntry { export interface HistoryEntry {
id: string; id: string;
name: string; name: string;

View File

@ -29,4 +29,20 @@ describe("audit-log", () => {
expect(content).toContain("Settings changed"); expect(content).toContain("Settings changed");
expect(content).toContain("changedKeys"); expect(content).toContain("changedKeys");
}); });
it("rotates oversized audit logs on startup", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-rotate-"));
tempDirs.push(baseDir);
const oversizedPath = path.join(baseDir, "audit.log");
fs.mkdirSync(baseDir, { recursive: true });
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
initAuditLog(baseDir);
expect(fs.existsSync(oversizedPath)).toBe(true);
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
const content = fs.readFileSync(oversizedPath, "utf8");
expect(content).toContain("Audit-Log Start");
});
}); });

View File

@ -341,6 +341,7 @@ describe("debug-server", () => {
expect(manifest.debugServer?.port).toBeGreaterThan(0); expect(manifest.debugServer?.port).toBeGreaterThan(0);
expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>"); expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>");
expect(manifest.quickstart?.[1]).toContain("server IP"); expect(manifest.quickstart?.[1]).toContain("server IP");
expect(manifest.setupCheckEndpoint).toBe("/debug/setup");
expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt"); expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt");
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true); expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true);
expect(JSON.stringify(manifest)).not.toContain(fixture.token); expect(JSON.stringify(manifest)).not.toContain(fixture.token);
@ -351,6 +352,24 @@ describe("debug-server", () => {
expect(metaPayload.supportFiles?.aiManifest).toBe(manifestPath); expect(metaPayload.supportFiles?.aiManifest).toBe(manifestPath);
expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath()); expect(metaPayload.supportFiles?.traceConfig).toBe(getTraceConfigPath());
expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath()); expect(metaPayload.supportFiles?.traceLog).toBe(getTraceLogPath());
expect(metaPayload.supportChecks?.setup).toBe("/debug/setup");
});
it("serves a debug setup check with trace expiry details", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/debug/setup?token=${fixture.token}`);
expect(response.ok).toBe(true);
const payload = await response.json() as Record<string, any>;
expect(payload.enabled).toBe(true);
expect(payload.host).toBe("0.0.0.0");
expect(payload.localOnly).toBe(false);
expect(payload.tokenConfigured).toBe(true);
expect(payload.aiManifestPresent).toBe(true);
expect(payload.traceEnabled).toBe(true);
expect(payload.traceAutoDisableAt).toBeTruthy();
expect(payload.remoteUrlTemplates?.health).toContain("<SERVER_IP_OR_DNS>");
expect(Array.isArray(payload.notes)).toBe(true);
}); });
it("serves package details and package log by package query", async () => { it("serves package details and package log by package query", async () => {
@ -449,6 +468,7 @@ describe("debug-server", () => {
const entries = zip.getEntries().map((entry) => entry.entryName); const entries = zip.getEntries().map((entry) => entry.entryName);
expect(entries).toContain("overview/settings.json"); expect(entries).toContain("overview/settings.json");
expect(entries).toContain("overview/accounts.json"); expect(entries).toContain("overview/accounts.json");
expect(entries).toContain("overview/debug-setup.json");
expect(entries).toContain("overview/trace-config.json"); expect(entries).toContain("overview/trace-config.json");
expect(entries).toContain("logs/audit.log"); expect(entries).toContain("logs/audit.log");
expect(entries).toContain("logs/trace.log"); expect(entries).toContain("logs/trace.log");

View File

@ -48,6 +48,43 @@ describe("trace-log", () => {
const traceConfig = getTraceConfig(); const traceConfig = getTraceConfig();
expect(traceConfig.enabled).toBe(true); expect(traceConfig.enabled).toBe(true);
expect(traceConfig.autoDisableAt).toBeTruthy();
expect(JSON.parse(fs.readFileSync(traceConfigPath!, "utf8")).enabled).toBe(true); expect(JSON.parse(fs.readFileSync(traceConfigPath!, "utf8")).enabled).toBe(true);
}); });
it("auto-disables support trace after the requested duration", async () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-expire-"));
tempDirs.push(baseDir);
configureLogger(baseDir);
initTraceLog(baseDir);
setTraceEnabled(true, "expire-test", 50);
await new Promise((resolve) => setTimeout(resolve, 350));
const traceConfig = getTraceConfig();
expect(traceConfig.enabled).toBe(false);
expect(traceConfig.autoDisableAt).toBeNull();
const traceLogPath = getTraceLogPath();
expect(traceLogPath).not.toBeNull();
const traceContent = fs.readFileSync(traceLogPath!, "utf8");
expect(traceContent).toContain("Support-Trace automatisch deaktiviert");
});
it("rotates oversized trace logs on startup", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-tlog-rotate-"));
tempDirs.push(baseDir);
const oversizedPath = path.join(baseDir, "trace.log");
fs.mkdirSync(baseDir, { recursive: true });
fs.writeFileSync(oversizedPath, "x".repeat(10 * 1024 * 1024 + 256), "utf8");
initTraceLog(baseDir);
expect(fs.existsSync(oversizedPath)).toBe(true);
expect(fs.existsSync(`${oversizedPath}.old`)).toBe(true);
const currentContent = fs.readFileSync(oversizedPath, "utf8");
expect(currentContent).toContain("Trace-Log Start");
});
}); });