From 34c04e0587fa4dc54112703743a517a2f65ecd85 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Fri, 25 Sep 2026 17:27:36 -0600 Subject: [PATCH] fix(proxies): match surfshark's current server api Server names replace numeric server IDs; unknown queries return None so the next proxy provider can answer. --- docs/guide/proxies-and-vpn.md | 26 +++-- tests/core/test_surfsharkvpn_proxy.py | 111 +++++++++++++++++++ unshackle/core/proxies/gluetun.py | 6 +- unshackle/core/proxies/proxy.py | 4 +- unshackle/core/proxies/surfsharkvpn.py | 144 +++++++++++-------------- 5 files changed, 198 insertions(+), 93 deletions(-) create mode 100644 tests/core/test_surfsharkvpn_proxy.py diff --git a/docs/guide/proxies-and-vpn.md b/docs/guide/proxies-and-vpn.md index 8c4b87ab..f5c82402 100644 --- a/docs/guide/proxies-and-vpn.md +++ b/docs/guide/proxies-and-vpn.md @@ -503,7 +503,8 @@ For a given query region, Gluetun resolves the server like this: that alias always wins. 2. unshackle changes a `us1239`-style query (country code + number) with no explicit mapping into a provider-specific server hostname (for example `us1239.nordvpn.com` - for NordVPN, `us-1239.prod.surfshark.com` for Surfshark). + for NordVPN). Surfshark has no numbered servers, so map a Surfshark region to a + server name such as `us-dal.prod.surfshark.com` in `server_hostnames`. 3. unshackle expands a bare two-letter code to the full country name Gluetun expects. A few providers (Windscribe, VyprVPN, VPN Secure) select by **region** rather than @@ -765,21 +766,32 @@ proxy_providers: surfsharkvpn: username: YOUR_SERVICE_USERNAME password: YOUR_SERVICE_PASSWORD - server_map: # optional - us: 1234 + server_map: # optional: region -> server name + us: us-dal + us:seattle: us-sea ``` **Required keys:** `username`, `password`, validated with the same 48-character rule as NordVPN (combined username+password must be 48 alphanumeric characters, case-insensitive, no `@`). -`server_map` optionally pins server IDs per region. +`server_map` optionally pins a server per region. Give the server name from Surfshark's +server list, for example `us-dal`. A full hostname such as `us-dal.prod.surfshark.com` also +works. Surfshark does not use numeric server IDs. unshackle ignores a numeric or empty value +and logs a warning. A `us:seattle` entry applies only to that city query, and it does not +fall back to the `us` entry. **Query forms:** | Query | Meaning | |---|---| -| `us` | A random US server. | -| `us-bos` | A specific server. | -| `us:seattle` | A random server in Seattle. | +| `surfsharkvpn:us` | A random US server. `uk` and `gb` both give a UK server. | +| `surfsharkvpn:us-bos` | A specific server from Surfshark's server list. | +| `surfsharkvpn:us:seattle` | A random server in Seattle. | + +unshackle ignores case, spaces, hyphens and accents in a city name, so `us:new-york` +matches New York. A city also matches the start of a longer name: `de:frankfurt` matches +Frankfurt am Main. A bare `us` also reaches Surfshark, but a bare `us-bos` or +`us:seattle` can stop at a proxy provider earlier in the order, so use the +`surfsharkvpn:` prefix for those forms. The returned proxy is HTTPS on **port 443**. diff --git a/tests/core/test_surfsharkvpn_proxy.py b/tests/core/test_surfsharkvpn_proxy.py new file mode 100644 index 00000000..afa37b10 --- /dev/null +++ b/tests/core/test_surfsharkvpn_proxy.py @@ -0,0 +1,111 @@ +"""Unit tests for the SurfsharkVPN proxy provider against the current cluster API shape.""" + +from __future__ import annotations + +import logging + +import pytest + +from unshackle.core.proxies.surfsharkvpn import SurfsharkVPN + +pytestmark = pytest.mark.unit + +USERNAME = "a" * 24 +PASSWORD = "b" * 24 +CLUSTERS = [ + {"id": "4c333aa2", "countryCode": "US", "connectionName": "us-sea.prod.surfshark.com", "location": "Seattle"}, + {"id": "f8aa8d00", "countryCode": "US", "connectionName": "us-dal.prod.surfshark.com", "location": "Dallas"}, + { + "id": "1b2c3d4e", + "countryCode": "US", + "connectionName": "us-nyc-st002.prod.surfshark.com", + "location": "New York", + }, + {"id": "9794c127", "countryCode": "GB", "connectionName": "uk-lon.prod.surfshark.com", "location": "London"}, + { + "id": "5e6f7a8b", + "countryCode": "DE", + "connectionName": "de-fra.prod.surfshark.com", + "location": "Frankfurt am Main", + }, +] +US_HOSTS = {"us-sea.prod.surfshark.com:443", "us-dal.prod.surfshark.com:443", "us-nyc-st002.prod.surfshark.com:443"} + + +@pytest.fixture(autouse=True) +def fake_clusters(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(SurfsharkVPN, "get_countries", staticmethod(lambda: CLUSTERS)) + + +def host(uri: str | None) -> str: + assert uri is not None + return uri.rsplit("@", 1)[1] + + +def test_country_picks_a_server_name_from_the_api() -> None: + uri = SurfsharkVPN(USERNAME, PASSWORD).get_proxy("us") + assert host(uri) in US_HOSTS + assert uri.startswith(f"https://{USERNAME}:{PASSWORD}@") # type: ignore[union-attr] + + +@pytest.mark.parametrize("query", ["gb", "uk"]) +def test_gb_and_uk_pick_uk_hosts(query: str) -> None: + assert host(SurfsharkVPN(USERNAME, PASSWORD).get_proxy(query)) == "uk-lon.prod.surfshark.com:443" + + +@pytest.mark.parametrize( + ("query", "expected"), + [ + ("us:dallas", "us-dal"), + ("us:new-york", "us-nyc-st002"), + ("us:newyork", "us-nyc-st002"), + ("de:frankfurt", "de-fra"), + ], +) +def test_city_filters_by_location(query: str, expected: str) -> None: + assert host(SurfsharkVPN(USERNAME, PASSWORD).get_proxy(query)) == f"{expected}.prod.surfshark.com:443" + + +def test_unknown_city_raises() -> None: + with pytest.raises(ValueError, match="No servers found in city"): + SurfsharkVPN(USERNAME, PASSWORD).get_proxy("us:boston") + + +@pytest.mark.parametrize("server", ["us-dal", "us-nyc-st002", "US-DAL\n"]) +def test_specific_server_query(server: str) -> None: + uri = SurfsharkVPN(USERNAME, PASSWORD).get_proxy(server) + assert host(uri) == f"{server.strip().lower()}.prod.surfshark.com:443" + + +@pytest.mark.parametrize("query", ["us-central-150", "us150", "zz", "us-dal:seattle", "https"]) +def test_unrecognised_query_returns_none_for_the_next_provider(query: str) -> None: + assert SurfsharkVPN(USERNAME, PASSWORD).get_proxy(query) is None + + +@pytest.mark.parametrize("server", ["us-dal", "us-dal.prod.surfshark.com", " us-dal "]) +def test_server_map_takes_a_server_name(server: str) -> None: + proxy = SurfsharkVPN(USERNAME, PASSWORD, server_map={"us": server}) + assert host(proxy.get_proxy("us")) == "us-dal.prod.surfshark.com:443" + + +def test_server_map_city_key() -> None: + proxy = SurfsharkVPN(USERNAME, PASSWORD, server_map={"us:seattle": "us-sea"}) + assert host(proxy.get_proxy("us:seattle")) == "us-sea.prod.surfshark.com:443" + + +def test_server_map_city_query_does_not_fall_back_to_the_country_entry() -> None: + proxy = SurfsharkVPN(USERNAME, PASSWORD, server_map={"us": "us-sea"}) + assert host(proxy.get_proxy("us:dallas")) == "us-dal.prod.surfshark.com:443" + + +def test_server_map_alias_needs_no_api_entry() -> None: + proxy = SurfsharkVPN(USERNAME, PASSWORD, server_map={"nl": "nl-ams"}) + assert host(proxy.get_proxy("nl")) == "nl-ams.prod.surfshark.com:443" + + +@pytest.mark.parametrize("server", [3844, None]) +def test_unusable_server_map_entry_is_ignored_with_a_warning(server: object, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + 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 diff --git a/unshackle/core/proxies/gluetun.py b/unshackle/core/proxies/gluetun.py index d86cf6ad..2a3905c2 100644 --- a/unshackle/core/proxies/gluetun.py +++ b/unshackle/core/proxies/gluetun.py @@ -534,9 +534,13 @@ class Gluetun(Proxy): Server hostname (e.g., "us1239.nordvpn.com") """ country_lower = country_code.lower() + if provider_name == "surfshark": + raise ValueError( + "Surfshark has no numbered servers. Map the region to a server name such as " + f"'{country_lower}-dal.prod.surfshark.com' in server_hostnames instead." + ) hostname_formats = { "nordvpn": f"{country_lower}{server_num}.nordvpn.com", - "surfshark": f"{country_lower}-{server_num}.prod.surfshark.com", "expressvpn": f"{country_lower}-{server_num}.expressvpn.com", "cyberghost": f"{country_lower}-s{server_num}.cg-dialup.net", # Generic fallback for other providers diff --git a/unshackle/core/proxies/proxy.py b/unshackle/core/proxies/proxy.py index b8f9875c..bb974552 100644 --- a/unshackle/core/proxies/proxy.py +++ b/unshackle/core/proxies/proxy.py @@ -23,8 +23,8 @@ class Proxy: """ Get a proxy URI from the proxy provider. - Return None only when this method accepts the query but can give no proxy. - Otherwise, use exceptions to denote any errors with the call or query. + Return None when this proxy provider has no proxy for the query, so that a bare query + moves on to the next proxy provider. Use exceptions for errors with the call. The returned Proxy URI must be a string supported by Python-Requests: '{scheme}://[{user}:{pass}@]{host}:{port}' diff --git a/unshackle/core/proxies/surfsharkvpn.py b/unshackle/core/proxies/surfsharkvpn.py index b7d7eaeb..b331ae90 100644 --- a/unshackle/core/proxies/surfsharkvpn.py +++ b/unshackle/core/proxies/surfsharkvpn.py @@ -1,15 +1,20 @@ import json +import logging import random import re from typing import Optional import requests +from unidecode import unidecode from unshackle.core.proxies.proxy import Proxy +from unshackle.core.utilities import COUNTRY_CODE_ALIASES + +log = logging.getLogger("proxies.surfsharkvpn") class SurfsharkVPN(Proxy): - def __init__(self, username: str, password: str, server_map: Optional[dict[str, int]] = None): + def __init__(self, username: str, password: str, server_map: Optional[dict[str, str]] = None): """ Proxy provider that uses SurfsharkVPN Service Credentials. @@ -27,11 +32,25 @@ class SurfsharkVPN(Proxy): ) if server_map is not None and not isinstance(server_map, dict): - raise TypeError(f"Expected server_map to be a dict mapping a region to a server ID, not '{server_map!r}'.") + raise TypeError( + f"Expected server_map to be a dict mapping a region to a server name, not '{server_map!r}'." + ) self.username = username self.password = password - self.server_map = server_map or {} + self.server_map: dict[str, str] = {} + for region, server in (server_map or {}).items(): + server = str(server or "").strip() + if not server or server.isdigit(): + # A warning, not an error: one stale entry must not disable the whole proxy provider. + log.warning( + "SurfsharkVPN: ignoring server_map entry '%s: %s'. Use a server name such as 'us-dal'. " + "SurfsharkVPN no longer uses numeric server IDs.", + region, + server, + ) + continue + self.server_map[region] = server self.countries = self.get_countries() @@ -46,108 +65,66 @@ class SurfsharkVPN(Proxy): Get an HTTP(SSL) proxy URI for a SurfsharkVPN server. Supports: - - Country code: "us", "ca", "gb" - - Country ID: "228" + - Country code: "us", "ca", "gb" (or "uk") - Specific server: "us-bos" (Boston) - - City selection: "us:seattle", "ca:toronto" + - City selection: "us:seattle", "us:new-york" - Returns None if SurfsharkVPN has no servers in the queried country. A city with no matching - server raises ValueError. + Returns None for a query that names no SurfsharkVPN country or server, so that the + next proxy provider can answer it. A city with no matching server raises ValueError. """ - query = query.lower() + query = query.strip().lower() city = None - # Check if query includes city specification (e.g., "us:seattle") if ":" in query: query, city = query.split(":", maxsplit=1) city = city.strip() - if re.match(r"^[a-z]{2}\d+$", query): - # country and surfsharkvpn server id, e.g., au-per, be-anr, us-bos - hostname = f"{query}.prod.surfshark.com" + server_map_key = f"{query}:{city}" if city else query + if server_map_key in self.server_map: + server = self.server_map[server_map_key] + 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] + if not servers: + return None + server = self.get_random_server(servers, city) 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) - else: - raise ValueError(f"The query provided is unsupported and unrecognized: {query}") - if not country: - return + return None - server_map_key = f"{country['countryCode'].lower()}:{city}" if city else country["countryCode"].lower() - server_mapping = self.server_map.get(server_map_key) or ( - self.server_map.get(country["countryCode"].lower()) if not city else None - ) + return f"https://{self.username}:{self.password}@{self.get_hostname(server)}:443" - if server_mapping: - hostname = f"{country['code'].lower()}{server_mapping}.prod.surfshark.com" - else: - random_server = self.get_random_server(country["countryCode"], city) - if not random_server: - raise ValueError( - f"The SurfsharkVPN Country {query} currently has no random servers. " - "Try again later. If the issue persists, double-check the query." - ) - hostname = random_server + @staticmethod + def get_hostname(server: str) -> str: + """Expand a server name such as 'us-dal' to its full hostname.""" + server = server.lower() + return server if server.endswith(".surfshark.com") else f"{server}.prod.surfshark.com" - return f"https://{self.username}:{self.password}@{hostname}:443" - - def get_country(self, by_id: Optional[int] = None, by_code: Optional[str] = None) -> Optional[dict]: - """Find a Country and its metadata.""" - if all(x is None for x in (by_id, by_code)): - raise ValueError("At least one search query must be made.") - - for country in self.countries: - if all( - [ - by_id is None or country["id"] == int(by_id), - by_code is None or country["countryCode"] == by_code.upper(), - ] - ): - return country - - def get_random_server(self, country_id: str, city: Optional[str] = None): + def get_random_server(self, servers: list[dict], city: Optional[str] = None) -> str: """ - Get a random server for a Country, optionally filtered by city. + Get a random server name from a country's servers, optionally filtered by city. - Args: - country_id: The country code (e.g., "US", "CA") - city: Optional city name to filter by (case-insensitive) - - Note: The API may include a 'location' field with city information. - If not available, this will return any server from the country. + A city matches with case, spaces, hyphens and accents ignored ("new-york" matches "New York"), + exactly first, then as a prefix ("frankfurt" matches "Frankfurt am Main"). """ - servers = [x for x in self.countries if x["countryCode"].lower() == country_id.lower()] - if city: - city_lower = city.lower() - # Check if servers have a 'location' field for city filtering - city_servers = [ - x - for x in servers - if x.get("location", "").lower() == city_lower or x.get("city", "").lower() == city_lower + wanted = self.fold(city) + located = [(self.fold(x.get("location", "")), x) for x in servers] + servers = [x for location, x in located if location == wanted] or [ + x for location, x in located if location.startswith(wanted) ] - - if city_servers: - servers = city_servers - else: + if not servers: raise ValueError( - f"No servers found in city '{city}' for country '{country_id}'. " - "Try a different city or check the city name spelling." + f"No servers found in city '{city}'. Try a different city or check the city name spelling." ) - # Get connection names from filtered servers - if not servers: - raise ValueError(f"Could not get random server for country '{country_id}': no servers found.") + return random.choice(servers)["connectionName"] - connection_names = [x["connectionName"] for x in servers if "connectionName" in x] - if not connection_names: - raise ValueError( - f"Could not get random server for country '{country_id}': no servers with connectionName found." - ) - - return random.choice(connection_names) + @staticmethod + def fold(text: str) -> str: + """Reduce a place name to lowercase ASCII letters and digits.""" + return re.sub(r"[^a-z0-9]", "", unidecode(text).lower()) @staticmethod def get_countries() -> list[dict]: @@ -158,6 +135,7 @@ class SurfsharkVPN(Proxy): "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", "Content-Type": "application/json", }, + timeout=10, ) if not res.ok: raise ValueError(f"Failed to get a list of SurfsharkVPN countries [{res.status_code}]")