mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-12 01:19:02 +00:00
feat: Gluetun VPN integration and remote service enhancements
Major features: - Native Docker-based Gluetun VPN proxy provider with multi-provider support (NordVPN, Windscribe, Surfshark, ExpressVPN, and 50+ more) - Stateless remote service architecture with local session caching - Client-side authentication for remote services (browser, 2FA, OAuth support) Key changes: - core/proxies/windscribevpn.py: Enhanced proxy handling - core/crypto.py: Cryptographic utilities - docs/VPN_PROXY_SETUP.md: Comprehensive VPN/proxy documentation
This commit is contained in:
@@ -672,6 +672,8 @@ class dl:
|
||||
self.log.info(f"Loaded {proxy_provider.__class__.__name__}: {proxy_provider}")
|
||||
|
||||
if proxy:
|
||||
# Store original proxy query for service-specific proxy_map
|
||||
original_proxy_query = proxy
|
||||
requested_provider = None
|
||||
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
|
||||
# requesting proxy from a specific proxy provider
|
||||
|
||||
@@ -80,6 +80,7 @@ def status_command(remote: Optional[str]) -> None:
|
||||
|
||||
from unshackle.core.local_session_cache import get_local_session_cache
|
||||
|
||||
|
||||
# Get local session cache
|
||||
cache = get_local_session_cache()
|
||||
|
||||
|
||||
284
unshackle/core/crypto.py
Normal file
284
unshackle/core/crypto.py
Normal file
@@ -0,0 +1,284 @@
|
||||
"""Cryptographic utilities for secure remote service authentication."""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
try:
|
||||
from nacl.public import Box, PrivateKey, PublicKey
|
||||
from nacl.secret import SecretBox
|
||||
from nacl.utils import random
|
||||
|
||||
NACL_AVAILABLE = True
|
||||
except ImportError:
|
||||
NACL_AVAILABLE = False
|
||||
|
||||
log = logging.getLogger("crypto")
|
||||
|
||||
|
||||
class CryptoError(Exception):
|
||||
"""Cryptographic operation error."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ServerKeyPair:
|
||||
"""
|
||||
Server-side key pair for secure remote authentication.
|
||||
|
||||
Uses NaCl (libsodium) for public key cryptography.
|
||||
The server generates a key pair and shares the public key with clients.
|
||||
Clients encrypt sensitive data with the public key, which only the server can decrypt.
|
||||
"""
|
||||
|
||||
def __init__(self, private_key: Optional[PrivateKey] = None):
|
||||
"""
|
||||
Initialize server key pair.
|
||||
|
||||
Args:
|
||||
private_key: Existing private key, or None to generate new
|
||||
"""
|
||||
if not NACL_AVAILABLE:
|
||||
raise CryptoError("PyNaCl is not installed. Install with: pip install pynacl")
|
||||
|
||||
self.private_key = private_key or PrivateKey.generate()
|
||||
self.public_key = self.private_key.public_key
|
||||
|
||||
def get_public_key_b64(self) -> str:
|
||||
"""
|
||||
Get base64-encoded public key for sharing with clients.
|
||||
|
||||
Returns:
|
||||
Base64-encoded public key
|
||||
"""
|
||||
return base64.b64encode(bytes(self.public_key)).decode("utf-8")
|
||||
|
||||
def decrypt_message(self, encrypted_message: str, client_public_key_b64: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Decrypt a message from a client.
|
||||
|
||||
Args:
|
||||
encrypted_message: Base64-encoded encrypted message
|
||||
client_public_key_b64: Base64-encoded client public key
|
||||
|
||||
Returns:
|
||||
Decrypted message as dictionary
|
||||
"""
|
||||
try:
|
||||
# Decode keys
|
||||
client_public_key = PublicKey(base64.b64decode(client_public_key_b64))
|
||||
encrypted_data = base64.b64decode(encrypted_message)
|
||||
|
||||
# Create box for decryption
|
||||
box = Box(self.private_key, client_public_key)
|
||||
|
||||
# Decrypt
|
||||
decrypted = box.decrypt(encrypted_data)
|
||||
return json.loads(decrypted.decode("utf-8"))
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Decryption failed: {e}")
|
||||
raise CryptoError(f"Failed to decrypt message: {e}")
|
||||
|
||||
def save_to_file(self, path: Path) -> None:
|
||||
"""
|
||||
Save private key to file.
|
||||
|
||||
Args:
|
||||
path: Path to save the key
|
||||
"""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
key_data = {
|
||||
"private_key": base64.b64encode(bytes(self.private_key)).decode("utf-8"),
|
||||
"public_key": self.get_public_key_b64(),
|
||||
}
|
||||
path.write_text(json.dumps(key_data, indent=2), encoding="utf-8")
|
||||
log.info(f"Server key pair saved to {path}")
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, path: Path) -> "ServerKeyPair":
|
||||
"""
|
||||
Load private key from file.
|
||||
|
||||
Args:
|
||||
path: Path to load the key from
|
||||
|
||||
Returns:
|
||||
ServerKeyPair instance
|
||||
"""
|
||||
if not path.exists():
|
||||
raise CryptoError(f"Key file not found: {path}")
|
||||
|
||||
try:
|
||||
key_data = json.loads(path.read_text(encoding="utf-8"))
|
||||
private_key_bytes = base64.b64decode(key_data["private_key"])
|
||||
private_key = PrivateKey(private_key_bytes)
|
||||
log.info(f"Server key pair loaded from {path}")
|
||||
return cls(private_key)
|
||||
except Exception as e:
|
||||
raise CryptoError(f"Failed to load key from {path}: {e}")
|
||||
|
||||
|
||||
class ClientCrypto:
|
||||
"""
|
||||
Client-side cryptography for secure remote authentication.
|
||||
|
||||
Generates ephemeral key pairs and encrypts sensitive data for the server.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize client crypto with ephemeral key pair."""
|
||||
if not NACL_AVAILABLE:
|
||||
raise CryptoError("PyNaCl is not installed. Install with: pip install pynacl")
|
||||
|
||||
# Generate ephemeral key pair for this session
|
||||
self.private_key = PrivateKey.generate()
|
||||
self.public_key = self.private_key.public_key
|
||||
|
||||
def get_public_key_b64(self) -> str:
|
||||
"""
|
||||
Get base64-encoded public key for sending to server.
|
||||
|
||||
Returns:
|
||||
Base64-encoded public key
|
||||
"""
|
||||
return base64.b64encode(bytes(self.public_key)).decode("utf-8")
|
||||
|
||||
def encrypt_credentials(
|
||||
self, credentials: Dict[str, Any], server_public_key_b64: str
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Encrypt credentials for the server.
|
||||
|
||||
Args:
|
||||
credentials: Dictionary containing sensitive data (username, password, cookies, etc.)
|
||||
server_public_key_b64: Base64-encoded server public key
|
||||
|
||||
Returns:
|
||||
Tuple of (encrypted_message_b64, client_public_key_b64)
|
||||
"""
|
||||
try:
|
||||
# Decode server public key
|
||||
server_public_key = PublicKey(base64.b64decode(server_public_key_b64))
|
||||
|
||||
# Create box for encryption
|
||||
box = Box(self.private_key, server_public_key)
|
||||
|
||||
# Encrypt
|
||||
message = json.dumps(credentials).encode("utf-8")
|
||||
encrypted = box.encrypt(message)
|
||||
|
||||
# Return base64-encoded encrypted message and client public key
|
||||
encrypted_b64 = base64.b64encode(encrypted).decode("utf-8")
|
||||
client_public_key_b64 = self.get_public_key_b64()
|
||||
|
||||
return encrypted_b64, client_public_key_b64
|
||||
|
||||
except Exception as e:
|
||||
log.error(f"Encryption failed: {e}")
|
||||
raise CryptoError(f"Failed to encrypt credentials: {e}")
|
||||
|
||||
|
||||
def encrypt_credential_data(
|
||||
username: Optional[str], password: Optional[str], cookies: Optional[str], server_public_key_b64: str
|
||||
) -> Tuple[str, str]:
|
||||
"""
|
||||
Helper function to encrypt credential data.
|
||||
|
||||
Args:
|
||||
username: Username or None
|
||||
password: Password or None
|
||||
cookies: Cookie file content or None
|
||||
server_public_key_b64: Server's public key
|
||||
|
||||
Returns:
|
||||
Tuple of (encrypted_data_b64, client_public_key_b64)
|
||||
"""
|
||||
client_crypto = ClientCrypto()
|
||||
|
||||
credentials = {}
|
||||
if username and password:
|
||||
credentials["username"] = username
|
||||
credentials["password"] = password
|
||||
if cookies:
|
||||
credentials["cookies"] = cookies
|
||||
|
||||
return client_crypto.encrypt_credentials(credentials, server_public_key_b64)
|
||||
|
||||
|
||||
def decrypt_credential_data(encrypted_data_b64: str, client_public_key_b64: str, server_keypair: ServerKeyPair) -> Dict[str, Any]:
|
||||
"""
|
||||
Helper function to decrypt credential data.
|
||||
|
||||
Args:
|
||||
encrypted_data_b64: Base64-encoded encrypted data
|
||||
client_public_key_b64: Client's public key
|
||||
server_keypair: Server's key pair
|
||||
|
||||
Returns:
|
||||
Decrypted credentials dictionary
|
||||
"""
|
||||
return server_keypair.decrypt_message(encrypted_data_b64, client_public_key_b64)
|
||||
|
||||
|
||||
# Session-only authentication helpers
|
||||
|
||||
|
||||
def serialize_authenticated_session(service_instance) -> Dict[str, Any]:
|
||||
"""
|
||||
Serialize an authenticated service session for remote use.
|
||||
|
||||
This extracts session cookies and headers WITHOUT including credentials.
|
||||
|
||||
Args:
|
||||
service_instance: Authenticated service instance
|
||||
|
||||
Returns:
|
||||
Dictionary with session data (cookies, headers) but NO credentials
|
||||
"""
|
||||
from unshackle.core.api.session_serializer import serialize_session
|
||||
|
||||
session_data = serialize_session(service_instance.session)
|
||||
|
||||
# Add additional metadata
|
||||
session_data["authenticated"] = True
|
||||
session_data["service_tag"] = service_instance.__class__.__name__
|
||||
|
||||
return session_data
|
||||
|
||||
|
||||
def is_session_valid(session_data: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Check if session data appears valid.
|
||||
|
||||
Args:
|
||||
session_data: Session data dictionary
|
||||
|
||||
Returns:
|
||||
True if session has cookies or auth headers
|
||||
"""
|
||||
if not session_data:
|
||||
return False
|
||||
|
||||
# Check for cookies or authorization headers
|
||||
has_cookies = bool(session_data.get("cookies"))
|
||||
has_auth = "Authorization" in session_data.get("headers", {})
|
||||
|
||||
return has_cookies or has_auth
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ServerKeyPair",
|
||||
"ClientCrypto",
|
||||
"CryptoError",
|
||||
"encrypt_credential_data",
|
||||
"decrypt_credential_data",
|
||||
"serialize_authenticated_session",
|
||||
"is_session_valid",
|
||||
"NACL_AVAILABLE",
|
||||
]
|
||||
@@ -45,22 +45,27 @@ class WindscribeVPN(Proxy):
|
||||
"""
|
||||
Get an HTTPS proxy URI for a WindscribeVPN server.
|
||||
|
||||
Note: Windscribe's static OpenVPN credentials work reliably on US, AU, and NZ servers.
|
||||
Supports:
|
||||
- Country code: "us", "ca", "gb"
|
||||
- City selection: "us:seattle", "ca:toronto"
|
||||
"""
|
||||
query = query.lower()
|
||||
supported_regions = {"us", "au", "nz"}
|
||||
city = None
|
||||
|
||||
if query not in supported_regions and query not in self.server_map:
|
||||
raise ValueError(
|
||||
f"Windscribe proxy does not currently support the '{query.upper()}' region. "
|
||||
f"Supported regions with reliable credentials: {', '.join(sorted(supported_regions))}. "
|
||||
)
|
||||
# Check if query includes city specification (e.g., "ca:toronto")
|
||||
if ":" in query:
|
||||
query, city = query.split(":", maxsplit=1)
|
||||
city = city.strip()
|
||||
|
||||
if query in self.server_map:
|
||||
# Check server_map for pinned servers (can include city)
|
||||
server_map_key = f"{query}:{city}" if city else query
|
||||
if server_map_key in self.server_map:
|
||||
hostname = self.server_map[server_map_key]
|
||||
elif query in self.server_map and not city:
|
||||
hostname = self.server_map[query]
|
||||
else:
|
||||
if re.match(r"^[a-z]+$", query):
|
||||
hostname = self.get_random_server(query)
|
||||
hostname = self.get_random_server(query, city)
|
||||
else:
|
||||
raise ValueError(f"The query provided is unsupported and unrecognized: {query}")
|
||||
|
||||
@@ -70,22 +75,40 @@ class WindscribeVPN(Proxy):
|
||||
hostname = hostname.split(':')[0]
|
||||
return f"https://{self.username}:{self.password}@{hostname}:443"
|
||||
|
||||
def get_random_server(self, country_code: str) -> Optional[str]:
|
||||
def get_random_server(self, country_code: str, city: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Get a random server hostname for a country.
|
||||
Get a random server hostname for a country, optionally filtered by city.
|
||||
|
||||
Returns None if no servers are available for the country.
|
||||
Args:
|
||||
country_code: The country code (e.g., "us", "ca")
|
||||
city: Optional city name to filter by (case-insensitive)
|
||||
|
||||
Returns:
|
||||
A random hostname from matching servers, or None if none available.
|
||||
"""
|
||||
for location in self.countries:
|
||||
if location.get("country_code", "").lower() == country_code.lower():
|
||||
hostnames = []
|
||||
for group in location.get("groups", []):
|
||||
# Filter by city if specified
|
||||
if city:
|
||||
group_city = group.get("city", "")
|
||||
if group_city.lower() != city.lower():
|
||||
continue
|
||||
|
||||
# Collect hostnames from this group
|
||||
for host in group.get("hosts", []):
|
||||
if hostname := host.get("hostname"):
|
||||
hostnames.append(hostname)
|
||||
|
||||
if hostnames:
|
||||
return random.choice(hostnames)
|
||||
elif city:
|
||||
# No servers found for the specified city
|
||||
raise ValueError(
|
||||
f"No servers found in city '{city}' for country code '{country_code}'. "
|
||||
"Try a different city or check the city name spelling."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user