feat: merge upstream dev branch

- Add Gluetun dynamic VPN-to-HTTP proxy provider
   - Add remote services and authentication system
   - Add country code utilities
   - Add Docker binary detection
   - Update proxy providers
This commit is contained in:
Andy
2025-11-25 20:14:48 +00:00
parent 2d4bf140fa
commit 965482a1e4
27 changed files with 6678 additions and 98 deletions

936
CONFIG.md

File diff suppressed because it is too large Load Diff

159
docs/GLUETUN.md Normal file
View File

@@ -0,0 +1,159 @@
# Gluetun VPN Proxy
Gluetun provides Docker-managed VPN proxies supporting 50+ VPN providers.
## Prerequisites
**Docker must be installed and running.**
```bash
# Linux
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER # Then log out/in
# Windows/Mac
# Install Docker Desktop: https://www.docker.com/products/docker-desktop/
```
## Quick Start
### 1. Configuration
Add to `~/.config/unshackle/unshackle.yaml`:
```yaml
proxy_providers:
gluetun:
providers:
nordvpn:
vpn_type: wireguard
credentials:
private_key: YOUR_PRIVATE_KEY
```
### 2. Usage
Use 2-letter country codes directly:
```bash
uv run unshackle dl SERVICE CONTENT --proxy gluetun:nordvpn:us
uv run unshackle dl SERVICE CONTENT --proxy gluetun:nordvpn:uk
```
Format: `gluetun:provider:region`
## Provider Credential Requirements
Each provider has different credential requirements. See the [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki/tree/main/setup/providers) for complete details.
| Provider | VPN Type | Required Credentials |
|----------|----------|---------------------|
| NordVPN | WireGuard | `private_key` only |
| ProtonVPN | WireGuard | `private_key` only |
| Windscribe | WireGuard | `private_key`, `addresses`, `preshared_key` (all required) |
| Surfshark | WireGuard | `private_key`, `addresses` |
| Mullvad | WireGuard | `private_key`, `addresses` |
| IVPN | WireGuard | `private_key`, `addresses` |
| ExpressVPN | OpenVPN | `username`, `password` (no WireGuard support) |
| Any | OpenVPN | `username`, `password` |
### Configuration Examples
**NordVPN/ProtonVPN** (only private_key needed):
```yaml
providers:
nordvpn:
vpn_type: wireguard
credentials:
private_key: YOUR_PRIVATE_KEY
```
**Windscribe** (all three credentials required):
```yaml
providers:
windscribe:
vpn_type: wireguard
credentials:
private_key: YOUR_PRIVATE_KEY
addresses: 10.x.x.x/32
preshared_key: YOUR_PRESHARED_KEY # Required, can be empty string
```
**OpenVPN** (any provider):
```yaml
providers:
expressvpn:
vpn_type: openvpn
credentials:
username: YOUR_USERNAME
password: YOUR_PASSWORD
```
## Server Selection
Most providers use `SERVER_COUNTRIES`, but some use `SERVER_REGIONS`:
| Variable | Providers |
|----------|-----------|
| `SERVER_COUNTRIES` | NordVPN, ProtonVPN, Surfshark, Mullvad, ExpressVPN, and most others |
| `SERVER_REGIONS` | Windscribe, VyprVPN, VPN Secure |
Unshackle handles this automatically - just use 2-letter country codes.
## Global Settings
```yaml
proxy_providers:
gluetun:
providers: {...}
base_port: 8888 # Starting port (default: 8888)
auto_cleanup: true # Remove containers on exit (default: true)
verify_ip: true # Verify IP matches region (default: true)
container_prefix: "unshackle-gluetun"
auth_user: username # Proxy auth (optional)
auth_password: password
```
## Features
- **Container Reuse**: First request takes 10-30s; subsequent requests are instant
- **IP Verification**: Automatically verifies VPN exit IP matches requested region
- **Concurrent Sessions**: Multiple downloads share the same container
- **Specific Servers**: Use `--proxy gluetun:nordvpn:us1239` for specific server selection
## Container Management
```bash
# View containers
docker ps | grep unshackle-gluetun
# Check logs
docker logs unshackle-gluetun-nordvpn-us
# Remove all containers
docker ps -a | grep unshackle-gluetun | awk '{print $1}' | xargs docker rm -f
```
## Troubleshooting
### Docker Permission Denied (Linux)
```bash
sudo usermod -aG docker $USER
# Then log out and log back in
```
### VPN Connection Failed
Check container logs for specific errors:
```bash
docker logs unshackle-gluetun-nordvpn-us
```
Common issues:
- Invalid/missing credentials
- Windscribe requires `preshared_key` (can be empty string)
- VPN provider server issues
## Resources
- [Gluetun Wiki](https://github.com/qdm12/gluetun-wiki) - Official provider documentation
- [Gluetun GitHub](https://github.com/qdm12/gluetun)

View File

@@ -62,6 +62,7 @@ dependencies = [
"aiohttp-swagger3>=0.9.0,<1",
"pysubs2>=1.7.0,<2",
"PyExecJS>=1.5.1,<2",
"pycountry>=24.6.1",
]
[project.urls]

View File

@@ -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, SurfsharkVPN, WindscribeVPN
from unshackle.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN
from unshackle.core.service import Service
from unshackle.core.services import Services
from unshackle.core.title_cacher import get_account_hash
@@ -261,13 +261,6 @@ class dl:
default=None,
help="Wanted episodes, e.g. `S01-S05,S07`, `S01E01-S02E03`, `S02-S02E03`, e.t.c, defaults to all.",
)
@click.option(
"-le",
"--latest-episode",
is_flag=True,
default=False,
help="Download only the single most recent episode available.",
)
@click.option(
"-l",
"--lang",
@@ -275,6 +268,12 @@ class dl:
default="orig",
help="Language wanted for Video and Audio. Use 'orig' to select the original language, e.g. 'orig,en' for both original and English.",
)
@click.option(
"--latest-episode",
is_flag=True,
default=False,
help="Download only the single most recent episode available.",
)
@click.option(
"-vl",
"--v-lang",
@@ -665,6 +664,8 @@ class dl:
self.proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"]))
if config.proxy_providers.get("windscribevpn"):
self.proxy_providers.append(WindscribeVPN(**config.proxy_providers["windscribevpn"]))
if config.proxy_providers.get("gluetun"):
self.proxy_providers.append(Gluetun(**config.proxy_providers["gluetun"]))
if binaries.HolaProxy:
self.proxy_providers.append(Hola())
for proxy_provider in self.proxy_providers:
@@ -675,7 +676,8 @@ class dl:
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
# requesting proxy from a specific proxy provider
requested_provider, proxy = proxy.split(":", maxsplit=1)
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
# Match simple region codes (us, ca, uk1) or provider:region format (nordvpn:ca, windscribe:us)
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match(r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
proxy = proxy.lower()
with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"):
if requested_provider:
@@ -699,8 +701,14 @@ class dl:
proxy = ctx.params["proxy"] = proxy_uri
self.log.info(f"Using {proxy_provider.__class__.__name__} Proxy: {proxy}")
break
# Store proxy query info for service-specific overrides
ctx.params["proxy_query"] = proxy
ctx.params["proxy_provider"] = requested_provider
else:
self.log.info(f"Using explicit Proxy: {proxy}")
# For explicit proxies, store None for query/provider
ctx.params["proxy_query"] = None
ctx.params["proxy_provider"] = None
ctx.obj = ContextData(
config=self.service_config, cdm=self.cdm, proxy_providers=self.proxy_providers, profile=self.profile

View File

@@ -97,6 +97,7 @@ def check() -> None:
"cat": "Network",
},
{"name": "Caddy", "binary": binaries.Caddy, "required": False, "desc": "Web server", "cat": "Network"},
{"name": "Docker", "binary": binaries.Docker, "required": False, "desc": "Gluetun VPN", "cat": "Network"},
]
# Track overall status

View File

@@ -0,0 +1,225 @@
"""CLI command for authenticating remote services."""
from typing import Optional
import click
from rich.table import Table
from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.constants import context_settings
from unshackle.core.remote_auth import RemoteAuthenticator
@click.group(short_help="Manage remote service authentication.", context_settings=context_settings)
def remote_auth() -> None:
"""Authenticate and manage sessions for remote services."""
pass
@remote_auth.command(name="authenticate")
@click.argument("service", type=str)
@click.option(
"-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False
)
@click.option("-p", "--profile", type=str, help="Profile to use for authentication")
def authenticate_command(service: str, remote: Optional[str], profile: Optional[str]) -> None:
"""
Authenticate a service locally and upload session to remote server.
This command:
1. Authenticates the service locally (shows browser, handles 2FA, etc.)
2. Extracts the authenticated session
3. Uploads the session to the remote server
The server will use this pre-authenticated session for all requests.
Examples:
unshackle remote-auth authenticate DSNP
unshackle remote-auth authenticate NF --profile john
unshackle remote-auth auth AMZN --remote my-server
"""
# Get remote server config
remote_config = _get_remote_config(remote)
if not remote_config:
return
remote_url = remote_config["url"]
api_key = remote_config["api_key"]
server_name = remote_config.get("name", remote_url)
console.print(f"\n[bold cyan]Authenticating {service} for remote server:[/bold cyan] {server_name}")
console.print(f"[dim]Server: {remote_url}[/dim]\n")
# Create authenticator
authenticator = RemoteAuthenticator(remote_url, api_key)
# Authenticate and save locally
success = authenticator.authenticate_and_save(service, profile)
if success:
console.print(f"\n[bold green]✓ Success![/bold green] Session saved locally. You can now use remote_{service} service.")
else:
console.print(f"\n[bold red]✗ Failed to authenticate {service}[/bold red]")
raise click.Abort()
@remote_auth.command(name="status")
@click.option(
"-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False
)
def status_command(remote: Optional[str]) -> None:
"""
Show status of all authenticated sessions in local cache.
Examples:
unshackle remote-auth status
unshackle remote-auth status --remote my-server
"""
import datetime
from unshackle.core.local_session_cache import get_local_session_cache
# Get local session cache
cache = get_local_session_cache()
# Get remote server config (optional filter)
remote_url = None
if remote:
remote_config = _get_remote_config(remote)
if remote_config:
remote_url = remote_config["url"]
server_name = remote_config.get("name", remote_url)
else:
server_name = "All Remotes"
# Get sessions (filtered by remote if specified)
sessions = cache.list_sessions(remote_url)
if not sessions:
if remote_url:
console.print(f"\n[yellow]No authenticated sessions for {server_name}[/yellow]")
else:
console.print("\n[yellow]No authenticated sessions in local cache[/yellow]")
console.print("\nUse [cyan]unshackle remote-auth authenticate <SERVICE>[/cyan] to add sessions")
return
# Display sessions in table
table = Table(title=f"Local Authenticated Sessions - {server_name}")
table.add_column("Remote", style="magenta")
table.add_column("Service", style="cyan")
table.add_column("Profile", style="green")
table.add_column("Cached", style="dim")
table.add_column("Age", style="yellow")
table.add_column("Status", style="bold")
for session in sessions:
cached_time = datetime.datetime.fromtimestamp(session["cached_at"]).strftime("%Y-%m-%d %H:%M")
# Format age
age_seconds = session["age_seconds"]
if age_seconds < 3600:
age_str = f"{age_seconds // 60}m"
elif age_seconds < 86400:
age_str = f"{age_seconds // 3600}h"
else:
age_str = f"{age_seconds // 86400}d"
# Status
status = "[red]Expired" if session["expired"] else "[green]Valid"
# Short remote URL for display
remote_display = session["remote_url"].replace("https://", "").replace("http://", "")
if len(remote_display) > 30:
remote_display = remote_display[:27] + "..."
table.add_row(
remote_display,
session["service_tag"],
session["profile"],
cached_time,
age_str,
status
)
console.print()
console.print(table)
console.print("\n[dim]Sessions are stored locally and expire after 24 hours[/dim]")
console.print()
@remote_auth.command(name="delete")
@click.argument("service", type=str)
@click.option(
"-r", "--remote", type=str, help="Remote server name or URL (from config)", required=False
)
@click.option("-p", "--profile", type=str, default="default", help="Profile name")
def delete_command(service: str, remote: Optional[str], profile: str) -> None:
"""
Delete an authenticated session from local cache.
Examples:
unshackle remote-auth delete DSNP
unshackle remote-auth delete NF --profile john
"""
from unshackle.core.local_session_cache import get_local_session_cache
# Get remote server config
remote_config = _get_remote_config(remote)
if not remote_config:
return
remote_url = remote_config["url"]
cache = get_local_session_cache()
console.print(f"\n[yellow]Deleting local session for {service} (profile: {profile})...[/yellow]")
deleted = cache.delete_session(remote_url, service, profile)
if deleted:
console.print("[green]✓ Session deleted from local cache[/green]")
else:
console.print(f"[red]✗ No session found for {service} (profile: {profile})[/red]")
def _get_remote_config(remote: Optional[str]) -> Optional[dict]:
"""
Get remote server configuration.
Args:
remote: Remote server name or URL, or None for first configured remote
Returns:
Remote config dict or None
"""
if not config.remote_services:
console.print("[red]No remote services configured in unshackle.yaml[/red]")
console.print("\nAdd a remote service to your config:")
console.print("[dim]remote_services:")
console.print(" - url: https://your-server.com")
console.print(" api_key: your-api-key")
console.print(" name: my-server[/dim]")
return None
# If no remote specified, use the first one
if not remote:
return config.remote_services[0]
# Check if remote is a name
for remote_config in config.remote_services:
if remote_config.get("name") == remote:
return remote_config
# Check if remote is a URL
for remote_config in config.remote_services:
if remote_config.get("url") == remote:
return remote_config
console.print(f"[red]Remote server '{remote}' not found in config[/red]")
console.print("\nAvailable remotes:")
for remote_config in config.remote_services:
name = remote_config.get("name", remote_config.get("url"))
console.print(f" - {name}")
return None

View File

@@ -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, SurfsharkVPN
from unshackle.core.proxies import Basic, Gluetun, Hola, NordVPN, SurfsharkVPN, WindscribeVPN
from unshackle.core.service import Service
from unshackle.core.services import Services
from unshackle.core.utils.click_types import ContextData
@@ -71,6 +71,10 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
proxy_providers.append(NordVPN(**config.proxy_providers["nordvpn"]))
if config.proxy_providers.get("surfsharkvpn"):
proxy_providers.append(SurfsharkVPN(**config.proxy_providers["surfsharkvpn"]))
if config.proxy_providers.get("windscribevpn"):
proxy_providers.append(WindscribeVPN(**config.proxy_providers["windscribevpn"]))
if config.proxy_providers.get("gluetun"):
proxy_providers.append(Gluetun(**config.proxy_providers["gluetun"]))
if binaries.HolaProxy:
proxy_providers.append(Hola())
for proxy_provider in proxy_providers:
@@ -81,7 +85,8 @@ def search(ctx: click.Context, no_proxy: bool, profile: Optional[str] = None, pr
if re.match(r"^[a-z]+:.+$", proxy, re.IGNORECASE):
# requesting proxy from a specific proxy provider
requested_provider, proxy = proxy.split(":", maxsplit=1)
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
# Match simple region codes (us, ca, uk1) or provider:region format (nordvpn:ca, windscribe:us)
if re.match(r"^[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE) or re.match(r"^[a-z]+:[a-z]{2}(?:\d+)?$", proxy, re.IGNORECASE):
proxy = proxy.lower()
with console.status(f"Getting a Proxy to {proxy}...", spinner="dots"):
if requested_provider:

View File

@@ -24,7 +24,13 @@ from unshackle.core.constants import context_settings
default=False,
help="Include technical debug information (tracebacks, stderr) in API error responses.",
)
def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug_api: bool) -> None:
@click.option(
"--debug",
is_flag=True,
default=False,
help="Enable debug logging for API operations.",
)
def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug_api: bool, debug: bool) -> None:
"""
Serve your Local Widevine Devices and REST API for Remote Access.
@@ -39,12 +45,60 @@ def serve(host: str, port: int, caddy: bool, api_only: bool, no_key: bool, debug
\b
The REST API provides programmatic access to unshackle functionality.
Configure authentication in your config under serve.users and serve.api_secret.
Configure authentication in your config under serve.api_secret and serve.api_keys.
\b
API KEY TIERS:
Premium API keys can use server-side CDM for decryption. Configure in unshackle.yaml:
\b
serve:
api_secret: "your-api-secret"
api_keys:
- key: "basic-user-key"
tier: "basic"
allowed_cdms: []
- key: "premium-user-key"
tier: "premium"
default_cdm: "chromecdm_2101"
allowed_cdms: ["*"] # or list specific CDMs: ["chromecdm_2101", "chromecdm_2202"]
\b
REMOTE SERVICES:
The server exposes endpoints that allow remote unshackle clients to use
your configured services without needing the service implementations.
Remote clients can authenticate, get titles/tracks, and receive session data
for downloading. Configure remote clients in unshackle.yaml:
\b
remote_services:
- url: "http://your-server:8786"
api_key: "your-api-key"
name: "my-server"
\b
Available remote endpoints:
- GET /api/remote/services - List available services
- POST /api/remote/{service}/search - Search for content
- POST /api/remote/{service}/titles - Get titles
- POST /api/remote/{service}/tracks - Get tracks
- POST /api/remote/{service}/chapters - Get chapters
- POST /api/remote/{service}/license - Get DRM license (uses client CDM)
- POST /api/remote/{service}/decrypt - Decrypt using server CDM (premium only)
"""
from pywidevine import serve as pywidevine_serve
log = logging.getLogger("serve")
# Configure logging level based on --debug flag
if debug:
logging.basicConfig(level=logging.DEBUG, format="%(name)s - %(levelname)s - %(message)s")
log.info("Debug logging enabled for API operations")
else:
# Set API loggers to WARNING to reduce noise unless --debug is used
logging.getLogger("api").setLevel(logging.WARNING)
logging.getLogger("api.remote").setLevel(logging.WARNING)
# Validate API secret for REST API routes (unless --no-key is used)
if not no_key:
api_secret = config.serve.get("api_secret")

View File

@@ -0,0 +1,137 @@
"""API key tier management for remote services."""
import logging
from typing import Any, Dict, List, Optional
from aiohttp import web
log = logging.getLogger("api.keys")
def get_api_key_from_request(request: web.Request) -> Optional[str]:
"""
Extract API key from request headers.
Args:
request: aiohttp request object
Returns:
API key string or None
"""
return request.headers.get("X-API-Key") or request.headers.get("Authorization", "").replace("Bearer ", "")
def get_api_key_config(app: web.Application, api_key: str) -> Optional[Dict[str, Any]]:
"""
Get configuration for a specific API key.
Args:
app: aiohttp application
api_key: API key to look up
Returns:
API key configuration dict or None if not found
"""
config = app.get("config", {})
# Check new-style tiered API keys
api_keys = config.get("api_keys", [])
for key_config in api_keys:
if isinstance(key_config, dict) and key_config.get("key") == api_key:
return key_config
# Check legacy users list (backward compatibility)
users = config.get("users", [])
if api_key in users:
return {
"key": api_key,
"tier": "basic",
"allowed_cdms": []
}
return None
def is_premium_user(app: web.Application, api_key: str) -> bool:
"""
Check if an API key belongs to a premium user.
Premium users can use server-side CDM for decryption.
Args:
app: aiohttp application
api_key: API key to check
Returns:
True if premium user, False otherwise
"""
key_config = get_api_key_config(app, api_key)
if not key_config:
return False
tier = key_config.get("tier", "basic")
return tier == "premium"
def get_allowed_cdms(app: web.Application, api_key: str) -> List[str]:
"""
Get list of CDMs that an API key is allowed to use.
Args:
app: aiohttp application
api_key: API key to check
Returns:
List of allowed CDM names, or empty list if not premium
"""
key_config = get_api_key_config(app, api_key)
if not key_config:
return []
allowed_cdms = key_config.get("allowed_cdms", [])
# Handle wildcard
if allowed_cdms == "*" or allowed_cdms == ["*"]:
return ["*"]
return allowed_cdms if isinstance(allowed_cdms, list) else []
def get_default_cdm(app: web.Application, api_key: str) -> Optional[str]:
"""
Get default CDM for an API key.
Args:
app: aiohttp application
api_key: API key to check
Returns:
Default CDM name or None
"""
key_config = get_api_key_config(app, api_key)
if not key_config:
return None
return key_config.get("default_cdm")
def can_use_cdm(app: web.Application, api_key: str, cdm_name: str) -> bool:
"""
Check if an API key can use a specific CDM.
Args:
app: aiohttp application
api_key: API key to check
cdm_name: CDM name to check access for
Returns:
True if allowed, False otherwise
"""
allowed_cdms = get_allowed_cdms(app, api_key)
# Wildcard access
if "*" in allowed_cdms:
return True
# Specific CDM access
return cdm_name in allowed_cdms

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,9 @@ from unshackle.core import __version__
from unshackle.core.api.errors import APIError, APIErrorCode, build_error_response, handle_api_exception
from unshackle.core.api.handlers import (cancel_download_job_handler, download_handler, get_download_job_handler,
list_download_jobs_handler, list_titles_handler, list_tracks_handler)
from unshackle.core.api.remote_handlers import (remote_decrypt, remote_get_chapters, remote_get_license,
remote_get_titles, remote_get_tracks, remote_list_services,
remote_search)
from unshackle.core.services import Services
from unshackle.core.update_checker import UpdateChecker
@@ -730,6 +733,15 @@ def setup_routes(app: web.Application) -> None:
app.router.add_get("/api/download/jobs/{job_id}", download_job_detail)
app.router.add_delete("/api/download/jobs/{job_id}", cancel_download_job)
# Remote service endpoints
app.router.add_get("/api/remote/services", remote_list_services)
app.router.add_post("/api/remote/{service}/search", remote_search)
app.router.add_post("/api/remote/{service}/titles", remote_get_titles)
app.router.add_post("/api/remote/{service}/tracks", remote_get_tracks)
app.router.add_post("/api/remote/{service}/chapters", remote_get_chapters)
app.router.add_post("/api/remote/{service}/license", remote_get_license)
app.router.add_post("/api/remote/{service}/decrypt", remote_decrypt)
def setup_swagger(app: web.Application) -> None:
"""Setup Swagger UI documentation."""
@@ -754,5 +766,13 @@ def setup_swagger(app: web.Application) -> None:
web.get("/api/download/jobs", download_jobs),
web.get("/api/download/jobs/{job_id}", download_job_detail),
web.delete("/api/download/jobs/{job_id}", cancel_download_job),
# Remote service routes
web.get("/api/remote/services", remote_list_services),
web.post("/api/remote/{service}/search", remote_search),
web.post("/api/remote/{service}/titles", remote_get_titles),
web.post("/api/remote/{service}/tracks", remote_get_tracks),
web.post("/api/remote/{service}/chapters", remote_get_chapters),
web.post("/api/remote/{service}/license", remote_get_license),
web.post("/api/remote/{service}/decrypt", remote_decrypt),
]
)

