Add support audit logging and AI debug manifest

This commit is contained in:
Sucukdeluxe 2026-03-09 01:59:08 +01:00
parent 01e0f27841
commit 78fc80f04b
7 changed files with 695 additions and 36 deletions

View File

@ -181,6 +181,8 @@ Runtime files are stored in Electron's `userData` directory, including:
- `rd_session_state.json`
- `rd_history.json`
- `rd_downloader.log`
- `audit.log`
- `debug_ai_manifest.json`
- `session-logs/session_*.txt`
- `package-logs/package_*.txt`
- `item-logs/item_*.txt`
@ -198,17 +200,24 @@ Enable it by creating these files in the same runtime folder that contains `rd_d
- `debug_host.txt` (optional)
Default is `127.0.0.1`. Set `0.0.0.0` only if you really want remote access and protect it with firewall, VPN, or reverse proxy.
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.
Available endpoints after restart:
- `GET /health`
- `GET /meta`
- `GET /host/diagnostics`
- `GET /status`
- `GET /settings`
- `GET /accounts`
- `GET /stats`
- `GET /history?limit=50&status=completed`
- `GET /packages?package=Release&includeItems=1`
- `GET /items?status=downloading&package=Release`
- `GET /session?package=Release`
- `GET /log?lines=100&grep=keyword`
- `GET /logs/main?lines=100&grep=keyword`
- `GET /logs/audit?lines=100&grep=keyword`
- `GET /logs/session?lines=100&grep=keyword`
- `GET /logs/package?package=Release&lines=100&grep=keyword`
- `GET /logs/item?item=episode.part2.rar&lines=100&grep=keyword`
@ -223,12 +232,17 @@ Example from PowerShell:
```powershell
Invoke-RestMethod "http://SERVER:9868/diagnostics?token=YOUR_TOKEN&package=Release"
Invoke-RestMethod "http://SERVER:9868/settings?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/history?token=YOUR_TOKEN&limit=20"
Invoke-RestMethod "http://SERVER:9868/logs/audit?token=YOUR_TOKEN&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/host/diagnostics?token=YOUR_TOKEN"
```
This makes it easy to share one URL plus token during support, so current package status, session state, package/session logs, and host-side Windows crash hints can be inspected remotely.
This makes it easy to share one URL plus token during support, so current package status, session state, history, redacted account/settings state, audit actions, package/session/item logs, and host-side Windows crash hints can be inspected remotely.
## Troubleshooting

View File

@ -36,6 +36,8 @@ import { addHistoryEntry, cancelPendingAsyncSaves, clearHistory, createStoragePa
import { abortActiveUpdateDownload, checkGitHubUpdate, installLatestUpdate } from "./update";
import { startDebugServer, stopDebugServer } from "./debug-server";
import { encryptBackup, decryptBackup } from "./backup-crypto";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "./audit-log";
import { buildAccountSummary, diffAccountSummary } from "./support-data";
function sanitizeSettingsPatch(partial: Partial<AppSettings>): Partial<AppSettings> {
const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined);
@ -74,6 +76,7 @@ export class AppController {
initSessionLog(this.storagePaths.baseDir);
initPackageLogs(this.storagePaths.baseDir);
initItemLogs(this.storagePaths.baseDir);
initAuditLog(this.storagePaths.baseDir);
this.settings = loadSettings(this.storagePaths);
const session = loadSession(this.storagePaths);
this.megaWebFallback = new MegaWebFallback(() => ({
@ -98,6 +101,10 @@ export class AppController {
});
logger.info(`App gestartet v${APP_VERSION}`);
logger.info(`Log-Datei: ${getLogFilePath()}`);
logAuditEvent("INFO", "App gestartet", {
appVersion: APP_VERSION,
runtimeDir: this.storagePaths.baseDir
});
startDebugServer(this.manager, this.storagePaths.baseDir);
if (this.settings.autoResumeOnStart) {
@ -169,6 +176,14 @@ export class AppController {
return this.settings;
}
public getAuditLogPath(): string | null {
return getAuditLogPath();
}
private audit(level: "INFO" | "WARN" | "ERROR", message: string, fields?: Record<string, unknown>): void {
logAuditEvent(level, message, fields);
}
public updateSettings(partial: Partial<AppSettings>): AppSettings {
const sanitizedPatch = sanitizeSettingsPatch(partial);
const previousSettings = this.settings;
@ -197,6 +212,10 @@ export class AppController {
this.settings = nextSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
this.audit("INFO", "Einstellungen aktualisiert", {
changedKeys: Object.keys(sanitizedPatch),
accountChanges: diffAccountSummary(previousSettings, this.settings)
});
if (previousSettings.rememberToken && !this.settings.rememberToken) {
void this.realDebridWebFallback.clearSessions().catch((error) => {
logger.warn(`Real-Debrid Web-Session konnte nicht gelöscht werden: ${String(error)}`);
@ -220,6 +239,7 @@ export class AppController {
this.settings = nextSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
this.audit("INFO", "Provider-Tagesnutzung zurückgesetzt", { provider });
return this.settings;
}
@ -232,19 +252,27 @@ export class AppController {
this.settings = nextSettings;
saveSettings(this.storagePaths, this.settings);
this.manager.setSettings(this.settings);
this.audit("INFO", "Debrid-Link-Key-Tagesnutzung zurückgesetzt", { keyId });
return this.settings;
}
public async openRealDebridLoginWindow(): Promise<void> {
this.audit("INFO", "Real-Debrid Login-Fenster geöffnet");
await this.realDebridWebFallback.openLoginWindow();
}
public async openAllDebridLoginWindow(): Promise<void> {
this.audit("INFO", "AllDebrid Login-Fenster geöffnet");
await this.allDebridWebFallback.openLoginWindow();
}
public async importBestDebridCookies(filePath: string): Promise<number> {
return this.bestDebridWebFallback.importCookiesFromFile(filePath);
const imported = await this.bestDebridWebFallback.importCookiesFromFile(filePath);
this.audit("INFO", "BestDebrid Cookies importiert", {
filePath,
imported
});
return imported;
}
public async getAllDebridHostInfo(host = "rapidgator"): Promise<AllDebridHostInfo> {
@ -293,9 +321,17 @@ export class AppController {
public addLinks(payload: AddLinksPayload): { addedPackages: number; addedLinks: number; invalidCount: number } {
const parsed = parseCollectorInput(payload.rawText, payload.packageName || this.settings.packageName);
if (parsed.length === 0) {
this.audit("WARN", "Links hinzufügen ohne gültigen Inhalt", {
hasPackageName: Boolean(payload.packageName)
});
return { addedPackages: 0, addedLinks: 0, invalidCount: 1 };
}
const result = this.manager.addPackages(parsed);
this.audit("INFO", "Links hinzugefügt", {
addedPackages: result.addedPackages,
addedLinks: result.addedLinks,
requestedPackages: parsed.length
});
return { ...result, invalidCount: 0 };
}
@ -307,6 +343,11 @@ export class AppController {
...(pkg.fileNames ? { fileNames: pkg.fileNames } : {})
}));
const result = this.manager.addPackages(merged);
this.audit("INFO", "Container importiert", {
files: filePaths.length,
addedPackages: result.addedPackages,
addedLinks: result.addedLinks
});
return result;
}
@ -319,58 +360,73 @@ export class AppController {
}
public clearAll(): void {
this.audit("WARN", "Queue komplett geleert");
this.manager.clearAll();
}
public async start(): Promise<void> {
this.audit("INFO", "Session-Start ausgelöst");
await this.manager.start();
}
public async startPackages(packageIds: string[]): Promise<void> {
this.audit("INFO", "Paket-Start ausgelöst", { packageIds });
await this.manager.startPackages(packageIds);
}
public async startItems(itemIds: string[]): Promise<void> {
this.audit("INFO", "Item-Start ausgelöst", { itemIds });
await this.manager.startItems(itemIds);
}
public stop(): void {
this.audit("INFO", "Session-Stopp ausgelöst");
this.manager.stop();
}
public togglePause(): boolean {
return this.manager.togglePause();
const paused = this.manager.togglePause();
this.audit("INFO", "Pause umgeschaltet", { paused });
return paused;
}
public retryExtraction(packageId: string): void {
this.audit("INFO", "Extraktion manuell wiederholt", { packageId });
this.manager.retryExtraction(packageId);
}
public extractNow(packageId: string): void {
this.audit("INFO", "Jetzt entpacken ausgelöst", { packageId });
this.manager.extractNow(packageId);
}
public resetPackage(packageId: string): void {
this.audit("INFO", "Paket zurückgesetzt", { packageId });
this.manager.resetPackage(packageId);
}
public cancelPackage(packageId: string): void {
this.audit("WARN", "Paket abgebrochen", { packageId });
this.manager.cancelPackage(packageId);
}
public renamePackage(packageId: string, newName: string): void {
this.audit("INFO", "Paket umbenannt", { packageId, newName });
this.manager.renamePackage(packageId, newName);
}
public reorderPackages(packageIds: string[]): void {
this.audit("INFO", "Paketreihenfolge geändert", { packageIds });
this.manager.reorderPackages(packageIds);
}
public removeItem(itemId: string): void {
this.audit("WARN", "Item entfernt", { itemId });
this.manager.removeItem(itemId);
}
public togglePackage(packageId: string): void {
this.audit("INFO", "Paket aktiviert/deaktiviert", { packageId });
this.manager.togglePackage(packageId);
}
@ -387,12 +443,14 @@ export class AppController {
}
public resetSessionStats(): void {
this.audit("INFO", "Session-Statistik zurückgesetzt");
this.manager.resetSessionStats();
}
public resetDownloadStats(): void {
this.manager.resetDownloadStats();
this.settings = this.manager.getSettings();
this.audit("INFO", "Download-Statistik zurückgesetzt");
}
public exportBackup(): Buffer {
@ -407,6 +465,11 @@ export class AppController {
session,
history
});
this.audit("INFO", "Backup exportiert", {
historyEntries: history.length,
sessionItems: Object.keys(session.items).length,
sessionPackages: Object.keys(session.packages).length
});
return encryptBackup(payload);
}
@ -475,6 +538,10 @@ export class AppController {
this.manager.skipShutdownPersist = true;
this.manager.blockAllPersistence = true;
logger.info("Backup wiederhergestellt (verschlüsseltes Format)");
this.audit("WARN", "Backup importiert", {
historyEntries: Array.isArray(parsed.history) ? parsed.history.length : 0,
accountSummary: buildAccountSummary(this.settings)
});
return { restored: true, message: "Backup wiederhergestellt. Bitte App neustarten." };
}
@ -501,6 +568,8 @@ export class AppController {
shutdownSessionLog();
shutdownPackageLogs();
shutdownItemLogs();
this.audit("INFO", "App beendet");
shutdownAuditLog();
logger.info("App beendet");
}
@ -509,26 +578,38 @@ export class AppController {
}
public clearHistory(): void {
this.audit("WARN", "Verlauf geleert");
clearHistory(this.storagePaths);
}
public setPackagePriority(packageId: string, priority: PackagePriority): void {
this.audit("INFO", "Paket-Priorität geändert", { packageId, priority });
this.manager.setPackagePriority(packageId, priority);
}
public skipItems(itemIds: string[]): void {
this.audit("INFO", "Items übersprungen", { itemIds });
this.manager.skipItems(itemIds);
}
public resetItems(itemIds: string[]): void {
this.audit("INFO", "Items zurückgesetzt", { itemIds });
this.manager.resetItems(itemIds);
}
public removeHistoryEntry(entryId: string): void {
this.audit("INFO", "Verlaufseintrag entfernt", { entryId });
removeHistoryEntry(this.storagePaths, entryId);
}
public addToHistory(entry: HistoryEntry): void {
this.audit("INFO", "Verlaufseintrag hinzugefügt", {
id: entry.id,
name: entry.name,
status: entry.status,
provider: entry.provider,
fileCount: entry.fileCount
});
addHistoryEntry(this.storagePaths, entry);
}
}

80
src/main/audit-log.ts Normal file
View File

@ -0,0 +1,80 @@
import fs from "node:fs";
import path from "node:path";
type AuditLevel = "INFO" | "WARN" | "ERROR";
let auditLogPath: string | null = null;
function sanitizeFieldValue(value: unknown): string {
if (value === undefined || value === null) {
return "";
}
if (typeof value === "string") {
return value.replace(/\r?\n/g, "\\n");
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
try {
return JSON.stringify(value).replace(/\r?\n/g, "\\n");
} catch {
return String(value);
}
}
function formatFields(fields?: Record<string, unknown>): string {
if (!fields) {
return "";
}
const parts = Object.entries(fields)
.filter(([, value]) => value !== undefined && value !== null && sanitizeFieldValue(value) !== "")
.map(([key, value]) => `${key}=${sanitizeFieldValue(value)}`);
return parts.length > 0 ? ` | ${parts.join(" | ")}` : "";
}
export function initAuditLog(baseDir: string): void {
auditLogPath = path.join(baseDir, "audit.log");
try {
fs.mkdirSync(path.dirname(auditLogPath), { recursive: true });
if (!fs.existsSync(auditLogPath)) {
fs.writeFileSync(auditLogPath, "", "utf8");
}
fs.appendFileSync(auditLogPath, `=== Audit-Log Start: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
auditLogPath = null;
}
}
export function logAuditEvent(level: AuditLevel, message: string, fields?: Record<string, unknown>): void {
if (!auditLogPath) {
return;
}
try {
fs.appendFileSync(
auditLogPath,
`${new Date().toISOString()} [${level}] ${message}${formatFields(fields)}\n`,
"utf8"
);
} catch {
// ignore write errors
}
}
export function getAuditLogPath(): string | null {
if (!auditLogPath) {
return null;
}
return fs.existsSync(auditLogPath) ? auditLogPath : null;
}
export function shutdownAuditLog(): void {
if (!auditLogPath) {
return;
}
try {
fs.appendFileSync(auditLogPath, `=== Audit-Log Ende: ${new Date().toISOString()} ===\n`, "utf8");
} catch {
// ignore
}
auditLogPath = null;
}

