From a562f9162ab56ca9bd031998f2ca208906c1bd42 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe Date: Mon, 31 Aug 2026 13:20:03 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20erzwinge=20festen=20Proxy=20f=C3=BCr=20?= =?UTF-8?q?API-Anfragen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_MEMORY.md | 21 ++- package-lock.json | 18 +- package.json | 9 +- src/main/all-debrid-web.ts | 22 ++- src/main/app-controller.ts | 17 ++ src/main/bestdebrid-web.ts | 15 +- src/main/constants.ts | 1 + src/main/download-manager.ts | 6 +- src/main/main.ts | 10 +- src/main/network-proxy.ts | 165 ++++++++++++++++++ src/main/proxy-segmented-download.ts | 74 ++++++-- src/main/realdebrid-web.ts | 14 +- src/main/renderer-state.ts | 1 + src/main/storage.ts | 1 + src/renderer/App.tsx | 3 +- src/renderer/views/settings/settings-model.ts | 20 ++- src/shared/types.ts | 1 + tests/network-proxy.test.ts | 156 +++++++++++++++++ tests/proxy-segmented-download.test.ts | 23 ++- tests/realdebrid-web.test.ts | 2 + tests/settings-view.test.tsx | 10 +- tests/storage.test.ts | 4 + tests/visual/fixtures.ts | 1 + 23 files changed, 530 insertions(+), 64 deletions(-) create mode 100644 src/main/network-proxy.ts create mode 100644 tests/network-proxy.test.ts diff --git a/PROJECT_MEMORY.md b/PROJECT_MEMORY.md index a0b9a8b..d04ae8f 100644 --- a/PROJECT_MEMORY.md +++ b/PROJECT_MEMORY.md @@ -67,7 +67,7 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält - Provider-Reihenfolge, Hoster-Overrides, Kontostatus, Tageslimits, Cooldowns und optionale Fallbacks bestimmen das Routing. - Real-Debrid und Debrid-Link unterstützen Kontopools; Auswahl und Rotation berücksichtigen Aktivität, Fairness, Limits und Fehlerklassen. - Item-Pipeline: Vorprüfung und Recovery, Provider-Auswahl, Unrestrict mit Timeout, sichere Zielpfadreservierung, Speicherplatzreservierung, HTTP-Download mit Range-Resume, optionale Integritätsprüfung, Abschluss und Paket-Postprocessing. -- Optionaler Proxy-Segmentdownload teilt neue Dateien ab 8 MiB in 2 bis 32 exakte HTTP-Range-Bereiche und lädt sie parallel über verschiedene authentifizierte HTTP-CONNECT-Proxys. Bestehende Teil-Dateien und aktive Geschwindigkeitslimits bleiben beim bisherigen Direktdownload; bei fehlender Range-Unterstützung, ungültiger Liste oder Proxy-/Segmentfehlern wird automatisch auf diesen Pfad zurückgefallen. +- Der optionale Proxy-only-Modus leitet sämtliche Main-Prozess-API-, Link-Auflösungs- und normalen Download-Requests über einen fest ausgewählten authentifizierten HTTP-CONNECT-Proxy. Electron-Web-Logins und deren Session-Fetches erhalten denselben festen Proxy. Neue Dateien ab 8 MiB können zusätzlich in 2 bis 32 exakten HTTP-Range-Bereichen parallel über die gesamte Proxy-Liste geladen werden. - Download-Retries unterscheiden Netzwerk-, Range-, Hoster-, Provider-, Konto-, Quota-, Disk- und permanente Linkfehler. Stop, Pause, Shutdown und Neustart besitzen getrennte Park- und Abbruchpfade. - Die Queue priorisiert hoch vor normal vor niedrig, beachtet globale und providerbezogene Parallelitätsgrenzen und schützt sich mit Scheduler-Generation, Heartbeat und Stall-Watchdogs gegen veraltete Tasks. @@ -99,11 +99,14 @@ Diese Datei hält den verifizierten technischen Arbeitsstand fest. Sie enthält ## Aktuelle unveröffentlichte Änderung -- Unter „Geschwindigkeit“ kann der Proxy-Segmentdownload aktiviert, eine externe Proxy-Textdatei ausgewählt und die Zahl der Verbindungen pro Download zwischen 2 und 32 festgelegt werden. Bei fünf parallelen Downloads und 16 Segmenten sind damit bis zu 80 Proxy-Verbindungen möglich; `maxParallel` bleibt die vorhandene globale Downloadgrenze. +- Unter „Geschwindigkeit“ kann Proxy-only aktiviert, eine externe Proxy-Textdatei ausgewählt, ein fester API-Proxy über den 1-basierten gültigen Listeneintrag festgelegt und die Zahl der Segmentverbindungen pro Download zwischen 2 und 32 gewählt werden. Bei fünf parallelen Downloads und 16 Segmenten sind damit bis zu 80 Segmentverbindungen möglich; `maxParallel` bleibt die vorhandene globale Downloadgrenze. +- Der feste Proxy übernimmt API-Aufrufe, Account-Prüfungen, Updates, Benachrichtigungen, Link-Auflösung, Web-Logins und alle Einzel-Proxy-Downloads. Ist die Liste nicht lesbar, leer oder der ausgewählte Eintrag nicht vorhanden, blockiert ein Fail-closed-Dispatcher sämtliche globalen Requests. Ist der feste Proxy offline, schlägt die Anfrage fehl; er wird nicht automatisch ersetzt und es gibt bei aktivem Modus keinen direkten Rückfall über die echte Verbindung. +- Electron-Provider-Sitzungen werden vor Login, Navigation und Session-Fetch auf denselben festen Proxy gesetzt. Authentifizierungsdaten werden ausschließlich beim passenden Proxy-Authentifizierungsereignis geliefert. Bereits bekannte Sitzungen werden bei einer Einstellungsänderung neu konfiguriert und bestehende Verbindungen geschlossen. - Unterstützt werden insbesondere `Benutzer:Passwort@Host:Port`, Proxy-URLs, `Host:Port` und `Host:Port:Benutzer:Passwort`. Die Liste wird nur im Main-Prozess gelesen und anhand von Dateigröße und Änderungszeit gecacht. Proxy-Zugangsdaten werden nicht protokolliert, nicht an den Renderer übertragen und nicht in Git übernommen; lediglich der lokale Listenpfad ist eine normale Einstellung. - Jeder Proxy-Download prüft zuerst `Range: bytes=0-1`, übernimmt die autoritative HTTP-Gesamtgröße, reserviert eine zielnahe temporäre Datei und schreibt jedes Segment positionsgenau. Die Zwei-Byte-Probe umgeht ein reproduziertes Real-Debrid-Verhalten, bei dem nur `bytes=0-0` trotz 206-Header die gesamte Datei streamt. Segmente versuchen bei Verbindungs-, Authentifizierungs-, Timeout- oder Range-Fehlern andere Proxys. Erst nach vollständiger Größenprüfung und `datasync` wird die Datei atomar zum Ziel umbenannt. -- Abbruch und Segmentfehler beenden alle zugehörigen Tunnel, entfernen die temporäre Datei und rechnen nur eindeutigen Segmentfortschritt zurück. Tatsächlich empfangener Providerverkehr bleibt für Statistik und Geschwindigkeitsmessung erhalten. Ein fehlgeschlagener Proxy-Versuch startet anschließend den unveränderten Direktdownload neu. -- Der bestehende Range-Resume-Pfad für Teil-Dateien wurde nicht ersetzt. Ein aktives Geschwindigkeitslimit überspringt die Proxy-Segmentierung, damit das konfigurierte Limit nicht umgangen wird. +- Abbruch und Segmentfehler beenden alle zugehörigen Tunnel, entfernen die temporäre Datei und rechnen nur eindeutigen Segmentfortschritt zurück. Tatsächlich empfangener Providerverkehr bleibt für Statistik und Geschwindigkeitsmessung erhalten. Ein fehlgeschlagener Segmentversuch wechselt anschließend auf den bestehenden Einzel-Downloadpfad, der bei Proxy-only weiterhin zwingend den festen Proxy verwendet. +- Der bestehende Range-Resume-Pfad für Teil-Dateien wurde nicht ersetzt. Ein aktives Geschwindigkeitslimit überspringt die Proxy-Segmentierung, damit das konfigurierte Limit nicht umgangen wird; auch dieser Einzel-Download läuft im Proxy-only-Modus über den festen Proxy. +- Für den globalen Node-Dispatcher wird `undici` exakt in Version `6.28.0` verwendet. Der Segment-Downloader behält `https-proxy-agent` für seine voneinander unabhängigen Tunnel. Der Produktions-Audit meldet für die neue direkte Undici-Abhängigkeit keine offenen Advisories. - Ein Start-/Beenden-Fehler nach Real-Debrid-Web-Downloads wurde behoben. Der persistente unsichtbare Web-Generator blieb nach dem Schließen des Hauptfensters geöffnet, verhinderte dadurch `window-all-closed` und hielt den Single-Instance-Lock. Weitere Starts wurden als Zweitinstanz sofort beendet, während der alte `second-instance`-Handler ohne Hauptfenster nichts tat. - Das Hauptfenster ist unter Windows nun ausdrücklich Besitzer des App-Lebenszyklus: Nach einem natürlichen, vom Renderer bestätigten Schließen wird der kontrollierte Shutdown auch bei verbleibenden Providerfenstern gestartet. Der normale `beforeunload`-Pfad bleibt erhalten, damit noch nicht übertragene Linksammler-Änderungen synchron gesichert werden können. - `second-instance` und `activate` stellen ein vorhandenes Hauptfenster wieder her oder erzeugen ein fehlendes neu. Ein Start während eines bereits laufenden Shutdowns plant genau einen Relaunch nach dem Prozessende ein. @@ -149,12 +152,13 @@ npm exec -- tsc --noEmit ## Verifizierungen vom 31. August 2026 - Proxy-Parser gegen die bereitgestellte externe Liste geprüft: 1.000 von 1.000 Einträgen gültig; dabei wurden weder Proxy-Verbindungen aufgebaut noch Listeneinträge ausgegeben. -- Proxy-Segmenttests: 5 von 5 erfolgreich. Abgedeckt sind vier parallele authentifizierte CONNECT-Proxys mit exakten Byte-Bereichen und identischem Dateiergebnis, Ersatz eines abgelehnten Proxys, Direkt-Fallback ohne Restdatei bei ignoriertem Range-Header sowie Abbruch mit Fortschritts-Rückrechnung und Temp-Bereinigung. +- Proxy-Segmenttests: 6 von 6 erfolgreich. Abgedeckt sind die feste Auswahl nach gültigem 1-basiertem Listeneintrag, vier parallele authentifizierte CONNECT-Proxys mit exakten Byte-Bereichen und identischem Dateiergebnis, Ersatz eines abgelehnten Segment-Proxys, sauberer Einzel-Proxy-Fallback ohne Restdatei bei ignoriertem Range-Header sowie Abbruch mit Fortschritts-Rückrechnung und Temp-Bereinigung. +- Proxy-only-Routingtests: 3 von 3 erfolgreich. Ein normaler globaler API-`fetch` verwendete ausschließlich den ausgewählten zweiten authentifizierten CONNECT-Proxy; der erste Proxy blieb unbenutzt. Bei fehlender Liste wurde die Zieladresse nicht kontaktiert. Electron-Regeln enthielten keine Zugangsdaten, während die Authentifizierung nur für exakt passenden Proxy-Host und -Port bereitgestellt wurde. - Reale Proxy-Prüfung mit einer 930.376.313 Byte großen Real-Debrid-RAR-Datei: 16 Verbindungen, vollständiger Segmenttransfer in rund 11,7 Sekunden mit 76,09 MiB/s mittlerem Nutzdurchsatz und zwischenzeitlich rund 94 MiB/s; Gesamtlauf einschließlich `datasync` und SHA-256 dauerte 13,19 Sekunden. Der zusätzliche Prüfverkehr betrug exakt 2 Byte. - Die reale Testdatei besitzt SHA-256 `f60f70a03cc49f544aab97d66254cb39801d7bbad827d1db935ef8b25c8b544f`, eine gültige RAR-Signatur und ein fehlerfrei lesbares Inhaltsverzeichnis. 32 verteilte 64-Byte-Stichproben an Anfang und Ende aller 16 Segmente stimmten mit frischen Range-Antworten über 32 verschiedene Listeneinträge überein. - Settings-/Storage-Regressionen decken Aktivierung, Pfad, Wertebereich 2 bis 32, Dateiauswahl und die Darstellung von 80 möglichen Tunneln bei 5 × 16 ab. -- Vollständiger Client-Lauf nach der Proxy-Änderung: 139 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.668 Tests erfolgreich und 4 übersprungen. Nur die zwei bekannten Symlink-Fixtures in `public-release-metadata.test.ts` scheiterten vor ihrer Produktassertion mit Windows-`EPERM`. -- Finaler Wiederholungslauf ohne die zwei privilegierten Symlink-Fixtures: 139 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.646 Tests erfolgreich und 4 übersprungen. +- Vollständiger Client-Lauf nach Proxy-only: 140 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.672 Tests erfolgreich und 4 übersprungen. Nur die zwei bekannten Symlink-Fixtures in `public-release-metadata.test.ts` scheiterten vor ihrer Produktassertion mit Windows-`EPERM`. +- Finaler Wiederholungslauf ohne die nicht ausführbare Symlink-Testdatei: 140 Testdateien erfolgreich, 1 JVM-Testdatei übersprungen; 2.650 Tests erfolgreich und 4 übersprungen. - Nach der Proxy-Änderung erfolgreich: TypeScript, Main-Build, Renderer-Build und Self-Check. Die bekannte Vite-Warnung zum nun rund 575 KiB großen Renderer-Chunk bleibt bestehen. - Root-Installation mit `npm ci`: erfolgreich. - Backup-API-Installation mit `npm ci --prefix services/backup-api`: erfolgreich. @@ -185,7 +189,8 @@ npm exec -- tsc --noEmit - `npm audit` meldet aktuell 36 Treffer: 2 niedrig, 5 mittel, 25 hoch und 4 kritisch. Direkte Treffer betreffen unter anderem Electron 31.7.7, Vite 6.4.1, Vitest 2.1.9, concurrently 9.2.1 und electron-builder 25.1.8. Ein Upgrade braucht einen eigenen getesteten Migrationsschritt; kein automatisches `npm audit fix` ausführen. - Der separate Backup-API-Baum hat aktuell 0 Audit-Treffer. - Windows ohne Developer Mode beziehungsweise passende Berechtigung kann die zwei Symlink-basierten Public-Release-Metadaten-Tests nicht ausführen. -- Proxy-Bündelung garantiert keine lineare Addition der Einzelgeschwindigkeiten. Ergebnis und Stabilität hängen unter anderem von Proxy-Latenz, Proxy-Bandbreite, Provider-/CDN-IP-Bindung, Range-Unterstützung, Zielserver-Limits, Dateigröße und lokaler Schreibgeschwindigkeit ab. Nach einem späten Segmentfehler kann vor dem Direkt-Fallback zusätzlicher Providerverkehr angefallen sein. +- Proxy-Bündelung garantiert keine lineare Addition der Einzelgeschwindigkeiten. Ergebnis und Stabilität hängen unter anderem von Proxy-Latenz, Proxy-Bandbreite, Provider-/CDN-IP-Bindung, Range-Unterstützung, Zielserver-Limits, Dateigröße und lokaler Schreibgeschwindigkeit ab. Nach einem späten Segmentfehler kann vor dem festen Einzel-Proxy-Fallback zusätzlicher Providerverkehr angefallen sein. +- Der feste API-Proxy wird absichtlich nicht automatisch rotiert, damit API- und Login-Sitzungen eine stabile Ausgangs-IP behalten. Bei Ausfall muss ein anderer gültiger Listeneintrag ausgewählt werden; bis dahin schlägt Proxy-only geschlossen fehl. - Die bereitgestellte Proxy-Liste wurde mit dem ausdrücklich bereitgestellten Real-Debrid-Testlink erfolgreich real geprüft. Die Messung gilt nur für diesen Link, diesen Zeitpunkt und einen parallelen Download; daraus folgt noch keine Garantie für fünf gleichzeitige Downloads, andere CDN-Knoten oder dauerhafte Proxy-Leistung. - Der Arbeitsordner enthält `&`; ohne PowerShell-7-Skriptshell können npm-`cmd`-Shims fehlschlagen. - Es gibt keine `.github`-Workflows und damit keine serverseitige CI-Absicherung im GitHub-Repository. diff --git a/package-lock.json b/package-lock.json index 53b5998..c1cc297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,12 @@ "name": "multi-debrid-downloader", "version": "2.0.74", "license": "MIT", - "dependencies": { - "adm-zip": "0.6.0", - "https-proxy-agent": "7.0.6", - "react": "^18.3.1", + "dependencies": { + "adm-zip": "0.6.0", + "https-proxy-agent": "7.0.6", + "react": "^18.3.1", "react-dom": "^18.3.1", + "undici": "6.28.0", "uuid": "11.1.1" }, "devDependencies": { @@ -7727,6 +7728,15 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", + "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", diff --git a/package.json b/package.json index d5bef76..7871f93 100644 --- a/package.json +++ b/package.json @@ -23,11 +23,12 @@ "release:win": "npm run build && electron-builder --publish never --win nsis portable", "verify:release": "node scripts/verify_public_release.mjs" }, - "dependencies": { - "adm-zip": "0.6.0", - "https-proxy-agent": "7.0.6", - "react": "^18.3.1", + "dependencies": { + "adm-zip": "0.6.0", + "https-proxy-agent": "7.0.6", + "react": "^18.3.1", "react-dom": "^18.3.1", + "undici": "6.28.0", "uuid": "11.1.1" }, "devDependencies": { diff --git a/src/main/all-debrid-web.ts b/src/main/all-debrid-web.ts index 2e41bc6..fead6d0 100644 --- a/src/main/all-debrid-web.ts +++ b/src/main/all-debrid-web.ts @@ -3,6 +3,7 @@ import { AllDebridHostInfo } from "../shared/types"; import { UnrestrictedLink } from "./realdebrid"; import { filenameFromUrl, sleep } from "./utils"; import { ALLDEBRID_LOGIN_HOSTS, applyRemoteLoginSecurity, createRemoteLoginWebPreferences } from "./browser-security"; +import { configureElectronProxySession } from "./network-proxy"; const ALLDEBRID_BASE_URL = "https://alldebrid.com"; const ALLDEBRID_LOGIN_URL = `${ALLDEBRID_BASE_URL}/register/?from=de`; @@ -211,9 +212,10 @@ export class AllDebridWebFallback { window.focus(); } - public async getHostInfo(host: string): Promise { - const currentSession = session.fromPartition(this.getPartition()); - const response = await currentSession.fetch(ALLDEBRID_STATUS_URL, { + public async getHostInfo(host: string): Promise { + const currentSession = session.fromPartition(this.getPartition()); + await configureElectronProxySession(currentSession); + const response = await currentSession.fetch(ALLDEBRID_STATUS_URL, { headers: { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", Referer: ALLDEBRID_SERVICE_REFERER, @@ -285,9 +287,10 @@ export class AllDebridWebFallback { return run; } - private async ensureLoginWindow(): Promise { - const partition = this.getPartition(); - const existing = this.loginWindow; + private async ensureLoginWindow(): Promise { + const partition = this.getPartition(); + await configureElectronProxySession(session.fromPartition(partition)); + const existing = this.loginWindow; if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) { return existing; } @@ -327,9 +330,10 @@ export class AllDebridWebFallback { body: URLSearchParams, referer: string, signal?: AbortSignal - ): Promise<{ response: Response; text: string }> { - const currentSession = session.fromPartition(this.getPartition()); - const response = await currentSession.fetch(url, { + ): Promise<{ response: Response; text: string }> { + const currentSession = session.fromPartition(this.getPartition()); + await configureElectronProxySession(currentSession); + const response = await currentSession.fetch(url, { method: "POST", headers: { Accept: "application/json, text/javascript, */*; q=0.01", diff --git a/src/main/app-controller.ts b/src/main/app-controller.ts index 9861016..bae2f53 100644 --- a/src/main/app-controller.ts +++ b/src/main/app-controller.ts @@ -88,6 +88,7 @@ import { NotificationOutbox } from "./notification-outbox"; import { sendNotification } from "./notify"; import { DownloadHealthMonitor } from "./download-health-monitor"; import { shouldDeferAutoResumeToDailyStart } from "./daily-start-scheduler"; +import { configureNetworkProxy, shutdownNetworkProxy } from "./network-proxy"; function sanitizeSettingsPatch(partial: Partial): Partial { const entries = Object.entries(partial || {}).filter(([, value]) => value !== undefined); @@ -168,6 +169,7 @@ export class AppController { this.logDirectory ); } + this.applyNetworkProxyConfiguration(); this.initializeLogStorage(); this.runHistoryLifecycleCleanup("Start", () => resetHistoryForRetention(this.storagePaths, this.settings.historyRetentionMode)); const loadResult = loadSessionWithStatus(this.storagePaths); @@ -613,6 +615,7 @@ export class AppController { this.overlayLiveUsageCounters(restoredSettings); saveSettings(this.storagePaths, restoredSettings); this.settings = restoredSettings; + this.applyNetworkProxyConfiguration(); runtimeApplied = true; this.manager.setSettings(this.settings, { settingsOnlyImport: true }); this.manager.persistNowSync(); @@ -622,6 +625,7 @@ export class AppController { this.rollbackImportPersistence(rollback, "fehlgeschlagenem Settings-Import"); } this.settings = previousSettings; + this.applyNetworkProxyConfiguration(); if (runtimeApplied) { try { this.manager.setSettings(previousSettings, { settingsOnlyImport: true }); @@ -688,6 +692,7 @@ export class AppController { throw error; } this.settings = nextSettings; + this.applyNetworkProxyConfiguration(); this.manager.setSettings(this.settings); this.audit("INFO", "Einstellungen aktualisiert", { changedKeys: Object.keys(sanitizedPatch), @@ -1492,6 +1497,7 @@ export class AppController { this.manager.skipShutdownPersist = true; this.manager.blockAllPersistence = true; this.settings = restoredSettings; + this.applyNetworkProxyConfiguration(); runtimeApplied = true; this.manager.setSettings(this.settings); this.manager.stop(); @@ -1505,6 +1511,7 @@ export class AppController { this.manager.skipShutdownPersist = previousSkipShutdownPersist; this.manager.blockAllPersistence = previousBlockAllPersistence; this.settings = previousSettings; + this.applyNetworkProxyConfiguration(); if (runtimeApplied) { try { this.manager.setSettings(previousSettings, { settingsOnlyImport: true }); @@ -1617,6 +1624,7 @@ export class AppController { this.pendingRealDebridWebAccountIds.clear(); this.allDebridWebFallback.dispose(); this.bestDebridWebFallback.dispose(); + await shutdownNetworkProxy(); if (this.settings.historyRetentionMode === "session") { this.runHistoryLifecycleCleanup("Beenden", () => clearHistory(this.storagePaths)); } @@ -1629,6 +1637,15 @@ export class AppController { logger.info("App beendet"); } + private applyNetworkProxyConfiguration(): void { + const state = configureNetworkProxy(this.settings); + if (state.status === "active") { + logger.info(`Proxy-only aktiv: fester API-Proxy ${state.selectedIndex}/${state.proxyCount}`); + } else if (state.status === "blocked") { + logger.warn(`Proxy-only blockiert Netzwerkanfragen: ${state.reason}`); + } + } + private async waitForShutdownTask(task: Promise, deadlineAt: number): Promise { const remainingMs = Math.max(0, deadlineAt - Date.now()); if (remainingMs <= 0) { diff --git a/src/main/bestdebrid-web.ts b/src/main/bestdebrid-web.ts index 52eeb89..75c0428 100644 --- a/src/main/bestdebrid-web.ts +++ b/src/main/bestdebrid-web.ts @@ -2,7 +2,8 @@ import fs from "node:fs"; import { session, type Session } from "electron"; import { UnrestrictedLink } from "./realdebrid"; import { filenameFromUrl, sleep } from "./utils"; -import { logger } from "./logger"; +import { logger } from "./logger"; +import { configureElectronProxySession } from "./network-proxy"; const BESTDEBRID_BASE_URL = "https://bestdebrid.com"; const BESTDEBRID_DOWNLOADER_URL = `${BESTDEBRID_BASE_URL}/en/downloader/`; @@ -178,8 +179,9 @@ export class BestDebridWebFallback { throw new Error("BestDebrid: Cookie-Datei enthält keinen Login-Cookie. Bitte nach dem Login erneut exportieren."); } - const currentSession = session.fromPartition(this.getPartition()); - await this.clearPartitionState(currentSession); + const currentSession = session.fromPartition(this.getPartition()); + await configureElectronProxySession(currentSession); + await this.clearPartitionState(currentSession); for (const cookie of bestDebridCookies) { const url = `https://${cookie.domain.replace(/^\./, "")}${cookie.path}`; @@ -244,9 +246,10 @@ export class BestDebridWebFallback { } private async generate(link: string, signal?: AbortSignal): Promise<{ kind: "success"; value: UnrestrictedLink } | { kind: "login_required" }> { - throwIfAborted(signal); - const currentSession = session.fromPartition(this.getPartition()); - const response = await currentSession.fetch(BESTDEBRID_GENERATE_URL, { + throwIfAborted(signal); + const currentSession = session.fromPartition(this.getPartition()); + await configureElectronProxySession(currentSession); + const response = await currentSession.fetch(BESTDEBRID_GENERATE_URL, { method: "POST", headers: { Accept: "application/json, text/javascript, */*; q=0.01", diff --git a/src/main/constants.ts b/src/main/constants.ts index dcec092..e4adb81 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -108,6 +108,7 @@ export function defaultSettings(): AppSettings { speedLimitMode: "global", proxyDownloadEnabled: false, proxyListPath: "", + proxyApiProxyIndex: 1, proxyConnectionsPerDownload: 16, updateRepo: DEFAULT_UPDATE_REPO, autoUpdateCheck: true, diff --git a/src/main/download-manager.ts b/src/main/download-manager.ts index 2b18b66..b7f49d0 100644 --- a/src/main/download-manager.ts +++ b/src/main/download-manager.ts @@ -11001,7 +11001,7 @@ export class DownloadManager extends EventEmitter { if (!proxyAttempted && attempt === 1 && existingBytes === 0 && this.settings.proxyDownloadEnabled) { proxyAttempted = true; if (this.settings.speedLimitEnabled) { - logAttemptEvent("INFO", "Proxy-Segmentierung wegen aktivem Geschwindigkeitslimit übersprungen", { + logAttemptEvent("INFO", "Proxy-Segmentierung wegen aktivem Geschwindigkeitslimit übersprungen; fester Proxy wird verwendet", { attempt }); } else { @@ -11104,10 +11104,10 @@ export class DownloadManager extends EventEmitter { item.downloadedBytes = 0; item.progressPercent = 0; item.speedBps = 0; - item.fullStatus = "Direktdownload wird gestartet"; + item.fullStatus = "Einzel-Proxy-Download wird gestartet"; item.updatedAt = nowMs(); this.emitState(); - logAttemptEvent("WARN", "Proxy-Segmentdownload nicht verwendet, Direktdownload folgt", { + logAttemptEvent("WARN", "Proxy-Segmentdownload nicht verwendet, fester Einzel-Proxy folgt", { attempt, reason: proxyReasonLabels[proxyResult.reason] }); diff --git a/src/main/main.ts b/src/main/main.ts index 99c754e..b328d72 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -32,6 +32,7 @@ import { validateCollectorPersistenceState, validateCollectorTextPreparationRequest } from "../shared/collector"; +import { getProxyAuthentication } from "./network-proxy"; forceDarkNativeTheme(nativeTheme); @@ -1271,7 +1272,14 @@ function formatRendererErrorReport(rawReport: unknown): string { return parts.join(" "); } -app.on("child-process-gone", (_event, details) => { +app.on("login", (event, _webContents, _requestDetails, authInfo, callback) => { + const credentials = getProxyAuthentication(authInfo); + if (!credentials) return; + event.preventDefault(); + callback(credentials.username, credentials.password); +}); + +app.on("child-process-gone", (_event, details) => { const killed = details.reason !== "clean-exit" && details.reason !== "killed"; const line = `Subprozess beendet: type=${details.type} reason=${details.reason} exitCode=${details.exitCode ?? "?"}${details.name ? ` name=${details.name}` : ""}${details.serviceName ? ` service=${details.serviceName}` : ""}`; if (killed) { diff --git a/src/main/network-proxy.ts b/src/main/network-proxy.ts new file mode 100644 index 0000000..d44cd2e --- /dev/null +++ b/src/main/network-proxy.ts @@ -0,0 +1,165 @@ +import type { Session } from "electron"; +import { getGlobalDispatcher, ProxyAgent, setGlobalDispatcher, type Dispatcher } from "undici"; +import type { AppSettings } from "../shared/types"; +import { selectFixedProxy, type ProxyEndpoint } from "./proxy-segmented-download"; + +export type NetworkProxyState = + | { status: "disabled" } + | { status: "active"; selectedIndex: number; proxyCount: number } + | { status: "blocked"; reason: "proxy_file_unavailable" | "no_valid_proxies" | "proxy_index_unavailable" }; + +interface ActiveProxyConfiguration { + status: "active"; + proxy: ProxyEndpoint; + selectedIndex: number; + proxyCount: number; + fingerprint: string; +} + +interface BlockedProxyConfiguration { + status: "blocked"; + reason: "proxy_file_unavailable" | "no_valid_proxies" | "proxy_index_unavailable"; + fingerprint: string; +} + +type ProxyConfiguration = { status: "disabled"; fingerprint: "disabled" } | ActiveProxyConfiguration | BlockedProxyConfiguration; + +const originalDispatcher = getGlobalDispatcher(); +const configuredSessions = new WeakMap(); +const knownSessions = new Set(); +let currentConfiguration: ProxyConfiguration = { status: "disabled", fingerprint: "disabled" }; +let installedDispatcher: Dispatcher | null = null; + +function blockedDispatcher(reason: string): Dispatcher { + return { + dispatch: (_options, handler) => { + queueMicrotask(() => handler.onError?.(new Error(`proxy_only_blocked:${reason}`))); + return true; + }, + close: async () => {}, + destroy: async () => {} + } as Dispatcher; +} + +function closeInstalledDispatcher(dispatcher: Dispatcher | null): void { + if (!dispatcher || dispatcher === originalDispatcher) return; + void dispatcher.close().catch(() => {}); +} + +function installDispatcher(dispatcher: Dispatcher | null): void { + const previous = installedDispatcher; + installedDispatcher = dispatcher; + setGlobalDispatcher(dispatcher || originalDispatcher); + if (previous && previous !== dispatcher) closeInstalledDispatcher(previous); +} + +function refreshKnownElectronSessions(): void { + for (const currentSession of knownSessions) { + void configureElectronProxySession(currentSession).catch(() => {}); + } +} + +export function configureNetworkProxy(settings: Pick): NetworkProxyState { + if (!settings.proxyDownloadEnabled) { + if (currentConfiguration.status !== "disabled") { + currentConfiguration = { status: "disabled", fingerprint: "disabled" }; + installDispatcher(null); + refreshKnownElectronSessions(); + } + return { status: "disabled" }; + } + + const selected = selectFixedProxy(settings.proxyListPath, settings.proxyApiProxyIndex); + if (selected.status !== "ok") { + const fingerprint = `blocked:${selected.status}`; + if (currentConfiguration.fingerprint !== fingerprint) { + currentConfiguration = { status: "blocked", reason: selected.status, fingerprint }; + installDispatcher(blockedDispatcher(selected.status)); + refreshKnownElectronSessions(); + } + return { status: "blocked", reason: selected.status }; + } + + const fingerprint = `${selected.proxy.url}\0${selected.proxy.authorization}\0${selected.selectedIndex}`; + if (currentConfiguration.fingerprint !== fingerprint) { + const dispatcher = new ProxyAgent({ + uri: selected.proxy.url, + ...(selected.proxy.authorization ? { token: selected.proxy.authorization } : {}) + }); + currentConfiguration = { + status: "active", + proxy: selected.proxy, + selectedIndex: selected.selectedIndex, + proxyCount: selected.proxyCount, + fingerprint + }; + installDispatcher(dispatcher); + refreshKnownElectronSessions(); + } + return { status: "active", selectedIndex: selected.selectedIndex, proxyCount: selected.proxyCount }; +} + +export function getNetworkProxyState(): NetworkProxyState { + if (currentConfiguration.status === "disabled") return { status: "disabled" }; + if (currentConfiguration.status === "blocked") { + return { status: "blocked", reason: currentConfiguration.reason }; + } + return { + status: "active", + selectedIndex: currentConfiguration.selectedIndex, + proxyCount: currentConfiguration.proxyCount + }; +} + +export function getProxyAuthentication(authInfo: { isProxy: boolean; host: string; port: number }): { username: string; password: string } | null { + if (!authInfo.isProxy || currentConfiguration.status !== "active") return null; + const proxy = currentConfiguration.proxy; + if (authInfo.host.toLowerCase() !== proxy.hostname.toLowerCase() || authInfo.port !== proxy.port) return null; + if (!proxy.username && !proxy.password) return null; + return { username: proxy.username, password: proxy.password }; +} + +export async function configureElectronProxySession(currentSession: Session): Promise { + knownSessions.add(currentSession); + const previousFingerprint = configuredSessions.get(currentSession); + if (previousFingerprint === currentConfiguration.fingerprint) return; + + try { + if (currentConfiguration.status === "disabled") { + if (previousFingerprint !== undefined) { + await currentSession.setProxy({ mode: "direct" }); + await currentSession.closeAllConnections(); + } + } else if (currentConfiguration.status === "active") { + await currentSession.setProxy({ + mode: "fixed_servers", + proxyRules: currentConfiguration.proxy.url.replace(/\/$/, "") + }); + await currentSession.closeAllConnections(); + } else { + await currentSession.setProxy({ + mode: "fixed_servers", + proxyRules: "http://127.0.0.1:1" + }); + await currentSession.closeAllConnections(); + } + configuredSessions.set(currentSession, currentConfiguration.fingerprint); + } catch { + if (currentConfiguration.status !== "disabled") { + await currentSession.setProxy({ mode: "fixed_servers", proxyRules: "http://127.0.0.1:1" }).catch(() => {}); + await currentSession.closeAllConnections().catch(() => {}); + } + throw new Error("proxy_only_session_configuration_failed"); + } +} + +export async function shutdownNetworkProxy(): Promise { + const active = installedDispatcher; + installedDispatcher = null; + currentConfiguration = { status: "disabled", fingerprint: "disabled" }; + setGlobalDispatcher(originalDispatcher); + knownSessions.clear(); + if (active && active !== originalDispatcher) { + await active.close().catch(() => {}); + } +} diff --git a/src/main/proxy-segmented-download.ts b/src/main/proxy-segmented-download.ts index c59013e..0c3bac9 100644 --- a/src/main/proxy-segmented-download.ts +++ b/src/main/proxy-segmented-download.ts @@ -15,10 +15,14 @@ const MAX_REDIRECTS = 5; const MAX_PROXY_FILE_BYTES = 8 * 1024 * 1024; const DISK_ERROR_CODES = new Set(["ENOSPC", "EDQUOT", "EACCES", "EPERM", "EROFS", "EIO", "ENODEV"]); -interface ProxyEndpoint { +export interface ProxyEndpoint { id: number; url: string; authorization: string; + username: string; + password: string; + hostname: string; + port: number; } interface CachedProxyFile { @@ -110,9 +114,11 @@ function parseProxyLine(rawLine: string, id: number): ProxyEndpoint | null { return null; } let authorization = ""; + let username = ""; + let password = ""; if (parsed.username || parsed.password) { - const username = decodeURIComponent(parsed.username); - const password = decodeURIComponent(parsed.password); + username = decodeURIComponent(parsed.username); + password = decodeURIComponent(parsed.password); authorization = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; parsed.username = ""; parsed.password = ""; @@ -120,7 +126,15 @@ function parseProxyLine(rawLine: string, id: number): ProxyEndpoint | null { parsed.pathname = ""; parsed.search = ""; parsed.hash = ""; - return { id, url: parsed.toString(), authorization }; + return { + id, + url: parsed.toString(), + authorization, + username, + password, + hostname: parsed.hostname, + port + }; } catch { return null; } @@ -133,6 +147,45 @@ export function parseProxyList(content: string): number { .reduce((count, line, index) => count + (parseProxyLine(line, index) ? 1 : 0), 0); } +function parseUniqueProxyEndpoints(content: string): ProxyEndpoint[] { + const seen = new Set(); + return content + .replace(/^\uFEFF/, "") + .split(/\r?\n/) + .map((line, index) => parseProxyLine(line, index)) + .filter((proxy): proxy is ProxyEndpoint => { + if (!proxy) return false; + const key = `${proxy.url}\0${proxy.authorization}`; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export type FixedProxySelection = + | { status: "ok"; proxy: ProxyEndpoint; selectedIndex: number; proxyCount: number } + | { status: "proxy_file_unavailable" | "no_valid_proxies" | "proxy_index_unavailable" }; + +export function selectFixedProxy(proxyListPath: string, requestedIndex: number): FixedProxySelection { + const filePath = String(proxyListPath || "").trim(); + if (!filePath) return { status: "proxy_file_unavailable" }; + try { + const normalizedPath = path.resolve(filePath); + const stat = fs.statSync(normalizedPath); + if (!stat.isFile() || stat.size <= 0 || stat.size > MAX_PROXY_FILE_BYTES) { + return { status: stat.size <= 0 ? "no_valid_proxies" : "proxy_file_unavailable" }; + } + const proxies = parseUniqueProxyEndpoints(fs.readFileSync(normalizedPath, "utf8")); + if (proxies.length === 0) return { status: "no_valid_proxies" }; + const selectedIndex = Math.max(1, Math.floor(requestedIndex || 1)); + const proxy = proxies[selectedIndex - 1]; + if (!proxy) return { status: "proxy_index_unavailable" }; + return { status: "ok", proxy, selectedIndex, proxyCount: proxies.length }; + } catch { + return { status: "proxy_file_unavailable" }; + } +} + async function loadProxyFile(filePath: string): Promise< { status: "ok"; proxies: ProxyEndpoint[] } | { status: "unavailable" } @@ -151,18 +204,7 @@ async function loadProxyFile(filePath: string): Promise< : { status: "empty" }; } const content = await fs.promises.readFile(normalizedPath, "utf8"); - const seen = new Set(); - const proxies = content - .replace(/^\uFEFF/, "") - .split(/\r?\n/) - .map((line, index) => parseProxyLine(line, index)) - .filter((proxy): proxy is ProxyEndpoint => { - if (!proxy) return false; - const key = `${proxy.url}\0${proxy.authorization}`; - if (seen.has(key)) return false; - seen.add(key); - return true; - }); + const proxies = parseUniqueProxyEndpoints(content); proxyFileCache.set(normalizedPath, { mtimeMs: stat.mtimeMs, size: stat.size, proxies }); return proxies.length > 0 ? { status: "ok", proxies } : { status: "empty" }; } catch { diff --git a/src/main/realdebrid-web.ts b/src/main/realdebrid-web.ts index 13ced34..603513e 100644 --- a/src/main/realdebrid-web.ts +++ b/src/main/realdebrid-web.ts @@ -4,6 +4,7 @@ import { sleep } from "./utils"; import { API_BASE_URL, REQUEST_RETRIES } from "./constants"; import { applyRemoteLoginSecurity, createRemoteLoginWebPreferences, REALDEBRID_LOGIN_HOSTS } from "./browser-security"; import { buildRealDebridWebGenerationScript, normalizeRealDebridWebGenerationResult } from "./realdebrid-web-page"; +import { configureElectronProxySession } from "./network-proxy"; const RD_BASE_URL = "https://real-debrid.com"; const RD_LOGIN_URL = RD_BASE_URL; @@ -366,9 +367,10 @@ export class RealDebridWebFallback { return raceWithAbort(run, signal); } - private async ensureLoginWindow(): Promise { - const partition = this.getPartition(); - const existing = this.loginWindow; + private async ensureLoginWindow(): Promise { + const partition = this.getPartition(); + await configureElectronProxySession(session.fromPartition(partition)); + const existing = this.loginWindow; if (existing && !existing.isDestroyed() && this.loginWindowPartition === partition) { return existing; } @@ -431,6 +433,7 @@ export class RealDebridWebFallback { this.throwIfDisposed(); throwIfAborted(signal); const partition = this.getPartition(); + await configureElectronProxySession(session.fromPartition(partition)); const existing = this.generatorWindow; if (existing && !existing.isDestroyed() && this.generatorWindowPartition === partition) { return existing; @@ -603,8 +606,9 @@ export class RealDebridWebFallback { } } - const currentSession = session.fromPartition(this.getPartition()); - const response = await currentSession.fetch(RD_APITOKEN_URL, { + const currentSession = session.fromPartition(this.getPartition()); + await configureElectronProxySession(currentSession); + const response = await currentSession.fetch(RD_APITOKEN_URL, { headers: { Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", Referer: RD_BASE_URL + "/", diff --git a/src/main/renderer-state.ts b/src/main/renderer-state.ts index 2d3ba77..031a2b3 100644 --- a/src/main/renderer-state.ts +++ b/src/main/renderer-state.ts @@ -206,6 +206,7 @@ export function createRendererSettings(settings: AppSettings): RendererSettings speedLimitMode: settings.speedLimitMode, proxyDownloadEnabled: settings.proxyDownloadEnabled, proxyListPath: settings.proxyListPath, + proxyApiProxyIndex: settings.proxyApiProxyIndex, proxyConnectionsPerDownload: settings.proxyConnectionsPerDownload, updateRepo: settings.updateRepo, autoUpdateCheck: settings.autoUpdateCheck, diff --git a/src/main/storage.ts b/src/main/storage.ts index 63341e2..cc4dec5 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -614,6 +614,7 @@ export function normalizeSettings(settings: AppSettings): AppSettings { speedLimitMode: settings.speedLimitMode, proxyDownloadEnabled: Boolean(settings.proxyDownloadEnabled), proxyListPath: asText(settings.proxyListPath).slice(0, 4096), + proxyApiProxyIndex: clampNumber(settings.proxyApiProxyIndex, defaults.proxyApiProxyIndex, 1, 100000), proxyConnectionsPerDownload: clampNumber(settings.proxyConnectionsPerDownload, defaults.proxyConnectionsPerDownload, 2, 32), autoUpdateCheck: Boolean(settings.autoUpdateCheck), updateRepo: migrateUpdateRepo(asText(settings.updateRepo), defaults.updateRepo), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 8ef2810..41349ed 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -953,7 +953,7 @@ const emptySnapshot = (): UiSnapshot => ({ removeSamplesAfterExtract: false, enableIntegrityCheck: true, autoResumeOnStart: true, autoReconnect: false, reconnectWaitSeconds: 45, completedCleanupPolicy: "never", maxParallel: 4, maxParallelExtract: 2, extractCpuPriority: "high", retryLimit: 0, speedLimitEnabled: false, speedLimitKbps: 0, speedLimitMode: "global", - proxyDownloadEnabled: false, proxyListPath: "", proxyConnectionsPerDownload: 16, + proxyDownloadEnabled: false, proxyListPath: "", proxyApiProxyIndex: 1, proxyConnectionsPerDownload: 16, updateRepo: "", autoUpdateCheck: true, clipboardWatch: false, minimizeToTray: false, theme: "dark", themePreference: "dark", logStorageLocation: "appdata", collapseNewPackages: true, animatePackageDisclosure: true, historyRetentionMode: "permanent", historyMaxEntries: 500, historyMaxAgeDays: 0, autoSortPackagesByProgress: false, autoSkipExtracted: false, hideExtractedItems: true, confirmDeleteSelection: true, backupIncludeDownloads: false, backupIncludeRemoteDiagnostics: false, notifyMention: "", notifyOnPackageCompleted: false, notifyOnPackageFailed: false, notifyOnRunFinished: false, @@ -6066,6 +6066,7 @@ export function App(): ReactElement { historyMaxAgeDays: [0, 3650, 0], maxParallelExtract: [1, 8, 2], reconnectWaitSeconds: [10, 600, 45], + proxyApiProxyIndex: [1, 100000, 1], proxyConnectionsPerDownload: [2, 32, 16] }; const bounds = numericLimits[fieldId as keyof RendererSettingsDraft]; diff --git a/src/renderer/views/settings/settings-model.ts b/src/renderer/views/settings/settings-model.ts index f9e6e98..2b4fafa 100644 --- a/src/renderer/views/settings/settings-model.ts +++ b/src/renderer/views/settings/settings-model.ts @@ -424,15 +424,15 @@ export function buildSettingsFormViewModel({ }, { id: "speed-proxy", - title: "Proxy-Download", - description: "Teilt neue Downloads in Byte-Bereiche und lädt sie parallel über verschiedene HTTP-Proxys. Falls der Server keine Bereiche unterstützt oder die Proxys ausfallen, läuft der bestehende Direktdownload weiter.", + title: "Proxy-only", + description: "Leitet API-, Login- und Link-Anfragen über einen festen HTTP-Proxy. Downloads verwenden zusätzlich mehrere Proxys parallel. Bei aktivem Modus gibt es keinen direkten Rückfall über deine echte Verbindung.", fields: [ { id: "proxyDownloadEnabled", kind: "switch", - label: "Proxy-Download aktivieren", + label: "Proxy-only aktivieren", value: settings.proxyDownloadEnabled, - help: "Gilt für neue Dateien ab 8 MiB. Ein aktives Geschwindigkeitslimit verwendet weiterhin den Direktdownload." + help: "Ist der feste API-Proxy nicht erreichbar oder ungültig, schlagen Netzwerkanfragen geschlossen fehl. Es wird nie ungefragt direkt verbunden." }, { id: "proxyListPath", @@ -444,6 +444,16 @@ export function buildSettingsFormViewModel({ disabled: !settings.proxyDownloadEnabled, help: "Unterstützt unter anderem Benutzer:Passwort@Host:Port. Zugangsdaten werden nicht protokolliert." }, + { + id: "proxyApiProxyIndex", + kind: "number", + label: "Fester API-Proxy (gültiger Listeneintrag)", + value: String(settings.proxyApiProxyIndex), + min: 1, + max: 100000, + disabled: !settings.proxyDownloadEnabled, + help: "Dieser 1-basierte Eintrag bleibt für API, Login und Link-Auflösung fest. Ist er offline, wird nicht automatisch ein anderer Proxy oder die direkte Verbindung verwendet." + }, { id: "proxyConnectionsPerDownload", kind: "number", @@ -452,7 +462,7 @@ export function buildSettingsFormViewModel({ min: 2, max: 32, disabled: !settings.proxyDownloadEnabled, - help: `Bei ${settings.maxParallel} gleichzeitigen Downloads sind bis zu ${settings.maxParallel * settings.proxyConnectionsPerDownload} Proxy-Verbindungen möglich.` + help: `Bei ${settings.maxParallel} gleichzeitigen Downloads sind bis zu ${settings.maxParallel * settings.proxyConnectionsPerDownload} parallele Segmentverbindungen möglich. Kleine Dateien, Teil-Downloads und aktive Geschwindigkeitslimits laufen über den festen API-Proxy.` } ] }, diff --git a/src/shared/types.ts b/src/shared/types.ts index 4bc24e2..a38f5cc 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -142,6 +142,7 @@ export interface DailyStartSettings { export interface ProxyDownloadSettings { proxyDownloadEnabled: boolean; proxyListPath: string; + proxyApiProxyIndex: number; proxyConnectionsPerDownload: number; } diff --git a/tests/network-proxy.test.ts b/tests/network-proxy.test.ts new file mode 100644 index 0000000..1022340 --- /dev/null +++ b/tests/network-proxy.test.ts @@ -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; +} + +const cleanups: Array<() => Promise> = []; + +afterEach(async () => { + await shutdownNetworkProxy(); + while (cleanups.length > 0) { + await cleanups.pop()?.(); + } +}); + +async function listen(server: http.Server): Promise { + await new Promise((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(); + server.on("connection", (socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + }); + const close = async (): Promise => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + }; + cleanups.push(close); + return { port: address.port, close }; +} + +async function createConnectProxy(username: string, password: string, acceptedConnections: { value: number }): Promise { + 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 { + 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(); + }); +}); diff --git a/tests/proxy-segmented-download.test.ts b/tests/proxy-segmented-download.test.ts index 358f29e..39caac1 100644 --- a/tests/proxy-segmented-download.test.ts +++ b/tests/proxy-segmented-download.test.ts @@ -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; diff --git a/tests/realdebrid-web.test.ts b/tests/realdebrid-web.test.ts index e628e7e..91ee57b 100644 --- a/tests/realdebrid-web.test.ts +++ b/tests/realdebrid-web.test.ts @@ -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"])); diff --git a/tests/settings-view.test.tsx b/tests/settings-view.test.tsx index 67c3338..9f6b258 100644 --- a/tests/settings-view.test.tsx +++ b/tests/settings-view.test.tsx @@ -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") })); }); diff --git a/tests/storage.test.ts b/tests/storage.test.ts index f417a11..cd4f42a 100644 --- a/tests/storage.test.ts +++ b/tests/storage.test.ts @@ -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); diff --git a/tests/visual/fixtures.ts b/tests/visual/fixtures.ts index 07bd55f..bb81eb8 100644 --- a/tests/visual/fixtures.ts +++ b/tests/visual/fixtures.ts @@ -119,6 +119,7 @@ function createSettings(): AppSettings { speedLimitMode: "global", proxyDownloadEnabled: false, proxyListPath: "", + proxyApiProxyIndex: 1, proxyConnectionsPerDownload: 16, updateRepo: "Sucukdeluxe/Multi-Debrid-Downloader", autoUpdateCheck: true,