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:
Andy
2026-01-24 11:23:13 -07:00
parent e3767716f3
commit e77f000494
7 changed files with 857 additions and 13 deletions

View File

@@ -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

View File

@@ -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
View 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",
]

View File

@@ -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