feat: harden remote diagnostics authentication
Bind the diagnostics flow to bearer-only authentication and reject query token attempts with controlled responses. Compare bearer token bytes with timing-safe equality, remove wildcard CORS, and mark diagnostics/support responses as no-store. Move trace configuration mutation behind POST, add method failure handling, add a small per-IP/loopback in-memory request limit, and keep generated setup and support-manifest URLs token-free while pointing support access at a local bridge/tunnel flow. Sanitize backup remote diagnostics on export and restore so legacy token, endpoint, host mode, and port values are not persisted; restores only keep the allowlist and force local binding. Tests cover bearer accept/reject, query rejection, GET mutation rejection, no-store/CORS behavior, loopback default binding, rate limiting, token-free hints, and backup sanitation.
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
# Task 3 Report: Ferndiagnose-Authentifizierung
|
||||||
|
|
||||||
|
Status: umgesetzt.
|
||||||
|
|
||||||
|
Umgesetzt:
|
||||||
|
|
||||||
|
- Ferndiagnose-Endpunkte akzeptieren nur noch `Authorization: Bearer`.
|
||||||
|
- Query-Token werden kontrolliert mit `query_token_rejected` abgewiesen.
|
||||||
|
- Tokenvergleich nutzt `crypto.timingSafeEqual` nur bei gleicher Byte-Länge.
|
||||||
|
- Diagnoseantworten setzen `Cache-Control: no-store` und keine CORS-Wildcard.
|
||||||
|
- Nicht erlaubte Methoden liefern kontrolliert `method_not_allowed`.
|
||||||
|
- `/trace/config` liest per GET, Änderungen laufen nur noch per POST.
|
||||||
|
- Pro IP beziehungsweise Loopback gibt es ein kleines In-Memory-Limit mit `rate_limited`.
|
||||||
|
- Support-Manifest und Setup-Hinweise enthalten keine Token-URLs mehr und verweisen auf lokale Bridge/Tunnel-Nutzung.
|
||||||
|
- Backup-Remote-Diagnostics werden beim Export und Restore auf Allowlist plus lokale Bindung saniert; Ports, Host-Modus, Tokens und Endpoint-Felder werden nicht übernommen.
|
||||||
|
|
||||||
|
TDD-Nachweis:
|
||||||
|
|
||||||
|
- Baseline vor Teständerung: `npm run test:client -- tests/debug-server.test.ts tests/debug-server-allowlist.test.ts tests/backup-remote-diagnostics.test.ts` mit 33/33 grün.
|
||||||
|
- RED nach Teständerung: gleicher fokussierter Lauf mit erwarteten Fehlschlägen für no-store, Query-Rejection, GET-Mutation, Methodengate, Rate-Limit und Backup-Sanitizing.
|
||||||
|
- GREEN nach Implementierung: gleicher fokussierter Lauf mit 38/38 grün.
|
||||||
|
|
||||||
|
Verifikation:
|
||||||
|
|
||||||
|
- `npm run test:client -- tests/debug-server.test.ts tests/debug-server-allowlist.test.ts tests/backup-remote-diagnostics.test.ts`
|
||||||
|
- `npx tsc --noEmit`
|
||||||
|
- `npm run build`
|
||||||
|
- `git diff --check -- src/main/debug-server.ts src/main/debug-setup.ts src/main/backup-payload.ts tests/debug-server.test.ts tests/debug-server-allowlist.test.ts tests/backup-remote-diagnostics.test.ts`
|
||||||
|
- Feste Suchstrings in den geänderten Main-Dateien ohne Treffer: `?token=`, `Access-Control-Allow-Origin`, `remoteBaseUrlTemplate`, `searchParams.get("token")`, `Bearer ${authToken}`, `Cache-Control": "no-cache`.
|
||||||
|
|
||||||
|
Hinweise:
|
||||||
|
|
||||||
|
- Kein Release, Push oder Deployment ausgeführt.
|
||||||
|
- Der fokussierte Vitest-Lauf meldet weiterhin die bestehende Vite-CJS-Deprecation-Warnung.
|
||||||
+22
-20
@@ -3,9 +3,9 @@ import type { AppSettings, SessionState, HistoryEntry } from "../shared/types";
|
|||||||
export type BackupKind = "full" | "settings-only";
|
export type BackupKind = "full" | "settings-only";
|
||||||
|
|
||||||
export interface BackupRemoteDiagnostics {
|
export interface BackupRemoteDiagnostics {
|
||||||
allowlist: string[];
|
allowlist?: string[];
|
||||||
port: number;
|
port?: number;
|
||||||
hostMode: "local" | "network";
|
hostMode?: "local" | "network";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupPayload {
|
export interface BackupPayload {
|
||||||
@@ -49,36 +49,38 @@ export function buildBackupPayload(input: BuildBackupInput): BackupPayload {
|
|||||||
base.history = input.history;
|
base.history = input.history;
|
||||||
}
|
}
|
||||||
if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) {
|
if (Boolean(input.settings.backupIncludeRemoteDiagnostics) && input.remoteDiagnostics) {
|
||||||
base.remoteDiagnostics = input.remoteDiagnostics;
|
base.remoteDiagnostics = sanitizeBackupRemoteDiagnostics(input.remoteDiagnostics);
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RemoteDiagnosticsRestore {
|
export interface RemoteDiagnosticsRestore {
|
||||||
host?: "127.0.0.1" | "0.0.0.0";
|
host?: "127.0.0.1";
|
||||||
port?: number;
|
port?: number;
|
||||||
allowlist?: string[];
|
allowlist?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeAllowlist(value: unknown): string[] {
|
||||||
|
return Array.isArray(value)
|
||||||
|
? value.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeBackupRemoteDiagnostics(section: BackupRemoteDiagnostics): BackupRemoteDiagnostics {
|
||||||
|
return {
|
||||||
|
allowlist: sanitizeAllowlist(section.allowlist)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveRemoteDiagnosticsRestore(section: unknown): RemoteDiagnosticsRestore | null {
|
export function resolveRemoteDiagnosticsRestore(section: unknown): RemoteDiagnosticsRestore | null {
|
||||||
if (!section || typeof section !== "object") {
|
if (!section || typeof section !== "object") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const s = section as { allowlist?: unknown; port?: unknown; hostMode?: unknown };
|
const s = section as { allowlist?: unknown };
|
||||||
const allowlist = Array.isArray(s.allowlist)
|
return {
|
||||||
? s.allowlist.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0).map((entry) => entry.trim())
|
host: "127.0.0.1",
|
||||||
: undefined;
|
allowlist: sanitizeAllowlist(s.allowlist)
|
||||||
const port = (typeof s.port === "number" && Number.isInteger(s.port) && s.port >= 1024 && s.port <= 65535) ? s.port : undefined;
|
};
|
||||||
let host: "127.0.0.1" | "0.0.0.0" | undefined;
|
|
||||||
if (s.hostMode === "network") {
|
|
||||||
host = allowlist && allowlist.length > 0 ? "0.0.0.0" : "127.0.0.1";
|
|
||||||
} else if (s.hostMode === "local") {
|
|
||||||
host = "127.0.0.1";
|
|
||||||
}
|
|
||||||
if (host === undefined && port === undefined && allowlist === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { host, port, allowlist };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ImportPlan {
|
export interface ImportPlan {
|
||||||
|
|||||||
+116
-28
@@ -25,9 +25,11 @@ const DEFAULT_PORT = 9868;
|
|||||||
const DEFAULT_HOST = "127.0.0.1";
|
const DEFAULT_HOST = "127.0.0.1";
|
||||||
const MAX_LOG_LINES = 10000;
|
const MAX_LOG_LINES = 10000;
|
||||||
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
const SUPPORT_MANIFEST_FILE = "debug_support_manifest.json";
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60_000;
|
||||||
|
const RATE_LIMIT_MAX_REQUESTS = 120;
|
||||||
|
|
||||||
type DebugEndpointDescriptor = {
|
type DebugEndpointDescriptor = {
|
||||||
method: "GET";
|
method: "GET" | "POST";
|
||||||
path: string;
|
path: string;
|
||||||
queryExample?: string;
|
queryExample?: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -49,7 +51,8 @@ const DEBUG_ENDPOINTS: DebugEndpointDescriptor[] = [
|
|||||||
{ 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: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." },
|
{ method: "GET", path: "/errors", queryExample: "level=ERROR&limit=100", description: "Returns the in-memory ring of the most recent WARN/ERROR log lines." },
|
||||||
{ method: "GET", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "Reads or updates the support trace configuration." },
|
{ method: "GET", path: "/trace/config", description: "Reads the support trace configuration." },
|
||||||
|
{ method: "POST", path: "/trace/config", queryExample: "enable=1¬e=support&durationMinutes=120", description: "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: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." },
|
{ method: "GET", path: "/providers", description: "Live provider runtime state: per-account/key cooldowns (until/remaining/reason/category), in-flight depth, Mega rotation cursor, empty-response streaks. The 'why is it cooling down right now' view." },
|
||||||
@@ -70,6 +73,7 @@ let bindHost = DEFAULT_HOST;
|
|||||||
let bindPort = DEFAULT_PORT;
|
let bindPort = DEFAULT_PORT;
|
||||||
let runtimeBaseDir = "";
|
let runtimeBaseDir = "";
|
||||||
let allowlist: string[] = [];
|
let allowlist: string[] = [];
|
||||||
|
let requestLimits = new Map<string, { startedAt: number; count: number }>();
|
||||||
|
|
||||||
export interface DebugServerRuntimeStatus {
|
export interface DebugServerRuntimeStatus {
|
||||||
running: boolean;
|
running: boolean;
|
||||||
@@ -245,20 +249,33 @@ function checkAuth(req: http.IncomingMessage): boolean {
|
|||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const header = req.headers.authorization || "";
|
const header = typeof req.headers.authorization === "string" ? req.headers.authorization : "";
|
||||||
if (header === `Bearer ${authToken}`) {
|
const match = /^Bearer\s+(.+)$/i.exec(header);
|
||||||
return true;
|
if (!match) {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
const url = new URL(req.url || "/", "http://localhost");
|
const supplied = Buffer.from(match[1] || "", "utf8");
|
||||||
return url.searchParams.get("token") === authToken;
|
const expected = Buffer.from(authToken, "utf8");
|
||||||
|
if (supplied.length !== expected.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return crypto.timingSafeEqual(supplied, expected);
|
||||||
}
|
}
|
||||||
|
|
||||||
function jsonResponse(res: http.ServerResponse, status: number, data: unknown): void {
|
function noStoreHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
||||||
|
return {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"Pragma": "no-cache",
|
||||||
|
"Expires": "0",
|
||||||
|
...extra
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(res: http.ServerResponse, status: number, data: unknown, headers: Record<string, string> = {}): void {
|
||||||
const body = JSON.stringify(data, null, 2);
|
const body = JSON.stringify(data, null, 2);
|
||||||
res.writeHead(status, {
|
res.writeHead(status, {
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
"Access-Control-Allow-Origin": "*",
|
...noStoreHeaders(headers)
|
||||||
"Cache-Control": "no-cache"
|
|
||||||
});
|
});
|
||||||
res.end(body);
|
res.end(body);
|
||||||
}
|
}
|
||||||
@@ -273,13 +290,35 @@ function binaryResponse(
|
|||||||
res.writeHead(status, {
|
res.writeHead(status, {
|
||||||
"Content-Type": contentType,
|
"Content-Type": contentType,
|
||||||
"Content-Length": String(body.length),
|
"Content-Length": String(body.length),
|
||||||
"Access-Control-Allow-Origin": "*",
|
...noStoreHeaders(),
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {})
|
...(fileName ? { "Content-Disposition": `attachment; filename="${fileName}"` } : {})
|
||||||
});
|
});
|
||||||
res.end(body);
|
res.end(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function rateLimitKey(clientIp: string): string {
|
||||||
|
const normalized = normalizeIp(clientIp);
|
||||||
|
return isLoopbackIp(normalized) || normalized === "" ? "loopback" : normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRateLimit(clientIp: string): { allowed: boolean; retryAfterSeconds: number } {
|
||||||
|
const now = Date.now();
|
||||||
|
const key = rateLimitKey(clientIp);
|
||||||
|
const current = requestLimits.get(key);
|
||||||
|
if (!current || now - current.startedAt >= RATE_LIMIT_WINDOW_MS) {
|
||||||
|
requestLimits.set(key, { startedAt: now, count: 1 });
|
||||||
|
return { allowed: true, retryAfterSeconds: 0 };
|
||||||
|
}
|
||||||
|
current.count += 1;
|
||||||
|
if (current.count <= RATE_LIMIT_MAX_REQUESTS) {
|
||||||
|
return { allowed: true, retryAfterSeconds: 0 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
allowed: false,
|
||||||
|
retryAfterSeconds: Math.max(1, Math.ceil((RATE_LIMIT_WINDOW_MS - (now - current.startedAt)) / 1000))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeLinesParam(rawValue: string | null, fallback: number): number {
|
function normalizeLinesParam(rawValue: string | null, fallback: number): number {
|
||||||
const parsed = Number(rawValue || String(fallback));
|
const parsed = Number(rawValue || String(fallback));
|
||||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
@@ -352,8 +391,7 @@ function buildSupportManifest(baseDir: string): Record<string, unknown> {
|
|||||||
auth: {
|
auth: {
|
||||||
required: true,
|
required: true,
|
||||||
methods: [
|
methods: [
|
||||||
"Authorization: Bearer <token>",
|
"Authorization: Bearer <token>"
|
||||||
"?token=<token>"
|
|
||||||
],
|
],
|
||||||
tokenFile: path.join(baseDir, "debug_token.txt")
|
tokenFile: path.join(baseDir, "debug_token.txt")
|
||||||
},
|
},
|
||||||
@@ -378,15 +416,14 @@ function buildSupportManifest(baseDir: string): Record<string, unknown> {
|
|||||||
host: bindHost,
|
host: bindHost,
|
||||||
port: bindPort,
|
port: bindPort,
|
||||||
localBaseUrl: `http://127.0.0.1:${bindPort}`,
|
localBaseUrl: `http://127.0.0.1:${bindPort}`,
|
||||||
remoteBaseUrlTemplate: `http://<SERVER_IP_OR_DNS>:${bindPort}`
|
remoteBridgeBaseUrlTemplate: `http://127.0.0.1:${bindPort}`
|
||||||
},
|
},
|
||||||
setupCheckEndpoint: "/debug/setup",
|
setupCheckEndpoint: "/debug/setup",
|
||||||
selfCheckEndpoint: "/self-check",
|
selfCheckEndpoint: "/self-check",
|
||||||
remoteAccessRequirements: [
|
remoteAccessRequirements: [
|
||||||
"A reachable server IP or DNS name.",
|
"Use a trusted local bridge or tunnel to forward support traffic to the loopback diagnostics port.",
|
||||||
"The configured diagnostics port.",
|
"Send Authorization: Bearer with the token stored in debug_token.txt.",
|
||||||
"The token stored in debug_token.txt.",
|
"Do not expose the diagnostics port directly on LAN or WAN."
|
||||||
"A network route and firewall rule that permit access to the configured port."
|
|
||||||
],
|
],
|
||||||
endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({
|
endpoints: DEBUG_ENDPOINTS.map((endpoint) => ({
|
||||||
...endpoint,
|
...endpoint,
|
||||||
@@ -530,25 +567,46 @@ function buildStatusPayload(snapshot: UiSnapshot): Record<string, unknown> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasTraceMutationParams(url: URL): boolean {
|
||||||
|
return ["enable", "includeMainLog", "includeAudit", "logDebugRequests", "note", "durationMinutes"]
|
||||||
|
.some((key) => url.searchParams.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedMethods(pathname: string, url: URL): string[] {
|
||||||
|
if (pathname === "/trace/config") {
|
||||||
|
return hasTraceMutationParams(url) ? ["POST"] : ["GET", "POST"];
|
||||||
|
}
|
||||||
|
return ["GET"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectMethod(res: http.ServerResponse, methods: string[]): void {
|
||||||
|
jsonResponse(res, 405, {
|
||||||
|
error: "Method Not Allowed",
|
||||||
|
code: "method_not_allowed",
|
||||||
|
allowedMethods: methods
|
||||||
|
}, {
|
||||||
|
"Allow": methods.join(", ")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
|
||||||
const url = new URL(req.url || "/", "http://localhost");
|
const url = new URL(req.url || "/", "http://localhost");
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
|
const method = (req.method || "GET").toUpperCase();
|
||||||
const traceConfig = getTraceConfig();
|
const traceConfig = getTraceConfig();
|
||||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
||||||
logTraceEvent("INFO", "debug-http", "Request", {
|
logTraceEvent("INFO", "debug-http", "Request", {
|
||||||
method: req.method || "GET",
|
method,
|
||||||
url: sanitizeRequestUrlForTrace(req.url || "/"),
|
url: sanitizeRequestUrlForTrace(req.url || "/"),
|
||||||
clientIp: extractDebugClientIp(req)
|
clientIp: extractDebugClientIp(req)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
if (url.searchParams.has("token")) {
|
||||||
res.writeHead(204, {
|
jsonResponse(res, 400, {
|
||||||
"Access-Control-Allow-Origin": "*",
|
error: "Bad Request",
|
||||||
"Access-Control-Allow-Headers": "Authorization",
|
code: "query_token_rejected"
|
||||||
"Access-Control-Allow-Methods": "GET,OPTIONS"
|
|
||||||
});
|
});
|
||||||
res.end();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,15 +623,35 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const limit = checkRateLimit(peerIp);
|
||||||
|
if (!limit.allowed) {
|
||||||
|
jsonResponse(res, 429, {
|
||||||
|
error: "Too Many Requests",
|
||||||
|
code: "rate_limited",
|
||||||
|
retryAfterSeconds: limit.retryAfterSeconds
|
||||||
|
}, {
|
||||||
|
"Retry-After": String(limit.retryAfterSeconds)
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!checkAuth(req)) {
|
if (!checkAuth(req)) {
|
||||||
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
if (traceConfig.enabled && traceConfig.logDebugRequests) {
|
||||||
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
|
logTraceEvent("WARN", "debug-http", "Unauthorized request", {
|
||||||
method: req.method || "GET",
|
method,
|
||||||
url: sanitizeRequestUrlForTrace(req.url || "/"),
|
url: sanitizeRequestUrlForTrace(req.url || "/"),
|
||||||
clientIp: extractDebugClientIp(req)
|
clientIp: extractDebugClientIp(req)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
jsonResponse(res, 401, { error: "Unauthorized" });
|
jsonResponse(res, 401, { error: "Unauthorized", code: "unauthorized" }, {
|
||||||
|
"WWW-Authenticate": "Bearer"
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const methods = allowedMethods(pathname, url);
|
||||||
|
if (!methods.includes(method)) {
|
||||||
|
rejectMethod(res, methods);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,6 +798,14 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === "/trace/config") {
|
if (pathname === "/trace/config") {
|
||||||
|
if (method === "GET") {
|
||||||
|
jsonResponse(res, 200, {
|
||||||
|
path: getTraceConfigPath(),
|
||||||
|
logPath: getTraceLogPath(),
|
||||||
|
config: getTraceConfig()
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const patch: Record<string, unknown> = {};
|
const patch: Record<string, unknown> = {};
|
||||||
const enabled = toBooleanQuery(url.searchParams.get("enable"));
|
const enabled = toBooleanQuery(url.searchParams.get("enable"));
|
||||||
const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog"));
|
const includeMainLog = toBooleanQuery(url.searchParams.get("includeMainLog"));
|
||||||
@@ -1057,6 +1143,7 @@ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): voi
|
|||||||
|
|
||||||
function openServerSocket(): Promise<void> {
|
function openServerSocket(): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
|
requestLimits = new Map();
|
||||||
authToken = loadToken(runtimeBaseDir);
|
authToken = loadToken(runtimeBaseDir);
|
||||||
bindPort = getPort(runtimeBaseDir);
|
bindPort = getPort(runtimeBaseDir);
|
||||||
bindHost = getHost(runtimeBaseDir);
|
bindHost = getHost(runtimeBaseDir);
|
||||||
@@ -1176,6 +1263,7 @@ export function stopDebugServer(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
server = null;
|
server = null;
|
||||||
|
requestLimits = new Map();
|
||||||
logger.info("Debug-Server gestoppt");
|
logger.info("Debug-Server gestoppt");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,9 +352,9 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
|
|||||||
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
|
warnings.push("debug_token.txt fehlt oder ist leer. Der Debug-Server startet dann nicht.");
|
||||||
}
|
}
|
||||||
if (localOnly) {
|
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.");
|
notes.push("Der Debug-Server ist lokal gebunden. Remote-Support nutzt eine vertrauenswürdige Bridge oder einen Tunnel auf diese lokale Adresse.");
|
||||||
} else {
|
} else {
|
||||||
notes.push("Der Debug-Server ist für Remote-Zugriff konfiguriert. Firewall oder Provider-Regeln müssen separat offen sein.");
|
warnings.push("Der Debug-Server ist nicht lokal gebunden. Für Support sicherheitshalber auf 127.0.0.1 zurückstellen und eine Bridge oder einen Tunnel nutzen.");
|
||||||
}
|
}
|
||||||
if (!fs.existsSync(supportManifestPath)) {
|
if (!fs.existsSync(supportManifestPath)) {
|
||||||
warnings.push("debug_support_manifest.json fehlt. App einmal neu starten, damit das Support-Manifest neu geschrieben wird.");
|
warnings.push("debug_support_manifest.json fehlt. App einmal neu starten, damit das Support-Manifest neu geschrieben wird.");
|
||||||
@@ -422,14 +422,14 @@ export function getDebugSetupCheck(baseDir: string): DebugSetupCheckResult {
|
|||||||
warnings,
|
warnings,
|
||||||
notes,
|
notes,
|
||||||
localUrls: {
|
localUrls: {
|
||||||
health: `http://127.0.0.1:${port}/health?token=${token || "<TOKEN>"}`,
|
health: `http://127.0.0.1:${port}/health`,
|
||||||
meta: `http://127.0.0.1:${port}/meta?token=${token || "<TOKEN>"}`,
|
meta: `http://127.0.0.1:${port}/meta`,
|
||||||
diagnostics: `http://127.0.0.1:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
diagnostics: `http://127.0.0.1:${port}/diagnostics`
|
||||||
},
|
},
|
||||||
remoteUrlTemplates: {
|
remoteUrlTemplates: {
|
||||||
health: `http://<SERVER_IP_OR_DNS>:${port}/health?token=${token || "<TOKEN>"}`,
|
health: `http://127.0.0.1:${port}/health`,
|
||||||
meta: `http://<SERVER_IP_OR_DNS>:${port}/meta?token=${token || "<TOKEN>"}`,
|
meta: `http://127.0.0.1:${port}/meta`,
|
||||||
diagnostics: `http://<SERVER_IP_OR_DNS>:${port}/diagnostics?token=${token || "<TOKEN>"}`
|
diagnostics: `http://127.0.0.1:${port}/diagnostics`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ async function getFreePort(): Promise<number> {
|
|||||||
return address.port;
|
return address.port;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitForReady(url: string): Promise<void> {
|
async function waitForReady(url: string, token = "rt-secret"): Promise<void> {
|
||||||
const deadline = Date.now() + 5000;
|
const deadline = Date.now() + 5000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url, { headers: bearerHeaders(token) });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -60,6 +60,10 @@ async function waitForReady(url: string): Promise<void> {
|
|||||||
throw new Error(`debug server not ready: ${url}`);
|
throw new Error(`debug server not ready: ${url}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bearerHeaders(token = "rt-secret"): HeadersInit {
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
stopDebugServer();
|
stopDebugServer();
|
||||||
while (tempDirs.length > 0) {
|
while (tempDirs.length > 0) {
|
||||||
@@ -75,7 +79,7 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("backup remoteDiagnostics export gating", () => {
|
describe("backup remoteDiagnostics export gating", () => {
|
||||||
it("includes remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => {
|
it("includes only sanitized remoteDiagnostics when backupIncludeRemoteDiagnostics is on", () => {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
backupIncludeRemoteDiagnostics: true
|
backupIncludeRemoteDiagnostics: true
|
||||||
@@ -89,14 +93,15 @@ describe("backup remoteDiagnostics export gating", () => {
|
|||||||
remoteDiagnostics: {
|
remoteDiagnostics: {
|
||||||
allowlist: ["192.0.2.0/24"],
|
allowlist: ["192.0.2.0/24"],
|
||||||
port: 8976,
|
port: 8976,
|
||||||
hostMode: "network"
|
hostMode: "network",
|
||||||
}
|
token: "legacy-backup-token",
|
||||||
|
publicHost: "legacy.example.test",
|
||||||
|
endpoint: "http://legacy.example.test:8976"
|
||||||
|
} as unknown as BackupRemoteDiagnostics
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(payload.remoteDiagnostics).toEqual({
|
expect(payload.remoteDiagnostics).toEqual({
|
||||||
allowlist: ["192.0.2.0/24"],
|
allowlist: ["192.0.2.0/24"]
|
||||||
port: 8976,
|
|
||||||
hostMode: "network"
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,13 +115,16 @@ describe("backup remoteDiagnostics export gating", () => {
|
|||||||
expect(payload.remoteDiagnostics).toBeUndefined();
|
expect(payload.remoteDiagnostics).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("the remoteDiagnostics section carries ONLY allowlist/port/hostMode (no token, publicHost, name)", () => {
|
it("the remoteDiagnostics section carries ONLY allowlist", () => {
|
||||||
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
const payload = buildBackupPayload(input({ backupIncludeRemoteDiagnostics: true }, { allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }));
|
||||||
expect(payload.remoteDiagnostics && Object.keys(payload.remoteDiagnostics).sort()).toEqual(["allowlist", "hostMode", "port"]);
|
expect(payload.remoteDiagnostics && Object.keys(payload.remoteDiagnostics).sort()).toEqual(["allowlist"]);
|
||||||
const sectionJson = JSON.stringify(payload.remoteDiagnostics);
|
const sectionJson = JSON.stringify(payload.remoteDiagnostics);
|
||||||
expect(sectionJson.toLowerCase()).not.toContain("token");
|
expect(sectionJson.toLowerCase()).not.toContain("token");
|
||||||
expect(sectionJson).not.toContain("publicHost");
|
expect(sectionJson).not.toContain("publicHost");
|
||||||
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
|
expect(sectionJson.toLowerCase()).not.toContain("\"name\"");
|
||||||
|
expect(sectionJson.toLowerCase()).not.toContain("endpoint");
|
||||||
|
expect(sectionJson.toLowerCase()).not.toContain("hostmode");
|
||||||
|
expect(sectionJson.toLowerCase()).not.toContain("port");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -129,23 +137,36 @@ describe("backupIncludeRemoteDiagnostics settings persistence", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("resolveRemoteDiagnosticsRestore", () => {
|
describe("resolveRemoteDiagnosticsRestore", () => {
|
||||||
it("maps network + non-empty allowlist to 0.0.0.0", () => {
|
it("scrubs network restores to loopback and keeps only allowlist", () => {
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
|
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "network" }))
|
||||||
.toEqual({ host: "0.0.0.0", port: 9868, allowlist: ["10.0.0.5"] });
|
.toEqual({ host: "127.0.0.1", allowlist: ["10.0.0.5"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("SAFETY: network with EMPTY allowlist binds local, never 0.0.0.0", () => {
|
it("scrubs legacy token and endpoint fields during restore planning", () => {
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: [], port: 9868, hostMode: "network" })?.host).toBe("127.0.0.1");
|
const restore = resolveRemoteDiagnosticsRestore({
|
||||||
|
allowlist: ["198.51.100.8"],
|
||||||
|
port: 9999,
|
||||||
|
hostMode: "network",
|
||||||
|
token: "legacy-backup-token",
|
||||||
|
publicHost: "legacy.example.test",
|
||||||
|
endpoint: "http://legacy.example.test:9999"
|
||||||
|
});
|
||||||
|
expect(restore).toEqual({ host: "127.0.0.1", allowlist: ["198.51.100.8"] });
|
||||||
|
expect(JSON.stringify(restore).toLowerCase()).not.toContain("token");
|
||||||
|
expect(JSON.stringify(restore).toLowerCase()).not.toContain("endpoint");
|
||||||
|
expect(JSON.stringify(restore).toLowerCase()).not.toContain("publichost");
|
||||||
|
expect(JSON.stringify(restore).toLowerCase()).not.toContain("9999");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("maps local to 127.0.0.1", () => {
|
it("maps local to 127.0.0.1", () => {
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" })?.host).toBe("127.0.0.1");
|
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868, hostMode: "local" }))
|
||||||
|
.toEqual({ host: "127.0.0.1", allowlist: ["10.0.0.5"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects an out-of-range or non-integer port", () => {
|
it("does not restore any port values", () => {
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })?.port).toBeUndefined();
|
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 80, hostMode: "network" })).toEqual({ host: "127.0.0.1", allowlist: ["10.0.0.5"] });
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })?.port).toBeUndefined();
|
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 70000, hostMode: "network" })).toEqual({ host: "127.0.0.1", allowlist: ["10.0.0.5"] });
|
||||||
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })?.port).toBeUndefined();
|
expect(resolveRemoteDiagnosticsRestore({ allowlist: ["10.0.0.5"], port: 9868.5, hostMode: "network" })).toEqual({ host: "127.0.0.1", allowlist: ["10.0.0.5"] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters non-string and blank allowlist entries and trims", () => {
|
it("filters non-string and blank allowlist entries and trims", () => {
|
||||||
@@ -153,11 +174,14 @@ describe("resolveRemoteDiagnosticsRestore", () => {
|
|||||||
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
|
expect(r?.allowlist).toEqual(["10.0.0.5", "8.8.8.8"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns null for missing or empty/invalid sections", () => {
|
it("returns null for missing sections", () => {
|
||||||
expect(resolveRemoteDiagnosticsRestore(undefined)).toBeNull();
|
expect(resolveRemoteDiagnosticsRestore(undefined)).toBeNull();
|
||||||
expect(resolveRemoteDiagnosticsRestore(null)).toBeNull();
|
expect(resolveRemoteDiagnosticsRestore(null)).toBeNull();
|
||||||
expect(resolveRemoteDiagnosticsRestore("x")).toBeNull();
|
expect(resolveRemoteDiagnosticsRestore("x")).toBeNull();
|
||||||
expect(resolveRemoteDiagnosticsRestore({})).toBeNull();
|
});
|
||||||
|
|
||||||
|
it("migrates empty legacy sections to loopback", () => {
|
||||||
|
expect(resolveRemoteDiagnosticsRestore({})).toEqual({ host: "127.0.0.1", allowlist: [] });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -172,7 +196,7 @@ describe("backup remoteDiagnostics live restore round-trip", () => {
|
|||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||||
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt-secret`);
|
await waitForReady(`http://127.0.0.1:${startPort}/health`);
|
||||||
expect(getDebugAllowlist()).toEqual([]);
|
expect(getDebugAllowlist()).toEqual([]);
|
||||||
|
|
||||||
const payload = buildBackupPayload(input(
|
const payload = buildBackupPayload(input(
|
||||||
@@ -186,17 +210,17 @@ describe("backup remoteDiagnostics live restore round-trip", () => {
|
|||||||
const status = await restartDebugServer();
|
const status = await restartDebugServer();
|
||||||
|
|
||||||
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
|
expect(getDebugAllowlist()).toEqual(["203.0.113.4", "10.0.0.0/24"]);
|
||||||
expect(status.port).toBe(restorePort);
|
expect(status.port).toBe(startPort);
|
||||||
expect(status.host).toBe("0.0.0.0");
|
expect(status.host).toBe("127.0.0.1");
|
||||||
expect(status.allowlistCount).toBe(2);
|
expect(status.allowlistCount).toBe(2);
|
||||||
|
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret");
|
expect(fs.readFileSync(path.join(baseDir, "debug_token.txt"), "utf8").trim()).toBe("rt-secret");
|
||||||
expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
|
expect(fs.existsSync(path.join(baseDir, "debug_remote.json"))).toBe(false);
|
||||||
|
|
||||||
await waitForReady(`http://127.0.0.1:${restorePort}/health?token=rt-secret`);
|
await waitForReady(`http://127.0.0.1:${startPort}/health`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("full-backup path writes the debug_* files to disk without a restart (boot picks them up)", async () => {
|
it("full-backup path writes only safe debug files to disk without a restart", async () => {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote2-"));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-backup-remote2-"));
|
||||||
tempDirs.push(baseDir);
|
tempDirs.push(baseDir);
|
||||||
const startPort = await getFreePort();
|
const startPort = await getFreePort();
|
||||||
@@ -205,13 +229,13 @@ describe("backup remoteDiagnostics live restore round-trip", () => {
|
|||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||||
await waitForReady(`http://127.0.0.1:${startPort}/health?token=rt2`);
|
await waitForReady(`http://127.0.0.1:${startPort}/health`, "rt2");
|
||||||
|
|
||||||
const restore = resolveRemoteDiagnosticsRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
|
const restore = resolveRemoteDiagnosticsRestore({ allowlist: ["198.51.100.9"], port: 9100, hostMode: "network" });
|
||||||
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
writeDebugServerConfig({ host: restore!.host, port: restore!.port, allowlist: restore!.allowlist });
|
||||||
|
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("0.0.0.0");
|
expect(fs.readFileSync(path.join(baseDir, "debug_host.txt"), "utf8").trim()).toBe("127.0.0.1");
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe("9100");
|
expect(fs.readFileSync(path.join(baseDir, "debug_port.txt"), "utf8").trim()).toBe(String(startPort));
|
||||||
expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
|
expect(fs.readFileSync(path.join(baseDir, "debug_allowlist.txt"), "utf8")).toContain("198.51.100.9");
|
||||||
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
|
expect(getDebugServerRuntimeStatus().port).toBe(startPort);
|
||||||
});
|
});
|
||||||
@@ -227,9 +251,9 @@ describe("debug-server live diagnostics endpoints", () => {
|
|||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), "127.0.0.1", "utf8");
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), "", "utf8");
|
||||||
startDebugServer({} as unknown as DownloadManager, baseDir);
|
startDebugServer({} as unknown as DownloadManager, baseDir);
|
||||||
await waitForReady(`http://127.0.0.1:${port}/health?token=prov-secret`);
|
await waitForReady(`http://127.0.0.1:${port}/health`, "prov-secret");
|
||||||
|
|
||||||
const provRes = await fetch(`http://127.0.0.1:${port}/providers?token=prov-secret`);
|
const provRes = await fetch(`http://127.0.0.1:${port}/providers`, { headers: bearerHeaders("prov-secret") });
|
||||||
expect(provRes.status).toBe(200);
|
expect(provRes.status).toBe(200);
|
||||||
const prov = await provRes.json();
|
const prov = await provRes.json();
|
||||||
expect(typeof prov.capturedAtMs).toBe("number");
|
expect(typeof prov.capturedAtMs).toBe("number");
|
||||||
@@ -242,7 +266,7 @@ describe("debug-server live diagnostics endpoints", () => {
|
|||||||
const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
|
const unauth = await fetch(`http://127.0.0.1:${port}/providers`);
|
||||||
expect(unauth.status).toBe(401);
|
expect(unauth.status).toBe(401);
|
||||||
|
|
||||||
const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion?token=prov-secret`);
|
const convRes = await fetch(`http://127.0.0.1:${port}/logs/conversion`, { headers: bearerHeaders("prov-secret") });
|
||||||
expect(convRes.status).toBe(200);
|
expect(convRes.status).toBe(200);
|
||||||
const conv = await convRes.json();
|
const conv = await convRes.json();
|
||||||
expect(Array.isArray(conv.lines)).toBe(true);
|
expect(Array.isArray(conv.lines)).toBe(true);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ async function waitForReady(url: string): Promise<void> {
|
|||||||
const deadline = Date.now() + 5000;
|
const deadline = Date.now() + 5000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url);
|
const res = await fetch(url, { headers: bearerHeaders(TOKEN) });
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -47,18 +47,34 @@ async function waitForReady(url: string): Promise<void> {
|
|||||||
throw new Error(`debug server not ready: ${url}`);
|
throw new Error(`debug server not ready: ${url}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startWithAllowlist(allowlist: string[], host = "0.0.0.0"): Promise<{ baseUrl: string }> {
|
function bearerHeaders(token: string): HeadersInit {
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authedFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init.headers || {}),
|
||||||
|
...bearerHeaders(TOKEN)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWithAllowlist(allowlist: string[], host = "127.0.0.1", writeHost = true): Promise<{ baseUrl: string }> {
|
||||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-"));
|
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "rd-allow-"));
|
||||||
tempDirs.push(baseDir);
|
tempDirs.push(baseDir);
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), TOKEN, "utf8");
|
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_port.txt"), String(port), "utf8");
|
||||||
|
if (writeHost) {
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_host.txt"), host, "utf8");
|
||||||
|
}
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8");
|
fs.writeFileSync(path.join(baseDir, "debug_allowlist.txt"), allowlist.join("\n"), "utf8");
|
||||||
const manager = {} as unknown as DownloadManager;
|
const manager = {} as unknown as DownloadManager;
|
||||||
startDebugServer(manager, baseDir);
|
startDebugServer(manager, baseDir);
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
|
await waitForReady(`${baseUrl}/health`);
|
||||||
return { baseUrl };
|
return { baseUrl };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,11 +133,18 @@ describe("debug-server allowlist matcher (pure)", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("debug-server allowlist enforcement (wired)", () => {
|
describe("debug-server allowlist enforcement (wired)", () => {
|
||||||
|
it("binds to loopback when no host file exists", async () => {
|
||||||
|
await startWithAllowlist([], "0.0.0.0", false);
|
||||||
|
const status = getDebugServerRuntimeStatus();
|
||||||
|
expect(status.host).toBe("127.0.0.1");
|
||||||
|
expect(status.localOnly).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => {
|
it("allows a loopback connection and ignores a spoofed X-Forwarded-For", async () => {
|
||||||
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
|
const { baseUrl } = await startWithAllowlist(["8.8.8.8"]);
|
||||||
const plain = await fetch(`${baseUrl}/health?token=${TOKEN}`);
|
const plain = await authedFetch(`${baseUrl}/health`);
|
||||||
expect(plain.status).toBe(200);
|
expect(plain.status).toBe(200);
|
||||||
const spoofed = await fetch(`${baseUrl}/health?token=${TOKEN}`, {
|
const spoofed = await authedFetch(`${baseUrl}/health`, {
|
||||||
headers: { "X-Forwarded-For": "203.0.113.9" }
|
headers: { "X-Forwarded-For": "203.0.113.9" }
|
||||||
});
|
});
|
||||||
expect(spoofed.status).toBe(200);
|
expect(spoofed.status).toBe(200);
|
||||||
@@ -140,7 +163,7 @@ describe("debug-server allowlist enforcement (wired)", () => {
|
|||||||
const status = await restartDebugServer();
|
const status = await restartDebugServer();
|
||||||
expect(status.running).toBe(true);
|
expect(status.running).toBe(true);
|
||||||
expect(status.allowlistCount).toBe(2);
|
expect(status.allowlistCount).toBe(2);
|
||||||
await waitForReady(`${baseUrl}/health?token=${TOKEN}`);
|
await waitForReady(`${baseUrl}/health`);
|
||||||
expect((await fetch(`${baseUrl}/health?token=${TOKEN}`)).status).toBe(200);
|
expect((await authedFetch(`${baseUrl}/health`)).status).toBe(200);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+92
-27
@@ -98,7 +98,7 @@ async function waitForReady(url: string): Promise<void> {
|
|||||||
const deadline = Date.now() + 5000;
|
const deadline = Date.now() + 5000;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url);
|
const response = await fetch(url, { headers: bearerHeaders("debug-secret") });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -109,6 +109,20 @@ async function waitForReady(url: string): Promise<void> {
|
|||||||
throw new Error(`debug server not ready: ${url}`);
|
throw new Error(`debug server not ready: ${url}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bearerHeaders(token: string): HeadersInit {
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function authedFetch(url: string, token: string, init: RequestInit = {}): Promise<Response> {
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init.headers || {}),
|
||||||
|
...bearerHeaders(token)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildSnapshot(baseDir: string): UiSnapshot {
|
function buildSnapshot(baseDir: string): UiSnapshot {
|
||||||
const settings = {
|
const settings = {
|
||||||
...defaultSettings(),
|
...defaultSettings(),
|
||||||
@@ -227,7 +241,6 @@ async function createFixture() {
|
|||||||
|
|
||||||
fs.writeFileSync(path.join(baseDir, "debug_token.txt"), token, "utf8");
|
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_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 debridLinkApiKeys = "key-a\nkey-b";
|
||||||
const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys);
|
const debridLinkKeyIds = getDebridLinkApiKeyIds(debridLinkApiKeys);
|
||||||
|
|
||||||
@@ -315,7 +328,7 @@ async function createFixture() {
|
|||||||
|
|
||||||
startDebugServer(manager, baseDir);
|
startDebugServer(manager, baseDir);
|
||||||
const baseUrl = `http://127.0.0.1:${port}`;
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
await waitForReady(`${baseUrl}/health?token=${token}`);
|
await waitForReady(`${baseUrl}/health`);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -348,12 +361,14 @@ afterEach(() => {
|
|||||||
describe("debug-server", () => {
|
describe("debug-server", () => {
|
||||||
it("serves diagnostics with main, session, and package log tails", async () => {
|
it("serves diagnostics with main, session, and package log tails", async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
const response = await fetch(`${fixture.baseUrl}/diagnostics?token=${fixture.token}&package=server-package&lines=20`);
|
const response = await authedFetch(`${fixture.baseUrl}/diagnostics?package=server-package&lines=20`, fixture.token);
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
|
expect(response.headers.get("cache-control")).toContain("no-store");
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
const payload = await response.json() as Record<string, any>;
|
const payload = await response.json() as Record<string, any>;
|
||||||
|
|
||||||
expect(payload.meta?.appVersion).toBeTruthy();
|
expect(payload.meta?.appVersion).toBeTruthy();
|
||||||
expect(payload.meta?.debugServer?.host).toBe("0.0.0.0");
|
expect(payload.meta?.debugServer?.host).toBe("127.0.0.1");
|
||||||
expect(payload.status?.running).toBe(true);
|
expect(payload.status?.running).toBe(true);
|
||||||
expect(payload.host?.platform).toBe("win32");
|
expect(payload.host?.platform).toBe("win32");
|
||||||
expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41);
|
expect(payload.host?.recentKernelPower?.[0]?.id).toBe(41);
|
||||||
@@ -379,15 +394,17 @@ describe("debug-server", () => {
|
|||||||
expect(JSON.stringify(manifest)).not.toMatch(new RegExp(`\\b(?:${forbiddenSupportMarkers.join("|")})\\b`, "i"));
|
expect(JSON.stringify(manifest)).not.toMatch(new RegExp(`\\b(?:${forbiddenSupportMarkers.join("|")})\\b`, "i"));
|
||||||
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
|
expect(JSON.stringify(manifest)).not.toContain(fixture.token);
|
||||||
expect(manifest.debugServer?.port).toBeGreaterThan(0);
|
expect(manifest.debugServer?.port).toBeGreaterThan(0);
|
||||||
expect(manifest.debugServer?.remoteBaseUrlTemplate).toContain("<SERVER_IP_OR_DNS>");
|
expect(manifest.auth?.methods).toEqual(["Authorization: Bearer <token>"]);
|
||||||
expect(manifest.remoteAccessRequirements).toContain("A reachable server IP or DNS name.");
|
expect(JSON.stringify(manifest)).not.toContain("?token=");
|
||||||
|
expect(manifest.debugServer?.remoteBridgeBaseUrlTemplate).toContain("127.0.0.1");
|
||||||
|
expect(manifest.remoteAccessRequirements.join("\n")).toContain("local bridge");
|
||||||
expect(manifest.setupCheckEndpoint).toBe("/debug/setup");
|
expect(manifest.setupCheckEndpoint).toBe("/debug/setup");
|
||||||
expect(manifest.selfCheckEndpoint).toBe("/self-check");
|
expect(manifest.selfCheckEndpoint).toBe("/self-check");
|
||||||
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(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/logs/main")).toBe(true);
|
expect(manifest.endpoints?.some((entry: Record<string, any>) => entry.path === "/logs/main")).toBe(true);
|
||||||
|
|
||||||
const metaResponse = await fetch(`${fixture.baseUrl}/meta?token=${fixture.token}`);
|
const metaResponse = await authedFetch(`${fixture.baseUrl}/meta`, fixture.token);
|
||||||
expect(metaResponse.ok).toBe(true);
|
expect(metaResponse.ok).toBe(true);
|
||||||
const metaPayload = await metaResponse.json() as Record<string, any>;
|
const metaPayload = await metaResponse.json() as Record<string, any>;
|
||||||
expect(metaPayload.supportFiles?.supportManifest).toBe(manifestPath);
|
expect(metaPayload.supportFiles?.supportManifest).toBe(manifestPath);
|
||||||
@@ -401,7 +418,7 @@ describe("debug-server", () => {
|
|||||||
|
|
||||||
it("serves a debug setup check with trace expiry details", async () => {
|
it("serves a debug setup check with trace expiry details", async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
const response = await fetch(`${fixture.baseUrl}/debug/setup?token=${fixture.token}`);
|
const response = await authedFetch(`${fixture.baseUrl}/debug/setup`, fixture.token);
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
const payload = await response.json() as Record<string, any>;
|
const payload = await response.json() as Record<string, any>;
|
||||||
|
|
||||||
@@ -409,8 +426,8 @@ describe("debug-server", () => {
|
|||||||
expect(Array.isArray(payload.warnings)).toBe(true);
|
expect(Array.isArray(payload.warnings)).toBe(true);
|
||||||
expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok");
|
expect(payload.status).toBe(payload.warnings.length > 0 ? "warn" : "ok");
|
||||||
expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
|
expect(payload.runtimeBaseDir).toBe(fixture.baseDir);
|
||||||
expect(payload.host).toBe("0.0.0.0");
|
expect(payload.host).toBe("127.0.0.1");
|
||||||
expect(payload.localOnly).toBe(false);
|
expect(payload.localOnly).toBe(true);
|
||||||
expect(payload.tokenConfigured).toBe(true);
|
expect(payload.tokenConfigured).toBe(true);
|
||||||
expect(payload.supportManifestPresent).toBe(true);
|
expect(payload.supportManifestPresent).toBe(true);
|
||||||
expect(payload[`${legacyManifestField}Present`]).toBeUndefined();
|
expect(payload[`${legacyManifestField}Present`]).toBeUndefined();
|
||||||
@@ -425,13 +442,17 @@ describe("debug-server", () => {
|
|||||||
expect(payload.logSummary?.packageLogs?.fileCount).toBe(1);
|
expect(payload.logSummary?.packageLogs?.fileCount).toBe(1);
|
||||||
expect(payload.logSummary?.itemLogs?.fileCount).toBe(1);
|
expect(payload.logSummary?.itemLogs?.fileCount).toBe(1);
|
||||||
expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0);
|
expect(payload.supportBundle?.estimatedBytes).toBeGreaterThan(0);
|
||||||
expect(payload.remoteUrlTemplates?.health).toContain("<SERVER_IP_OR_DNS>");
|
expect(JSON.stringify(payload.localUrls)).not.toContain(fixture.token);
|
||||||
|
expect(JSON.stringify(payload.remoteUrlTemplates)).not.toContain(fixture.token);
|
||||||
|
expect(JSON.stringify(payload.localUrls)).not.toContain("?token=");
|
||||||
|
expect(JSON.stringify(payload.remoteUrlTemplates)).not.toContain("?token=");
|
||||||
|
expect(payload.remoteUrlTemplates?.health).toContain("127.0.0.1");
|
||||||
expect(Array.isArray(payload.notes)).toBe(true);
|
expect(Array.isArray(payload.notes)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves the self-check alias", async () => {
|
it("serves the self-check alias", async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
const response = await fetch(`${fixture.baseUrl}/self-check?token=${fixture.token}`);
|
const response = await authedFetch(`${fixture.baseUrl}/self-check`, fixture.token);
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
const payload = await response.json() as Record<string, any>;
|
const payload = await response.json() as Record<string, any>;
|
||||||
expect(Array.isArray(payload.warnings)).toBe(true);
|
expect(Array.isArray(payload.warnings)).toBe(true);
|
||||||
@@ -441,7 +462,7 @@ describe("debug-server", () => {
|
|||||||
|
|
||||||
it("writes the client IP into the debug trace log", async () => {
|
it("writes the client IP into the debug trace log", async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
const response = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`, {
|
const response = await authedFetch(`${fixture.baseUrl}/health`, fixture.token, {
|
||||||
headers: {
|
headers: {
|
||||||
"X-Forwarded-For": "203.0.113.46"
|
"X-Forwarded-For": "203.0.113.46"
|
||||||
}
|
}
|
||||||
@@ -458,13 +479,13 @@ describe("debug-server", () => {
|
|||||||
it("serves package details and package log by package query", async () => {
|
it("serves package details and package log by package query", async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
|
|
||||||
const packagesResponse = await fetch(`${fixture.baseUrl}/packages?token=${fixture.token}&package=server&includeItems=1`);
|
const packagesResponse = await authedFetch(`${fixture.baseUrl}/packages?package=server&includeItems=1`, fixture.token);
|
||||||
expect(packagesResponse.ok).toBe(true);
|
expect(packagesResponse.ok).toBe(true);
|
||||||
const packagesPayload = await packagesResponse.json() as Record<string, any>;
|
const packagesPayload = await packagesResponse.json() as Record<string, any>;
|
||||||
expect(packagesPayload.count).toBe(1);
|
expect(packagesPayload.count).toBe(1);
|
||||||
expect(packagesPayload.packages?.[0]?.items?.length).toBe(2);
|
expect(packagesPayload.packages?.[0]?.items?.length).toBe(2);
|
||||||
|
|
||||||
const logResponse = await fetch(`${fixture.baseUrl}/logs/package?token=${fixture.token}&package=server-package&lines=20`);
|
const logResponse = await authedFetch(`${fixture.baseUrl}/logs/package?package=server-package&lines=20`, fixture.token);
|
||||||
expect(logResponse.ok).toBe(true);
|
expect(logResponse.ok).toBe(true);
|
||||||
const logPayload = await logResponse.json() as Record<string, any>;
|
const logPayload = await logResponse.json() as Record<string, any>;
|
||||||
expect(logPayload.package?.name).toBe("server-package");
|
expect(logPayload.package?.name).toBe("server-package");
|
||||||
@@ -474,7 +495,7 @@ describe("debug-server", () => {
|
|||||||
it("serves item log by item query", async () => {
|
it("serves item log by item query", async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
|
|
||||||
const response = await fetch(`${fixture.baseUrl}/logs/item?token=${fixture.token}&item=episode.part2.rar&lines=20`);
|
const response = await authedFetch(`${fixture.baseUrl}/logs/item?item=episode.part2.rar&lines=20`, fixture.token);
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
const payload = await response.json() as Record<string, any>;
|
const payload = await response.json() as Record<string, any>;
|
||||||
expect(payload.item?.id).toBe("item-2");
|
expect(payload.item?.id).toBe("item-2");
|
||||||
@@ -484,7 +505,7 @@ describe("debug-server", () => {
|
|||||||
|
|
||||||
it("serves host diagnostics separately", async () => {
|
it("serves host diagnostics separately", async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
const response = await fetch(`${fixture.baseUrl}/host/diagnostics?token=${fixture.token}`);
|
const response = await authedFetch(`${fixture.baseUrl}/host/diagnostics`, fixture.token);
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
const payload = await response.json() as Record<string, any>;
|
const payload = await response.json() as Record<string, any>;
|
||||||
expect(payload.platform).toBe("win32");
|
expect(payload.platform).toBe("win32");
|
||||||
@@ -495,28 +516,30 @@ describe("debug-server", () => {
|
|||||||
it("serves audit log, settings, accounts, stats, and history", async () => {
|
it("serves audit log, settings, accounts, stats, and history", async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
|
|
||||||
const auditResponse = await fetch(`${fixture.baseUrl}/logs/audit?token=${fixture.token}&lines=20`);
|
const auditResponse = await authedFetch(`${fixture.baseUrl}/logs/audit?lines=20`, fixture.token);
|
||||||
expect(auditResponse.ok).toBe(true);
|
expect(auditResponse.ok).toBe(true);
|
||||||
const auditPayload = await auditResponse.json() as Record<string, any>;
|
const auditPayload = await auditResponse.json() as Record<string, any>;
|
||||||
expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE");
|
expect((auditPayload.lines || []).join("\n")).toContain("AUDIT-LINE");
|
||||||
|
|
||||||
const renameResponse = await fetch(`${fixture.baseUrl}/logs/rename?token=${fixture.token}&lines=20`);
|
const renameResponse = await authedFetch(`${fixture.baseUrl}/logs/rename?lines=20`, fixture.token);
|
||||||
expect(renameResponse.ok).toBe(true);
|
expect(renameResponse.ok).toBe(true);
|
||||||
const renamePayload = await renameResponse.json() as Record<string, any>;
|
const renamePayload = await renameResponse.json() as Record<string, any>;
|
||||||
expect((renamePayload.lines || []).join("\n")).toContain("RENAME-LINE");
|
expect((renamePayload.lines || []).join("\n")).toContain("RENAME-LINE");
|
||||||
|
|
||||||
const traceResponse = await fetch(`${fixture.baseUrl}/logs/trace?token=${fixture.token}&lines=50`);
|
const traceResponse = await authedFetch(`${fixture.baseUrl}/logs/trace?lines=50`, fixture.token);
|
||||||
expect(traceResponse.ok).toBe(true);
|
expect(traceResponse.ok).toBe(true);
|
||||||
const tracePayload = await traceResponse.json() as Record<string, any>;
|
const tracePayload = await traceResponse.json() as Record<string, any>;
|
||||||
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT");
|
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-EVENT");
|
||||||
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE");
|
expect((tracePayload.lines || []).join("\n")).toContain("TRACE-MAIN-LINE");
|
||||||
|
|
||||||
const traceConfigResponse = await fetch(`${fixture.baseUrl}/trace/config?token=${fixture.token}&enable=0¬e=test`);
|
const traceConfigGetResponse = await authedFetch(`${fixture.baseUrl}/trace/config?enable=0¬e=test`, fixture.token);
|
||||||
|
expect(traceConfigGetResponse.status).toBe(405);
|
||||||
|
const traceConfigResponse = await authedFetch(`${fixture.baseUrl}/trace/config?enable=0¬e=test`, fixture.token, { method: "POST" });
|
||||||
expect(traceConfigResponse.ok).toBe(true);
|
expect(traceConfigResponse.ok).toBe(true);
|
||||||
const traceConfigPayload = await traceConfigResponse.json() as Record<string, any>;
|
const traceConfigPayload = await traceConfigResponse.json() as Record<string, any>;
|
||||||
expect(traceConfigPayload.config?.enabled).toBe(false);
|
expect(traceConfigPayload.config?.enabled).toBe(false);
|
||||||
|
|
||||||
const settingsResponse = await fetch(`${fixture.baseUrl}/settings?token=${fixture.token}`);
|
const settingsResponse = await authedFetch(`${fixture.baseUrl}/settings`, fixture.token);
|
||||||
expect(settingsResponse.ok).toBe(true);
|
expect(settingsResponse.ok).toBe(true);
|
||||||
const settingsPayload = await settingsResponse.json() as Record<string, any>;
|
const settingsPayload = await settingsResponse.json() as Record<string, any>;
|
||||||
expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true);
|
expect(settingsPayload.accounts?.realDebrid?.configured).toBe(true);
|
||||||
@@ -525,19 +548,19 @@ describe("debug-server", () => {
|
|||||||
expect(JSON.stringify(settingsPayload)).not.toContain("key-a");
|
expect(JSON.stringify(settingsPayload)).not.toContain("key-a");
|
||||||
expect(JSON.stringify(settingsPayload)).not.toContain("key-b");
|
expect(JSON.stringify(settingsPayload)).not.toContain("key-b");
|
||||||
|
|
||||||
const accountsResponse = await fetch(`${fixture.baseUrl}/accounts?token=${fixture.token}`);
|
const accountsResponse = await authedFetch(`${fixture.baseUrl}/accounts`, fixture.token);
|
||||||
expect(accountsResponse.ok).toBe(true);
|
expect(accountsResponse.ok).toBe(true);
|
||||||
const accountsPayload = await accountsResponse.json() as Record<string, any>;
|
const accountsPayload = await accountsResponse.json() as Record<string, any>;
|
||||||
expect(accountsPayload.debridLink?.keyCount).toBe(2);
|
expect(accountsPayload.debridLink?.keyCount).toBe(2);
|
||||||
expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1);
|
expect(accountsPayload.debridLink?.disabledKeyCount).toBe(1);
|
||||||
|
|
||||||
const statsResponse = await fetch(`${fixture.baseUrl}/stats?token=${fixture.token}`);
|
const statsResponse = await authedFetch(`${fixture.baseUrl}/stats`, fixture.token);
|
||||||
expect(statsResponse.ok).toBe(true);
|
expect(statsResponse.ok).toBe(true);
|
||||||
const statsPayload = await statsResponse.json() as Record<string, any>;
|
const statsPayload = await statsResponse.json() as Record<string, any>;
|
||||||
expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0);
|
expect(statsPayload.session?.totalDownloaded).toBeGreaterThan(0);
|
||||||
expect(statsPayload.allTime?.totalDownloadedAllTime).toBeGreaterThan(0);
|
expect(statsPayload.allTime?.totalDownloadedAllTime).toBeGreaterThan(0);
|
||||||
|
|
||||||
const historyResponse = await fetch(`${fixture.baseUrl}/history?token=${fixture.token}&limit=10`);
|
const historyResponse = await authedFetch(`${fixture.baseUrl}/history?limit=10`, fixture.token);
|
||||||
expect(historyResponse.ok).toBe(true);
|
expect(historyResponse.ok).toBe(true);
|
||||||
const historyPayload = await historyResponse.json() as Record<string, any>;
|
const historyPayload = await historyResponse.json() as Record<string, any>;
|
||||||
expect(historyPayload.total).toBe(1);
|
expect(historyPayload.total).toBe(1);
|
||||||
@@ -548,9 +571,11 @@ describe("debug-server", () => {
|
|||||||
it("downloads a support bundle zip", async () => {
|
it("downloads a support bundle zip", async () => {
|
||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
fs.writeFileSync(path.join(fixture.baseDir, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
|
fs.writeFileSync(path.join(fixture.baseDir, legacyManifestFile), JSON.stringify({ purpose: "legacy" }), "utf8");
|
||||||
const response = await fetch(`${fixture.baseUrl}/support/bundle?token=${fixture.token}`);
|
const response = await authedFetch(`${fixture.baseUrl}/support/bundle`, fixture.token);
|
||||||
expect(response.ok).toBe(true);
|
expect(response.ok).toBe(true);
|
||||||
expect(response.headers.get("content-type")).toContain("application/zip");
|
expect(response.headers.get("content-type")).toContain("application/zip");
|
||||||
|
expect(response.headers.get("cache-control")).toContain("no-store");
|
||||||
|
expect(response.headers.get("access-control-allow-origin")).toBeNull();
|
||||||
|
|
||||||
const buffer = Buffer.from(await response.arrayBuffer());
|
const buffer = Buffer.from(await response.arrayBuffer());
|
||||||
const zip = new AdmZip(buffer);
|
const zip = new AdmZip(buffer);
|
||||||
@@ -574,5 +599,45 @@ describe("debug-server", () => {
|
|||||||
const fixture = await createFixture();
|
const fixture = await createFixture();
|
||||||
const response = await fetch(`${fixture.baseUrl}/status`);
|
const response = await fetch(`${fixture.baseUrl}/status`);
|
||||||
expect(response.status).toBe(401);
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.headers.get("www-authenticate")).toContain("Bearer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts bearer auth and rejects query tokens", async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const bearer = await authedFetch(`${fixture.baseUrl}/health`, fixture.token);
|
||||||
|
expect(bearer.status).toBe(200);
|
||||||
|
|
||||||
|
const queryOnly = await fetch(`${fixture.baseUrl}/health?token=${fixture.token}`);
|
||||||
|
expect(queryOnly.status).toBe(400);
|
||||||
|
const queryOnlyPayload = await queryOnly.json() as Record<string, any>;
|
||||||
|
expect(queryOnlyPayload.code).toBe("query_token_rejected");
|
||||||
|
|
||||||
|
const mixed = await authedFetch(`${fixture.baseUrl}/health?token=bad-fixture-token`, fixture.token);
|
||||||
|
expect(mixed.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unsupported methods with a controlled method response", async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
const response = await authedFetch(`${fixture.baseUrl}/status`, fixture.token, { method: "PUT" });
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toContain("GET");
|
||||||
|
const payload = await response.json() as Record<string, any>;
|
||||||
|
expect(payload.code).toBe("method_not_allowed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits repeated loopback requests with a controlled response", async () => {
|
||||||
|
const fixture = await createFixture();
|
||||||
|
let limited: Response | null = null;
|
||||||
|
for (let i = 0; i < 130; i += 1) {
|
||||||
|
const response = await authedFetch(`${fixture.baseUrl}/health`, fixture.token);
|
||||||
|
if (response.status === 429) {
|
||||||
|
limited = response;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(limited).not.toBeNull();
|
||||||
|
expect(limited!.headers.get("retry-after")).toBeTruthy();
|
||||||
|
const payload = await limited!.json() as Record<string, any>;
|
||||||
|
expect(payload.code).toBe("rate_limited");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user