forked from kenzuya/unshackle
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:
526
docs/VPN_PROXY_SETUP.md
Normal file
526
docs/VPN_PROXY_SETUP.md
Normal file
@@ -0,0 +1,526 @@
|
||||
# VPN-to-HTTP Proxy Bridge for Unshackle
|
||||
|
||||
## Overview
|
||||
|
||||
This guide explains how to use **Gluetun** - a Docker-based VPN client that creates an isolated HTTP proxy from VPN services (including WireGuard). This allows Unshackle to use VPN providers like ExpressVPN, Windscribe, NordVPN, and many others without affecting your system's normal internet connection.
|
||||
|
||||
> **Note**: Unshackle now has **native Gluetun integration**! You can use `--proxy gluetun:windscribe:us` directly without manual Docker setup. See [CONFIG.md](../CONFIG.md#gluetun-dict) for configuration. The guide below is for advanced users who want to manage Gluetun containers manually.
|
||||
|
||||
## Why This Approach?
|
||||
|
||||
- **Network Isolation**: VPN connection runs in Docker container, doesn't affect host system
|
||||
- **HTTP Proxy Interface**: Exposes standard HTTP proxy that Unshackle can use directly
|
||||
- **WireGuard Support**: Modern, fast, and secure VPN protocol
|
||||
- **Kill Switch**: Built-in protection prevents IP leaks if VPN disconnects
|
||||
- **Multi-Provider**: Supports 50+ VPN providers out of the box
|
||||
- **Cross-Platform**: Works on Linux and Windows (via Docker Desktop or WSL2)
|
||||
|
||||
## Supported VPN Providers
|
||||
|
||||
Gluetun supports many providers including:
|
||||
- ExpressVPN
|
||||
- Windscribe
|
||||
- NordVPN
|
||||
- Surfshark
|
||||
- ProtonVPN
|
||||
- Private Internet Access
|
||||
- Mullvad
|
||||
- And 50+ more
|
||||
|
||||
Full list: https://github.com/qdm12/gluetun/wiki
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Linux
|
||||
```bash
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in for group changes to take effect
|
||||
```
|
||||
|
||||
### Windows
|
||||
- Install [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop/)
|
||||
- Enable WSL2 backend (recommended)
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Create Gluetun Configuration Directory
|
||||
|
||||
```bash
|
||||
mkdir -p ~/gluetun-config
|
||||
cd ~/gluetun-config
|
||||
```
|
||||
|
||||
### 2. Create Docker Compose File
|
||||
|
||||
Create `docker-compose.yml` with your VPN provider configuration:
|
||||
|
||||
#### Example: Windscribe with WireGuard
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
gluetun:
|
||||
image: qmcgaw/gluetun:latest
|
||||
container_name: gluetun
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
ports:
|
||||
- 8888:8888/tcp # HTTP proxy
|
||||
- 8388:8388/tcp # Shadowsocks (optional)
|
||||
- 8388:8388/udp # Shadowsocks (optional)
|
||||
environment:
|
||||
# VPN Provider Settings
|
||||
- VPN_SERVICE_PROVIDER=windscribe
|
||||
- VPN_TYPE=wireguard
|
||||
|
||||
# Get these from your Windscribe account
|
||||
- WIREGUARD_PRIVATE_KEY=your_private_key_here
|
||||
- WIREGUARD_ADDRESSES=your_address_here
|
||||
- WIREGUARD_PRESHARED_KEY=your_preshared_key_here # if applicable
|
||||
|
||||
# Server location (optional)
|
||||
- SERVER_COUNTRIES=US
|
||||
# or specific city
|
||||
# - SERVER_CITIES=New York
|
||||
|
||||
# HTTP Proxy Settings
|
||||
- HTTPPROXY=on
|
||||
- HTTPPROXY_LOG=on
|
||||
- HTTPPROXY_LISTENING_ADDRESS=:8888
|
||||
|
||||
# Timezone
|
||||
- TZ=America/New_York
|
||||
|
||||
# Logging
|
||||
- LOG_LEVEL=info
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
# Health check
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "https://api.ipify.org"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
#### Example: ExpressVPN with WireGuard
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
gluetun:
|
||||
image: qmcgaw/gluetun:latest
|
||||
container_name: gluetun
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
ports:
|
||||
- 8888:8888/tcp # HTTP proxy
|
||||
environment:
|
||||
- VPN_SERVICE_PROVIDER=expressvpn
|
||||
- VPN_TYPE=wireguard
|
||||
|
||||
# Get these from ExpressVPN's WireGuard configuration
|
||||
- WIREGUARD_PRIVATE_KEY=your_private_key_here
|
||||
- WIREGUARD_ADDRESSES=your_address_here
|
||||
|
||||
- HTTPPROXY=on
|
||||
- HTTPPROXY_LISTENING_ADDRESS=:8888
|
||||
- TZ=America/New_York
|
||||
- LOG_LEVEL=info
|
||||
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
#### Example: NordVPN with WireGuard
|
||||
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
gluetun:
|
||||
image: qmcgaw/gluetun:latest
|
||||
container_name: gluetun
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
ports:
|
||||
- 8888:8888/tcp # HTTP proxy
|
||||
environment:
|
||||
- VPN_SERVICE_PROVIDER=nordvpn
|
||||
- VPN_TYPE=wireguard
|
||||
|
||||
# NordVPN token (get from NordVPN dashboard)
|
||||
- WIREGUARD_PRIVATE_KEY=your_private_key_here
|
||||
- WIREGUARD_ADDRESSES=your_address_here
|
||||
|
||||
- SERVER_COUNTRIES=US
|
||||
|
||||
- HTTPPROXY=on
|
||||
- HTTPPROXY_LISTENING_ADDRESS=:8888
|
||||
- TZ=America/New_York
|
||||
- LOG_LEVEL=info
|
||||
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
### 3. Getting Your WireGuard Credentials
|
||||
|
||||
#### Windscribe
|
||||
1. Log into Windscribe account
|
||||
2. Go to "My Account" → "WireGuard"
|
||||
3. Generate a config file for your desired location
|
||||
4. Extract the private key and addresses from the config
|
||||
|
||||
#### ExpressVPN
|
||||
1. Log into ExpressVPN
|
||||
2. Navigate to the manual configuration section
|
||||
3. Select WireGuard and download the configuration
|
||||
4. Extract credentials from the config file
|
||||
|
||||
#### NordVPN
|
||||
1. Log into NordVPN dashboard
|
||||
2. Go to Services → NordVPN → Manual setup
|
||||
3. Generate WireGuard credentials
|
||||
4. Copy the private key and addresses
|
||||
|
||||
### 4. Start Gluetun Container
|
||||
|
||||
```bash
|
||||
cd ~/gluetun-config
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Check logs to verify connection:
|
||||
```bash
|
||||
docker logs gluetun -f
|
||||
```
|
||||
|
||||
You should see messages indicating successful VPN connection and HTTP proxy starting on port 8888.
|
||||
|
||||
### 5. Test the Proxy
|
||||
|
||||
```bash
|
||||
# Test that the proxy works
|
||||
curl -x http://localhost:8888 https://api.ipify.org
|
||||
|
||||
# This should show your VPN's IP address, not your real IP
|
||||
```
|
||||
|
||||
## Integrating with Unshackle
|
||||
|
||||
### Option 1: Using Basic Proxy Configuration
|
||||
|
||||
Add to your Unshackle config (`~/.config/unshackle/config.yaml`):
|
||||
|
||||
```yaml
|
||||
proxies:
|
||||
Basic:
|
||||
us: "http://localhost:8888"
|
||||
uk: "http://localhost:8888" # if you have multiple Gluetun containers
|
||||
```
|
||||
|
||||
Then use in Unshackle:
|
||||
```bash
|
||||
uv run unshackle dl SERVICE_NAME CONTENT_ID --proxy us
|
||||
```
|
||||
|
||||
### Option 2: Creating Multiple VPN Proxy Containers
|
||||
|
||||
You can run multiple Gluetun containers for different regions:
|
||||
|
||||
**gluetun-us.yml:**
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
gluetun-us:
|
||||
image: qmcgaw/gluetun:latest
|
||||
container_name: gluetun-us
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
ports:
|
||||
- 8888:8888/tcp # HTTP proxy
|
||||
environment:
|
||||
- VPN_SERVICE_PROVIDER=windscribe
|
||||
- VPN_TYPE=wireguard
|
||||
- SERVER_COUNTRIES=US
|
||||
- WIREGUARD_PRIVATE_KEY=your_key
|
||||
- WIREGUARD_ADDRESSES=your_address
|
||||
- HTTPPROXY=on
|
||||
- HTTPPROXY_LISTENING_ADDRESS=:8888
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
**gluetun-uk.yml:**
|
||||
```yaml
|
||||
version: "3"
|
||||
services:
|
||||
gluetun-uk:
|
||||
image: qmcgaw/gluetun:latest
|
||||
container_name: gluetun-uk
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
ports:
|
||||
- 8889:8888/tcp # Different host port
|
||||
environment:
|
||||
- VPN_SERVICE_PROVIDER=windscribe
|
||||
- VPN_TYPE=wireguard
|
||||
- SERVER_COUNTRIES=GB
|
||||
- WIREGUARD_PRIVATE_KEY=your_key
|
||||
- WIREGUARD_ADDRESSES=your_address
|
||||
- HTTPPROXY=on
|
||||
- HTTPPROXY_LISTENING_ADDRESS=:8888
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Then in Unshackle config:
|
||||
```yaml
|
||||
proxies:
|
||||
Basic:
|
||||
us: "http://localhost:8888"
|
||||
uk: "http://localhost:8889"
|
||||
ca: "http://localhost:8890"
|
||||
```
|
||||
|
||||
### Option 3: Using with Authentication (Recommended for Security)
|
||||
|
||||
Add authentication to your Gluetun proxy:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- HTTPPROXY=on
|
||||
- HTTPPROXY_LISTENING_ADDRESS=:8888
|
||||
- HTTPPROXY_USER=myusername
|
||||
- HTTPPROXY_PASSWORD=mypassword
|
||||
```
|
||||
|
||||
Then in Unshackle config:
|
||||
```yaml
|
||||
proxies:
|
||||
Basic:
|
||||
us: "http://myusername:mypassword@localhost:8888"
|
||||
```
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Port Forwarding (for torrenting services)
|
||||
|
||||
Some VPN providers support port forwarding:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- VPN_PORT_FORWARDING=on
|
||||
- VPN_PORT_FORWARDING_LISTENING_PORT=8000
|
||||
```
|
||||
|
||||
### SOCKS5 Proxy (Alternative to HTTP)
|
||||
|
||||
Gluetun also supports SOCKS5 proxy:
|
||||
|
||||
```yaml
|
||||
ports:
|
||||
- 1080:1080/tcp # SOCKS5 proxy
|
||||
environment:
|
||||
- SHADOWSOCKS=on
|
||||
- SHADOWSOCKS_LISTENING_ADDRESS=:1080
|
||||
```
|
||||
|
||||
### DNS Over TLS
|
||||
|
||||
For enhanced privacy:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- DOT=on
|
||||
- DOT_PROVIDERS=cloudflare
|
||||
```
|
||||
|
||||
### Custom Firewall Rules
|
||||
|
||||
Block specific ports or IPs:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- FIREWALL_OUTBOUND_SUBNETS=192.168.1.0/24 # Allow LAN access
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container Fails to Start
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
docker logs gluetun
|
||||
```
|
||||
|
||||
Common issues:
|
||||
- Missing `NET_ADMIN` capability
|
||||
- `/dev/net/tun` not available
|
||||
- Invalid WireGuard credentials
|
||||
|
||||
### VPN Not Connecting
|
||||
|
||||
1. Verify credentials are correct
|
||||
2. Check VPN provider status
|
||||
3. Try different server location
|
||||
4. Check firewall isn't blocking VPN ports
|
||||
|
||||
### Proxy Not Working
|
||||
|
||||
Test connectivity:
|
||||
```bash
|
||||
# Check if port is open
|
||||
docker exec gluetun netstat -tlnp | grep 8888
|
||||
|
||||
# Test proxy directly
|
||||
curl -v -x http://localhost:8888 https://api.ipify.org
|
||||
```
|
||||
|
||||
### IP Leak Prevention
|
||||
|
||||
Verify your IP is hidden:
|
||||
```bash
|
||||
# Without proxy (should show your real IP)
|
||||
curl https://api.ipify.org
|
||||
|
||||
# With proxy (should show VPN IP)
|
||||
curl -x http://localhost:8888 https://api.ipify.org
|
||||
```
|
||||
|
||||
### Performance Issues
|
||||
|
||||
- WireGuard is generally faster than OpenVPN
|
||||
- Try different VPN servers closer to your location
|
||||
- Check container resource limits
|
||||
- Monitor with `docker stats gluetun`
|
||||
|
||||
## Managing Gluetun
|
||||
|
||||
### Start Container
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Stop Container
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Restart Container
|
||||
```bash
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### Update Gluetun
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
docker logs gluetun -f
|
||||
```
|
||||
|
||||
### Check Status
|
||||
```bash
|
||||
docker ps | grep gluetun
|
||||
```
|
||||
|
||||
## Windows-Specific Notes
|
||||
|
||||
### Using Docker Desktop
|
||||
|
||||
1. Ensure WSL2 backend is enabled in Docker Desktop settings
|
||||
2. Use PowerShell or WSL2 terminal for commands
|
||||
3. Access proxy from Windows: `http://localhost:8888`
|
||||
4. Access from WSL2: `http://host.docker.internal:8888`
|
||||
|
||||
### Using WSL2 Directly
|
||||
|
||||
If running Unshackle in WSL2:
|
||||
```yaml
|
||||
proxies:
|
||||
Basic:
|
||||
us: "http://localhost:8888" # If Gluetun is in same WSL2 distro
|
||||
# or
|
||||
us: "http://host.docker.internal:8888" # If Gluetun is in Docker Desktop
|
||||
```
|
||||
|
||||
## Network Isolation Benefits
|
||||
|
||||
The Docker-based approach provides several benefits:
|
||||
|
||||
1. **Namespace Isolation**: VPN connection exists only in container
|
||||
2. **No System Route Changes**: Host routing table remains unchanged
|
||||
3. **No Connection Drops**: Host internet connection unaffected
|
||||
4. **Easy Switching**: Start/stop VPN without affecting other applications
|
||||
5. **Multiple Simultaneous VPNs**: Run multiple containers with different locations
|
||||
6. **Kill Switch**: Automatic with container networking
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **WireGuard**: Modern protocol, faster than OpenVPN, less CPU usage
|
||||
- **Docker Overhead**: Minimal (< 5% performance impact)
|
||||
- **Memory Usage**: ~50-100MB per container
|
||||
- **Network Latency**: Negligible with localhost connection
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Enable authentication** on HTTP proxy (HTTPPROXY_USER/PASSWORD)
|
||||
2. **Bind to localhost only** (don't expose 0.0.0.0 unless needed)
|
||||
3. **Use Docker networks** for container-to-container communication
|
||||
4. **Keep Gluetun updated** for security patches
|
||||
5. **Monitor logs** for unauthorized access attempts
|
||||
|
||||
## References
|
||||
|
||||
- [Gluetun GitHub Repository](https://github.com/qdm12/gluetun)
|
||||
- [Gluetun Wiki - Setup Guides](https://github.com/qdm12/gluetun/wiki)
|
||||
- [Windscribe Setup Guide](https://github.com/qdm12/gluetun/wiki/Windscribe)
|
||||
- [Docker Installation](https://docs.docker.com/engine/install/)
|
||||
|
||||
## Alternative Solutions
|
||||
|
||||
If Gluetun doesn't meet your needs, consider:
|
||||
|
||||
### 1. **Pritunl Client + Tinyproxy**
|
||||
- Run Pritunl in Docker with Tinyproxy
|
||||
- More complex setup but more control
|
||||
|
||||
### 2. **OpenConnect + Privoxy**
|
||||
- For Cisco AnyConnect VPNs
|
||||
- Network namespace isolation on Linux
|
||||
|
||||
### 3. **WireGuard + SOCKS5 Proxy**
|
||||
- Manual WireGuard setup with microsocks/dante
|
||||
- Maximum control but requires networking knowledge
|
||||
|
||||
### 4. **Network Namespaces (Linux Only)**
|
||||
```bash
|
||||
# Create namespace
|
||||
sudo ip netns add vpn
|
||||
|
||||
# Setup WireGuard in namespace
|
||||
sudo ip netns exec vpn wg-quick up wg0
|
||||
|
||||
# Run proxy in namespace
|
||||
sudo ip netns exec vpn tinyproxy -d -c /etc/tinyproxy.conf
|
||||
```
|
||||
|
||||
However, **Gluetun is recommended** for its ease of use, maintenance, and cross-platform support.
|
||||
|
||||
## Conclusion
|
||||
|
||||
Using Gluetun provides a robust, isolated, and easy-to-manage solution for connecting Unshackle to VPN services that don't offer HTTP proxies. The Docker-based approach ensures your system's network remains stable while giving you full VPN benefits for Unshackle downloads.
|
||||
@@ -88,7 +88,6 @@ dev = [
|
||||
"types-requests>=2.31.0.20240406,<3",
|
||||
"isort>=5.13.2,<8",
|
||||
"ruff>=0.3.7,<0.15",
|
||||
"unshackle",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
9
uv.lock
generated
9
uv.lock
generated
@@ -1090,6 +1090,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycountry"
|
||||
version = "24.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/76/57/c389fa68c50590881a75b7883eeb3dc15e9e73a0fdc001cdd45c13290c92/pycountry-24.6.1.tar.gz", hash = "sha256:b61b3faccea67f87d10c1f2b0fc0be714409e8fcdcc1315613174f6466c10221", size = 6043910, upload-time = "2024-06-01T04:12:15.05Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/ec/1fb891d8a2660716aadb2143235481d15ed1cbfe3ad669194690b0604492/pycountry-24.6.1-py3-none-any.whl", hash = "sha256:f1a4fb391cd7214f8eefd39556d740adcc233c778a27f8942c8dca351d6ce06f", size = 6335189, upload-time = "2024-06-01T04:11:49.711Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "2.22"
|
||||
|
||||
Reference in New Issue
Block a user