feat: erzwinge festen Proxy für API-Anfragen
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]));
|
||||
|
||||
@@ -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")
|
||||
}));
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -119,6 +119,7 @@ function createSettings(): AppSettings {
|
||||
speedLimitMode: "global",
|
||||
proxyDownloadEnabled: false,
|
||||
proxyListPath: "",
|
||||
proxyApiProxyIndex: 1,
|
||||
proxyConnectionsPerDownload: 16,
|
||||
updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader",
|
||||
autoUpdateCheck: true,
|
||||
|
||||
Reference in New Issue
Block a user