View File

@@ -0,0 +1,236 @@
"""Session serialization helpers for remote services."""
from http.cookiejar import CookieJar
from typing import Any, Dict, Optional
import requests
from unshackle.core.credential import Credential
def serialize_session(session: requests.Session) -> Dict[str, Any]:
"""
Serialize a requests.Session into a JSON-serializable dictionary.
Extracts cookies, headers, and other session data that can be
transferred to a remote client for downloading.
Args:
session: The requests.Session to serialize
Returns:
Dictionary containing serialized session data
"""
session_data = {
"cookies": {},
"headers": {},
"proxies": session.proxies.copy() if session.proxies else {},
}
# Serialize cookies
if session.cookies:
for cookie in session.cookies:
session_data["cookies"][cookie.name] = {
"value": cookie.value,
"domain": cookie.domain,
"path": cookie.path,
"secure": cookie.secure,
"expires": cookie.expires,
}
# Serialize headers (exclude proxy-authorization for security)
if session.headers:
for key, value in session.headers.items():
# Skip proxy-related headers as they're server-specific
if key.lower() not in ["proxy-authorization"]:
session_data["headers"][key] = value
return session_data
def deserialize_session(
session_data: Dict[str, Any], target_session: Optional[requests.Session] = None
) -> requests.Session:
"""
Deserialize session data into a requests.Session.
Applies cookies, headers, and other session data from a remote server
to a local session for downloading.
Args:
session_data: Dictionary containing serialized session data
target_session: Optional existing session to update (creates new if None)
Returns:
requests.Session with applied session data
"""
if target_session is None:
target_session = requests.Session()
# Apply cookies
if "cookies" in session_data:
for cookie_name, cookie_data in session_data["cookies"].items():
target_session.cookies.set(
name=cookie_name,
value=cookie_data["value"],
domain=cookie_data.get("domain"),
path=cookie_data.get("path", "/"),
secure=cookie_data.get("secure", False),
expires=cookie_data.get("expires"),
)
# Apply headers
if "headers" in session_data:
target_session.headers.update(session_data["headers"])
# Note: We don't apply proxies from remote as the local client
# should use its own proxy configuration
return target_session
def extract_session_tokens(session: requests.Session) -> Dict[str, Any]:
"""
Extract authentication tokens and similar data from a session.
Looks for common authentication patterns like Bearer tokens,
API keys in headers, etc.
Args:
session: The requests.Session to extract tokens from
Returns:
Dictionary containing extracted tokens
"""
tokens = {}
# Check for Authorization header
if "Authorization" in session.headers:
tokens["authorization"] = session.headers["Authorization"]
# Check for common API key headers
for key in ["X-API-Key", "Api-Key", "X-Auth-Token"]:
if key in session.headers:
tokens[key.lower().replace("-", "_")] = session.headers[key]
return tokens
def apply_session_tokens(tokens: Dict[str, Any], target_session: requests.Session) -> None:
"""
Apply authentication tokens to a session.
Args:
tokens: Dictionary containing tokens to apply
target_session: Session to apply tokens to
"""
# Apply Authorization header
if "authorization" in tokens:
target_session.headers["Authorization"] = tokens["authorization"]
# Apply other token headers
token_header_map = {
"x_api_key": "X-API-Key",
"api_key": "Api-Key",
"x_auth_token": "X-Auth-Token",
}
for token_key, header_name in token_header_map.items():
if token_key in tokens:
target_session.headers[header_name] = tokens[token_key]
def serialize_cookies(cookie_jar: Optional[CookieJar]) -> Dict[str, Any]:
"""
Serialize a CookieJar into a JSON-serializable dictionary.
Args:
cookie_jar: The CookieJar to serialize
Returns:
Dictionary containing serialized cookies
"""
if not cookie_jar:
return {}
cookies = {}
for cookie in cookie_jar:
cookies[cookie.name] = {
"value": cookie.value,
"domain": cookie.domain,
"path": cookie.path,
"secure": cookie.secure,
"expires": cookie.expires,
}
return cookies
def deserialize_cookies(cookies_data: Dict[str, Any]) -> CookieJar:
"""
Deserialize cookies into a CookieJar.
Args:
cookies_data: Dictionary containing serialized cookies
Returns:
CookieJar with cookies
"""
import http.cookiejar
cookie_jar = http.cookiejar.CookieJar()
for cookie_name, cookie_data in cookies_data.items():
cookie = http.cookiejar.Cookie(
version=0,
name=cookie_name,
value=cookie_data["value"],
port=None,
port_specified=False,
domain=cookie_data.get("domain", ""),
domain_specified=bool(cookie_data.get("domain")),
domain_initial_dot=cookie_data.get("domain", "").startswith("."),
path=cookie_data.get("path", "/"),
path_specified=True,
secure=cookie_data.get("secure", False),
expires=cookie_data.get("expires"),
discard=False,
comment=None,
comment_url=None,
rest={},
)
cookie_jar.set_cookie(cookie)
return cookie_jar
def serialize_credential(credential: Optional[Credential]) -> Optional[Dict[str, str]]:
"""
Serialize a Credential into a JSON-serializable dictionary.
Args:
credential: The Credential to serialize
Returns:
Dictionary containing username and password, or None
"""
if not credential:
return None
return {"username": credential.username, "password": credential.password}
def deserialize_credential(credential_data: Optional[Dict[str, str]]) -> Optional[Credential]:
"""
Deserialize credential data into a Credential object.
Args:
credential_data: Dictionary containing username and password
Returns:
Credential object or None
"""
if not credential_data:
return None
return Credential(username=credential_data["username"], password=credential_data["password"])

