mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-13 01:49:00 +00:00
feat(gluetun): improve VPN connection display and Windscribe support
This commit is contained in:
@@ -1,18 +1,13 @@
|
||||
"""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:
|
||||
|
||||
@@ -10,7 +10,7 @@ import requests
|
||||
from requests.cookies import cookiejar_from_dict, get_cookie_header
|
||||
|
||||
from unshackle.core import binaries
|
||||
from unshackle.core.binaries import FFMPEG, ShakaPackager, Mp4decrypt
|
||||
from unshackle.core.binaries import FFMPEG, Mp4decrypt, ShakaPackager
|
||||
from unshackle.core.config import config
|
||||
from unshackle.core.console import console
|
||||
from unshackle.core.constants import DOWNLOAD_CANCELLED
|
||||
|
||||
@@ -102,6 +102,62 @@ class Gluetun(Proxy):
|
||||
"purevpn": "purevpn",
|
||||
}
|
||||
|
||||
# Windscribe uses specific region names instead of country codes
|
||||
# See: https://github.com/qdm12/gluetun-wiki/blob/main/setup/providers/windscribe.md
|
||||
WINDSCRIBE_REGION_MAP = {
|
||||
# Country codes to Windscribe region names
|
||||
"us": "US East",
|
||||
"us-east": "US East",
|
||||
"us-west": "US West",
|
||||
"us-central": "US Central",
|
||||
"ca": "Canada East",
|
||||
"ca-east": "Canada East",
|
||||
"ca-west": "Canada West",
|
||||
"uk": "United Kingdom",
|
||||
"gb": "United Kingdom",
|
||||
"de": "Germany",
|
||||
"fr": "France",
|
||||
"nl": "Netherlands",
|
||||
"au": "Australia",
|
||||
"jp": "Japan",
|
||||
"sg": "Singapore",
|
||||
"hk": "Hong Kong",
|
||||
"kr": "South Korea",
|
||||
"in": "India",
|
||||
"it": "Italy",
|
||||
"es": "Spain",
|
||||
"ch": "Switzerland",
|
||||
"se": "Sweden",
|
||||
"no": "Norway",
|
||||
"dk": "Denmark",
|
||||
"fi": "Finland",
|
||||
"at": "Austria",
|
||||
"be": "Belgium",
|
||||
"ie": "Ireland",
|
||||
"pl": "Poland",
|
||||
"pt": "Portugal",
|
||||
"cz": "Czech Republic",
|
||||
"ro": "Romania",
|
||||
"hu": "Hungary",
|
||||
"gr": "Greece",
|
||||
"tr": "Turkey",
|
||||
"ru": "Russia",
|
||||
"ua": "Ukraine",
|
||||
"br": "Brazil",
|
||||
"mx": "Mexico",
|
||||
"ar": "Argentina",
|
||||
"za": "South Africa",
|
||||
"nz": "New Zealand",
|
||||
"th": "Thailand",
|
||||
"ph": "Philippines",
|
||||
"id": "Indonesia",
|
||||
"my": "Malaysia",
|
||||
"vn": "Vietnam",
|
||||
"tw": "Taiwan",
|
||||
"ae": "United Arab Emirates",
|
||||
"il": "Israel",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
providers: Optional[dict] = None,
|
||||
@@ -196,9 +252,7 @@ class Gluetun(Proxy):
|
||||
# Parse query
|
||||
parts = query.split(":")
|
||||
if len(parts) != 2:
|
||||
raise ValueError(
|
||||
f"Invalid query format: '{query}'. Expected 'provider:region' (e.g., 'windscribe:us')"
|
||||
)
|
||||
raise ValueError(f"Invalid query format: '{query}'. Expected 'provider:region' (e.g., 'windscribe:us')")
|
||||
|
||||
provider_name = parts[0].lower()
|
||||
region = parts[1].lower()
|
||||
@@ -206,9 +260,7 @@ class Gluetun(Proxy):
|
||||
# Check if provider is configured
|
||||
if provider_name not in self.providers:
|
||||
available = ", ".join(self.providers.keys())
|
||||
raise ValueError(
|
||||
f"VPN provider '{provider_name}' not configured. Available providers: {available}"
|
||||
)
|
||||
raise ValueError(f"VPN provider '{provider_name}' not configured. Available providers: {available}")
|
||||
|
||||
# Create query key for tracking
|
||||
query_key = f"{provider_name}:{region}"
|
||||
@@ -333,11 +385,11 @@ class Gluetun(Proxy):
|
||||
# Get container logs for better error message
|
||||
logs = self._get_container_logs(container_name, tail=30)
|
||||
error_msg = f"Gluetun container '{container_name}' failed to start"
|
||||
if hasattr(self, '_last_wait_error') and self._last_wait_error:
|
||||
if hasattr(self, "_last_wait_error") and self._last_wait_error:
|
||||
error_msg += f": {self._last_wait_error}"
|
||||
if logs:
|
||||
# Extract last few relevant lines
|
||||
log_lines = [line for line in logs.strip().split('\n') if line.strip()][-5:]
|
||||
log_lines = [line for line in logs.strip().split("\n") if line.strip()][-5:]
|
||||
error_msg += "\nRecent logs:\n" + "\n".join(log_lines)
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
@@ -396,22 +448,49 @@ class Gluetun(Proxy):
|
||||
success=True,
|
||||
)
|
||||
|
||||
def get_connection_info(self, query: str) -> Optional[dict]:
|
||||
"""
|
||||
Get connection info for a proxy query.
|
||||
|
||||
Args:
|
||||
query: Query format "provider:region" (e.g., "windscribe:us")
|
||||
|
||||
Returns:
|
||||
Dict with connection info including public_ip, country, city, or None if not found.
|
||||
"""
|
||||
parts = query.split(":")
|
||||
if len(parts) != 2:
|
||||
return None
|
||||
|
||||
provider_name = parts[0].lower()
|
||||
region = parts[1].lower()
|
||||
query_key = f"{provider_name}:{region}"
|
||||
|
||||
container = self.active_containers.get(query_key)
|
||||
if not container:
|
||||
return None
|
||||
|
||||
return {
|
||||
"provider": container.get("provider"),
|
||||
"region": container.get("region"),
|
||||
"public_ip": container.get("public_ip"),
|
||||
"country": container.get("ip_country"),
|
||||
"city": container.get("ip_city"),
|
||||
"org": container.get("ip_org"),
|
||||
}
|
||||
|
||||
def _validate_provider_config(self, provider_name: str, config: dict):
|
||||
"""Validate a provider's configuration."""
|
||||
vpn_type = config.get("vpn_type", "wireguard").lower()
|
||||
credentials = config.get("credentials", {})
|
||||
|
||||
if vpn_type not in ["wireguard", "openvpn"]:
|
||||
raise ValueError(
|
||||
f"Provider '{provider_name}': Invalid vpn_type '{vpn_type}'. Use 'wireguard' or 'openvpn'"
|
||||
)
|
||||
raise ValueError(f"Provider '{provider_name}': Invalid vpn_type '{vpn_type}'. Use 'wireguard' or 'openvpn'")
|
||||
|
||||
if vpn_type == "wireguard":
|
||||
# private_key is always required for WireGuard
|
||||
if "private_key" not in credentials:
|
||||
raise ValueError(
|
||||
f"Provider '{provider_name}': WireGuard requires 'private_key' in credentials"
|
||||
)
|
||||
raise ValueError(f"Provider '{provider_name}': WireGuard requires 'private_key' in credentials")
|
||||
|
||||
# Provider-specific WireGuard requirements based on Gluetun wiki:
|
||||
# - NordVPN, ProtonVPN: only private_key required
|
||||
@@ -435,9 +514,7 @@ class Gluetun(Proxy):
|
||||
# Providers that require addresses (but not preshared_key)
|
||||
elif provider_lower in ["surfshark", "mullvad", "ivpn"]:
|
||||
if "addresses" not in credentials:
|
||||
raise ValueError(
|
||||
f"Provider '{provider_name}': WireGuard requires 'addresses' in credentials"
|
||||
)
|
||||
raise ValueError(f"Provider '{provider_name}': WireGuard requires 'addresses' in credentials")
|
||||
|
||||
elif vpn_type == "openvpn":
|
||||
if "username" not in credentials or "password" not in credentials:
|
||||
@@ -651,7 +728,12 @@ class Gluetun(Proxy):
|
||||
# Use country/city selection
|
||||
if country:
|
||||
if uses_regions:
|
||||
env_vars["SERVER_REGIONS"] = country
|
||||
# Convert country code to provider-specific region name
|
||||
if gluetun_provider == "windscribe":
|
||||
region_name = self.WINDSCRIBE_REGION_MAP.get(country.lower(), country)
|
||||
env_vars["SERVER_REGIONS"] = region_name
|
||||
else:
|
||||
env_vars["SERVER_REGIONS"] = country
|
||||
else:
|
||||
env_vars["SERVER_COUNTRIES"] = country
|
||||
if city:
|
||||
@@ -666,6 +748,16 @@ class Gluetun(Proxy):
|
||||
# Merge extra environment variables
|
||||
env_vars.update(extra_env)
|
||||
|
||||
# Debug log environment variables (redact sensitive values)
|
||||
if debug_logger:
|
||||
safe_env = {k: ("***" if "KEY" in k or "PASSWORD" in k else v) for k, v in env_vars.items()}
|
||||
debug_logger.log(
|
||||
level="DEBUG",
|
||||
operation="gluetun_env_vars",
|
||||
message=f"Environment variables for {container_name}",
|
||||
context={"env_vars": safe_env, "gluetun_provider": gluetun_provider},
|
||||
)
|
||||
|
||||
# Build docker run command
|
||||
cmd = [
|
||||
"docker",
|
||||
@@ -791,7 +883,7 @@ class Gluetun(Proxy):
|
||||
return None
|
||||
|
||||
# Parse port from output like "map[8888/tcp:[{127.0.0.1 8888}]]"
|
||||
port_match = re.search(r'127\.0\.0\.1\s+(\d+)', result.stdout)
|
||||
port_match = re.search(r"127\.0\.0\.1\s+(\d+)", result.stdout)
|
||||
if not port_match:
|
||||
return None
|
||||
|
||||
@@ -854,11 +946,9 @@ class Gluetun(Proxy):
|
||||
Returns:
|
||||
True if container is ready, False if it failed or timed out
|
||||
"""
|
||||
log = logging.getLogger("Gluetun")
|
||||
debug_logger = get_debug_logger()
|
||||
start_time = time.time()
|
||||
last_error = None
|
||||
last_status = None
|
||||
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
@@ -900,21 +990,6 @@ class Gluetun(Proxy):
|
||||
proxy_ready = "[http proxy] listening" in all_logs
|
||||
vpn_ready = "initialization sequence completed" in all_logs
|
||||
|
||||
# Log status changes to help debug slow connections
|
||||
current_status = None
|
||||
if vpn_ready:
|
||||
current_status = "VPN connected"
|
||||
elif "peer connection initiated" in all_logs:
|
||||
current_status = "VPN connecting..."
|
||||
elif "[openvpn]" in all_logs or "[wireguard]" in all_logs:
|
||||
current_status = "Starting VPN..."
|
||||
elif "[firewall]" in all_logs:
|
||||
current_status = "Configuring firewall..."
|
||||
|
||||
if current_status and current_status != last_status:
|
||||
log.info(current_status)
|
||||
last_status = current_status
|
||||
|
||||
if proxy_ready and vpn_ready:
|
||||
# Give a brief moment for the proxy to fully initialize
|
||||
time.sleep(1)
|
||||
@@ -947,7 +1022,7 @@ class Gluetun(Proxy):
|
||||
for error in error_indicators:
|
||||
if error in all_logs:
|
||||
# Extract the error line for better messaging
|
||||
for line in (stdout + stderr).split('\n'):
|
||||
for line in (stdout + stderr).split("\n"):
|
||||
if error in line.lower():
|
||||
last_error = line.strip()
|
||||
break
|
||||
@@ -975,7 +1050,6 @@ class Gluetun(Proxy):
|
||||
"container_name": container_name,
|
||||
"timeout": timeout,
|
||||
"last_error": last_error,
|
||||
"last_status": last_status,
|
||||
},
|
||||
success=False,
|
||||
duration_ms=duration_ms,
|
||||
@@ -986,10 +1060,7 @@ class Gluetun(Proxy):
|
||||
"""Get exit information for a stopped container."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker", "inspect", container_name,
|
||||
"--format", "{{.State.ExitCode}}:{{.State.Error}}"
|
||||
],
|
||||
["docker", "inspect", container_name, "--format", "{{.State.ExitCode}}:{{.State.Error}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
@@ -998,7 +1069,7 @@ class Gluetun(Proxy):
|
||||
parts = result.stdout.strip().split(":", 1)
|
||||
return {
|
||||
"exit_code": int(parts[0]) if parts[0].isdigit() else -1,
|
||||
"error": parts[1] if len(parts) > 1 else ""
|
||||
"error": parts[1] if len(parts) > 1 else "",
|
||||
}
|
||||
return None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
|
||||
@@ -1104,7 +1175,13 @@ class Gluetun(Proxy):
|
||||
f"(IP: {ip_info.get('ip')}, City: {ip_info.get('city')})"
|
||||
)
|
||||
|
||||
# Verification successful
|
||||
# Verification successful - store IP info in container record
|
||||
if query_key in self.active_containers:
|
||||
self.active_containers[query_key]["public_ip"] = ip_info.get("ip")
|
||||
self.active_containers[query_key]["ip_country"] = actual_country
|
||||
self.active_containers[query_key]["ip_city"] = ip_info.get("city")
|
||||
self.active_containers[query_key]["ip_org"] = ip_info.get("org")
|
||||
|
||||
duration_ms = (time.time() - start_time) * 1000
|
||||
if debug_logger:
|
||||
debug_logger.log(
|
||||
@@ -1145,7 +1222,7 @@ class Gluetun(Proxy):
|
||||
|
||||
# Wait before retry (exponential backoff)
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = 2 ** attempt # 1, 2, 4 seconds
|
||||
wait_time = 2**attempt # 1, 2, 4 seconds
|
||||
time.sleep(wait_time)
|
||||
|
||||
# All retries exhausted
|
||||
@@ -1253,9 +1330,9 @@ class Gluetun(Proxy):
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup containers on object destruction."""
|
||||
if hasattr(self, 'auto_cleanup') and self.auto_cleanup:
|
||||
if hasattr(self, "auto_cleanup") and self.auto_cleanup:
|
||||
try:
|
||||
if hasattr(self, 'active_containers') and self.active_containers:
|
||||
if hasattr(self, "active_containers") and self.active_containers:
|
||||
self.cleanup()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -112,18 +112,18 @@ class Episode(Title):
|
||||
if config.dash_naming:
|
||||
# Format: Title - SXXEXX - Episode Name
|
||||
name = self.title.replace("$", "S") # e.g., Arli$$
|
||||
|
||||
|
||||
# Add year if configured
|
||||
if self.year and config.series_year:
|
||||
name += f" {self.year}"
|
||||
|
||||
|
||||
# Add season and episode
|
||||
name += f" - S{self.season:02}E{self.number:02}"
|
||||
|
||||
|
||||
# Add episode name with dash separator
|
||||
if self.name:
|
||||
name += f" - {self.name}"
|
||||
|
||||
|
||||
name = name.strip()
|
||||
else:
|
||||
# Standard format without extra dashes
|
||||
|
||||
Reference in New Issue
Block a user