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.
This commit is contained in:
imSp4rky
2026-09-25 17:27:36 -06:00
parent 5838f2aed6
commit 34c04e0587
5 changed files with 198 additions and 93 deletions
+19 -7
View File
@@ -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**.
+111
View File
@@ -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
+5 -1
View File
@@ -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
+2 -2
View File
@@ -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}'
+61 -83
View File
@@ -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}]")