View File

@@ -52,6 +52,7 @@ Mkvpropedit = find("mkvpropedit")
DoviTool = find("dovi_tool")
HDR10PlusTool = find("hdr10plus_tool", "HDR10Plus_tool")
Mp4decrypt = find("mp4decrypt")
Docker = find("docker")
__all__ = (
@@ -71,5 +72,6 @@ __all__ = (
"DoviTool",
"HDR10PlusTool",
"Mp4decrypt",
"Docker",
"find",
)

View File

@@ -103,6 +103,8 @@ class Config:
self.debug: bool = kwargs.get("debug", False)
self.debug_keys: bool = kwargs.get("debug_keys", False)
self.remote_services: list[dict] = kwargs.get("remote_services") or []
@classmethod
def from_yaml(cls, path: Path) -> Config:
if not path.exists():

View File

@@ -0,0 +1,274 @@
"""Local client-side session cache for remote services.
Sessions are stored ONLY on the client machine, never on the server.
The server is completely stateless and receives session data with each request.
"""
import json
import logging
import time
from pathlib import Path
from typing import Any, Dict, Optional
log = logging.getLogger("LocalSessionCache")
class LocalSessionCache:
"""
Client-side session cache.
Stores authenticated sessions locally (similar to cookies/cache).
Server never stores sessions - client sends session with each request.
"""
def __init__(self, cache_dir: Path):
"""
Initialize local session cache.
Args:
cache_dir: Directory to store session cache files
"""
self.cache_dir = cache_dir
self.cache_dir.mkdir(parents=True, exist_ok=True)
self.sessions_file = cache_dir / "remote_sessions.json"
# Load existing sessions
self.sessions: Dict[str, Dict[str, Dict[str, Any]]] = self._load_sessions()
def _load_sessions(self) -> Dict[str, Dict[str, Dict[str, Any]]]:
"""Load sessions from cache file."""
if not self.sessions_file.exists():
return {}
try:
data = json.loads(self.sessions_file.read_text(encoding="utf-8"))
log.debug(f"Loaded {len(data)} remote sessions from cache")
return data
except Exception as e:
log.error(f"Failed to load sessions cache: {e}")
return {}
def _save_sessions(self) -> None:
"""Save sessions to cache file."""
try:
self.sessions_file.write_text(
json.dumps(self.sessions, indent=2, ensure_ascii=False),
encoding="utf-8"
)
log.debug(f"Saved {len(self.sessions)} remote sessions to cache")
except Exception as e:
log.error(f"Failed to save sessions cache: {e}")
def store_session(
self,
remote_url: str,
service_tag: str,
profile: str,
session_data: Dict[str, Any]
) -> None:
"""
Store an authenticated session locally.
Args:
remote_url: Remote server URL (as key)
service_tag: Service tag
profile: Profile name
session_data: Authenticated session data
"""
# Create nested structure
if remote_url not in self.sessions:
self.sessions[remote_url] = {}
if service_tag not in self.sessions[remote_url]:
self.sessions[remote_url][service_tag] = {}
# Store session with metadata
self.sessions[remote_url][service_tag][profile] = {
"session_data": session_data,
"cached_at": time.time(),
"service_tag": service_tag,
"profile": profile,
}
self._save_sessions()
log.info(f"Cached session for {service_tag} (profile: {profile}, remote: {remote_url})")
def get_session(
self,
remote_url: str,
service_tag: str,
profile: str
) -> Optional[Dict[str, Any]]:
"""
Retrieve a cached session.
Args:
remote_url: Remote server URL
service_tag: Service tag
profile: Profile name
Returns:
Session data or None if not found/expired
"""
try:
session_entry = self.sessions[remote_url][service_tag][profile]
# Check if expired (24 hours)
age = time.time() - session_entry["cached_at"]
if age > 86400: # 24 hours
log.info(f"Session expired for {service_tag} (age: {age:.0f}s)")
self.delete_session(remote_url, service_tag, profile)
return None
log.debug(f"Using cached session for {service_tag} (profile: {profile})")
return session_entry["session_data"]
except KeyError:
log.debug(f"No cached session for {service_tag} (profile: {profile})")
return None
def has_session(
self,
remote_url: str,
service_tag: str,
profile: str
) -> bool:
"""
Check if a valid session exists.
Args:
remote_url: Remote server URL
service_tag: Service tag
profile: Profile name
Returns:
True if valid session exists
"""
session = self.get_session(remote_url, service_tag, profile)
return session is not None
def delete_session(
self,
remote_url: str,
service_tag: str,
profile: str
) -> bool:
"""
Delete a cached session.
Args:
remote_url: Remote server URL
service_tag: Service tag
profile: Profile name
Returns:
True if session was deleted
"""
try:
del self.sessions[remote_url][service_tag][profile]
# Clean up empty nested dicts
if not self.sessions[remote_url][service_tag]:
del self.sessions[remote_url][service_tag]
if not self.sessions[remote_url]:
del self.sessions[remote_url]
self._save_sessions()
log.info(f"Deleted cached session for {service_tag} (profile: {profile})")
return True
except KeyError:
return False
def list_sessions(self, remote_url: Optional[str] = None) -> list[Dict[str, Any]]:
"""
List all cached sessions.
Args:
remote_url: Optional filter by remote URL
Returns:
List of session metadata
"""
sessions = []
remotes = [remote_url] if remote_url else self.sessions.keys()
for remote in remotes:
if remote not in self.sessions:
continue
for service_tag, profiles in self.sessions[remote].items():
for profile, entry in profiles.items():
age = time.time() - entry["cached_at"]
sessions.append({
"remote_url": remote,
"service_tag": service_tag,
"profile": profile,
"cached_at": entry["cached_at"],
"age_seconds": int(age),
"expired": age > 86400,
"has_cookies": bool(entry["session_data"].get("cookies")),
"has_headers": bool(entry["session_data"].get("headers")),
})
return sessions
def cleanup_expired(self) -> int:
"""
Remove expired sessions (older than 24 hours).
Returns:
Number of sessions removed
"""
removed = 0
current_time = time.time()
for remote_url in list(self.sessions.keys()):
for service_tag in list(self.sessions[remote_url].keys()):
for profile in list(self.sessions[remote_url][service_tag].keys()):
entry = self.sessions[remote_url][service_tag][profile]
age = current_time - entry["cached_at"]
if age > 86400: # 24 hours
del self.sessions[remote_url][service_tag][profile]
removed += 1
log.info(f"Removed expired session for {service_tag} (age: {age:.0f}s)")
# Clean up empty dicts
if not self.sessions[remote_url][service_tag]:
del self.sessions[remote_url][service_tag]
if not self.sessions[remote_url]:
del self.sessions[remote_url]
if removed > 0:
self._save_sessions()
return removed
# Global instance
_local_session_cache: Optional[LocalSessionCache] = None
def get_local_session_cache() -> LocalSessionCache:
"""
Get the global local session cache instance.
Returns:
LocalSessionCache instance
"""
global _local_session_cache
if _local_session_cache is None:
from unshackle.core.config import config
cache_dir = config.directories.cache / "remote_sessions"
_local_session_cache = LocalSessionCache(cache_dir)
# Clean up expired sessions on init
_local_session_cache.cleanup_expired()
return _local_session_cache
__all__ = ["LocalSessionCache", "get_local_session_cache"]

View File

@@ -1,7 +1,8 @@
from .basic import Basic
from .gluetun import Gluetun
from .hola import Hola
from .nordvpn import NordVPN
from .surfsharkvpn import SurfsharkVPN
from .windscribevpn import WindscribeVPN
__all__ = ("Basic", "Hola", "NordVPN", "SurfsharkVPN", "WindscribeVPN")
__all__ = ("Basic", "Gluetun", "Hola", "NordVPN", "SurfsharkVPN", "WindscribeVPN")

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,5 @@
import json
import random
import re
from typing import Optional
@@ -46,8 +47,21 @@ class NordVPN(Proxy):
HTTP proxies under port 80 were disabled on the 15th of Feb, 2021:
https://nordvpn.com/blog/removing-http-proxies
Supports:
- Country code: "us", "ca", "gb"
- Country ID: "228"
- Specific server: "us1234"
- City selection: "us:seattle", "ca:calgary"
"""
query = query.lower()
city = None
# Check if query includes city specification (e.g., "ca:calgary")
if ":" in query:
query, city = query.split(":", maxsplit=1)
city = city.strip()
if re.match(r"^[a-z]{2}\d+$", query):
# country and nordvpn server id, e.g., us1, fr1234
hostname = f"{query}.nordvpn.com"
@@ -64,7 +78,12 @@ class NordVPN(Proxy):
# NordVPN doesnt have servers in this region
return
server_mapping = self.server_map.get(country["code"].lower())
# Check server_map for pinned servers (can include city)
server_map_key = f"{country['code'].lower()}:{city}" if city else country["code"].lower()
server_mapping = self.server_map.get(server_map_key) or (
self.server_map.get(country["code"].lower()) if not city else None
)
if server_mapping:
# country was set to a specific server ID in config
hostname = f"{country['code'].lower()}{server_mapping}.nordvpn.com"
@@ -76,7 +95,19 @@ class NordVPN(Proxy):
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"]
# Filter by city if specified
if city:
city_servers = self.filter_servers_by_city(recommended_servers, city)
if not city_servers:
raise ValueError(
f"No servers found in city '{city}' for country '{country['name']}'. "
"Try a different city or check the city name spelling."
)
recommended_servers = city_servers
# Pick a random server from the filtered list
hostname = random.choice(recommended_servers)["hostname"]
if hostname.startswith("gb"):
# NordVPN uses the alpha2 of 'GB' in API responses, but 'UK' in the hostname
@@ -95,6 +126,41 @@ class NordVPN(Proxy):
):
return country
@staticmethod
def filter_servers_by_city(servers: list[dict], city: str) -> list[dict]:
"""
Filter servers by city name.
The API returns servers with location data that includes city information.
This method filters servers to only those in the specified city.
Args:
servers: List of server dictionaries from the NordVPN API
city: City name to filter by (case-insensitive)
Returns:
List of servers in the specified city
"""
city_lower = city.lower()
filtered = []
for server in servers:
# Each server has a 'locations' list with location data
locations = server.get("locations", [])
for location in locations:
# City data can be in different formats:
# - {"city": {"name": "Seattle", ...}}
# - {"city": "Seattle"}
city_data = location.get("city")
if city_data:
# Handle both dict and string formats
city_name = city_data.get("name") if isinstance(city_data, dict) else city_data
if city_name and city_name.lower() == city_lower:
filtered.append(server)
break # Found a match, no need to check other locations for this server
return filtered
@staticmethod
def get_recommended_servers(country_id: int) -> list[dict]:
"""

View File

@@ -44,8 +44,21 @@ class SurfsharkVPN(Proxy):
def get_proxy(self, query: str) -> Optional[str]:
"""
Get an HTTP(SSL) proxy URI for a SurfsharkVPN server.
Supports:
- Country code: "us", "ca", "gb"
- Country ID: "228"
- Specific server: "us-bos" (Boston)
- City selection: "us:seattle", "ca:toronto"
"""
query = query.lower()
city = None
# Check if query includes city specification (e.g., "us:seattle")
if ":" in query:
query, city = query.split(":", maxsplit=1)
city = city.strip()
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"
@@ -62,13 +75,18 @@ class SurfsharkVPN(Proxy):
# SurfsharkVPN doesnt have servers in this region
return
server_mapping = self.server_map.get(country["countryCode"].lower())
# Check server_map for pinned servers (can include city)
server_map_key = f"{country['countryCode'].lower()}:{city}" if city else country["countryCode"].lower()
server_mapping = self.server_map.get(server_map_key) or (
self.server_map.get(country["countryCode"].lower()) if not city else None
)
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"])
random_server = self.get_random_server(country["countryCode"], city)
if not random_server:
raise ValueError(
f"The SurfsharkVPN Country {query} currently has no random servers. "
@@ -92,18 +110,44 @@ class SurfsharkVPN(Proxy):
):
return country
def get_random_server(self, country_id: str):
def get_random_server(self, country_id: str, city: Optional[str] = None):
"""
Get the list of random Server for a Country.
Get a random server for a Country, optionally filtered by city.
Note: There may not always be more than one recommended server.
Args:
country_id: The country code (e.g., "US", "CA")
city: Optional city name to filter by (case-insensitive)
Note: The API may include a 'location' field with city information.
If not available, this will return any server from the country.
"""
country = [x["connectionName"] for x in self.countries if x["countryCode"].lower() == country_id.lower()]
servers = [x for x in self.countries if x["countryCode"].lower() == country_id.lower()]
# Filter by city if specified
if city:
city_lower = city.lower()
# Check if servers have a 'location' field for city filtering
city_servers = [
x
for x in servers
if x.get("location", "").lower() == city_lower or x.get("city", "").lower() == city_lower
]
if city_servers:
servers = city_servers
else:
raise ValueError(
f"No servers found in city '{city}' for country '{country_id}'. "
"Try a different city or check the city name spelling."
)
# Get connection names from filtered servers
connection_names = [x["connectionName"] for x in servers]
try:
country = random.choice(country)
return country
except Exception:
raise ValueError("Could not get random countrycode from the countries list.")
return random.choice(connection_names)
except (IndexError, KeyError):
raise ValueError(f"Could not get random server for country '{country_id}'.")
@staticmethod
def get_countries() -> list[dict]:

View File

@@ -0,0 +1,279 @@
"""Client-side authentication for remote services.
This module handles authenticating services locally on the client side,
then sending the authenticated session to the remote server.
This approach allows:
- Interactive browser-based logins
- 2FA/CAPTCHA handling
- OAuth flows
- Any authentication that requires user interaction
The server NEVER sees credentials - only authenticated sessions.
"""
import logging
from typing import Any, Dict, Optional
import click
import requests
import yaml
from unshackle.core.api.session_serializer import serialize_session
from unshackle.core.config import config
from unshackle.core.console import console
from unshackle.core.credential import Credential
from unshackle.core.local_session_cache import get_local_session_cache
from unshackle.core.services import Services
from unshackle.core.utils.click_types import ContextData
from unshackle.core.utils.collections import merge_dict
log = logging.getLogger("RemoteAuth")
class RemoteAuthenticator:
"""
Handles client-side authentication for remote services.
Workflow:
1. Load service locally
2. Authenticate using local credentials/cookies (can show browser, handle 2FA)
3. Extract authenticated session
4. Upload session to remote server
5. Server uses the pre-authenticated session
"""
def __init__(self, remote_url: str, api_key: str):
"""
Initialize remote authenticator.
Args:
remote_url: Base URL of remote server
api_key: API key for remote server
"""
self.remote_url = remote_url.rstrip("/")
self.api_key = api_key
self.session = requests.Session()
self.session.headers.update({"X-API-Key": self.api_key, "Content-Type": "application/json"})
def authenticate_service_locally(
self, service_tag: str, profile: Optional[str] = None, force_reauth: bool = False
) -> Dict[str, Any]:
"""
Authenticate a service locally and extract the session.
This runs the service authentication on the CLIENT side where browsers,
2FA, and interactive prompts can work.
Args:
service_tag: Service to authenticate (e.g., "DSNP", "NF")
profile: Optional profile to use for credentials
force_reauth: Force re-authentication even if session exists
Returns:
Serialized session data
Raises:
ValueError: If service not found or authentication fails
"""
console.print(f"[cyan]Authenticating {service_tag} locally...[/cyan]")
# Validate service exists
if service_tag not in Services.get_tags():
raise ValueError(f"Service {service_tag} not found locally")
# Load service
service_module = Services.load(service_tag)
# Load service config
service_config_path = Services.get_path(service_tag) / config.filenames.config
if service_config_path.exists():
service_config = yaml.safe_load(service_config_path.read_text(encoding="utf8"))
else:
service_config = {}
merge_dict(config.services.get(service_tag), service_config)
# Create Click context
@click.command()
@click.pass_context
def dummy_command(ctx: click.Context) -> None:
pass
ctx = click.Context(dummy_command)
ctx.obj = ContextData(config=service_config, cdm=None, proxy_providers=[], profile=profile)
# Create service instance
try:
# Get service initialization parameters
import inspect
service_init_params = inspect.signature(service_module.__init__).parameters
service_kwargs = {}
# Extract defaults from click command
if hasattr(service_module, "cli") and hasattr(service_module.cli, "params"):
for param in service_module.cli.params:
if hasattr(param, "name") and param.name not in service_kwargs:
if hasattr(param, "default") and param.default is not None:
service_kwargs[param.name] = param.default
# Filter to only valid parameters
filtered_kwargs = {k: v for k, v in service_kwargs.items() if k in service_init_params}
# Create service instance
service_instance = service_module(ctx, **filtered_kwargs)
# Get credentials and cookies
cookies = self._get_cookie_jar(service_tag, profile)
credential = self._get_credentials(service_tag, profile)
# Authenticate the service
console.print("[yellow]Authenticating... (this may show browser or prompts)[/yellow]")
service_instance.authenticate(cookies=cookies, credential=credential)
# Serialize the authenticated session
session_data = serialize_session(service_instance.session)
# Add metadata
session_data["service_tag"] = service_tag
session_data["profile"] = profile
session_data["authenticated"] = True
console.print(f"[green]✓ {service_tag} authenticated successfully![/green]")
log.info(f"Authenticated {service_tag} (profile: {profile or 'default'})")
return session_data
except Exception as e:
console.print(f"[red]✗ Authentication failed: {e}[/red]")
log.error(f"Failed to authenticate {service_tag}: {e}")
raise ValueError(f"Authentication failed for {service_tag}: {e}")
def save_session_locally(self, session_data: Dict[str, Any]) -> bool:
"""
Save authenticated session to local cache.
The session is stored only on the client machine, never on the server.
The server is completely stateless.
Args:
session_data: Serialized session data
Returns:
True if save successful
"""
service_tag = session_data.get("service_tag")
profile = session_data.get("profile", "default")
console.print("[cyan]Saving session to local cache...[/cyan]")
try:
# Get local session cache
cache = get_local_session_cache()
# Store session locally
cache.store_session(
remote_url=self.remote_url,
service_tag=service_tag,
profile=profile,
session_data=session_data
)
console.print("[green]✓ Session saved locally![/green]")
log.info(f"Saved session for {service_tag} (profile: {profile}) to local cache")
return True
except Exception as e:
console.print(f"[red]✗ Save failed: {e}[/red]")
log.error(f"Failed to save session locally: {e}")
return False
def authenticate_and_save(self, service_tag: str, profile: Optional[str] = None) -> bool:
"""
Authenticate locally and save session to local cache in one step.
Args:
service_tag: Service to authenticate
profile: Optional profile
Returns:
True if successful
"""
try:
# Authenticate locally
session_data = self.authenticate_service_locally(service_tag, profile)
# Save to local cache
return self.save_session_locally(session_data)
except Exception as e:
console.print(f"[red]Authentication and save failed: {e}[/red]")
return False
def check_local_session_status(self, service_tag: str, profile: Optional[str] = None) -> Dict[str, Any]:
"""
Check if a session exists in local cache.
Args:
service_tag: Service tag
profile: Optional profile
Returns:
Session status info
"""
try:
cache = get_local_session_cache()
session_data = cache.get_session(self.remote_url, service_tag, profile or "default")
if session_data:
# Get metadata
sessions = cache.list_sessions(self.remote_url)
for session in sessions:
if session["service_tag"] == service_tag and session["profile"] == (profile or "default"):
return {
"status": "success",
"exists": True,
"session_info": session
}
return {
"status": "success",
"exists": False,
"message": f"No session found for {service_tag} (profile: {profile or 'default'})"
}
except Exception as e:
log.error(f"Failed to check session status: {e}")
return {"status": "error", "message": "Failed to check session status"}
def _get_cookie_jar(self, service_tag: str, profile: Optional[str]):
"""Get cookie jar for service and profile."""
from unshackle.commands.dl import dl
return dl.get_cookie_jar(service_tag, profile)
def _get_credentials(self, service_tag: str, profile: Optional[str]) -> Optional[Credential]:
"""Get credentials for service and profile."""
from unshackle.commands.dl import dl
return dl.get_credentials(service_tag, profile)
def authenticate_remote_service(remote_url: str, api_key: str, service_tag: str, profile: Optional[str] = None) -> bool:
"""
Helper function to authenticate a remote service.
Args:
remote_url: Remote server URL
api_key: API key
service_tag: Service to authenticate
profile: Optional profile
Returns:
True if successful
"""
authenticator = RemoteAuthenticator(remote_url, api_key)
return authenticator.authenticate_and_save(service_tag, profile)
__all__ = ["RemoteAuthenticator", "authenticate_remote_service"]

View File

@@ -0,0 +1,593 @@
"""Remote service implementation for connecting to remote unshackle servers."""
import logging
import time
from collections.abc import Generator
from http.cookiejar import CookieJar
from typing import Any, Dict, Optional, Union
import click
import requests
from rich.padding import Padding
from rich.rule import Rule
from unshackle.core.api.session_serializer import deserialize_session
from unshackle.core.console import console
from unshackle.core.credential import Credential
from unshackle.core.local_session_cache import get_local_session_cache
from unshackle.core.search_result import SearchResult
from unshackle.core.titles import Episode, Movie, Movies, Series
from unshackle.core.tracks import Chapter, Chapters, Tracks
from unshackle.core.tracks.audio import Audio
from unshackle.core.tracks.subtitle import Subtitle
from unshackle.core.tracks.video import Video
class RemoteService:
"""
Remote Service wrapper that connects to a remote unshackle server.
This class mimics the Service interface but delegates all operations
to a remote unshackle server via API calls. It receives session data
from the remote server which is then used locally for downloading.
"""
ALIASES: tuple[str, ...] = ()
GEOFENCE: tuple[str, ...] = ()
def __init__(
self,
ctx: click.Context,
remote_url: str,
api_key: str,
service_tag: str,
service_metadata: Dict[str, Any],
**kwargs,
):
"""
Initialize remote service.
Args:
ctx: Click context
remote_url: Base URL of the remote unshackle server
api_key: API key for authentication
service_tag: The service tag on the remote server (e.g., "DSNP")
service_metadata: Metadata about the service from remote discovery
**kwargs: Additional service-specific parameters
"""
console.print(Padding(Rule(f"[rule.text]Remote Service: {service_tag}"), (1, 2)))
self.log = logging.getLogger(f"RemoteService.{service_tag}")
self.remote_url = remote_url.rstrip("/")
self.api_key = api_key
self.service_tag = service_tag
self.service_metadata = service_metadata
self.ctx = ctx
self.kwargs = kwargs
# Set GEOFENCE and ALIASES from metadata
if "geofence" in service_metadata:
self.GEOFENCE = tuple(service_metadata["geofence"])
if "aliases" in service_metadata:
self.ALIASES = tuple(service_metadata["aliases"])
# Create a session for API calls to the remote server
self.api_session = requests.Session()
self.api_session.headers.update({"X-API-Key": self.api_key, "Content-Type": "application/json"})
# This session will receive data from remote for actual downloading
self.session = requests.Session()
# Store authentication state
self.authenticated = False
self.credential = None
self.cookies_content = None # Raw cookie file content to send to remote
# Get profile from context if available
self.profile = "default"
if hasattr(ctx, "obj") and hasattr(ctx.obj, "profile"):
self.profile = ctx.obj.profile or "default"
# Initialize proxy providers for resolving proxy credentials
self._proxy_providers = None
if hasattr(ctx, "obj") and hasattr(ctx.obj, "proxy_providers"):
self._proxy_providers = ctx.obj.proxy_providers
def _resolve_proxy_locally(self, proxy: str) -> Optional[str]:
"""
Resolve proxy parameter locally using client's proxy providers.
This allows the client to resolve proxy providers (like NordVPN) and
send the full proxy URI with credentials to the server.
Args:
proxy: Proxy parameter (e.g., "nordvpn:ca1066", "us2104", or full URI)
Returns:
Resolved proxy URI with credentials, or None if no_proxy
"""
if not proxy:
return None
import re
# If already a full URI, return as-is
if re.match(r"^https?://", proxy):
self.log.debug(f"Using explicit proxy URI: {proxy}")
return proxy
# Try to resolve using local proxy providers
if self._proxy_providers:
try:
from unshackle.core.api.handlers import resolve_proxy
resolved = resolve_proxy(proxy, self._proxy_providers)
self.log.info(f"Resolved proxy '{proxy}' to: {resolved}")
return resolved
except Exception as e:
self.log.warning(f"Failed to resolve proxy locally: {e}")
# Fall back to sending proxy parameter as-is for server to resolve
return proxy
else:
self.log.debug(f"No proxy providers available, sending proxy as-is: {proxy}")
return proxy
def _add_proxy_to_request(self, data: Dict[str, Any]) -> None:
"""
Add resolved proxy information to request data.
Resolves proxy using local proxy providers and adds to request.
Server will use the resolved proxy URI (with credentials).
Args:
data: Request data dictionary to modify
"""
if hasattr(self.ctx, "params"):
no_proxy = self.ctx.params.get("no_proxy", False)
proxy_param = self.ctx.params.get("proxy")
if no_proxy:
data["no_proxy"] = True
elif proxy_param:
# Resolve proxy locally to get credentials
resolved_proxy = self._resolve_proxy_locally(proxy_param)
if resolved_proxy:
data["proxy"] = resolved_proxy
self.log.debug(f"Sending resolved proxy to server: {resolved_proxy}")
def _make_request(self, endpoint: str, data: Optional[Dict[str, Any]] = None, retry_count: int = 0) -> Dict[str, Any]:
"""
Make an API request to the remote server with retry logic.
Automatically handles authentication:
1. Check for cached session - send with request if found
2. If session expired, re-authenticate automatically
3. If no session, send credentials (server tries to auth)
4. If server returns AUTH_REQUIRED, authenticate locally
5. Retry request with new session
Args:
endpoint: API endpoint path (e.g., "/api/remote/DSNP/titles")
data: Optional JSON data to send
retry_count: Current retry attempt (for internal use)
Returns:
Response JSON data
Raises:
ConnectionError: If the request fails after all retries
"""
url = f"{self.remote_url}{endpoint}"
max_retries = 3 # Max network retries
retry_delays = [2, 4, 8] # Exponential backoff in seconds
# Ensure data is a dictionary
if data is None:
data = {}
# Priority 1: Check for pre-authenticated session in local cache
cache = get_local_session_cache()
cached_session = cache.get_session(self.remote_url, self.service_tag, self.profile)
if cached_session:
# Send pre-authenticated session data (server never stores it)
self.log.debug(f"Using cached session for {self.service_tag}")
data["pre_authenticated_session"] = cached_session
else:
# Priority 2: Fallback to credentials/cookies (old behavior)
# This allows server to authenticate if no local session exists
if self.cookies_content:
data["cookies"] = self.cookies_content
if self.credential:
data["credential"] = {"username": self.credential.username, "password": self.credential.password}
try:
if data:
response = self.api_session.post(url, json=data)
else:
response = self.api_session.get(url)
response.raise_for_status()
result = response.json()
# Check if session expired - re-authenticate automatically
if result.get("error_code") == "SESSION_EXPIRED":
console.print(f"[yellow]Session expired for {self.service_tag}[/yellow]")
console.print("[cyan]Re-authenticating...[/cyan]")
# Delete expired session from cache
cache.delete_session(self.remote_url, self.service_tag, self.profile)
# Perform local authentication
session_data = self._authenticate_locally()
if session_data:
# Save to cache for future requests
cache.store_session(
remote_url=self.remote_url,
service_tag=self.service_tag,
profile=self.profile,
session_data=session_data
)
# Retry request with new session
data["pre_authenticated_session"] = session_data
# Remove old auth data
data.pop("cookies", None)
data.pop("credential", None)
# Retry the request
response = self.api_session.post(url, json=data)
response.raise_for_status()
result = response.json()
# Check if server requires authentication
elif result.get("error_code") == "AUTH_REQUIRED" and not cached_session:
console.print(f"[yellow]Authentication required for {self.service_tag}[/yellow]")
console.print("[cyan]Authenticating locally...[/cyan]")
# Perform local authentication
session_data = self._authenticate_locally()
if session_data:
# Save to cache for future requests
cache.store_session(
remote_url=self.remote_url,
service_tag=self.service_tag,
profile=self.profile,
session_data=session_data
)
# Retry request with authenticated session
data["pre_authenticated_session"] = session_data
# Remove old auth data
data.pop("cookies", None)
data.pop("credential", None)
# Retry the request
response = self.api_session.post(url, json=data)
response.raise_for_status()
result = response.json()
# Apply session data if present
if "session" in result:
deserialize_session(result["session"], self.session)
return result
except requests.RequestException as e:
# Retry on network errors with exponential backoff
if retry_count < max_retries:
delay = retry_delays[retry_count]
self.log.warning(f"Request failed (attempt {retry_count + 1}/{max_retries + 1}): {e}")
self.log.info(f"Retrying in {delay} seconds...")
time.sleep(delay)
return self._make_request(endpoint, data, retry_count + 1)
else:
self.log.error(f"Remote API request failed after {max_retries + 1} attempts: {e}")
raise ConnectionError(f"Failed to communicate with remote server after {max_retries + 1} attempts: {e}")
def _authenticate_locally(self) -> Optional[Dict[str, Any]]:
"""
Authenticate the service locally when server requires it.
This performs interactive authentication (browser, 2FA, etc.)
and returns the authenticated session.
Returns:
Serialized session data or None if authentication fails
"""
from unshackle.core.remote_auth import RemoteAuthenticator
try:
authenticator = RemoteAuthenticator(self.remote_url, self.api_key)
session_data = authenticator.authenticate_service_locally(self.service_tag, self.profile)
console.print("[green]✓ Authentication successful![/green]")
return session_data
except Exception as e:
console.print(f"[red]✗ Authentication failed: {e}[/red]")
self.log.error(f"Local authentication failed: {e}")
return None
def authenticate(self, cookies: Optional[CookieJar] = None, credential: Optional[Credential] = None) -> None:
"""
Prepare authentication data to send to remote service.
Stores cookies and credentials to send with each API request.
The remote server will use these for authentication.
Args:
cookies: Cookie jar from local configuration
credential: Credentials from local configuration
"""
self.log.info("Preparing authentication for remote server...")
self.credential = credential
# Read cookies file content if cookies provided
if cookies and hasattr(cookies, "filename") and cookies.filename:
try:
from pathlib import Path
cookie_file = Path(cookies.filename)
if cookie_file.exists():
self.cookies_content = cookie_file.read_text()
self.log.info(f"Loaded cookies from {cookie_file}")
except Exception as e:
self.log.warning(f"Could not read cookie file: {e}")
self.authenticated = True
self.log.info("Authentication data ready for remote server")
def search(self, query: Optional[str] = None) -> Generator[SearchResult, None, None]:
"""
Search for content on the remote service.
Args:
query: Search query string
Yields:
SearchResult objects
"""
if query is None:
query = self.kwargs.get("query", "")
self.log.info(f"Searching remote service for: {query}")
data = {"query": query}
# Add proxy information (resolved locally with credentials)
self._add_proxy_to_request(data)
response = self._make_request(f"/api/remote/{self.service_tag}/search", data)
if response.get("status") == "success" and "results" in response:
for result in response["results"]:
yield SearchResult(
id_=result["id"],
title=result["title"],
description=result.get("description"),
label=result.get("label"),
url=result.get("url"),
)
def get_titles(self) -> Union[Movies, Series]:
"""
Get titles from the remote service.
Returns:
Movies or Series object containing title information
"""
title = self.kwargs.get("title")
if not title:
raise ValueError("No title provided")
self.log.info(f"Getting titles from remote service for: {title}")
data = {"title": title}
# Add additional parameters
for key, value in self.kwargs.items():
if key not in ["title"]:
data[key] = value
# Add proxy information (resolved locally with credentials)
self._add_proxy_to_request(data)
response = self._make_request(f"/api/remote/{self.service_tag}/titles", data)
if response.get("status") != "success" or "titles" not in response:
raise ValueError(f"Failed to get titles from remote: {response.get('message', 'Unknown error')}")
titles_data = response["titles"]
# Deserialize titles
titles = []
for title_info in titles_data:
if title_info["type"] == "movie":
titles.append(
Movie(
id_=title_info.get("id", title),
service=self.__class__,
name=title_info["name"],
year=title_info.get("year"),
data=title_info,
)
)
elif title_info["type"] == "episode":
titles.append(
Episode(
id_=title_info.get("id", title),
service=self.__class__,
title=title_info.get("series_title", title_info["name"]),
season=title_info.get("season", 0),
number=title_info.get("number", 0),
name=title_info.get("name"),
year=title_info.get("year"),
data=title_info,
)
)
# Return appropriate container
if titles and isinstance(titles[0], Episode):
return Series(titles)
else:
return Movies(titles)
def get_tracks(self, title: Union[Movie, Episode]) -> Tracks:
"""
Get tracks from the remote service.
Args:
title: Title object to get tracks for
Returns:
Tracks object containing video, audio, and subtitle tracks
"""
self.log.info(f"Getting tracks from remote service for: {title}")
title_input = self.kwargs.get("title")
data = {"title": title_input}
# Add episode information if applicable
if isinstance(title, Episode):
data["season"] = title.season
data["episode"] = title.number
# Add additional parameters
for key, value in self.kwargs.items():
if key not in ["title"]:
data[key] = value
# Add proxy information (resolved locally with credentials)
self._add_proxy_to_request(data)
response = self._make_request(f"/api/remote/{self.service_tag}/tracks", data)
if response.get("status") != "success":
raise ValueError(f"Failed to get tracks from remote: {response.get('message', 'Unknown error')}")
# Handle multiple episodes response
if "episodes" in response:
# For multiple episodes, return tracks for the matching title
for episode_data in response["episodes"]:
episode_title = episode_data["title"]
if (
isinstance(title, Episode)
and episode_title.get("season") == title.season
and episode_title.get("number") == title.number
):
return self._deserialize_tracks(episode_data, title)
raise ValueError(f"Could not find tracks for {title.season}x{title.number} in remote response")
# Single title response
return self._deserialize_tracks(response, title)
def _deserialize_tracks(self, data: Dict[str, Any], title: Union[Movie, Episode]) -> Tracks:
"""
Deserialize tracks from API response.
Args:
data: Track data from API
title: Title object these tracks belong to
Returns:
Tracks object
"""
tracks = Tracks()
# Deserialize video tracks
for video_data in data.get("video", []):
video = Video(
id_=video_data["id"],
url="", # URL will be populated during download from manifests
codec=Video.Codec[video_data["codec"]],
bitrate=video_data.get("bitrate", 0) * 1000 if video_data.get("bitrate") else None,
width=video_data.get("width"),
height=video_data.get("height"),
fps=video_data.get("fps"),
range_=Video.Range[video_data["range"]] if video_data.get("range") else None,
language=video_data.get("language"),
drm=video_data.get("drm"),
)
tracks.add(video)
# Deserialize audio tracks
for audio_data in data.get("audio", []):
audio = Audio(
id_=audio_data["id"],
url="", # URL will be populated during download
codec=Audio.Codec[audio_data["codec"]],
bitrate=audio_data.get("bitrate", 0) * 1000 if audio_data.get("bitrate") else None,
channels=audio_data.get("channels"),
language=audio_data.get("language"),
descriptive=audio_data.get("descriptive", False),
drm=audio_data.get("drm"),
)
if audio_data.get("atmos"):
audio.atmos = True
tracks.add(audio)
# Deserialize subtitle tracks
for subtitle_data in data.get("subtitles", []):
subtitle = Subtitle(
id_=subtitle_data["id"],
url="", # URL will be populated during download
codec=Subtitle.Codec[subtitle_data["codec"]],
language=subtitle_data.get("language"),
forced=subtitle_data.get("forced", False),
sdh=subtitle_data.get("sdh", False),
cc=subtitle_data.get("cc", False),
)
tracks.add(subtitle)
return tracks
def get_chapters(self, title: Union[Movie, Episode]) -> Chapters:
"""
Get chapters from the remote service.
Args:
title: Title object to get chapters for
Returns:
Chapters object
"""
self.log.info(f"Getting chapters from remote service for: {title}")
title_input = self.kwargs.get("title")
data = {"title": title_input}
# Add episode information if applicable
if isinstance(title, Episode):
data["season"] = title.season
data["episode"] = title.number
# Add proxy information (resolved locally with credentials)
self._add_proxy_to_request(data)
response = self._make_request(f"/api/remote/{self.service_tag}/chapters", data)
if response.get("status") != "success":
self.log.warning(f"Failed to get chapters from remote: {response.get('message', 'Unknown error')}")
return Chapters()
chapters = Chapters()
for chapter_data in response.get("chapters", []):
chapters.add(Chapter(timestamp=chapter_data["timestamp"], name=chapter_data.get("name")))
return chapters
@staticmethod
def get_session() -> requests.Session:
"""
Create a session for the remote service.
Returns:
A requests.Session object
"""
session = requests.Session()
return session

View File

@@ -0,0 +1,245 @@
"""Remote service discovery and management."""
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional
import requests
from unshackle.core.config import config
from unshackle.core.remote_service import RemoteService
log = logging.getLogger("RemoteServices")
class RemoteServiceManager:
"""
Manages discovery and registration of remote services.
This class connects to configured remote unshackle servers,
discovers available services, and creates RemoteService instances
that can be used like local services.
"""
def __init__(self):
"""Initialize the remote service manager."""
self.remote_services: Dict[str, type] = {}
self.remote_configs: List[Dict[str, Any]] = []
def discover_services(self) -> None:
"""
Discover services from all configured remote servers.
Reads the remote_services configuration, connects to each server,
retrieves available services, and creates RemoteService classes
for each discovered service.
"""
if not config.remote_services:
log.debug("No remote services configured")
return
log.info(f"Discovering services from {len(config.remote_services)} remote server(s)...")
for remote_config in config.remote_services:
try:
self._discover_from_server(remote_config)
except Exception as e:
log.error(f"Failed to discover services from {remote_config.get('url')}: {e}")
continue
log.info(f"Discovered {len(self.remote_services)} remote service(s)")
def _discover_from_server(self, remote_config: Dict[str, Any]) -> None:
"""
Discover services from a single remote server.
Args:
remote_config: Configuration for the remote server
(must contain 'url' and 'api_key')
"""
url = remote_config.get("url", "").rstrip("/")
api_key = remote_config.get("api_key", "")
server_name = remote_config.get("name", url)
if not url:
log.warning("Remote service configuration missing 'url', skipping")
return
if not api_key:
log.warning(f"Remote service {url} missing 'api_key', skipping")
return
log.info(f"Connecting to remote server: {server_name}")
try:
# Query the remote server for available services
response = requests.get(
f"{url}/api/remote/services",
headers={"X-API-Key": api_key, "Content-Type": "application/json"},
timeout=10,
)
response.raise_for_status()
data = response.json()
if data.get("status") != "success" or "services" not in data:
log.error(f"Invalid response from {url}: {data}")
return
services = data["services"]
log.info(f"Found {len(services)} service(s) on {server_name}")
# Create RemoteService classes for each service
for service_info in services:
self._register_remote_service(url, api_key, service_info, server_name)
except requests.RequestException as e:
log.error(f"Failed to connect to remote server {url}: {e}")
raise
def _register_remote_service(
self, remote_url: str, api_key: str, service_info: Dict[str, Any], server_name: str
) -> None:
"""
Register a remote service as a local service class.
Args:
remote_url: Base URL of the remote server
api_key: API key for authentication
service_info: Service metadata from the remote server
server_name: Friendly name of the remote server
"""
service_tag = service_info.get("tag")
if not service_tag:
log.warning(f"Service info missing 'tag': {service_info}")
return
# Create a unique tag for the remote service
# Use "remote_" prefix to distinguish from local services
remote_tag = f"remote_{service_tag}"
# Check if this remote service is already registered
if remote_tag in self.remote_services:
log.debug(f"Remote service {remote_tag} already registered, skipping")
return
log.info(f"Registering remote service: {remote_tag} from {server_name}")
# Create a dynamic class that inherits from RemoteService
# This allows us to create instances with the cli() method for Click integration
class DynamicRemoteService(RemoteService):
"""Dynamically created remote service class."""
def __init__(self, ctx, **kwargs):
super().__init__(
ctx=ctx,
remote_url=remote_url,
api_key=api_key,
service_tag=service_tag,
service_metadata=service_info,
**kwargs,
)
@staticmethod
def cli():
"""CLI method for Click integration."""
import click
# Create a dynamic Click command for this service
@click.command(
name=remote_tag,
short_help=f"Remote: {service_info.get('help', service_tag)}",
help=service_info.get("help", f"Remote service for {service_tag}"),
)
@click.argument("title", type=str, required=False)
@click.option("-q", "--query", type=str, help="Search query")
@click.pass_context
def remote_service_cli(ctx, title=None, query=None, **kwargs):
# Combine title and kwargs
params = {**kwargs}
if title:
params["title"] = title
if query:
params["query"] = query
return DynamicRemoteService(ctx, **params)
return remote_service_cli
# Set class name for better debugging
DynamicRemoteService.__name__ = remote_tag
DynamicRemoteService.__module__ = "unshackle.remote_services"
# Set GEOFENCE and ALIASES
if "geofence" in service_info:
DynamicRemoteService.GEOFENCE = tuple(service_info["geofence"])
if "aliases" in service_info:
# Add "remote_" prefix to aliases too
DynamicRemoteService.ALIASES = tuple(f"remote_{alias}" for alias in service_info["aliases"])
# Register the service
self.remote_services[remote_tag] = DynamicRemoteService
def get_service(self, tag: str) -> Optional[type]:
"""
Get a remote service class by tag.
Args:
tag: Service tag (e.g., "remote_DSNP")
Returns:
RemoteService class or None if not found
"""
return self.remote_services.get(tag)
def get_all_services(self) -> Dict[str, type]:
"""
Get all registered remote services.
Returns:
Dictionary mapping service tags to RemoteService classes
"""
return self.remote_services.copy()
def get_service_path(self, tag: str) -> Optional[Path]:
"""
Get the path for a remote service.
Remote services don't have local paths, so this returns None.
This method exists for compatibility with the Services interface.
Args:
tag: Service tag
Returns:
None (remote services have no local path)
"""
return None
# Global instance
_remote_service_manager: Optional[RemoteServiceManager] = None
def get_remote_service_manager() -> RemoteServiceManager:
"""
Get the global RemoteServiceManager instance.
Creates the instance on first call and discovers services.
Returns:
RemoteServiceManager instance
"""
global _remote_service_manager
if _remote_service_manager is None:
_remote_service_manager = RemoteServiceManager()
try:
_remote_service_manager.discover_services()
except Exception as e:
log.error(f"Failed to discover remote services: {e}")
return _remote_service_manager
__all__ = ("RemoteServiceManager", "get_remote_service_manager")

View File

@@ -53,8 +53,55 @@ class Service(metaclass=ABCMeta):
if not ctx.parent or not ctx.parent.params.get("no_proxy"):
if ctx.parent:
proxy = ctx.parent.params["proxy"]
proxy_query = ctx.parent.params.get("proxy_query")
proxy_provider_name = ctx.parent.params.get("proxy_provider")
else:
proxy = None
proxy_query = None
proxy_provider_name = None
# Check for service-specific proxy mapping
service_name = self.__class__.__name__
service_config_dict = config.services.get(service_name, {})
proxy_map = service_config_dict.get("proxy_map", {})
if proxy_map and proxy_query:
# Build the full proxy query key (e.g., "nordvpn:ca" or "us")
if proxy_provider_name:
full_proxy_key = f"{proxy_provider_name}:{proxy_query}"
else:
full_proxy_key = proxy_query
# Check if there's a mapping for this query
mapped_value = proxy_map.get(full_proxy_key)
if mapped_value:
self.log.info(f"Found service-specific proxy mapping: {full_proxy_key} -> {mapped_value}")
# Query the proxy provider with the mapped value
if proxy_provider_name:
# Specific provider requested
proxy_provider = next(
(x for x in ctx.obj.proxy_providers if x.__class__.__name__.lower() == proxy_provider_name),
None,
)
if proxy_provider:
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
if mapped_proxy_uri:
proxy = mapped_proxy_uri
self.log.info(f"Using mapped proxy from {proxy_provider.__class__.__name__}: {proxy}")
else:
self.log.warning(f"Failed to get proxy for mapped value '{mapped_value}', using default")
else:
self.log.warning(f"Proxy provider '{proxy_provider_name}' not found, using default proxy")
else:
# No specific provider, try all providers
for proxy_provider in ctx.obj.proxy_providers:
mapped_proxy_uri = proxy_provider.get_proxy(mapped_value)
if mapped_proxy_uri:
proxy = mapped_proxy_uri
self.log.info(f"Using mapped proxy from {proxy_provider.__class__.__name__}: {proxy}")
break
else:
self.log.warning(f"No provider could resolve mapped value '{mapped_value}', using default")
if not proxy:
# don't override the explicit proxy set by the user, even if they may be geoblocked

View File

@@ -25,6 +25,17 @@ class Services(click.MultiCommand):
# Click-specific methods
@staticmethod
def _get_remote_services():
"""Get remote services from the manager (lazy import to avoid circular dependency)."""
try:
from unshackle.core.remote_services import get_remote_service_manager
manager = get_remote_service_manager()
return manager.get_all_services()
except Exception:
return {}
def list_commands(self, ctx: click.Context) -> list[str]:
"""Returns a list of all available Services as command names for Click."""
return Services.get_tags()
@@ -51,13 +62,25 @@ class Services(click.MultiCommand):
@staticmethod
def get_tags() -> list[str]:
"""Returns a list of service tags from all available Services."""
return [x.parent.stem for x in _SERVICES]
"""Returns a list of service tags from all available Services (local + remote)."""
local_tags = [x.parent.stem for x in _SERVICES]
remote_services = Services._get_remote_services()
remote_tags = list(remote_services.keys())
return local_tags + remote_tags
@staticmethod
def get_path(name: str) -> Path:
"""Get the directory path of a command."""
tag = Services.get_tag(name)
# Check if it's a remote service
remote_services = Services._get_remote_services()
if tag in remote_services:
# Remote services don't have local paths
# Return a dummy path or raise an appropriate error
# For now, we'll raise KeyError to indicate no path exists
raise KeyError(f"Remote service '{tag}' has no local path")
for service in _SERVICES:
if service.parent.stem == tag:
return service.parent
@@ -72,19 +95,38 @@ class Services(click.MultiCommand):
"""
original_value = value
value = value.lower()
# Check local services
for path in _SERVICES:
tag = path.parent.stem
if value in (tag.lower(), *_ALIASES.get(tag, [])):
return tag
# Check remote services
remote_services = Services._get_remote_services()
for tag, service_class in remote_services.items():
if value == tag.lower():
return tag
if hasattr(service_class, "ALIASES"):
if value in (alias.lower() for alias in service_class.ALIASES):
return tag
return original_value
@staticmethod
def load(tag: str) -> Service:
"""Load a Service module by Service tag."""
"""Load a Service module by Service tag (local or remote)."""
# Check local services first
module = _MODULES.get(tag)
if not module:
raise KeyError(f"There is no Service added by the Tag '{tag}'")
if module:
return module
# Check remote services
remote_services = Services._get_remote_services()
if tag in remote_services:
return remote_services[tag]
raise KeyError(f"There is no Service added by the Tag '{tag}'")
__all__ = ("Services",)

View File

@@ -19,6 +19,7 @@ from urllib.parse import ParseResult, urlparse
from uuid import uuid4
import chardet
import pycountry
import requests
from construct import ValidationError
from fontTools import ttLib
@@ -272,6 +273,80 @@ def ap_case(text: str, keep_spaces: bool = False, stop_words: tuple[str] = None)
)
# Common country code aliases that differ from ISO 3166-1 alpha-2
COUNTRY_CODE_ALIASES = {
"uk": "gb", # United Kingdom -> Great Britain
}
def get_country_name(code: str) -> Optional[str]:
"""
Convert a 2-letter country code to full country name.
Args:
code: ISO 3166-1 alpha-2 country code (e.g., 'ca', 'us', 'gb', 'uk')
Returns:
Full country name (e.g., 'Canada', 'United States', 'United Kingdom') or None if not found
Examples:
>>> get_country_name('ca')
'Canada'
>>> get_country_name('US')
'United States'
>>> get_country_name('uk')
'United Kingdom'
"""
# Handle common aliases
code = COUNTRY_CODE_ALIASES.get(code.lower(), code.lower())
try:
country = pycountry.countries.get(alpha_2=code.upper())
if country:
return country.name
except (KeyError, LookupError):
pass
return None
def get_country_code(name: str) -> Optional[str]:
"""
Convert a country name to its 2-letter ISO 3166-1 alpha-2 code.
Args:
name: Full country name (e.g., 'Canada', 'United States', 'United Kingdom')
Returns:
2-letter country code in uppercase (e.g., 'CA', 'US', 'GB') or None if not found
Examples:
>>> get_country_code('Canada')
'CA'
>>> get_country_code('united states')
'US'
>>> get_country_code('United Kingdom')
'GB'
"""
try:
# Try exact name match first
country = pycountry.countries.get(name=name.title())
if country:
return country.alpha_2.upper()
# Try common name (e.g., "Bolivia" vs "Bolivia, Plurinational State of")
country = pycountry.countries.get(common_name=name.title())
if country:
return country.alpha_2.upper()
# Try fuzzy search as fallback
results = pycountry.countries.search_fuzzy(name)
if results:
return results[0].alpha_2.upper()
except (KeyError, LookupError):
pass
return None
def get_ip_info(session: Optional[requests.Session] = None) -> dict:
"""
Use ipinfo.io to get IP location information.

View File

@@ -408,6 +408,19 @@ services:
app_name: "AIV"
device_model: "Fire TV Stick 4K"
# Service-specific proxy mappings
# Override global proxy selection with specific servers for this service
# When --proxy matches a key in proxy_map, the mapped server will be used
# instead of the default/random server selection
proxy_map:
nordvpn:ca: ca1577 # Use ca1577 when --proxy nordvpn:ca is specified
nordvpn:us: us9842 # Use us9842 when --proxy nordvpn:us is specified
us: 123 # Use server 123 (from any provider) when --proxy us is specified
gb: 456 # Use server 456 (from any provider) when --proxy gb is specified
# Without this service, --proxy nordvpn:ca picks a random CA server
# With this config, --proxy nordvpn:ca EXAMPLE uses ca1577 specifically
# Other services or no service specified will still use random selection
# NEW: Configuration overrides (can be combined with profiles and certificates)
# Override dl command defaults for this service
dl:
@@ -478,8 +491,15 @@ proxy_providers:
nordvpn:
username: username_from_service_credentials
password: password_from_service_credentials
# server_map: global mapping that applies to ALL services
# Difference from service-specific proxy_map:
# - server_map: applies to ALL services when --proxy nordvpn:us is used
# - proxy_map: only applies to the specific service configured (see services: EXAMPLE: proxy_map above)
# - proxy_map takes precedence over server_map for that service
server_map:
us: 12 # force US server #12 for US proxies
ca:calgary: 2534 # force CA server #2534 for Calgary proxies
us:seattle: 7890 # force US server #7890 for Seattle 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)
@@ -487,12 +507,81 @@ proxy_providers:
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
us:seattle: 5678 # force US server #5678 for Seattle proxies
ca:toronto: 1234 # force CA server #1234 for Toronto proxies
windscribevpn:
username: your_windscribe_username # Service credentials from https://windscribe.com/getconfig/openvpn
password: your_windscribe_password # Service credentials (not your login password)
server_map:
us: "us-central-096.totallyacdn.com" # force US server
gb: "uk-london-055.totallyacdn.com" # force GB server
us:seattle: "us-west-011.totallyacdn.com" # force US Seattle server
ca:toronto: "ca-toronto-012.totallyacdn.com" # force CA Toronto server
# Gluetun: Dynamic Docker-based VPN proxy (supports 50+ VPN providers)
# Creates Docker containers running Gluetun to bridge VPN connections to HTTP proxies
# Requires Docker to be installed and running
# Usage: --proxy gluetun:windscribe:us or --proxy gluetun:nordvpn:de
gluetun:
# Global settings
base_port: 8888 # Starting port for HTTP proxies (increments for each container)
auto_cleanup: true # Automatically remove containers when done
container_prefix: "unshackle-gluetun" # Docker container name prefix
verify_ip: true # Verify VPN IP matches expected region
# Optional HTTP proxy authentication (for the proxy itself, not VPN)
# auth_user: proxy_user
# auth_password: proxy_password
# VPN provider configurations
providers:
# Windscribe (WireGuard) - Get credentials from https://windscribe.com/getconfig/wireguard
windscribe:
vpn_type: wireguard
credentials:
private_key: "YOUR_WIREGUARD_PRIVATE_KEY"
addresses: "YOUR_WIREGUARD_ADDRESS" # e.g., "10.x.x.x/32"
# Map friendly names to country codes
server_countries:
us: US
uk: GB
ca: CA
de: DE
# NordVPN (OpenVPN) - Get service credentials from https://my.nordaccount.com/dashboard/nordvpn/manual-configuration/
# Note: Service credentials are NOT your email+password - generate them from the link above
# nordvpn:
# vpn_type: openvpn
# credentials:
# username: "YOUR_NORDVPN_SERVICE_USERNAME"
# password: "YOUR_NORDVPN_SERVICE_PASSWORD"
# server_countries:
# us: US
# uk: GB
# ExpressVPN (OpenVPN) - Get credentials from ExpressVPN setup page
# expressvpn:
# vpn_type: openvpn
# credentials:
# username: "YOUR_EXPRESSVPN_USERNAME"
# password: "YOUR_EXPRESSVPN_PASSWORD"
# server_countries:
# us: US
# uk: GB
# Surfshark (WireGuard) - Get credentials from https://my.surfshark.com/vpn/manual-setup/main/wireguard
# surfshark:
# vpn_type: wireguard
# credentials:
# private_key: "YOUR_SURFSHARK_PRIVATE_KEY"
# addresses: "YOUR_SURFSHARK_ADDRESS"
# server_countries:
# us: US
# uk: GB
# Specific server selection: Use format like "us1239" to select specific servers
# Example: --proxy gluetun:nordvpn:us1239 connects to us1239.nordvpn.com
# Supported providers: nordvpn, surfshark, expressvpn, cyberghost
basic:
GB:
- "socks5://username:password@bhx.socks.ipvanish.com:1080" # 1 (Birmingham)

11
uv.lock generated
View File

@@ -1070,6 +1070,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/35/c7/d13c57e5a3408df2e5d910853e957ec8e253b41ba531e0f32036c8321240/pycaption-2.2.19-py3-none-any.whl", hash = "sha256:7eb84a05d40bb80400689f9431d05d8b77dec6535938b419ebed2c9d67283a4f", size = 124970, upload-time = "2025-09-30T07:15:21.945Z" },
]
[[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"
@@ -1585,6 +1594,7 @@ dependencies = [
{ name = "pproxy" },
{ name = "protobuf" },
{ name = "pycaption" },
{ name = "pycountry" },
{ name = "pycryptodomex" },
{ name = "pyexecjs" },
{ name = "pyjwt" },
@@ -1638,6 +1648,7 @@ requires-dist = [
{ name = "pproxy", specifier = ">=2.7.9,<3" },
{ name = "protobuf", specifier = ">=4.25.3,<7" },
{ name = "pycaption", specifier = ">=2.2.6,<3" },
{ name = "pycountry", specifier = ">=24.6.1" },
{ name = "pycryptodomex", specifier = ">=3.20.0,<4" },
{ name = "pyexecjs", specifier = ">=1.5.1,<2" },
{ name = "pyjwt", specifier = ">=2.8.0,<3" },