mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-13 12:35:05 +00:00
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:
@@ -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**.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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}'
|
||||
|
||||
@@ -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}]")
|
||||
|
||||
Reference in New Issue
Block a user