From 80c0dc4ebf76ff3e55965a27643fcaf6600a6137 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Fri, 25 Sep 2026 18:13:31 -0600 Subject: [PATCH] fix(proxies): return None for unservable queries and repair live providers Basic, NordVPN, Windscribe, Gluetun and Hola no longer raise on a query they cannot serve. Hola parses stdout only and reports a ban; NordVPN cities use the API filter; uk maps to gb. --- docs/guide/proxies-and-vpn.md | 19 ++- tests/core/test_basic_proxy.py | 30 +++++ tests/core/test_proxy_unservable_queries.py | 138 ++++++++++++++++++++ tests/core/test_surfsharkvpn_proxy.py | 7 + unshackle/core/proxies/basic.py | 17 +-- unshackle/core/proxies/gluetun.py | 4 +- unshackle/core/proxies/hola.py | 56 +++++--- unshackle/core/proxies/nordvpn.py | 85 ++++-------- unshackle/core/proxies/proton.py | 5 +- unshackle/core/proxies/surfsharkvpn.py | 7 +- unshackle/core/proxies/windscribevpn.py | 18 +-- 11 files changed, 278 insertions(+), 108 deletions(-) create mode 100644 tests/core/test_basic_proxy.py create mode 100644 tests/core/test_proxy_unservable_queries.py diff --git a/docs/guide/proxies-and-vpn.md b/docs/guide/proxies-and-vpn.md index c1d57340..92a578b5 100644 --- a/docs/guide/proxies-and-vpn.md +++ b/docs/guide/proxies-and-vpn.md @@ -744,12 +744,16 @@ for a plain proxy. On success, unshackle prints a summary such as `(Name - City) ## Hola -Hola requires no configuration at all. It uses the -[`hola-proxy`](https://github.com/Snawoot/hola-proxy) binary, and **auto-loads** whenever -that binary is found on your `PATH`. If you query Hola but the binary is missing, the -proxy provider raises an error that tells you to install it. +Hola requires no configuration at all. It uses the `hola-proxy` binary, and +**auto-loads** whenever that binary is found on your `PATH`. If you query Hola but the +binary is missing, the proxy provider raises an error that tells you to install it. -```shell title="Install hola-proxy, then just query a country" +The `hola-proxy` GitHub repository is no longer online, so build the binary with Go. The +Go module proxy still has the source for version 1.18.2. `go install` puts the binary in +`$(go env GOPATH)/bin`, which must be on your `PATH`. + +```shell title="Build hola-proxy, then query a country" +go install github.com/Snawoot/hola-proxy@v1.18.2 unshackle dl --proxy hola:us EXAMPLE 81234567 ``` @@ -758,8 +762,9 @@ for available proxies in that country and picks one at random, returning an HTTP !!! warning "Temporary bans" Hola's free tier can rate-limit or temporarily ban you if queried too aggressively. If - you see a *temporary ban detected* error, wait before retrying. Hola currently uses - only datacenter proxies. + you see a *temporary ban detected* error, wait before retrying. While a ban is active, + `hola-proxy` does not stop, so unshackle waits 30 seconds and then reports the ban. + Hola currently uses only datacenter proxies. ## Surfshark diff --git a/tests/core/test_basic_proxy.py b/tests/core/test_basic_proxy.py new file mode 100644 index 00000000..0cd0e16c --- /dev/null +++ b/tests/core/test_basic_proxy.py @@ -0,0 +1,30 @@ +"""Unit tests for the Basic proxy provider: a query it cannot serve returns None so a bare query moves on.""" + +from __future__ import annotations + +import pytest + +from unshackle.core.proxies import Basic + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def basic() -> Basic: + return Basic(us=["http://one.example:8080", "http://two.example:8080"], gb="http://gb.example:8080") + + +@pytest.mark.parametrize( + ("query", "expected"), [("us1", "http://one.example:8080"), ("US2", "http://two.example:8080")] +) +def test_numbered_entry(basic: Basic, query: str, expected: str) -> None: + assert basic.get_proxy(query) == expected + + +def test_single_entry_region(basic: Basic) -> None: + assert basic.get_proxy("gb") == "http://gb.example:8080" + + +@pytest.mark.parametrize("query", ["us-bos", "yul", "us:seattle", "us150", "us0", "ca"]) +def test_query_it_cannot_serve_returns_none(basic: Basic, query: str) -> None: + assert basic.get_proxy(query) is None diff --git a/tests/core/test_proxy_unservable_queries.py b/tests/core/test_proxy_unservable_queries.py new file mode 100644 index 00000000..5a5b6338 --- /dev/null +++ b/tests/core/test_proxy_unservable_queries.py @@ -0,0 +1,138 @@ +"""Every proxy provider returns None for a query it cannot serve, so a bare --proxy query moves on +to the next proxy provider instead of stopping at the first one that does not know the form.""" + +from __future__ import annotations + +import subprocess + +import pytest + +from unshackle.core.proxies import Gluetun, Hola, NordVPN, WindscribeVPN +from unshackle.core.proxies import hola as hola_module + +pytestmark = pytest.mark.unit + +CREDENTIALS = {"username": "a" * 24, "password": "b" * 24} + + +@pytest.fixture +def nordvpn(monkeypatch: pytest.MonkeyPatch) -> NordVPN: + countries = [{"id": 228, "code": "US", "name": "United States", "serverCount": 1}] + monkeypatch.setattr(NordVPN, "get_countries", staticmethod(lambda: countries)) + monkeypatch.setattr(NordVPN, "get_recommended_servers", staticmethod(lambda country_id, city_id=None: [])) + return NordVPN(**CREDENTIALS) + + +@pytest.fixture +def windscribe(monkeypatch: pytest.MonkeyPatch) -> WindscribeVPN: + countries = [ + {"country_code": "US", "groups": [{"city": "Dallas", "hosts": [{"hostname": "us-central-150.x.com"}]}]} + ] + monkeypatch.setattr(WindscribeVPN, "get_countries", staticmethod(lambda: countries)) + return WindscribeVPN(**CREDENTIALS) + + +@pytest.mark.parametrize("query", ["us-bos", "us_yyz", "zz", "us"]) +def test_nordvpn(nordvpn: NordVPN, query: str) -> None: + # "us" has no recommended servers in the fixture + assert nordvpn.get_proxy(query) is None + + +@pytest.mark.parametrize("query", ["us999", "us-bos", "us_yyz", "zz"]) +def test_windscribe(windscribe: WindscribeVPN, query: str) -> None: + assert windscribe.get_proxy(query) is None + + +def test_windscribe_still_finds_a_listed_server(windscribe: WindscribeVPN) -> None: + assert windscribe.get_proxy("us150") == f"https://{'a' * 24}:{'b' * 24}@us-central-150.x.com:443" + + +def test_gluetun_skips_a_bare_region() -> None: + assert Gluetun.__new__(Gluetun).get_proxy("us") is None + + +def hola_with_output(monkeypatch: pytest.MonkeyPatch, stdout: str, stderr: str = "") -> Hola: + def run(*args: object, **kwargs: object) -> subprocess.CompletedProcess: + return subprocess.CompletedProcess(args, 0, stdout=stdout, stderr=stderr) + + monkeypatch.setattr(hola_module.subprocess, "run", run) + hola = Hola.__new__(Hola) + hola.binary = "hola-proxy" + hola.countries = [{"us": "United States"}, {"gb": "United Kingdom"}] + return hola + + +HOLA_STDOUT = ( + "Login: user-uuid-1\nPassword: secret\nProxy-Authorization: basic abc\n\n" + "host,ip_address,direct,peer,hola,trial,trial_peer,vendor\n" + "zagent1.hola.org,203.0.113.7,22222,22223,22224,22225,22226,digitalocean\n" +) + + +def test_hola_parses_stdout_while_the_log_goes_to_stderr(monkeypatch: pytest.MonkeyPatch) -> None: + hola = hola_with_output(monkeypatch, HOLA_STDOUT, 'MAIN: INFO Action "list proxies" succeeded\n') + assert hola.get_proxy("us") == "http://user-uuid-1:secret@203.0.113.7:22223" + + +@pytest.mark.parametrize(("query", "stdout"), [("us-bos", ""), ("zz", HOLA_STDOUT), ("us", "no login"), ("us", "")]) +def test_hola(monkeypatch: pytest.MonkeyPatch, query: str, stdout: str) -> None: + assert hola_with_output(monkeypatch, stdout).get_proxy(query) is None + + +def test_hola_ban_while_hola_proxy_keeps_retrying(monkeypatch: pytest.MonkeyPatch) -> None: + def run(*args: object, **kwargs: object) -> None: + raise subprocess.TimeoutExpired("hola-proxy", 30, stderr=b"Transaction error: temporary ban detected. Retrying") + + monkeypatch.setattr(hola_module.subprocess, "run", run) + hola = Hola.__new__(Hola) + hola.binary = "hola-proxy" + hola.countries = [{"us": "United States"}] + with pytest.raises(ConnectionError, match="banned"): + hola.get_proxy("us") + + +@pytest.fixture +def nordvpn_with_cities(monkeypatch: pytest.MonkeyPatch) -> NordVPN: + countries = [ + { + "id": 228, + "code": "US", + "name": "United States", + "serverCount": 2, + "cities": [ + {"id": 1, "name": "Seattle", "dns_name": "seattle"}, + {"id": 2, "name": "New York", "dns_name": "new-york"}, + ], + }, + {"id": 227, "code": "GB", "name": "United Kingdom", "serverCount": 1, "cities": []}, + ] + by_city = {None: "us100.nordvpn.com", 1: "us1.nordvpn.com", 2: "us2.nordvpn.com"} + + def recommended(country_id: int, city_id: int | None = None) -> list[dict]: + return [{"hostname": "uk6018.nordvpn.com" if country_id == 227 else by_city[city_id]}] + + monkeypatch.setattr(NordVPN, "get_countries", staticmethod(lambda: countries)) + monkeypatch.setattr(NordVPN, "get_recommended_servers", staticmethod(recommended)) + return NordVPN(**CREDENTIALS) + + +@pytest.mark.parametrize( + ("query", "host"), + [ + ("us", "us100"), + ("us:new-york", "us2"), + ("us:new york", "us2"), + ("us:seattle", "us1"), + ("uk", "uk6018"), + ("gb", "uk6018"), + ("gb6018", "uk6018"), + ], +) +def test_nordvpn_city_and_uk(nordvpn_with_cities: NordVPN, query: str, host: str) -> None: + uri = nordvpn_with_cities.get_proxy(query) + assert uri is not None and uri.endswith(f"@{host}.proxy.nordvpn.com:89") + + +def test_nordvpn_unknown_city_raises(nordvpn_with_cities: NordVPN) -> None: + with pytest.raises(ValueError, match="No servers found in city 'boston'"): + nordvpn_with_cities.get_proxy("us:boston") diff --git a/tests/core/test_surfsharkvpn_proxy.py b/tests/core/test_surfsharkvpn_proxy.py index afa37b10..6769e687 100644 --- a/tests/core/test_surfsharkvpn_proxy.py +++ b/tests/core/test_surfsharkvpn_proxy.py @@ -109,3 +109,10 @@ def test_unusable_server_map_entry_is_ignored_with_a_warning(server: object, cap proxy = SurfsharkVPN(USERNAME, PASSWORD, server_map={"us": server}) # type: ignore[dict-item] assert "ignoring server_map entry" in caplog.text assert host(proxy.get_proxy("us")) in US_HOSTS + + +@pytest.mark.parametrize("key", ["gb", "uk", "GB"]) +@pytest.mark.parametrize("query", ["gb", "uk"]) +def test_server_map_uk_and_gb_are_the_same_region(key: str, query: str) -> None: + proxy = SurfsharkVPN(USERNAME, PASSWORD, server_map={key: "uk-lon"}) + assert host(proxy.get_proxy(query)) == "uk-lon.prod.surfshark.com:443" diff --git a/unshackle/core/proxies/basic.py b/unshackle/core/proxies/basic.py index 60e2ce38..afda6cc3 100644 --- a/unshackle/core/proxies/basic.py +++ b/unshackle/core/proxies/basic.py @@ -10,7 +10,7 @@ from unshackle.core.utils.redact import mask_proxy class Basic(Proxy): - def __init__(self, **countries: dict[str, Union[str, list[str]]]): + def __init__(self, **countries: Union[str, list[str]]): """Basic proxy provider that uses the proxies given in the configuration file.""" self.countries = {k.lower(): v for k, v in countries.items()} @@ -21,12 +21,12 @@ class Basic(Proxy): return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})" def get_proxy(self, query: str) -> Optional[str]: - """Get a proxy URI from the config.""" + """Get a proxy URI from the config. A query for a region or an entry number not in the config returns None.""" query = query.lower() - match = re.match(r"^([a-z]{2})(\d+)?$", query, re.IGNORECASE) + match = re.fullmatch(r"([a-z]{2})(\d+)?", query) if not match: - raise ValueError(f'The query "{query}" was not recognized...') + return None country_code = match.group(1) entry = match.group(2) @@ -38,12 +38,9 @@ class Basic(Proxy): if isinstance(servers, str): proxy = servers elif entry: - try: - proxy = servers[int(entry) - 1] - except IndexError: - raise ValueError( - f'There\'s only {len(servers)} prox{"y" if len(servers) == 1 else "ies"} for "{country_code}"...' - ) + if not 1 <= int(entry) <= len(servers): + return None + proxy = servers[int(entry) - 1] else: proxy = random.choice(servers) diff --git a/unshackle/core/proxies/gluetun.py b/unshackle/core/proxies/gluetun.py index 2a3905c2..a3bb6cd2 100644 --- a/unshackle/core/proxies/gluetun.py +++ b/unshackle/core/proxies/gluetun.py @@ -240,9 +240,11 @@ class Gluetun(Proxy): query: Query format: "provider:region" (e.g., "windscribe:us", "nordvpn:uk") Returns: - HTTP proxy URI or None if unavailable + HTTP proxy URI, or None for a bare region, so that a bare query moves on to the next proxy provider """ parts = query.split(":") + if len(parts) == 1: + return None if len(parts) != 2: raise ValueError(f"Invalid query format: '{query}'. Expected 'provider:region' (e.g., 'windscribe:us')") diff --git a/unshackle/core/proxies/hola.py b/unshackle/core/proxies/hola.py index 74dbda6c..bb608fc6 100644 --- a/unshackle/core/proxies/hola.py +++ b/unshackle/core/proxies/hola.py @@ -10,8 +10,7 @@ from unshackle.core.proxies.proxy import Proxy class Hola(Proxy): def __init__(self): """ - Proxy provider that uses Hola's direct connections through the hola-proxy project. - https://github.com/Snawoot/hola-proxy + Proxy provider that uses Hola's direct connections through the hola-proxy binary. """ self.binary = binaries.HolaProxy if not self.binary: @@ -28,33 +27,60 @@ class Hola(Proxy): """ Get an HTTP proxy URI for a Datacenter ('direct') or Residential ('lum') Hola server. + Returns None for a query that is not a country Hola lists, or when Hola gives no proxy for it. + TODO: - Add ability to select 'lum' proxies (residential proxies). - Return and use Proxy Authorization """ query = query.lower() + if not any(query in country for country in self.countries): + return None - p = subprocess.check_output([self.binary, "-country", query, "-list-proxies"], stderr=subprocess.STDOUT).decode( - "utf-8", errors="replace" - ) + ban = "Transaction error: temporary ban detected." + banned = ConnectionError("Hola banned your IP temporarily from it's services. Try change your IP.") + try: + # hola-proxy logs to stderr from another thread, so a merged stream splits the Login/Password lines. + result = subprocess.run( + [self.binary, "-country", query, "-list-proxies"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=30, + ) + except subprocess.TimeoutExpired as e: + # While banned, hola-proxy retries forever and never exits, so the ban only shows in its log so far. + log = e.stderr.decode("utf-8", errors="replace") if isinstance(e.stderr, bytes) else (e.stderr or "") + if ban in log: + raise banned from None + raise + if ban in result.stdout + result.stderr: + raise banned + result.check_returncode() - if "Transaction error: temporary ban detected." in p: - raise ConnectionError("Hola banned your IP temporarily from it's services. Try change your IP.") + login = re.search(r"Login: (.*)\nPassword: (.*)\nProxy-Authorization: (.*)", result.stdout) + if not login: + return None + username, password, proxy_authorization = login.groups() - username, password, proxy_authorization = re.search( - r"Login: (.*)\nPassword: (.*)\nProxy-Authorization: (.*)", p - ).groups() - - servers = re.findall(r"(zagent.*)", p) + servers = re.findall(r"(zagent.*)", result.stdout) proxies = [] for server in servers: host, ip_address, direct, peer, hola, trial, trial_peer, vendor = server.split(",") proxies.append(f"http://{username}:{password}@{ip_address}:{peer}") - proxy = random.choice(proxies) - return proxy + return random.choice(proxies) if proxies else None def get_countries(self) -> list[dict[str, str]]: """Get a list of available Countries.""" - p = subprocess.check_output([self.binary, "-list-countries"]).decode("utf-8", errors="replace") + p = subprocess.run( + [self.binary, "-list-countries"], + capture_output=True, + check=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ).stdout return [{code: name} for country in p.splitlines() for (code, name) in [country.split(" - ", maxsplit=1)]] diff --git a/unshackle/core/proxies/nordvpn.py b/unshackle/core/proxies/nordvpn.py index b54ef4fe..bb6e1a34 100644 --- a/unshackle/core/proxies/nordvpn.py +++ b/unshackle/core/proxies/nordvpn.py @@ -6,6 +6,7 @@ from typing import Optional import requests from unshackle.core.proxies.proxy import Proxy +from unshackle.core.utilities import COUNTRY_CODE_ALIASES class NordVPN(Proxy): @@ -52,10 +53,10 @@ class NordVPN(Proxy): - Country code: "us", "ca", "gb" - Country ID: "228" - Specific server: "us1234" - - City selection: "us:seattle", "ca:calgary" + - City selection: "us:seattle", "us:new-york", "ca:calgary" - Returns None if the country code or ID is not one NordVPN lists. A country with no recommended - servers, or a city with no matching server, raises ValueError. + Returns None for a query NordVPN cannot serve: an unrecognised form, a country it does not list, + or a country with no recommended servers. A city with no matching server raises ValueError. """ query = query.lower() city = None @@ -64,17 +65,17 @@ class NordVPN(Proxy): query, city = query.split(":", maxsplit=1) city = city.strip() - if re.match(r"^[a-z]{2}\d+$", query): + if re.fullmatch(r"[a-z]{2}\d+", query): hostname = f"{query}.proxy.nordvpn.com" else: if query.isdigit(): country = self.get_country(by_id=int(query)) - elif re.match(r"^[a-z]+$", query): - country = self.get_country(by_code=query) + elif re.fullmatch(r"[a-z]+", query): + country = self.get_country(by_code=COUNTRY_CODE_ALIASES.get(query, query)) else: - raise ValueError(f"The query provided is unsupported and unrecognized: {query}") + return None if not country: - return + return None server_map_key = f"{country['code'].lower()}:{city}" if city else country["code"].lower() server_mapping = self.server_map.get(server_map_key) or ( @@ -84,27 +85,27 @@ class NordVPN(Proxy): if server_mapping: hostname = f"{country['code'].lower()}{server_mapping}.proxy.nordvpn.com" else: - recommended_servers = self.get_recommended_servers(country["id"]) - if not recommended_servers: - raise ValueError( - f"The NordVPN Country {query} currently has no recommended servers. " - "Try again later. If the issue persists, double-check the query." - ) - + city_id = None if city: - city_servers = self.filter_servers_by_city(recommended_servers, city) - if not city_servers: + # Recommendations are ordered by distance from the caller, so the API has to filter by city. + city_id = next( + (x["id"] for x in country.get("cities", []) if city in (x["name"].lower(), x.get("dns_name"))), + None, + ) + if city_id is None: raise ValueError( f"No servers found in city '{city}' for country '{country['name']}'. " "Try a different city or check the city name spelling." ) - recommended_servers = city_servers + recommended_servers = self.get_recommended_servers(country["id"], city_id) + if not recommended_servers: + return None hostname = random.choice(recommended_servers)["hostname"] if hostname.startswith("gb"): # NordVPN uses the alpha2 of 'GB' in API responses, but 'UK' in the hostname - hostname = f"gb{hostname[2:]}" + hostname = f"uk{hostname[2:]}" if hostname.endswith(".nordvpn.com") and not hostname.endswith(".proxy.nordvpn.com"): hostname = hostname[: -len(".nordvpn.com")] + ".proxy.nordvpn.com" @@ -123,50 +124,16 @@ class NordVPN(Proxy): return country @staticmethod - def filter_servers_by_city(servers: list[dict], city: str) -> list[dict]: + def get_recommended_servers(country_id: int, city_id: Optional[int] = None) -> list[dict]: """ - Filter servers by city name. - - The API returns servers with location data that includes city information. - This method filters servers to only those in the specified city. - - Args: - servers: List of server dictionaries from the NordVPN API - city: City name to filter by (case-insensitive) - - Returns: - List of servers in the specified city - """ - city_lower = city.lower() - filtered = [] - - for server in servers: - locations = server.get("locations", []) - for location in locations: - # City data can be in different formats: - # - {"city": {"name": "Seattle", ...}} - # - {"city": "Seattle"} - city_data = location.get("city") - if city_data: - city_name = city_data.get("name") if isinstance(city_data, dict) else city_data - if city_name and city_name.lower() == city_lower: - filtered.append(server) - break # Found a match, no need to check other locations for this server - - return filtered - - @staticmethod - def get_recommended_servers(country_id: int) -> list[dict]: - """ - Get the list of recommended Servers for a Country. + Get the list of recommended Servers for a Country, or for one of its cities. Note: There may not always be more than one recommended server. """ - res = requests.get( - url="https://api.nordvpn.com/v1/servers/recommendations", - params={"filters[country_id]": country_id}, - timeout=10, - ) + params = {"filters[country_id]": country_id} + if city_id is not None: + params["filters[country_city_id]"] = city_id + res = requests.get(url="https://api.nordvpn.com/v1/servers/recommendations", params=params, timeout=10) if not res.ok: raise ValueError(f"Failed to get a list of NordVPN countries [{res.status_code}]") diff --git a/unshackle/core/proxies/proton.py b/unshackle/core/proxies/proton.py index 6522a152..98c1bd0e 100644 --- a/unshackle/core/proxies/proton.py +++ b/unshackle/core/proxies/proton.py @@ -187,9 +187,12 @@ class ProtonVPN(Proxy): return chosen def get_logicals(self) -> list[dict]: + """Get Proton's server list. Only a list that loaded is cached, so a later session can still fetch it.""" if self.logicals is None: response = self.api("GET", "/vpn/v1/logicals") - self.logicals = (response.json().get("LogicalServers") or []) if response is not None else [] + if response is None: + return [] + self.logicals = response.json().get("LogicalServers") or [] return self.logicals def get_max_tier(self) -> int: diff --git a/unshackle/core/proxies/surfsharkvpn.py b/unshackle/core/proxies/surfsharkvpn.py index b331ae90..0dfa6d26 100644 --- a/unshackle/core/proxies/surfsharkvpn.py +++ b/unshackle/core/proxies/surfsharkvpn.py @@ -50,7 +50,8 @@ class SurfsharkVPN(Proxy): server, ) continue - self.server_map[region] = server + country, sep, city = str(region).lower().partition(":") + self.server_map[COUNTRY_CODE_ALIASES.get(country, country) + sep + city] = server self.countries = self.get_countries() @@ -78,6 +79,7 @@ class SurfsharkVPN(Proxy): if ":" in query: query, city = query.split(":", maxsplit=1) city = city.strip() + query = COUNTRY_CODE_ALIASES.get(query, query) server_map_key = f"{query}:{city}" if city else query if server_map_key in self.server_map: @@ -85,8 +87,7 @@ class SurfsharkVPN(Proxy): elif not city and any(x["connectionName"].split(".", 1)[0] == query for x in self.countries): server = query elif re.fullmatch(r"[a-z]{2}", query): - country_code = COUNTRY_CODE_ALIASES.get(query, query).upper() - servers = [x for x in self.countries if x["countryCode"] == country_code] + servers = [x for x in self.countries if x["countryCode"] == query.upper()] if not servers: return None server = self.get_random_server(servers, city) diff --git a/unshackle/core/proxies/windscribevpn.py b/unshackle/core/proxies/windscribevpn.py index 6d4720cf..a5bbf34d 100644 --- a/unshackle/core/proxies/windscribevpn.py +++ b/unshackle/core/proxies/windscribevpn.py @@ -47,6 +47,8 @@ class WindscribeVPN(Proxy): - Country code: "us", "ca", "gb" - Specific server: "sg007", "us150", "us-central-150" - City selection: "us:seattle", "ca:toronto" + + Returns None for a query WindscribeVPN cannot serve. A city with no matching server raises ValueError. """ # Windscribe names GB hosts "uk-NNN"; Ukraine is "ua", so "uk" is unambiguous. query = re.sub(r"^uk(?=$|[:\d])", "gb", query.lower()) @@ -62,23 +64,15 @@ class WindscribeVPN(Proxy): elif query in self.server_map: hostname = self.server_map[query] else: - server_match = re.match(r"^([a-z]{2})(\d+)$", query) + server_match = re.fullmatch(r"([a-z]{2})(\d+)", query) if server_match: - country_code, server_num = server_match.groups() - hostname = self.get_specific_server(country_code, server_num) - if not hostname: - raise ValueError( - f"No WindscribeVPN server found matching '{query}'. " - f"Check the server number or use just '{country_code}' for a random server." - ) - elif re.match(r"^[a-z]+$", query): + hostname = self.get_specific_server(*server_match.groups()) + elif re.fullmatch(r"[a-z]+", query): hostname = self.get_random_server(query, city) elif "-" in query: hostname = next((h for h in self.iter_hostnames() if h.startswith(f"{query}.")), None) - if not hostname: - raise ValueError(f"No WindscribeVPN server found matching '{query}'.") else: - raise ValueError(f"The query provided is unsupported and unrecognized: {query}") + hostname = None if not hostname: return None