View File

@ -2,10 +2,13 @@ import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { APP_VERSION } from "./constants";
import { getAuditLogPath } from "./audit-log";
import { logger, getLogFilePath } from "./logger";
import { getItemLogPath as getPersistedItemLogPath } from "./item-log";
import { getSessionLogPath } from "./session-log";
import { getPackageLogPath as getPersistedPackageLogPath } from "./package-log";
import { createStoragePaths, loadHistory, loadSettings } from "./storage";
import { buildAccountSummary, buildRedactedSettingsPayload, buildStatsPayload, summarizeHistoryEntry } from "./support-data";
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
import type { DownloadManager } from "./download-manager";
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
@ -13,6 +16,35 @@ import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
const DEFAULT_PORT = 9868;
const DEFAULT_HOST = "127.0.0.1";
const MAX_LOG_LINES = 10000;
const AI_MANIFEST_FILE = "debug_ai_manifest.json";
type DebugEndpointDescriptor = {
method: "GET";
path: string;
queryExample?: string;
description: string;
};
const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
{ 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: "/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: "/logs/main", queryExample: "lines=100&grep=keyword", description: "Reads the main application log tail." },
{ method: "GET", path: "/logs/audit", queryExample: "lines=100&grep=keyword", description: "Reads the audit log for support-relevant UI and admin actions." },
{ 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/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: "/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: "/stats", description: "Returns live session stats plus persisted all-time totals." },
{ method: "GET", path: "/history", queryExample: "limit=50&status=completed", description: "Returns history entries with optional filters." },
{ method: "GET", path: "/status", description: "Returns a live high-level status overview." },
{ method: "GET", path: "/packages", queryExample: "package=Release&includeItems=1", description: "Lists packages and optional per-item detail." },
{ method: "GET", path: "/items", queryExample: "status=downloading&package=Release", description: "Lists items and supports status/package filters." },
{ method: "GET", path: "/session", queryExample: "package=Release", description: "Returns session-wide or package-scoped item state." },
{ method: "GET", path: "/diagnostics", queryExample: "package=Release&lines=150", description: "Returns a combined support snapshot with logs, status, settings, accounts, stats, history, and host diagnostics." }
];
let server: http.Server | null = null;
let manager: DownloadManager | null = null;
@ -21,6 +53,22 @@ let bindHost = DEFAULT_HOST;
let bindPort = DEFAULT_PORT;
let runtimeBaseDir = "";
function getStoragePaths() {
return createStoragePaths(runtimeBaseDir);
}
function readSupportSettings() {
return loadSettings(getStoragePaths());
}
function readSupportHistory() {
return loadHistory(getStoragePaths());
}
function getAiManifestPath(baseDir: string = runtimeBaseDir): string {
return path.join(baseDir, AI_MANIFEST_FILE);
}
function loadToken(baseDir: string): string {
const tokenPath = path.join(baseDir, "debug_token.txt");
try {
@ -113,6 +161,78 @@ function filterLines(lines: string[], grep: string): string[] {
return lines.filter((line) => line.toLowerCase().includes(pattern));
}
function formatEndpointSummary(endpoint: DebugEndpointDescriptor): string {
return `${endpoint.method} ${endpoint.path}${endpoint.queryExample ? `?${endpoint.queryExample}` : ""}`;
}
function getEndpointSummaries(): string[] {
return DEBUG_ENDPOINTS.map((endpoint) => formatEndpointSummary(endpoint));
}
function buildAiManifest(baseDir: string): Record<string, unknown> {
const remoteHostHint = bindHost === "0.0.0.0"
? "Use the server IP or DNS name for remote access. Ask the user only for that host value if it is unknown."
: "If remote access is required and the bind host is local-only, switch debug_host.txt to 0.0.0.0 and reopen the firewall.";
return {
schemaVersion: 1,
generatedAt: new Date().toISOString(),
appVersion: APP_VERSION,
runtimeBaseDir: baseDir,
purpose: "Machine-readable support manifest for AI tools and remote troubleshooting.",
quickstart: [
"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.",
"Call /meta first to confirm the server is reachable and to re-read the endpoint list.",
"Use /diagnostics for an overview, then drill into /logs/item, /logs/package, /status, /packages, /items, /settings, /accounts, /stats, or /history."
],
auth: {
required: true,
methods: [
"Authorization: Bearer <token>",
"?token=<token>"
],
tokenFile: path.join(baseDir, "debug_token.txt")
},
runtimeFiles: {
hostFile: path.join(baseDir, "debug_host.txt"),
portFile: path.join(baseDir, "debug_port.txt"),
tokenFile: path.join(baseDir, "debug_token.txt"),
mainLogFile: getLogFilePath(),
auditLogFile: getAuditLogPath(),
sessionLogFile: getSessionLogPath(),
packageLogDir: path.join(baseDir, "package-logs"),
itemLogDir: path.join(baseDir, "item-logs"),
settingsFile: path.join(baseDir, "rd_downloader_config.json"),
sessionFile: path.join(baseDir, "rd_session_state.json"),
historyFile: path.join(baseDir, "rd_history.json")
},
debugServer: {
enabled: Boolean(authToken),
host: bindHost,
port: bindPort,
localBaseUrl: `http://127.0.0.1:${bindPort}`,
remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}`,
remoteHostHint
},
askUserFor: [
"Server IP or DNS name, if remote access is required and not already known."
],
endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({
...endpoint,
summary: formatEndpointSummary(endpoint)
}))
};
}
function writeAiManifest(baseDir: string): void {
try {
fs.writeFileSync(getAiManifestPath(baseDir), JSON.stringify(buildAiManifest(baseDir), null, 2), "utf8");
} catch (error) {
logger.warn(`Debug-Server: KI-Support-Datei konnte nicht geschrieben werden: ${String(error)}`);
}
}
function summarizeItem(item: DownloadItem): Record<string, unknown> {
return {
id: item.id,
@ -264,25 +384,14 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
host: bindHost,
port: bindPort
},
supportFiles: {
aiManifest: getAiManifestPath()
},
logPaths: {
main: getLogFilePath(),
session: getSessionLogPath()
},
endpoints: [
"GET /health",
"GET /meta",
"GET /host/diagnostics",
"GET /log?lines=100&grep=keyword",
"GET /logs/main?lines=100&grep=keyword",
"GET /logs/session?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 /status",
"GET /packages?package=Release&includeItems=1",
"GET /items?status=downloading&package=Release",
"GET /session?package=Release",
"GET /diagnostics?package=Release&lines=150"
]
endpoints: getEndpointSummaries()
});
return;
}
@ -300,6 +409,19 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
if (pathname === "/logs/audit") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
const logPath = getAuditLogPath();
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
jsonResponse(res, 200, {
path: logPath,
lines,
count: lines.length
});
return;
}
if (pathname === "/logs/session") {
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
const grep = url.searchParams.get("grep") || "";
@ -361,6 +483,58 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
return;
}
if (pathname === "/settings") {
const settings = readSupportSettings();
jsonResponse(res, 200, buildRedactedSettingsPayload(settings));
return;
}
if (pathname === "/accounts") {
const settings = readSupportSettings();
jsonResponse(res, 200, buildAccountSummary(settings));
return;
}
if (pathname === "/stats") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
return;
}
const snapshot = manager.getSnapshot();
const settings = readSupportSettings();
jsonResponse(res, 200, {
...buildStatsPayload(snapshot),
allTime: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime
}
});
return;
}
if (pathname === "/history") {
const entries = readSupportHistory();
const limit = normalizeLinesParam(url.searchParams.get("limit"), 50);
const statusFilter = String(url.searchParams.get("status") || "").trim().toLowerCase();
const grep = String(url.searchParams.get("grep") || "").trim().toLowerCase();
let filtered = entries;
if (statusFilter) {
filtered = filtered.filter((entry) => String(entry.status || "").toLowerCase() === statusFilter);
}
if (grep) {
filtered = filtered.filter((entry) => JSON.stringify(summarizeHistoryEntry(entry)).toLowerCase().includes(grep));
}
const sliced = filtered
.sort((a, b) => Number(b.completedAt || 0) - Number(a.completedAt || 0))
.slice(0, limit);
jsonResponse(res, 200, {
count: sliced.length,
total: filtered.length,
entries: sliced.map((entry) => summarizeHistoryEntry(entry))
});
return;
}
if (pathname === "/status") {
if (!manager) {
jsonResponse(res, 503, { error: "Manager not initialized" });
@ -478,6 +652,16 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
}
},
status: buildStatusPayload(snapshot),
settings: buildRedactedSettingsPayload(readSupportSettings()),
stats: buildStatsPayload(snapshot),
accounts: buildAccountSummary(readSupportSettings()),
history: {
total: readSupportHistory().length,
recent: readSupportHistory()
.sort((a, b) => Number(b.completedAt || 0) - Number(a.completedAt || 0))
.slice(0, 10)
.map((entry) => summarizeHistoryEntry(entry))
},
host: getWindowsHostDiagnostics(),
selectedPackage: selectedPackage ? summarizePackage(snapshot, selectedPackage, true) : undefined,
logs: {
@ -485,6 +669,10 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
path: mainLogPath,
lines: filterLines(readLogTailFromFile(mainLogPath, lineCount), grep)
},
audit: {
path: getAuditLogPath(),
lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep)
},
session: {
path: sessionLogPath,
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
@ -500,35 +688,22 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
jsonResponse(res, 404, {
error: "Not found",
endpoints: [
"GET /health",
"GET /meta",
"GET /host/diagnostics",
"GET /log?lines=100&grep=keyword",
"GET /logs/main?lines=100&grep=keyword",
"GET /logs/session?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 /status",
"GET /packages?package=Release&includeItems=1",
"GET /items?status=downloading&package=Bloodline",
"GET /session?package=Criminal",
"GET /diagnostics?package=Criminal&lines=150"
]
endpoints: getEndpointSummaries()
});
}
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;
bindPort = getPort(baseDir);
bindHost = getHost(baseDir);
server = http.createServer(handleRequest);
server.listen(bindPort, bindHost, () => {

178
src/main/support-data.ts Normal file
View File

@ -0,0 +1,178 @@
import { getDebridLinkApiKeyIds } from "../shared/debrid-link-keys";
import type { AppSettings, HistoryEntry, UiSnapshot } from "../shared/types";
function hasText(value: unknown): boolean {
return String(value || "").trim().length > 0;
}
export function buildAccountSummary(settings: AppSettings): Record<string, unknown> {
const debridLinkKeyIds = getDebridLinkApiKeyIds(settings.debridLinkApiKeys);
const disabledDebridLinkIds = new Set(settings.debridLinkDisabledKeyIds || []);
return {
realDebrid: {
configured: hasText(settings.token) || settings.realDebridUseWebLogin,
tokenConfigured: hasText(settings.token),
webLoginEnabled: settings.realDebridUseWebLogin,
rememberToken: settings.rememberToken
},
megaDebrid: {
configured: (hasText(settings.megaLogin) && hasText(settings.megaPassword))
|| settings.megaDebridApiEnabled
|| settings.megaDebridWebEnabled,
loginConfigured: hasText(settings.megaLogin) && hasText(settings.megaPassword),
apiEnabled: settings.megaDebridApiEnabled,
webEnabled: settings.megaDebridWebEnabled,
preferApi: settings.megaDebridPreferApi
},
bestDebrid: {
configured: hasText(settings.bestToken) || settings.bestDebridUseWebLogin,
tokenConfigured: hasText(settings.bestToken),
webLoginEnabled: settings.bestDebridUseWebLogin
},
allDebrid: {
configured: hasText(settings.allDebridToken) || settings.allDebridUseWebLogin,
tokenConfigured: hasText(settings.allDebridToken),
webLoginEnabled: settings.allDebridUseWebLogin
},
ddownload: {
configured: hasText(settings.ddownloadLogin) && hasText(settings.ddownloadPassword)
},
oneFichier: {
configured: hasText(settings.oneFichierApiKey)
},
debridLink: {
configured: debridLinkKeyIds.length > 0,
keyCount: debridLinkKeyIds.length,
enabledKeyCount: debridLinkKeyIds.filter((id) => !disabledDebridLinkIds.has(id)).length,
disabledKeyCount: debridLinkKeyIds.filter((id) => disabledDebridLinkIds.has(id)).length
},
linkSnappy: {
configured: hasText(settings.linkSnappyLogin) && hasText(settings.linkSnappyPassword)
},
disabledProviders: [...(settings.disabledProviders || [])]
};
}
export function diffAccountSummary(previous: AppSettings, next: AppSettings): Record<string, unknown> {
const before = buildAccountSummary(previous);
const after = buildAccountSummary(next);
const changes: Record<string, unknown> = {};
for (const key of Object.keys(after)) {
const beforeJson = JSON.stringify(before[key]);
const afterJson = JSON.stringify(after[key]);
if (beforeJson !== afterJson) {
changes[key] = after[key];
}
}
return changes;
}
export function buildRedactedSettingsPayload(settings: AppSettings): Record<string, unknown> {
return {
paths: {
outputDir: settings.outputDir,
extractDir: settings.extractDir,
mkvLibraryDir: settings.mkvLibraryDir
},
providers: {
providerOrder: settings.providerOrder,
providerPrimary: settings.providerPrimary,
providerSecondary: settings.providerSecondary,
providerTertiary: settings.providerTertiary,
autoProviderFallback: settings.autoProviderFallback,
disabledProviders: settings.disabledProviders,
hosterRouting: settings.hosterRouting
},
extraction: {
autoExtract: settings.autoExtract,
autoExtractWhenStopped: settings.autoExtractWhenStopped,
hybridExtract: settings.hybridExtract,
createExtractSubfolder: settings.createExtractSubfolder,
cleanupMode: settings.cleanupMode,
extractConflictMode: settings.extractConflictMode,
removeLinkFilesAfterExtract: settings.removeLinkFilesAfterExtract,
removeSamplesAfterExtract: settings.removeSamplesAfterExtract,
enableIntegrityCheck: settings.enableIntegrityCheck,
archivePasswordCount: String(settings.archivePasswordList || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.length,
extractCpuPriority: settings.extractCpuPriority,
maxParallelExtract: settings.maxParallelExtract
},
downloads: {
maxParallel: settings.maxParallel,
retryLimit: settings.retryLimit,
autoResumeOnStart: settings.autoResumeOnStart,
autoReconnect: settings.autoReconnect,
reconnectWaitSeconds: settings.reconnectWaitSeconds,
autoSkipExtracted: settings.autoSkipExtracted,
completedCleanupPolicy: settings.completedCleanupPolicy
},
ui: {
packageName: settings.packageName,
theme: settings.theme,
collapseNewPackages: settings.collapseNewPackages,
hideExtractedItems: settings.hideExtractedItems,
confirmDeleteSelection: settings.confirmDeleteSelection,
clipboardWatch: settings.clipboardWatch,
minimizeToTray: settings.minimizeToTray,
columnOrder: settings.columnOrder
},
bandwidth: {
speedLimitEnabled: settings.speedLimitEnabled,
speedLimitKbps: settings.speedLimitKbps,
speedLimitMode: settings.speedLimitMode,
bandwidthSchedules: settings.bandwidthSchedules
},
updates: {
updateRepo: settings.updateRepo,
autoUpdateCheck: settings.autoUpdateCheck
},
statistics: {
totalDownloadedAllTime: settings.totalDownloadedAllTime,
totalCompletedFilesAllTime: settings.totalCompletedFilesAllTime,
providerDailyLimitBytes: settings.providerDailyLimitBytes,
providerDailyUsageBytes: settings.providerDailyUsageBytes,
providerTotalUsageBytes: settings.providerTotalUsageBytes,
debridLinkApiKeyDailyLimitBytes: settings.debridLinkApiKeyDailyLimitBytes,
debridLinkApiKeyDailyUsageBytes: settings.debridLinkApiKeyDailyUsageBytes,
debridLinkApiKeyTotalUsageBytes: settings.debridLinkApiKeyTotalUsageBytes,
providerDailyUsageDay: settings.providerDailyUsageDay
},
accounts: buildAccountSummary(settings)
};
}
export function buildStatsPayload(snapshot: UiSnapshot): Record<string, unknown> {
return {
session: snapshot.stats,
totals: {
totalPackages: Object.keys(snapshot.session.packages).length,
totalItems: Object.keys(snapshot.session.items).length,
speedText: snapshot.speedText,
etaText: snapshot.etaText,
canStart: snapshot.canStart,
canStop: snapshot.canStop,
canPause: snapshot.canPause
}
};
}
export function summarizeHistoryEntry(entry: HistoryEntry): Record<string, unknown> {
return {
id: entry.id,
name: entry.name,
status: entry.status,
provider: entry.provider,
fileCount: entry.fileCount,
totalBytes: entry.totalBytes,
downloadedBytes: entry.downloadedBytes,
durationSeconds: entry.durationSeconds,
completedAt: entry.completedAt,
outputDir: entry.outputDir,
urlCount: Array.isArray(entry.urls) ? entry.urls.length : 0
};
}

32
tests/audit-log.test.ts Normal file
View File

@ -0,0 +1,32 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
const tempDirs: string[] = [];
afterEach(() => {
shutdownAuditLog();
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe("audit-log", () => {
it("writes audit events to the audit log", () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-alog-"));
tempDirs.push(baseDir);
initAuditLog(baseDir);
logAuditEvent("INFO", "Settings changed", { changedKeys: ["token", "autoExtract"] });
const logPath = getAuditLogPath();
expect(logPath).not.toBeNull();
expect(fs.existsSync(logPath!)).toBe(true);
const content = fs.readFileSync(logPath!, "utf8");
expect(content).toContain("Audit-Log Start");
expect(content).toContain("Settings changed");
expect(content).toContain("changedKeys");
});
});

View File

@ -40,11 +40,14 @@ vi.mock("../src/main/windows-host-diagnostics", () => ({
}));
import { defaultSettings } from "../src/main/constants";
import { getAuditLogPath, initAuditLog, logAuditEvent, shutdownAuditLog } from "../src/main/audit-log";
import { startDebugServer, stopDebugServer } from "../src/main/debug-server";
import { ensureItemLog, initItemLogs, shutdownItemLogs } from "../src/main/item-log";
import { configureLogger, getLogFilePath } from "../src/main/logger";
import { ensurePackageLog, initPackageLogs, shutdownPackageLogs } from "../src/main/package-log";
import { getSessionLogPath, initSessionLog, shutdownSessionLog } from "../src/main/session-log";
import { createStoragePaths, saveHistory, saveSettings } from "../src/main/storage";
import { getDebridLinkApiKeyIds } from "../src/shared/debrid-link-keys";
import type { DownloadManager } from "../src/main/download-manager";
import type { UiSnapshot } from "../src/shared/types";
@ -188,13 +191,47 @@ async function createFixture() {
const token = "debug-secret";
const port = await getFreePort();
const snapshot = buildSnapshot(baseDir);
const storagePaths = createStoragePaths(baseDir);
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8");
fs.writeFileSync(path.join(baseDir, "debug_port.txt"), String(port), "utf8");
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "0.0.0.0", "utf8");
const debridLinkApiKeys = "key-a\nkey-b";
const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys);
saveSettings(storagePaths, {
...snapshot.settings,
token: "rd-secret-token",
realDebridUseWebLogin: true,
debridLinkApiKeys,
debridLinkDisabledKeyIds: debridLinkKeyIds[1] ? [debridLinkKeyIds[1]] : [],
totalDownloadedAllTime: 128 * 1024 * 1024,
totalCompletedFilesAllTime: 12
});
saveHistory(storagePaths, [
{
id: "hist-1",
name: "server-package",
totalBytes: 123,
downloadedBytes: 123,
fileCount: 2,
provider: "realdebrid",
completedAt: Date.now() - 5_000,
durationSeconds: 42,
status: "completed",
outputDir: path.join(baseDir, "downloads", "server-package"),
urls: ["https://hoster.example/file-1"]
}
]);
configureLogger(baseDir);
fs.writeFileSync(getLogFilePath(), "2026-03-09T00:00:00.000Z [INFO] MAIN-LINE\n", "utf8");
initAuditLog(baseDir);
const auditLogPath = getAuditLogPath();
if (!auditLogPath) {
throw new Error("audit log path missing");
}
logAuditEvent("INFO", "AUDIT-LINE", { scope: "settings" });
initSessionLog(baseDir);
const sessionLogPath = getSessionLogPath();
@ -239,7 +276,8 @@ async function createFixture() {
return {
baseUrl,
token
token,
baseDir
};
}
@ -248,6 +286,7 @@ afterEach(() => {
shutdownSessionLog();
shutdownPackageLogs();
shutdownItemLogs();
shutdownAuditLog();
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (!dir) {
@ -275,8 +314,31 @@ describe("debug-server", () => {
expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41);
expect(payload.selectedPackage?.name).toBe("server-package");
expect((payload.logs?.main?.lines || []).join("\n")).toContain("MAIN-LINE");
expect((payload.logs?.audit?.lines || []).join("\n")).toContain("AUDIT-LINE");
expect((payload.logs?.session?.lines || []).join("\n")).toContain("SESSION-LINE");
expect((payload.logs?.package?.lines || []).join("\n")).toContain("PACKAGE-LINE");
expect(payload.accounts?.realDebrid?.configured).toBe(true);
expect(payload.history?.total).toBe(1);
});
it("writes a machine-readable AI support manifest into the runtime folder", async () => {
const fixture = await createFixture();
const manifestPath = path.join(fixture.baseDir, "debug_ai_manifest.json");
expect(fs.existsSync(manifestPath)).toBe(true);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as Record<string, any>;
expect(manifest.appVersion).toBeTruthy();
expect(manifest.debugServer?.port).toBeGreaterThan(0);
expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>");
expect(manifest.quickstart?.[1]).toContain("server IP");
expect(manifest.runtimeFiles?.tokenFile).toContain("debug_token.txt");
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/diagnostics")).toBe(true);
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`);
expect(metaResponse.ok).toBe(true);
const metaPayload = await metaResponse.json() as Record<string, any>;
expect(metaPayload.supportFiles?.aiManifest).toBe(manifestPath);
});
it("serves package details and package log by package query", async () => {
@ -316,6 +378,43 @@ describe("debug-server", () => {
expect(payload.assessmentHints?.[0]).toContain("watchdog");
});
it("serves audit log, settings, accounts, stats, and history", async () => {
const fixture = await createFixture();
const auditResponse = await fetch(`${fixture.baseUrl}/logs/audit?token=${fixture.token}&lines=20`);
expect(auditResponse.ok).toBe(true);
const auditPayload = await auditResponse.json() as Record<string, any>;
expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE");
const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`);
expect(settingsResponse.ok).toBe(true);
const settingsPayload = await settingsResponse.json() as Record<string, any>;
expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true);
expect(settingsPayload.extraction?.archivePasswordCount).toBe(0);
expect(JSON.stringify(settingsPayload)).not.toContain("rd-secret-token");
expect(JSON.stringify(settingsPayload)).not.toContain("key-a");
expect(JSON.stringify(settingsPayload)).not.toContain("key-b");
const accountsResponse = await fetch(`${fixture.baseUrl}/accounts?token=${fixture.token}`);
expect(accountsResponse.ok).toBe(true);
const accountsPayload = await accountsResponse.json() as Record<string, any>;
expect(accountsPayload.debridLink?.keyCount).toBe(2);
expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1);
const statsResponse = await fetch(`${fixture.baseUrl}/stats?token=${fixture.token}`);
expect(statsResponse.ok).toBe(true);
const statsPayload = await statsResponse.json() as Record<string, any>;
expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0);
expect(statsPayload.allTime?.totalDownloadedAllTime).toBeGreaterThan(0);
const historyResponse = await fetch(`${fixture.baseUrl}/history?token=${fixture.token}&limit=10`);
expect(historyResponse.ok).toBe(true);
const historyPayload = await historyResponse.json() as Record<string, any>;
expect(historyPayload.total).toBe(1);
expect(historyPayload.entries?.[0]?.name).toBe("server-package");
expect(historyPayload.entries?.[0]?.urlCount).toBe(1);
});
it("rejects unauthenticated requests", async () => {
const fixture = await createFixture();
const response = await fetch(`${fixture.baseUrl}/status`);