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
+207 -32
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, () => {