From 427626745566c0010afdc347cf4445d63824275b Mon Sep 17 00:00:00 2001 From: Andy Date: Fri, 25 Jul 2025 09:03:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(proxies):=20=E2=9C=A8=20Add=20SurfsharkVPN?= =?UTF-8?q?=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original code by @p0llux12 - Discord - Introduced `SurfsharkVPN` class for proxy service integration. - Updated configuration to include `surfsharkvpn` in proxy providers. - Removed legacy `nordvpn` configuration from YAML. - Enhanced `dl.py` and `search.py` to utilize `SurfsharkVPN`. --- unshackle/commands/dl.py | 4 +- unshackle/commands/search.py | 4 +- unshackle/core/config.py | 1 - unshackle/core/proxies/__init__.py | 3 +- unshackle/core/proxies/surfsharkvpn.py | 124 +++++++++++++++++++++++++ unshackle/unshackle.yaml | 14 +-- 6 files changed, 139 insertions(+), 11 deletions(-) create mode 100644 unshackle/core/proxies/surfsharkvpn.py diff --git a/unshackle/commands/dl.py b/unshackle/commands/dl.py index 45a34e3..e598b19 100644 --- a/unshackle/commands/dl.py +++ b/unshackle/commands/dl.py @@ -48,7 +48,7 @@ from unshackle.core.constants import DOWNLOAD_LICENCE_ONLY, AnyTrack, context_se from unshackle.core.credential import Credential from unshackle.core.drm import DRM_T, PlayReady, Widevine from unshackle.core.events import events -from unshackle.core.proxies import Basic, Hola, NordVPN +from unshackle.core.proxies import Basic, Hola, NordVPN, SurfsharkVPN from unshackle.core.service import Service from unshackle.core.services import Services from unshackle.core.titles import Movie, Movies, Series, Song, Title_T @@ -309,6 +309,8 @@ class dl: self.proxy_providers.append(Basic(**config.proxy_providers["basic"])) if config.proxy_providers.get("nordvpn"): self.proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"])) + if config.proxy_providers.get("surfsharkvpn"): + self.proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"])) if binaries.HolaProxy: self.proxy_providers.append(Hola()) for proxy_provider in self.proxy_providers: diff --git a/unshackle/commands/search.py b/unshackle/commands/search.py index 0c6ec13..a6d63bb 100644 --- a/unshackle/commands/search.py +++ b/unshackle/commands/search.py @@ -16,7 +16,7 @@ from unshackle.core import binaries from unshackle.core.config import config from unshackle.core.console import console from unshackle.core.constants import context_settings -from unshackle.core.proxies import Basic, Hola, NordVPN +from unshackle.core.proxies import Basic, Hola, NordVPN, SurfsharkVPN from unshackle.core.service import Service from unshackle.core.services import Services from unshackle.core.utils.click_types import ContextData @@ -69,6 +69,8 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr proxy_providers.append(Basic(**config.proxy_providers["basic"])) if config.proxy_providers.get("nordvpn"): proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"])) + if config.proxy_providers.get("surfsharkvpn"): + proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"])) if binaries.HolaProxy: proxy_providers.append(Hola()) for proxy_provider in proxy_providers: diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 1fbb875..d2e4762 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -71,7 +71,6 @@ class Config: self.headers: dict = kwargs.get("headers") or {} self.key_vaults: list[dict[str, Any]] = kwargs.get("key_vaults", []) self.muxing: dict = kwargs.get("muxing") or {} - self.nordvpn: dict = kwargs.get("nordvpn") or {} self.proxy_providers: dict = kwargs.get("proxy_providers") or {} self.serve: dict = kwargs.get("serve") or {} self.services: dict = kwargs.get("services") or {} diff --git a/unshackle/core/proxies/__init__.py b/unshackle/core/proxies/__init__.py index 5768266..10008c1 100644 --- a/unshackle/core/proxies/__init__.py +++ b/unshackle/core/proxies/__init__.py @@ -1,5 +1,6 @@ from .basic import Basic from .hola import Hola from .nordvpn import NordVPN +from .surfsharkvpn import SurfsharkVPN -__all__ = ("Basic", "Hola", "NordVPN") +__all__ = ("Basic", "Hola", "NordVPN", "SurfsharkVPN") diff --git a/unshackle/core/proxies/surfsharkvpn.py b/unshackle/core/proxies/surfsharkvpn.py new file mode 100644 index 0000000..0b9c035 --- /dev/null +++ b/unshackle/core/proxies/surfsharkvpn.py @@ -0,0 +1,124 @@ +import json +import re +import random +from typing import Optional + +import requests + +from unshackle.core.proxies.proxy import Proxy + + +class SurfsharkVPN(Proxy): + def __init__(self, username: str, password: str, server_map: Optional[dict[str, int]] = None): + """ + Proxy Service using SurfsharkVPN Service Credentials. + + A username and password must be provided. These are Service Credentials, not your Login Credentials. + The Service Credentials can be found here: https://my.surfshark.com/vpn/manual-setup/main/openvpn + """ + if not username: + raise ValueError("No Username was provided to the SurfsharkVPN Proxy Service.") + if not password: + raise ValueError("No Password was provided to the SurfsharkVPN Proxy Service.") + if not re.match(r"^[a-z0-9]{48}$", username + password, re.IGNORECASE) or "@" in username: + raise ValueError( + "The Username and Password must be SurfsharkVPN Service Credentials, not your Login Credentials. " + "The Service Credentials can be found here: https://my.surfshark.com/vpn/manual-setup/main/openvpn" + ) + + 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}'.") + + self.username = username + self.password = password + self.server_map = server_map or {} + + self.countries = self.get_countries() + + def __repr__(self) -> str: + countries = len(set(x.get("country") for x in self.countries if x.get("country"))) + servers = sum(1 for x in self.countries if x.get("connectionName")) + + return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})" + + def get_proxy(self, query: str) -> Optional[str]: + """ + Get an HTTP(SSL) proxy URI for a SurfsharkVPN server. + """ + query = query.lower() + 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" + else: + if query.isdigit(): + # country id + country = self.get_country(by_id=int(query)) + elif re.match(r"^[a-z]+$", query): + # country code + country = self.get_country(by_code=query) + else: + raise ValueError(f"The query provided is unsupported and unrecognized: {query}") + if not country: + # SurfsharkVPN doesnt have servers in this region + return + + server_mapping = self.server_map.get(country["countryCode"].lower()) + if server_mapping: + # country was set to a specific server ID in config + hostname = f"{country['code'].lower()}{server_mapping}.prod.surfshark.com" + else: + # get the random server ID + random_server = self.get_random_server(country["countryCode"]) + 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 + + 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]: + """Search for a Country and it's 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): + """ + Get the list of random Server for a Country. + + Note: There may not always be more than one recommended server. + """ + country = [x["connectionName"] for x in self.countries if x["countryCode"].lower() == country_id.lower()] + try: + country = random.choice(country) + return country + except Exception: + raise ValueError("Could not get random countrycode from the countries list.") + + @staticmethod + def get_countries() -> list[dict]: + """Get a list of available Countries and their metadata.""" + res = requests.get( + url="https://api.surfshark.com/v3/server/clusters/all", + headers={ + "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", + }, + ) + if not res.ok: + raise ValueError(f"Failed to get a list of SurfsharkVPN countries [{res.status_code}]") + + try: + return res.json() + except json.JSONDecodeError: + raise ValueError("Could not decode list of SurfsharkVPN countries, not JSON data.") diff --git a/unshackle/unshackle.yaml b/unshackle/unshackle.yaml index 0f40de1..5c50934 100644 --- a/unshackle/unshackle.yaml +++ b/unshackle/unshackle.yaml @@ -145,13 +145,6 @@ services: # EXAMPLE: # api_key: "service_specific_key" -# Legacy NordVPN configuration (use proxy_providers instead) -nordvpn: - username: "" - password: "" - servers: - - us: 12 - # External proxy provider services proxy_providers: nordvpn: @@ -159,6 +152,13 @@ proxy_providers: password: password_from_service_credentials servers: - us: 12 # force US server #12 for US proxies + surfsharkvpn: + username: your_surfshark_service_username # Service credentials from https://my.surfshark.com/vpn/manual-setup/main/openvpn + password: your_surfshark_service_password # Service credentials (not your login password) + servers: + - us: 3844 # force US server #3844 for US proxies + - gb: 2697 # force GB server #2697 for GB proxies + - au: 4621 # force AU server #4621 for AU proxies basic: GB: - "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham)