Add support bundle and trace tooling
This commit is contained in:
+159
-6
@@ -1,6 +1,7 @@
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import crypto from "node:crypto";
|
||||
import { APP_VERSION } from "./constants";
|
||||
import { getAuditLogPath } from "./audit-log";
|
||||
import { logger, getLogFilePath } from "./logger";
|
||||
@@ -9,6 +10,8 @@ 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 { buildSupportBundle, getSupportBundleDefaultFileName } from "./support-bundle";
|
||||
import { getTraceConfig, getTraceConfigPath, getTraceLogPath, logTraceEvent, updateTraceConfig } from "./trace-log";
|
||||
import { getWindowsHostDiagnostics } from "./windows-host-diagnostics";
|
||||
import type { DownloadManager } from "./download-manager";
|
||||
import type { DownloadItem, PackageEntry, UiSnapshot } from "../shared/types";
|
||||
@@ -32,9 +35,11 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
|
||||
{ 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/trace", queryExample: "lines=100&grep=keyword", description: "Reads the optional support trace log." },
|
||||
{ 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: "/trace/config", queryExample: "enable=1¬e=support", description: "Reads or updates the support trace configuration." },
|
||||
{ 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." },
|
||||
@@ -43,6 +48,7 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
|
||||
{ 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: "/support/bundle", description: "Downloads a ZIP support bundle with logs, diagnostics, and redacted 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." }
|
||||
];
|
||||
|
||||
@@ -69,6 +75,10 @@ function getAiManifestPath(baseDir: string = runtimeBaseDir): string {
|
||||
return path.join(baseDir, AI_MANIFEST_FILE);
|
||||
}
|
||||
|
||||
function getDebugTokenPath(baseDir: string = runtimeBaseDir): string {
|
||||
return path.join(baseDir, "debug_token.txt");
|
||||
}
|
||||
|
||||
function loadToken(baseDir: string): string {
|
||||
const tokenPath = path.join(baseDir, "debug_token.txt");
|
||||
try {
|
||||
@@ -132,6 +142,23 @@ function jsonResponse(res: http.ServerResponse, status: number, data: unknown):
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function binaryResponse(
|
||||
res: http.ServerResponse,
|
||||
status: number,
|
||||
body: Buffer,
|
||||
contentType: string,
|
||||
fileName?: string
|
||||
): void {
|
||||
res.writeHead(status, {
|
||||
"Content-Type": contentType,
|
||||
"Content-Length": String(body.length),
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Cache-Control": "no-cache",
|
||||
...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {})
|
||||
});
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function normalizeLinesParam(rawValue: string | null, fallback: number): number {
|
||||
const parsed = Number(rawValue || String(fallback));
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
@@ -161,6 +188,31 @@ function filterLines(lines: string[], grep: string): string[] {
|
||||
return lines.filter((line) => line.toLowerCase().includes(pattern));
|
||||
}
|
||||
|
||||
function toBooleanQuery(value: string | null): boolean | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
if (/^(1|true|yes|on)$/i.test(value)) {
|
||||
return true;
|
||||
}
|
||||
if (/^(0|false|no|off)$/i.test(value)) {
|
||||
return false;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function sanitizeRequestUrlForTrace(rawUrl: string): string {
|
||||
try {
|
||||
const url = new URL(rawUrl || "/", "http://localhost");
|
||||
if (url.searchParams.has("token")) {
|
||||
url.searchParams.set("token", "***");
|
||||
}
|
||||
return `${url.pathname}${url.search}`;
|
||||
} catch {
|
||||
return String(rawUrl || "/");
|
||||
}
|
||||
}
|
||||
|
||||
function formatEndpointSummary(endpoint: DebugEndpointDescriptor): string {
|
||||
return `${endpoint.method} ${endpoint.path}${endpoint.queryExample ? `?${endpoint.queryExample}` : ""}`;
|
||||
}
|
||||
@@ -184,7 +236,8 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
|
||||
"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."
|
||||
"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."
|
||||
],
|
||||
auth: {
|
||||
required: true,
|
||||
@@ -200,6 +253,8 @@ function buildAiManifest(baseDir: string): Record<string, unknown> {
|
||||
tokenFile: path.join(baseDir, "debug_token.txt"),
|
||||
mainLogFile: getLogFilePath(),
|
||||
auditLogFile: getAuditLogPath(),
|
||||
traceLogFile: getTraceLogPath(),
|
||||
traceConfigFile: getTraceConfigPath(),
|
||||
sessionLogFile: getSessionLogPath(),
|
||||
packageLogDir: path.join(baseDir, "package-logs"),
|
||||
itemLogDir: path.join(baseDir, "item-logs"),
|
||||
@@ -233,6 +288,19 @@ function writeAiManifest(baseDir: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function rotateDebugToken(baseDir: string = runtimeBaseDir): { path: string; token: string } {
|
||||
const token = crypto.randomBytes(24).toString("hex");
|
||||
const tokenPath = getDebugTokenPath(baseDir);
|
||||
fs.writeFileSync(tokenPath, `${token}\n`, "utf8");
|
||||
if (baseDir === runtimeBaseDir) {
|
||||
authToken = token;
|
||||
writeAiManifest(baseDir);
|
||||
}
|
||||
logger.info(`Debug-Server Token rotiert: ${tokenPath}`);
|
||||
logTraceEvent("INFO", "support", "Debug-Token rotiert", { tokenPath });
|
||||
return { path: tokenPath, token };
|
||||
}
|
||||
|
||||
function summarizeItem(item: DownloadItem): Record<string, unknown> {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -348,6 +416,16 @@ function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> {
|
||||
}
|
||||
|
||||
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||
const url = new URL(req.url || "/", "http://localhost");
|
||||
const pathname = url.pathname;
|
||||
const traceConfig = getTraceConfig();
|
||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
||||
logTraceEvent("INFO", "debug-http", "Request", {
|
||||
method: req.method || "GET",
|
||||
url: sanitizeRequestUrlForTrace(req.url || "/")
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === "OPTIONS") {
|
||||
res.writeHead(204, {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
@@ -359,13 +437,16 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
}
|
||||
|
||||
if (!checkAuth(req)) {
|
||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
||||
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
|
||||
method: req.method || "GET",
|
||||
url: sanitizeRequestUrlForTrace(req.url || "/")
|
||||
});
|
||||
}
|
||||
jsonResponse(res, 401, { error: "Unauthorized" });
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || "/", "http://localhost");
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname === "/health") {
|
||||
jsonResponse(res, 200, {
|
||||
status: "ok",
|
||||
@@ -385,11 +466,15 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
port: bindPort
|
||||
},
|
||||
supportFiles: {
|
||||
aiManifest: getAiManifestPath()
|
||||
aiManifest: getAiManifestPath(),
|
||||
traceConfig: getTraceConfigPath(),
|
||||
traceLog: getTraceLogPath()
|
||||
},
|
||||
logPaths: {
|
||||
main: getLogFilePath(),
|
||||
session: getSessionLogPath()
|
||||
audit: getAuditLogPath(),
|
||||
session: getSessionLogPath(),
|
||||
trace: getTraceLogPath()
|
||||
},
|
||||
endpoints: getEndpointSummaries()
|
||||
});
|
||||
@@ -422,6 +507,21 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/logs/trace") {
|
||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
||||
const grep = url.searchParams.get("grep") || "";
|
||||
const logPath = getTraceLogPath();
|
||||
const lines = filterLines(readLogTailFromFile(logPath, count), grep);
|
||||
jsonResponse(res, 200, {
|
||||
path: logPath,
|
||||
configPath: getTraceConfigPath(),
|
||||
config: getTraceConfig(),
|
||||
lines,
|
||||
count: lines.length
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/logs/session") {
|
||||
const count = normalizeLinesParam(url.searchParams.get("lines"), 100);
|
||||
const grep = url.searchParams.get("grep") || "";
|
||||
@@ -435,6 +535,39 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/trace/config") {
|
||||
const patch: Record<string, unknown> = {};
|
||||
const enabled = toBooleanQuery(url.searchParams.get("enable"));
|
||||
const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog"));
|
||||
const includeAudit = toBooleanQuery(url.searchParams.get("includeAudit"));
|
||||
const logDebugRequests = toBooleanQuery(url.searchParams.get("logDebugRequests"));
|
||||
if (enabled !== null) {
|
||||
patch.enabled = enabled;
|
||||
}
|
||||
if (includeMainLog !== null) {
|
||||
patch.includeMainLog = includeMainLog;
|
||||
}
|
||||
if (includeAudit !== null) {
|
||||
patch.includeAudit = includeAudit;
|
||||
}
|
||||
if (logDebugRequests !== null) {
|
||||
patch.logDebugRequests = logDebugRequests;
|
||||
}
|
||||
const note = String(url.searchParams.get("note") || "").trim();
|
||||
const config = Object.keys(patch).length > 0
|
||||
? updateTraceConfig({ ...patch, ...(note ? { updatedAt: new Date().toISOString() } : {}) })
|
||||
: getTraceConfig();
|
||||
if (Object.keys(patch).length > 0) {
|
||||
logTraceEvent("INFO", "support", "Trace-Konfiguration über Debug-Server geändert", { ...patch, note });
|
||||
}
|
||||
jsonResponse(res, 200, {
|
||||
path: getTraceConfigPath(),
|
||||
logPath: getTraceLogPath(),
|
||||
config
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/logs/package") {
|
||||
if (!manager) {
|
||||
jsonResponse(res, 503, { error: "Manager not initialized" });
|
||||
@@ -626,6 +759,21 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/support/bundle") {
|
||||
if (!manager) {
|
||||
jsonResponse(res, 503, { error: "Manager not initialized" });
|
||||
return;
|
||||
}
|
||||
const fileName = getSupportBundleDefaultFileName();
|
||||
const body = buildSupportBundle(manager, runtimeBaseDir);
|
||||
logTraceEvent("INFO", "support", "Support-Bundle über Debug-Server heruntergeladen", {
|
||||
fileName,
|
||||
sizeBytes: body.length
|
||||
});
|
||||
binaryResponse(res, 200, body, "application/zip", fileName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/diagnostics") {
|
||||
if (!manager) {
|
||||
jsonResponse(res, 503, { error: "Manager not initialized" });
|
||||
@@ -673,6 +821,11 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
||||
path: getAuditLogPath(),
|
||||
lines: filterLines(readLogTailFromFile(getAuditLogPath(), lineCount), grep)
|
||||
},
|
||||
trace: {
|
||||
path: getTraceLogPath(),
|
||||
config: getTraceConfig(),
|
||||
lines: filterLines(readLogTailFromFile(getTraceLogPath(), lineCount), grep)
|
||||
},
|
||||
session: {
|
||||
path: sessionLogPath,
|
||||
lines: filterLines(readLogTailFromFile(sessionLogPath, lineCount), grep)
|
||||
|
||||
Reference in New Issue
Block a user