From af69e8aad4190df06c9975d7ce461c5743c220ed Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Fri, 23 Oct 2026 08:20:39 -0600 Subject: [PATCH] feat(proxy): try bare --proxy providers in proxy_providers key order Basic stays first and Control D last; the rest follow their config key order. Geofence and proxy_map lookups go through pick_proxy and skip a provider that fails. --- docs/guide/proxies-and-vpn.md | 39 ++++++++--------- docs/reference/configuration/network.md | 7 +++- tests/core/test_proxy_loading.py | 11 +++++ unshackle/core/proxies/resolve.py | 13 ++++-- unshackle/core/service.py | 56 +++++++------------------ 5 files changed, 59 insertions(+), 67 deletions(-) diff --git a/docs/guide/proxies-and-vpn.md b/docs/guide/proxies-and-vpn.md index 72932980..fd4f2d19 100644 --- a/docs/guide/proxies-and-vpn.md +++ b/docs/guide/proxies-and-vpn.md @@ -125,14 +125,18 @@ forwarder for it. See [Control D](#control-d). ### Provider load order When you do a download without `--no-proxy`, unshackle instantiates every configured -proxy provider once, in this fixed order. It uses that same order for bare-query -resolution: +proxy provider once. It uses that same order for bare-query resolution: -**Basic → ExpressVPN → NordVPN → Proton VPN → Surfshark → Windscribe → Gluetun → Hola → Control D** +1. Basic is always first, so your own proxies win. +2. The other proxy providers follow in the order of their keys under `proxy_providers` in + `unshackle.yaml`. Proxy providers that load without a config key, such as Hola, come + after the listed ones in this default order: + **ExpressVPN → NordVPN → Proton VPN → Surfshark → Windscribe → Gluetun → Hola** +3. Control D is always last. -So if you have both Basic and NordVPN configured and use `--proxy us`, a `us` entry in -your Basic config wins, because unshackle tries Basic first. To skip ahead to a specific -proxy provider, prefix the query (`--proxy nordvpn:us`). +So if you list `windscribevpn` above `nordvpn` and use `--proxy us`, a `us` entry in your +Basic config answers first, then Windscribe. To skip ahead to a specific proxy provider, +prefix the query (`--proxy nordvpn:us`). If a proxy provider cannot load, for example because its server list cannot be fetched or its config is not valid, unshackle logs a warning and continues with the other proxy @@ -1025,21 +1029,14 @@ The returned proxy is HTTPS on **port 443**. ## Developer notes -!!! note "Two resolution paths (developers)" - unshackle has **two** proxy-resolution code paths that are intentionally not - identical: - - - `unshackle/commands/dl.py` inlines its own logic and loads **all nine** providers, - including Windscribe and Gluetun. - - `unshackle/core/proxies/resolve.py::initialize_proxy_providers()` is the shared - resolver used by the REST API handlers and the remote-service client. It **omits** - Windscribe and Gluetun. - - Both paths accept the same query grammar (a direct URI, a `provider:query`, a bare - country query tried against providers in order, or a `controld://` resolver), but the - set of available providers differs. Keep this in mind when you add a proxy provider: - an entry in `dl.py` does not make it available over the API, and the opposite is also - true. +!!! note "One resolution path (developers)" + `dl`, `search`, the REST API handlers and the remote-service client all load proxy + providers through `load_proxy_providers()` in `unshackle/core/proxies/resolve.py`. + The REST API and the remote-service client call it through + `initialize_proxy_providers()`, which leaves out Gluetun because Gluetun starts a + local Docker container. All paths accept the same query grammar: a direct URI, a + `provider:query`, a bare query tried against providers in order, or a `controld://` + resolver. The remote-service client resolves `--proxy` with its own `resolve_remote_proxy_arg()`. It changes a `controld:` query to a `controld://` diff --git a/docs/reference/configuration/network.md b/docs/reference/configuration/network.md index c40a8cac..eb51b1fb 100644 --- a/docs/reference/configuration/network.md +++ b/docs/reference/configuration/network.md @@ -61,8 +61,11 @@ headers: - **Type:** `dict`  ·  **Default:** `{}` Proxy/VPN provider configuration. Each sub-key names a proxy provider, and unshackle passes its -block straight to that provider's constructor. See [Proxies & VPN](../../guide/proxies-and-vpn.md) -for the full proxy provider guide. Recognised proxy providers and their exit ports: +block straight to that provider's constructor. A bare `--proxy` query tries Basic first, then +the other proxy providers in the order of their sub-keys, and Control D last (see +[Provider load order](../../guide/proxies-and-vpn.md#provider-load-order)). See +[Proxies & VPN](../../guide/proxies-and-vpn.md) for the full proxy provider guide. Recognised proxy +providers and their exit ports: | Provider | Config key | Credentials | Proxy scheme/port | |----------|-----------|-------------|-------------------| diff --git a/tests/core/test_proxy_loading.py b/tests/core/test_proxy_loading.py index 576046a6..4ace7754 100644 --- a/tests/core/test_proxy_loading.py +++ b/tests/core/test_proxy_loading.py @@ -63,6 +63,17 @@ def test_raise_errors_raises_the_load_failure() -> None: load_proxy_providers(OTHERS, raise_errors=True) +def test_bare_query_order_follows_config_key_order_with_controld_last(monkeypatch: pytest.MonkeyPatch) -> None: + from unshackle.core.proxies import ControlD + + order = {"controld": {"token": "t"}, "surfsharkvpn": CREDENTIALS, "nordvpn": CREDENTIALS, "basic": {"us": "x"}} + monkeypatch.setattr(config, "proxy_providers", order) + monkeypatch.setattr(ControlD, "get_locations", lambda self: []) + loaded = load_proxy_providers(("expressvpn", "protonvpn", "hola"), quiet=True) + names = [getattr(p, "name", p.__class__.__name__) for p in loaded] + assert names == ["Basic", "SurfsharkVPN", "NordVPN", "ControlD"] + + def test_rest_path_loads_windscribe_but_not_gluetun(monkeypatch: pytest.MonkeyPatch) -> None: from unshackle.core.proxies import Gluetun, WindscribeVPN from unshackle.core.proxies.resolve import initialize_proxy_providers diff --git a/unshackle/core/proxies/resolve.py b/unshackle/core/proxies/resolve.py index b30b84ff..329ffb0f 100644 --- a/unshackle/core/proxies/resolve.py +++ b/unshackle/core/proxies/resolve.py @@ -54,7 +54,7 @@ def find_provider(proxy_providers: List[Any], name: str, hint: str = "") -> Any: def load_proxy_providers(exclude: tuple[str, ...] = (), raise_errors: bool = False, quiet: bool = False) -> List[Any]: """ - Build the proxy providers set in config, in the order a bare query tries them. + Build the proxy providers set in config: Basic first, then config key order, Control D last. Each proxy provider loads on its own. One that fails is logged and kept as Unavailable, so a bare query skips it and a query that names it reports why. *raise_errors* raises the failure instead. @@ -75,8 +75,15 @@ def load_proxy_providers(exclude: tuple[str, ...] = (), raise_errors: bool = Fal ) settings = config.proxy_providers - # Control D is last: a bare region reaches it only when nothing else answers, since it changes the account. - classes = (Basic, ExpressVPN, NordVPN, ProtonVPN, SurfsharkVPN, WindscribeVPN, Gluetun, Hola, ControlD) + rank = {key: i for i, key in enumerate(settings)} + classes = [ + Basic, + *sorted( + (ExpressVPN, NordVPN, ProtonVPN, SurfsharkVPN, WindscribeVPN, Gluetun, Hola), + key=lambda c: rank.get(c.__name__.lower(), len(rank)), + ), + ControlD, + ] def build(cls: type) -> Any: key = cls.__name__.lower() diff --git a/unshackle/core/service.py b/unshackle/core/service.py index 97fb353d..2bbf847e 100644 --- a/unshackle/core/service.py +++ b/unshackle/core/service.py @@ -25,7 +25,7 @@ from unshackle.core.constants import AnyTrack from unshackle.core.credential import Credential from unshackle.core.drm import DRM_T from unshackle.core.proxies.basic import Basic -from unshackle.core.proxies.resolve import find_provider +from unshackle.core.proxies.resolve import pick_proxy from unshackle.core.search_result import SearchResult from unshackle.core.session import ( BACKOFF_FACTOR, @@ -229,40 +229,16 @@ class Service(metaclass=ABCMeta): self.log.info( f"Found service-specific proxy mapping: {full_proxy_key} -> {sanitize_proxy_for_log(mapped_value)}" ) - if proxy_provider_name: - # Specific provider requested - try: - proxy_provider = find_provider(ctx.obj.proxy_providers, proxy_provider_name) - except ValueError: - proxy_provider = None - if proxy_provider: - mapped_proxy_uri = proxy_provider.get_proxy(mapped_value) - if mapped_proxy_uri: - proxy = mapped_proxy_uri - self.log.info( - f"Using mapped proxy from {proxy_provider.__class__.__name__}: " - f"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}" - ) - else: - self.log.warning( - f"Failed to get proxy for mapped value '{sanitize_proxy_for_log(mapped_value)}', using default" - ) - else: - self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy") - else: - for proxy_provider in ctx.obj.proxy_providers: - mapped_proxy_uri = proxy_provider.get_proxy(mapped_value) - if mapped_proxy_uri: - proxy = mapped_proxy_uri - self.log.info( - f"Using mapped proxy from {proxy_provider.__class__.__name__}: " - f"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}" - ) - break - else: - self.log.warning( - f"No provider could resolve mapped value '{sanitize_proxy_for_log(mapped_value)}', using default" - ) + try: + proxy_provider, proxy = pick_proxy(ctx.obj.proxy_providers, mapped_value, proxy_provider_name) + self.log.info( + f"Using mapped proxy from {proxy_provider.__class__.__name__}: " + f"{sanitize_proxy_for_log(proxy, mask_host=isinstance(proxy_provider, Basic))}" + ) + except ValueError: + self.log.warning( + f"No provider could resolve mapped value '{sanitize_proxy_for_log(mapped_value)}', using default" + ) if not proxy: # don't override the explicit proxy set by the user, even if they may be geoblocked @@ -284,12 +260,10 @@ class Service(metaclass=ABCMeta): self.log.info( f"Service is Geoblocked in your region, getting a Proxy to {requested_proxy}" ) - for proxy_provider in ctx.obj.proxy_providers: - proxy = proxy_provider.get_proxy(requested_proxy) - if proxy: - self.log.info(f"Got Proxy from {proxy_provider.__class__.__name__}") - break - if not proxy: + try: + proxy_provider, proxy = pick_proxy(ctx.obj.proxy_providers, requested_proxy) + self.log.info(f"Got Proxy from {proxy_provider.__class__.__name__}") + except ValueError: self.log.warning( f"No proxy available for {requested_proxy}. " f"Pass --proxy with a proxy in {requested_proxy}, or the request can fail."