feat: erzwinge festen Proxy für API-Anfragen

This commit is contained in:
Sucukdeluxe
2026-08-31 13:20:03 +02:00
parent 17ac99d373
commit a562f9162a
23 changed files with 530 additions and 64 deletions
+156
View File
@@ -0,0 +1,156 @@
import fs from "node:fs";
import http from "node:http";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { defaultSettings } from "../src/main/constants";
import {
configureElectronProxySession,
configureNetworkProxy,
getProxyAuthentication,
shutdownNetworkProxy
} from "../src/main/network-proxy";
interface RunningServer {
port: number;
close: () => Promise<void>;
}
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
await shutdownNetworkProxy();
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
});
async function listen(server: http.Server): Promise<RunningServer> {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Testserver konnte nicht gestartet werden");
const sockets = new Set<net.Socket>();
server.on("connection", (socket) => {
sockets.add(socket);
socket.once("close", () => sockets.delete(socket));
});
const close = async (): Promise<void> => {
for (const socket of sockets) socket.destroy();
await new Promise<void>((resolve) => server.close(() => resolve()));
};
cleanups.push(close);
return { port: address.port, close };
}
async function createConnectProxy(username: string, password: string, acceptedConnections: { value: number }): Promise<RunningServer> {
const server = http.createServer();
server.on("connect", (request, clientSocket, head) => {
const expectedAuth = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`;
if (request.headers["proxy-authorization"] !== expectedAuth) {
clientSocket.end("HTTP/1.1 407 Proxy Authentication Required\r\n\r\n");
return;
}
const target = new URL(`http://${request.url}`);
const upstream = net.connect(Number(target.port), target.hostname, () => {
acceptedConnections.value += 1;
clientSocket.write("HTTP/1.1 200 Connection Established\r\n\r\n");
if (head.length > 0) upstream.write(head);
clientSocket.pipe(upstream);
upstream.pipe(clientSocket);
});
upstream.once("error", () => clientSocket.destroy());
clientSocket.once("error", () => upstream.destroy());
});
return listen(server);
}
async function createTempDirectory(): Promise<string> {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "mdd-network-proxy-test-"));
cleanups.push(() => fs.promises.rm(directory, { recursive: true, force: true }));
return directory;
}
describe("proxy-only network routing", () => {
it("routes global API fetches only through the selected fixed proxy", async () => {
let targetRequests = 0;
const target = await listen(http.createServer((_request, response) => {
targetRequests += 1;
response.writeHead(200, { "Content-Type": "text/plain" });
response.end("proxied");
}));
const counters = [{ value: 0 }, { value: 0 }];
const proxies = await Promise.all([
createConnectProxy("first", "secret-one", counters[0]),
createConnectProxy("second", "secret-two", counters[1])
]);
const directory = await createTempDirectory();
const proxyFile = path.join(directory, "proxies.txt");
await fs.promises.writeFile(proxyFile, [
`first:secret-one@127.0.0.1:${proxies[0].port}`,
`second:secret-two@127.0.0.1:${proxies[1].port}`
].join("\n"));
expect(configureNetworkProxy({
...defaultSettings(),
proxyDownloadEnabled: true,
proxyListPath: proxyFile,
proxyApiProxyIndex: 2
})).toEqual({ status: "active", selectedIndex: 2, proxyCount: 2 });
const response = await fetch(`http://127.0.0.1:${target.port}/api`);
expect(await response.text()).toBe("proxied");
expect(targetRequests).toBe(1);
expect(counters[0].value).toBe(0);
expect(counters[1].value).toBe(1);
});
it("fails closed when the configured proxy list cannot be loaded", async () => {
let targetRequests = 0;
const target = await listen(http.createServer((_request, response) => {
targetRequests += 1;
response.end("direct-leak");
}));
expect(configureNetworkProxy({
...defaultSettings(),
proxyDownloadEnabled: true,
proxyListPath: path.join(os.tmpdir(), `missing-${Date.now()}.txt`),
proxyApiProxyIndex: 1
})).toEqual({ status: "blocked", reason: "proxy_file_unavailable" });
await expect(fetch(`http://127.0.0.1:${target.port}/must-not-connect`)).rejects.toThrow();
expect(targetRequests).toBe(0);
});
it("configures Electron sessions without embedding credentials and supplies matching proxy authentication", async () => {
const directory = await createTempDirectory();
const proxyFile = path.join(directory, "proxies.txt");
await fs.promises.writeFile(proxyFile, "api-user:api-password@proxy.example:3128");
configureNetworkProxy({
...defaultSettings(),
proxyDownloadEnabled: true,
proxyListPath: proxyFile,
proxyApiProxyIndex: 1
});
const calls: unknown[] = [];
let closedConnections = 0;
const fakeSession = {
setProxy: async (rules: unknown) => { calls.push(rules); },
closeAllConnections: async () => { closedConnections += 1; }
};
await configureElectronProxySession(fakeSession as never);
expect(calls).toEqual([{ mode: "fixed_servers", proxyRules: "http://proxy.example:3128" }]);
expect(JSON.stringify(calls)).not.toContain("api-password");
expect(closedConnections).toBe(1);
expect(getProxyAuthentication({ isProxy: true, host: "proxy.example", port: 3128 })).toEqual({
username: "api-user",
password: "api-password"
});
expect(getProxyAuthentication({ isProxy: false, host: "proxy.example", port: 3128 })).toBeNull();
expect(getProxyAuthentication({ isProxy: true, host: "other.example", port: 3128 })).toBeNull();
});
});
+22 -1
View File
@@ -4,7 +4,7 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { downloadWithProxySegments, parseProxyList } from "../src/main/proxy-segmented-download";
import { downloadWithProxySegments, parseProxyList, selectFixedProxy } from "../src/main/proxy-segmented-download";
interface RunningServer {
port: number;
@@ -117,6 +117,27 @@ describe("proxy segmented download", () => {
expect(String(count)).not.toContain(secret);
});
it("selects one fixed valid proxy by its 1-based list index", async () => {
const directory = await createTempDirectory();
const proxyFile = path.join(directory, "fixed-proxies.txt");
await fs.promises.writeFile(proxyFile, [
"invalid",
"first:secret-one@127.0.0.1:8080",
"second:secret-two@127.0.0.1:9090"
].join("\n"));
const selected = selectFixedProxy(proxyFile, 2);
expect(selected.status).toBe("ok");
if (selected.status !== "ok") throw new Error("Proxy wurde nicht ausgewählt");
expect(selected.selectedIndex).toBe(2);
expect(selected.proxyCount).toBe(2);
expect(selected.proxy.url).toBe("http://127.0.0.1:9090/");
expect(selected.proxy.username).toBe("second");
expect(selected.proxy.password).toBe("secret-two");
expect(selected.proxy.url).not.toContain("secret-two");
});
it("downloads exact byte segments through different authenticated proxies", async () => {
const content = Buffer.allocUnsafe(256 * 1024);
for (let index = 0; index < content.length; index += 1) content[index] = index % 251;
+2
View File
@@ -480,6 +480,7 @@ describe("realdebrid-web", () => {
"persist:realdebrid-web-rdw_second"
]);
mockFromPartition.mockClear();
await first.clearSessions();
expect(mockFromPartition).toHaveBeenCalledWith("persist:realdebrid-web-rdw_first");
@@ -691,6 +692,7 @@ describe("realdebrid-web", () => {
await controller.openRealDebridLoginWindow({ accountId: "rdw_close_auth", create: true, dailyLimitBytes: 123_456 });
await vi.waitFor(() => expect(mockExecuteJavaScript).toHaveBeenCalledTimes(1));
mockFromPartition.mockClear();
mockBrowserWindows[0]?.close();
resolveClosingToken("close-time-token");
await vi.waitFor(() => expect(controller.settings.realDebridWebAccountIds).toEqual(["rdw_close_auth"]));
+9 -1
View File
@@ -650,6 +650,7 @@ describe("settings views", () => {
maxParallel: 5,
proxyDownloadEnabled: true,
proxyListPath: "C:\\proxy-list.txt",
proxyApiProxyIndex: 7,
proxyConnectionsPerDownload: 16
}),
archivePasswordList: "",
@@ -664,6 +665,7 @@ describe("settings views", () => {
expect(proxyGroup?.fields.map((field) => field.id)).toEqual([
"proxyDownloadEnabled",
"proxyListPath",
"proxyApiProxyIndex",
"proxyConnectionsPerDownload"
]);
expect(proxyGroup?.fields.find((field) => field.id === "proxyListPath")).toEqual(expect.objectContaining({
@@ -671,11 +673,17 @@ describe("settings views", () => {
actionLabel: "Datei wählen",
disabled: false
}));
expect(proxyGroup?.fields.find((field) => field.id === "proxyApiProxyIndex")).toEqual(expect.objectContaining({
value: "7",
min: 1,
max: 100000,
disabled: false
}));
expect(proxyGroup?.fields.find((field) => field.id === "proxyConnectionsPerDownload")).toEqual(expect.objectContaining({
value: "16",
min: 2,
max: 32,
help: expect.stringContaining("80 Proxy-Verbindungen")
help: expect.stringContaining("80 parallele Segmentverbindungen")
}));
});
+4
View File
@@ -584,6 +584,7 @@ describe("settings storage", () => {
speedLimitKbps: -1,
proxyDownloadEnabled: true,
proxyListPath: " C:\\proxies.txt ",
proxyApiProxyIndex: 999999,
proxyConnectionsPerDownload: 999,
outputDir: " ",
extractDir: " ",
@@ -604,6 +605,7 @@ describe("settings storage", () => {
expect(normalized.speedLimitKbps).toBe(0);
expect(normalized.proxyDownloadEnabled).toBe(true);
expect(normalized.proxyListPath).toBe("C:\\proxies.txt");
expect(normalized.proxyApiProxyIndex).toBe(100000);
expect(normalized.proxyConnectionsPerDownload).toBe(32);
expect(normalized.outputDir).toBe(defaultSettings().outputDir);
expect(normalized.extractDir).toBe(defaultSettings().extractDir);
@@ -639,6 +641,7 @@ describe("settings storage", () => {
speedLimitMode: "not-valid",
proxyDownloadEnabled: 1,
proxyListPath: " C:\\proxy-list.txt ",
proxyApiProxyIndex: "0",
proxyConnectionsPerDownload: "1",
updateRepo: "",
autoSortPackagesByProgress: false
@@ -655,6 +658,7 @@ describe("settings storage", () => {
expect(loaded.speedLimitMode).toBe("global");
expect(loaded.proxyDownloadEnabled).toBe(true);
expect(loaded.proxyListPath).toBe("C:\\proxy-list.txt");
expect(loaded.proxyApiProxyIndex).toBe(1);
expect(loaded.proxyConnectionsPerDownload).toBe(2);
expect(loaded.updateRepo).toBe(defaultSettings().updateRepo);
expect(loaded.autoSortPackagesByProgress).toBe(false);
+1
View File
@@ -119,6 +119,7 @@ function createSettings(): AppSettings {
speedLimitMode: "global",
proxyDownloadEnabled: false,
proxyListPath: "",
proxyApiProxyIndex: 1,
proxyConnectionsPerDownload: 16,
updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader",
autoUpdateCheck: true,