Initial Commit
This commit is contained in:
5
unshackle/core/proxies/__init__.py
Normal file
5
unshackle/core/proxies/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .basic import Basic
|
||||
from .hola import Hola
|
||||
from .nordvpn import NordVPN
|
||||
|
||||
__all__ = ("Basic", "Hola", "NordVPN")
|
||||
54
unshackle/core/proxies/basic.py
Normal file
54
unshackle/core/proxies/basic.py
Normal file
@@ -0,0 +1,54 @@
|
||||
import random
|
||||
import re
|
||||
from typing import Optional, Union
|
||||
|
||||
from requests.utils import prepend_scheme_if_needed
|
||||
from urllib3.util import parse_url
|
||||
|
||||
from unshackle.core.proxies.proxy import Proxy
|
||||
|
||||
|
||||
class Basic(Proxy):
|
||||
def __init__(self, **countries: dict[str, Union[str, list[str]]]):
|
||||
"""Basic Proxy Service using Proxies specified in the config."""
|
||||
self.countries = {k.lower(): v for k, v in countries.items()}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
countries = len(self.countries)
|
||||
servers = len(self.countries.values())
|
||||
|
||||
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."""
|
||||
query = query.lower()
|
||||
|
||||
match = re.match(r"^([a-z]{2})(\d+)?$", query, re.IGNORECASE)
|
||||
if not match:
|
||||
raise ValueError(f'The query "{query}" was not recognized...')
|
||||
|
||||
country_code = match.group(1)
|
||||
entry = match.group(2)
|
||||
|
||||
servers: Optional[Union[str, list[str]]] = self.countries.get(country_code)
|
||||
if not servers:
|
||||
return None
|
||||
|
||||
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}"...'
|
||||
)
|
||||
else:
|
||||
proxy = random.choice(servers)
|
||||
|
||||
proxy = prepend_scheme_if_needed(proxy, "http")
|
||||
parsed_proxy = parse_url(proxy)
|
||||
if not parsed_proxy.host:
|
||||
raise ValueError(f"The proxy '{proxy}' is not a valid proxy URI supported by Python-Requests.")
|
||||
|
||||
return proxy
|
||||
60
unshackle/core/proxies/hola.py
Normal file
60
unshackle/core/proxies/hola.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Optional
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.proxies.proxy import Proxy
|
||||
|
||||
|
||||
class Hola(Proxy):
|
||||
def __init__(self):
|
||||
"""
|
||||
Proxy Service using Hola's direct connections via the hola-proxy project.
|
||||
https://github.com/Snawoot/hola-proxy
|
||||
"""
|
||||
self.binary = binaries.HolaProxy
|
||||
if not self.binary:
|
||||
raise EnvironmentError("hola-proxy executable not found but is required for the Hola proxy provider.")
|
||||
|
||||
self.countries = self.get_countries()
|
||||
|
||||
def __repr__(self) -> str:
|
||||
countries = len(self.countries)
|
||||
|
||||
return f"{countries} Countr{['ies', 'y'][countries == 1]}"
|
||||
|
||||
def get_proxy(self, query: str) -> Optional[str]:
|
||||
"""
|
||||
Get an HTTP proxy URI for a Datacenter ('direct') or Residential ('lum') Hola server.
|
||||
|
||||
TODO: - Add ability to select 'lum' proxies (residential proxies).
|
||||
- Return and use Proxy Authorization
|
||||
"""
|
||||
query = query.lower()
|
||||
|
||||
p = subprocess.check_output(
|
||||
[self.binary, "-country", query, "-list-proxies"], stderr=subprocess.STDOUT
|
||||
).decode()
|
||||
|
||||
if "Transaction error: temporary ban detected." in p:
|
||||
raise ConnectionError("Hola banned your IP temporarily from it's services. Try change your IP.")
|
||||
|
||||
username, password, proxy_authorization = re.search(
|
||||
r"Login: (.*)\nPassword: (.*)\nProxy-Authorization: (.*)", p
|
||||
).groups()
|
||||
|
||||
servers = re.findall(r"(zagent.*)", p)
|
||||
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
|
||||
|
||||
def get_countries(self) -> list[dict[str, str]]:
|
||||
"""Get a list of available Countries."""
|
||||
p = subprocess.check_output([self.binary, "-list-countries"]).decode("utf8")
|
||||
|
||||
return [{code: name} for country in p.splitlines() for (code, name) in [country.split(" - ", maxsplit=1)]]
|
||||
128
unshackle/core/proxies/nordvpn.py
Normal file
128
unshackle/core/proxies/nordvpn.py
Normal file
@@ -0,0 +1,128 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
from unshackle.core.proxies.proxy import Proxy
|
||||
|
||||
|
||||
class NordVPN(Proxy):
|
||||
def __init__(self, username: str, password: str, server_map: Optional[dict[str, int]] = None):
|
||||
"""
|
||||
Proxy Service using NordVPN 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.nordaccount.com/dashboard/nordvpn/
|
||||
"""
|
||||
if not username:
|
||||
raise ValueError("No Username was provided to the NordVPN Proxy Service.")
|
||||
if not password:
|
||||
raise ValueError("No Password was provided to the NordVPN 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 NordVPN Service Credentials, not your Login Credentials. "
|
||||
"The Service Credentials can be found here: https://my.nordaccount.com/dashboard/nordvpn/"
|
||||
)
|
||||
|
||||
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(self.countries)
|
||||
servers = sum(x["serverCount"] for x in self.countries)
|
||||
|
||||
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 NordVPN server.
|
||||
|
||||
HTTP proxies under port 80 were disabled on the 15th of Feb, 2021:
|
||||
https://nordvpn.com/blog/removing-http-proxies
|
||||
"""
|
||||
query = query.lower()
|
||||
if re.match(r"^[a-z]{2}\d+$", query):
|
||||
# country and nordvpn server id, e.g., us1, fr1234
|
||||
hostname = f"{query}.nordvpn.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:
|
||||
# NordVPN doesnt have servers in this region
|
||||
return
|
||||
|
||||
server_mapping = self.server_map.get(country["code"].lower())
|
||||
if server_mapping:
|
||||
# country was set to a specific server ID in config
|
||||
hostname = f"{country['code'].lower()}{server_mapping}.nordvpn.com"
|
||||
else:
|
||||
# get the recommended server ID
|
||||
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."
|
||||
)
|
||||
hostname = recommended_servers[0]["hostname"]
|
||||
|
||||
if hostname.startswith("gb"):
|
||||
# NordVPN uses the alpha2 of 'GB' in API responses, but 'UK' in the hostname
|
||||
hostname = f"gb{hostname[2:]}"
|
||||
|
||||
return f"https://{self.username}:{self.password}@{hostname}:89"
|
||||
|
||||
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["code"] == by_code.upper()]
|
||||
):
|
||||
return country
|
||||
|
||||
@staticmethod
|
||||
def get_recommended_servers(country_id: int) -> list[dict]:
|
||||
"""
|
||||
Get the list of recommended Servers for a Country.
|
||||
|
||||
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}
|
||||
)
|
||||
if not res.ok:
|
||||
raise ValueError(f"Failed to get a list of NordVPN countries [{res.status_code}]")
|
||||
|
||||
try:
|
||||
return res.json()
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Could not decode list of NordVPN countries, not JSON data.")
|
||||
|
||||
@staticmethod
|
||||
def get_countries() -> list[dict]:
|
||||
"""Get a list of available Countries and their metadata."""
|
||||
res = requests.get(
|
||||
url="https://api.nordvpn.com/v1/servers/countries",
|
||||
)
|
||||
if not res.ok:
|
||||
raise ValueError(f"Failed to get a list of NordVPN countries [{res.status_code}]")
|
||||
|
||||
try:
|
||||
return res.json()
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError("Could not decode list of NordVPN countries, not JSON data.")
|
||||
31
unshackle/core/proxies/proxy.py
Normal file
31
unshackle/core/proxies/proxy.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from abc import abstractmethod
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Proxy:
|
||||
@abstractmethod
|
||||
def __init__(self, **kwargs):
|
||||
"""
|
||||
The constructor initializes the Service using passed configuration data.
|
||||
|
||||
Any authorization or pre-fetching of data should be done here.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def __repr__(self) -> str:
|
||||
"""Return a string denoting a list of Countries and Servers (if possible)."""
|
||||
countries = ...
|
||||
servers = ...
|
||||
return f"{countries} Countr{['ies', 'y'][countries == 1]} ({servers} Server{['s', ''][servers == 1]})"
|
||||
|
||||
@abstractmethod
|
||||
def get_proxy(self, query: str) -> Optional[str]:
|
||||
"""
|
||||
Get a Proxy URI from the Proxy Service.
|
||||
|
||||
Only return None if the query was accepted, but no proxy could be returned.
|
||||
Otherwise, please use exceptions to denote any errors with the call or query.
|
||||
|
||||
The returned Proxy URI must be a string supported by Python-Requests:
|
||||
'{scheme}://[{user}:{pass}@]{host}:{port}'
|
||||
"""
|
||||
Reference in New Issue
Block a user