import atexit import logging import os import re import stat import subprocess import tempfile import threading import time from typing import Optional import requests from unshackle.core import binaries from unshackle.core.proxies.proxy import Proxy from unshackle.core.utilities import get_country_code, get_country_name, get_debug_logger, get_ip_info # Global registry for cleanup on exit _gluetun_instances: list["Gluetun"] = [] _cleanup_lock = threading.Lock() _cleanup_registered = False def _cleanup_all_gluetun_containers(): """Cleanup all Gluetun containers on exit.""" # Get instances without holding the lock during cleanup with _cleanup_lock: instances = list(_gluetun_instances) _gluetun_instances.clear() # Cleanup each instance (no lock held, so no deadlock possible) for instance in instances: try: instance.cleanup() except Exception: pass def _register_cleanup(): """Register cleanup handlers (only once).""" global _cleanup_registered with _cleanup_lock: if not _cleanup_registered: # Only use atexit for cleanup - don't override signal handlers # This allows Ctrl+C to work normally while still cleaning up on exit atexit.register(_cleanup_all_gluetun_containers) _cleanup_registered = True class Gluetun(Proxy): """ Dynamic Gluetun VPN-to-HTTP Proxy Provider with multi-provider support. Automatically manages Docker containers running Gluetun for WireGuard/OpenVPN VPN connections. Supports multiple VPN providers in a single configuration using query format: provider:region Supported VPN providers: windscribe, expressvpn, nordvpn, surfshark, protonvpn, mullvad, privateinternetaccess, cyberghost, vyprvpn, torguard, and 50+ more. Configuration example in unshackle.yaml: proxy_providers: gluetun: providers: windscribe: vpn_type: wireguard credentials: private_key: YOUR_KEY addresses: YOUR_ADDRESS server_countries: us: US uk: GB nordvpn: vpn_type: wireguard credentials: private_key: YOUR_KEY addresses: YOUR_ADDRESS server_countries: us: US de: DE # Global settings (optional) base_port: 8888 auto_cleanup: true container_prefix: "unshackle-gluetun" Usage: --proxy gluetun:windscribe:us --proxy gluetun:nordvpn:de """ # Mapping of common VPN provider names to Gluetun identifiers PROVIDER_MAPPING = { "windscribe": "windscribe", "expressvpn": "expressvpn", "nordvpn": "nordvpn", "surfshark": "surfshark", "protonvpn": "protonvpn", "mullvad": "mullvad", "pia": "private internet access", "privateinternetaccess": "private internet access", "cyberghost": "cyberghost", "vyprvpn": "vyprvpn", "torguard": "torguard", "ipvanish": "ipvanish", "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, base_port: int = 8888, auto_cleanup: bool = True, container_prefix: str = "unshackle-gluetun", auth_user: Optional[str] = None, auth_password: Optional[str] = None, verify_ip: bool = True, **kwargs, ): """ Initialize Gluetun proxy provider with multi-provider support. Args: providers: Dict of VPN provider configurations Format: { "windscribe": { "vpn_type": "wireguard", "credentials": {"private_key": "...", "addresses": "..."}, "server_countries": {"us": "US", "uk": "GB"} }, "nordvpn": {...} } base_port: Starting port for HTTP proxies (default: 8888) auto_cleanup: Automatically remove stopped containers (default: True) container_prefix: Docker container name prefix (default: "unshackle-gluetun") auth_user: Optional HTTP proxy authentication username auth_password: Optional HTTP proxy authentication password verify_ip: Automatically verify IP and region after connection (default: True) """ # Check Docker availability using binaries module if not binaries.Docker: raise RuntimeError( "Docker is not available. Please install Docker to use Gluetun proxy.\n" "Visit: https://docs.docker.com/engine/install/" ) self.providers = providers or {} self.base_port = base_port self.auto_cleanup = auto_cleanup self.container_prefix = container_prefix self.auth_user = auth_user self.auth_password = auth_password self.verify_ip = verify_ip # Track active containers: {query_key: {"container_name": ..., "port": ..., ...}} self.active_containers = {} # Lock for thread-safe port allocation self._port_lock = threading.Lock() # Validate provider configurations for provider_name, config in self.providers.items(): self._validate_provider_config(provider_name, config) # Register this instance for cleanup on exit _register_cleanup() with _cleanup_lock: _gluetun_instances.append(self) # Log initialization debug_logger = get_debug_logger() if debug_logger: debug_logger.log( level="INFO", operation="gluetun_init", message=f"Gluetun proxy provider initialized with {len(self.providers)} provider(s)", context={ "providers": list(self.providers.keys()), "base_port": base_port, "auto_cleanup": auto_cleanup, "verify_ip": verify_ip, "container_prefix": container_prefix, }, ) def __repr__(self) -> str: provider_count = len(self.providers) return f"Gluetun ({provider_count} provider{['s', ''][provider_count == 1]})" def get_proxy(self, query: str) -> Optional[str]: """ Get an HTTP proxy URI for a Gluetun VPN connection. Args: query: Query format: "provider:region" (e.g., "windscribe:us", "nordvpn:uk") Returns: HTTP proxy URI or None if unavailable """ # Parse query parts = query.split(":") if len(parts) != 2: raise ValueError(f"Invalid query format: '{query}'. Expected 'provider:region' (e.g., 'windscribe:us')") provider_name = parts[0].lower() region = parts[1].lower() # 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}") # Create query key for tracking query_key = f"{provider_name}:{region}" container_name = f"{self.container_prefix}-{provider_name}-{region}" debug_logger = get_debug_logger() # Check if container already exists (in memory OR in Docker) # This handles multiple concurrent Unshackle sessions if query_key in self.active_containers: container = self.active_containers[query_key] if self._is_container_running(container["container_name"]): if debug_logger: debug_logger.log( level="DEBUG", operation="gluetun_container_reuse", message=f"Reusing existing container (in-memory): {query_key}", context={ "query_key": query_key, "container_name": container["container_name"], "port": container["port"], }, ) # Re-verify if needed if self.verify_ip: self._verify_container(query_key) return self._build_proxy_uri(container["port"]) else: # Not in memory, but might exist in Docker (from another session) existing_info = self._get_existing_container_info(container_name) if existing_info: # Container exists in Docker, reuse it self.active_containers[query_key] = existing_info if debug_logger: debug_logger.log( level="INFO", operation="gluetun_container_reuse_docker", message=f"Reusing existing Docker container: {query_key}", context={ "query_key": query_key, "container_name": container_name, "port": existing_info["port"], }, ) # Re-verify if needed if self.verify_ip: self._verify_container(query_key) return self._build_proxy_uri(existing_info["port"]) # Get provider configuration provider_config = self.providers[provider_name] # Determine server location server_countries = provider_config.get("server_countries", {}) server_cities = provider_config.get("server_cities", {}) server_hostnames = provider_config.get("server_hostnames", {}) country = server_countries.get(region) city = server_cities.get(region) hostname = server_hostnames.get(region) # Check if region is a specific server pattern (e.g., us1239, uk5678) # Format: 2-letter country code + number specific_server_match = re.match(r"^([a-z]{2})(\d+)$", region, re.IGNORECASE) if specific_server_match and not country and not city and not hostname: # Specific server requested (e.g., us1239) country_code = specific_server_match.group(1).upper() server_num = specific_server_match.group(2) # Build hostname based on provider hostname = self._build_server_hostname(provider_name, country_code, server_num) country = country_code # Set country for verification # If not explicitly mapped and not a specific server, try to use query as country code elif not country and not city and not hostname: if re.match(r"^[a-z]{2}$", region): # Convert country code to full name for Gluetun country = get_country_name(region) if not country: raise ValueError( f"Country code '{region}' not recognized. " f"Configure it in server_countries or use a valid ISO 3166-1 alpha-2 code." ) else: raise ValueError( f"Region '{region}' not recognized for provider '{provider_name}'. " f"Configure it in server_countries or server_cities, or use a 2-letter country code." ) # Remove any stopped container with the same name self._remove_stopped_container(container_name) # Find available port port = self._get_available_port() # Create container (name already set above) try: self._create_container( container_name=container_name, port=port, provider_name=provider_name, provider_config=provider_config, country=country, city=city, hostname=hostname, ) # Store container info self.active_containers[query_key] = { "container_name": container_name, "port": port, "provider": provider_name, "region": region, "country": country, "city": city, "hostname": hostname, } # Wait for container to be ready (60s timeout for VPN connection) if not self._wait_for_container(container_name, timeout=60): # 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: 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:] error_msg += "\nRecent logs:\n" + "\n".join(log_lines) raise RuntimeError(error_msg) # Verify IP and region if enabled if self.verify_ip: self._verify_container(query_key) return self._build_proxy_uri(port) except Exception as e: # Cleanup on failure self._remove_container(container_name) if query_key in self.active_containers: del self.active_containers[query_key] raise RuntimeError(f"Failed to create Gluetun container: {e}") def cleanup(self): """Stop and remove all managed Gluetun containers.""" debug_logger = get_debug_logger() container_count = len(self.active_containers) if container_count > 0 and debug_logger: debug_logger.log( level="DEBUG", operation="gluetun_cleanup_start", message=f"Cleaning up {container_count} Gluetun container(s)", context={ "container_count": container_count, "containers": list(self.active_containers.keys()), }, ) for query_key, container_info in list(self.active_containers.items()): container_name = container_info["container_name"] self._remove_container(container_name) if debug_logger: debug_logger.log( level="DEBUG", operation="gluetun_container_removed", message=f"Removed Gluetun container: {container_name}", context={ "query_key": query_key, "container_name": container_name, }, ) self.active_containers.clear() if container_count > 0 and debug_logger: debug_logger.log( level="INFO", operation="gluetun_cleanup_complete", message=f"Cleanup complete: removed {container_count} container(s)", context={"container_count": container_count}, 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'") 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") # Provider-specific WireGuard requirements based on Gluetun wiki: # - NordVPN, ProtonVPN: only private_key required # - Windscribe: private_key, addresses, AND preshared_key required (preshared_key MUST be set) # - Surfshark, Mullvad, IVPN: private_key AND addresses required provider_lower = provider_name.lower() # Windscribe requires preshared_key (can be empty string, but must be set) if provider_lower == "windscribe": if "preshared_key" not in credentials: raise ValueError( f"Provider '{provider_name}': Windscribe WireGuard requires 'preshared_key' in credentials " "(can be empty string, but must be set). Get it from windscribe.com/getconfig/wireguard" ) if "addresses" not in credentials: raise ValueError( f"Provider '{provider_name}': Windscribe WireGuard requires 'addresses' in credentials. " "Get it from windscribe.com/getconfig/wireguard" ) # 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") elif vpn_type == "openvpn": if "username" not in credentials or "password" not in credentials: raise ValueError( f"Provider '{provider_name}': OpenVPN requires 'username' and 'password' in credentials" ) def _get_available_port(self) -> int: """Find an available port starting from base_port (thread-safe).""" with self._port_lock: used_ports = {info["port"] for info in self.active_containers.values()} port = self.base_port while port in used_ports or self._is_port_in_use(port): port += 1 return port def _is_port_in_use(self, port: int) -> bool: """Check if a port is in use on the system or by any Docker container.""" import socket # First check if the port is available on the system try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", port)) except OSError: # Port is in use by something on the system return True # Also check Docker containers (in case of port forwarding) try: result = subprocess.run( ["docker", "ps", "--format", "{{.Ports}}"], capture_output=True, text=True, timeout=5, ) if result.returncode == 0: return f":{port}->" in result.stdout or f"0.0.0.0:{port}" in result.stdout return False except (subprocess.TimeoutExpired, FileNotFoundError): return False def _build_server_hostname(self, provider_name: str, country_code: str, server_num: str) -> str: """ Build a server hostname for specific server selection. Args: provider_name: VPN provider name (e.g., "nordvpn") country_code: 2-letter country code (e.g., "US") server_num: Server number (e.g., "1239") Returns: Server hostname (e.g., "us1239.nordvpn.com") """ # Convert to lowercase for hostname country_lower = country_code.lower() # Provider-specific hostname formats hostname_formats = { "nordvpn": f"{country_lower}{server_num}.nordvpn.com", "surfshark": f"{country_lower}-{server_num}.prod.surfshark.com", "expressvpn": f"{country_lower}-{server_num}.expressvpn.com", "cyberghost": f"{country_lower}-s{server_num}.cg-dialup.net", # Generic fallback for other providers } # Get provider-specific format or use generic if provider_name in hostname_formats: return hostname_formats[provider_name] else: # Generic format: country_code + server_num return f"{country_lower}{server_num}" def _ensure_image_available(self, image: str = "qmcgaw/gluetun:latest") -> bool: """ Ensure the Gluetun Docker image is available locally. If the image is not present, it will be pulled. This prevents the container creation from timing out during the first run. Args: image: Docker image name with tag Returns: True if image is available, False otherwise """ log = logging.getLogger("Gluetun") # Check if image exists locally try: result = subprocess.run( ["docker", "image", "inspect", image], capture_output=True, text=True, timeout=10, encoding="utf-8", errors="replace", ) if result.returncode == 0: return True log.debug(f"Image inspect failed: {result.stderr}") except subprocess.TimeoutExpired: log.warning("Docker image inspect timed out") except FileNotFoundError: log.error("Docker command not found - is Docker installed and in PATH?") return False # Image not found, pull it log.info(f"Pulling Docker image {image}...") try: result = subprocess.run( ["docker", "pull", image], capture_output=True, text=True, timeout=300, # 5 minutes for pull encoding="utf-8", errors="replace", ) if result.returncode == 0: return True log.error(f"Docker pull failed: {result.stderr}") return False except subprocess.TimeoutExpired: raise RuntimeError(f"Timed out pulling Docker image '{image}'") def _create_container( self, container_name: str, port: int, provider_name: str, provider_config: dict, country: Optional[str] = None, city: Optional[str] = None, hostname: Optional[str] = None, ): """Create and start a Gluetun Docker container.""" debug_logger = get_debug_logger() start_time = time.time() if debug_logger: debug_logger.log( level="DEBUG", operation="gluetun_container_create_start", message=f"Creating Gluetun container: {container_name}", context={ "container_name": container_name, "port": port, "provider": provider_name, "country": country, "city": city, "hostname": hostname, }, ) # Ensure the Gluetun image is available (pulls if needed) gluetun_image = "qmcgaw/gluetun:latest" if not self._ensure_image_available(gluetun_image): if debug_logger: debug_logger.log( level="ERROR", operation="gluetun_image_pull_failed", message=f"Failed to pull Docker image: {gluetun_image}", success=False, ) raise RuntimeError(f"Failed to ensure Gluetun Docker image '{gluetun_image}' is available") vpn_type = provider_config.get("vpn_type", "wireguard").lower() credentials = provider_config.get("credentials", {}) extra_env = provider_config.get("extra_env", {}) # Normalize provider name gluetun_provider = self.PROVIDER_MAPPING.get(provider_name.lower(), provider_name.lower()) # Build environment variables env_vars = { "VPN_SERVICE_PROVIDER": gluetun_provider, "VPN_TYPE": vpn_type, "HTTPPROXY": "on", "HTTPPROXY_LISTENING_ADDRESS": ":8888", "HTTPPROXY_LOG": "on", "TZ": os.environ.get("TZ", "UTC"), "LOG_LEVEL": "info", } # Add credentials if vpn_type == "wireguard": env_vars["WIREGUARD_PRIVATE_KEY"] = credentials["private_key"] # addresses is optional - not needed for some providers like NordVPN if "addresses" in credentials: env_vars["WIREGUARD_ADDRESSES"] = credentials["addresses"] # preshared_key is required for Windscribe, optional for others if "preshared_key" in credentials: env_vars["WIREGUARD_PRESHARED_KEY"] = credentials["preshared_key"] elif vpn_type == "openvpn": env_vars["OPENVPN_USER"] = credentials.get("username", "") env_vars["OPENVPN_PASSWORD"] = credentials.get("password", "") # Add server location # Priority: hostname > country + city > country only # Note: Different providers support different server selection variables # - Most providers: SERVER_COUNTRIES, SERVER_CITIES # - Windscribe, VyprVPN, VPN Secure: SERVER_REGIONS, SERVER_CITIES (no SERVER_COUNTRIES) if hostname: # Specific server hostname requested (e.g., us1239.nordvpn.com) env_vars["SERVER_HOSTNAMES"] = hostname else: # Providers that use SERVER_REGIONS instead of SERVER_COUNTRIES region_only_providers = {"windscribe", "vyprvpn", "vpn secure"} uses_regions = gluetun_provider in region_only_providers # Use country/city selection if country: if uses_regions: # 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: env_vars["SERVER_CITIES"] = city # Add authentication if configured if self.auth_user: env_vars["HTTPPROXY_USER"] = self.auth_user if self.auth_password: env_vars["HTTPPROXY_PASSWORD"] = self.auth_password # Merge extra environment variables env_vars.update(extra_env) # Debug log environment variables (redact sensitive values) if debug_logger: redact_markers = ("KEY", "PASSWORD", "PASS", "TOKEN", "SECRET", "USER") safe_env = {k: ("***" if any(m in k for m in redact_markers) 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", "run", "-d", "--name", container_name, "--cap-add=NET_ADMIN", "--device=/dev/net/tun", "-p", f"127.0.0.1:{port}:8888/tcp", ] # Avoid exposing credentials in process listings by using --env-file instead of many "-e KEY=VALUE". env_file_path: str | None = None try: fd, env_file_path = tempfile.mkstemp(prefix=f"unshackle-{container_name}-", suffix=".env") try: # Best-effort restrictive permissions. if os.name != "nt": if hasattr(os, "fchmod"): os.fchmod(fd, 0o600) else: os.chmod(env_file_path, 0o600) else: os.chmod(env_file_path, stat.S_IREAD | stat.S_IWRITE) with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f: for key, value in env_vars.items(): if "=" in key: raise ValueError(f"Invalid env var name for docker env-file: {key!r}") v = "" if value is None else str(value) if "\n" in v or "\r" in v: raise ValueError(f"Invalid env var value (contains newline) for {key!r}") f.write(f"{key}={v}\n") except Exception: # If we fail before fdopen closes the descriptor, make sure it's not leaked. try: os.close(fd) except Exception: pass raise cmd.extend(["--env-file", env_file_path]) # Add Gluetun image cmd.append(gluetun_image) # Execute docker run try: result = subprocess.run( cmd, capture_output=True, text=True, timeout=30, encoding="utf-8", errors="replace", ) except subprocess.TimeoutExpired: if debug_logger: debug_logger.log( level="ERROR", operation="gluetun_container_create_timeout", message=f"Docker run timed out for {container_name}", context={"container_name": container_name}, success=False, duration_ms=(time.time() - start_time) * 1000, ) raise RuntimeError("Docker run command timed out") if result.returncode != 0: error_msg = result.stderr or "unknown error" if debug_logger: debug_logger.log( level="ERROR", operation="gluetun_container_create_failed", message=f"Docker run failed for {container_name}", context={ "container_name": container_name, "return_code": result.returncode, "stderr": error_msg, }, success=False, duration_ms=(time.time() - start_time) * 1000, ) raise RuntimeError(f"Docker run failed: {error_msg}") # Log successful container creation if debug_logger: duration_ms = (time.time() - start_time) * 1000 debug_logger.log( level="INFO", operation="gluetun_container_created", message=f"Gluetun container created: {container_name}", context={ "container_name": container_name, "port": port, "provider": provider_name, "vpn_type": vpn_type, "country": country, "city": city, "hostname": hostname, "container_id": result.stdout.strip()[:12] if result.stdout else None, }, success=True, duration_ms=duration_ms, ) finally: if env_file_path: # Best-effort "secure delete": overwrite then unlink (not guaranteed on all filesystems). try: with open(env_file_path, "r+b") as f: try: f.seek(0, os.SEEK_END) length = f.tell() f.seek(0) if length > 0: f.write(b"\x00" * length) f.flush() os.fsync(f.fileno()) except Exception: pass except Exception: pass try: os.remove(env_file_path) except FileNotFoundError: pass except Exception: pass def _is_container_running(self, container_name: str) -> bool: """Check if a Docker container is running.""" try: result = subprocess.run( [ "docker", "ps", "--filter", f"name=^{re.escape(container_name)}$", "--format", "{{.Names}}", ], capture_output=True, text=True, timeout=5, ) if result.returncode != 0: return False names = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()] return any(name == container_name for name in names) except (subprocess.TimeoutExpired, FileNotFoundError): return False def _get_existing_container_info(self, container_name: str) -> Optional[dict]: """ Check if a container exists in Docker and get its info. This handles multiple Unshackle sessions - if another session already created the container, we'll reuse it instead of trying to create a duplicate. Args: container_name: Name of the container to check Returns: Dict with container info if exists and running, None otherwise """ try: # Check if container is running if not self._is_container_running(container_name): return None # Get container port mapping # Format: "127.0.0.1:8888->8888/tcp" result = subprocess.run( ["docker", "inspect", container_name, "--format", "{{.NetworkSettings.Ports}}"], capture_output=True, text=True, timeout=5, ) if result.returncode != 0: 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) if not port_match: return None port = int(port_match.group(1)) # Extract provider and region from container name # Format: unshackle-gluetun-provider-region name_pattern = f"{self.container_prefix}-(.+)-([^-]+)$" name_match = re.match(name_pattern, container_name) if not name_match: return None provider_name = name_match.group(1) region = name_match.group(2) # Get expected country and hostname from config (if available) country = None hostname = None # Check if region is a specific server (e.g., us1239) specific_server_match = re.match(r"^([a-z]{2})(\d+)$", region, re.IGNORECASE) if specific_server_match: country_code = specific_server_match.group(1).upper() server_num = specific_server_match.group(2) hostname = self._build_server_hostname(provider_name, country_code, server_num) country = country_code # Otherwise check config elif provider_name in self.providers: provider_config = self.providers[provider_name] server_countries = provider_config.get("server_countries", {}) country = server_countries.get(region) if not country and re.match(r"^[a-z]{2}$", region): country = region.upper() return { "container_name": container_name, "port": port, "provider": provider_name, "region": region, "country": country, "city": None, "hostname": hostname, } except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): return None def _wait_for_container(self, container_name: str, timeout: int = 60) -> bool: """ Wait for Gluetun container to be ready by checking logs for proxy readiness. Gluetun logs "http proxy listening" when the HTTP proxy is ready to accept connections. Args: container_name: Name of the container to wait for timeout: Maximum time to wait in seconds (default: 60) Returns: True if container is ready, False if it failed or timed out """ debug_logger = get_debug_logger() start_time = time.time() last_error = None if debug_logger: debug_logger.log( level="DEBUG", operation="gluetun_container_wait_start", message=f"Waiting for container to be ready: {container_name}", context={"container_name": container_name, "timeout": timeout}, ) while time.time() - start_time < timeout: try: # First check if container is still running if not self._is_container_running(container_name): # Container may have exited - check if it crashed exit_info = self._get_container_exit_info(container_name) if exit_info: last_error = f"Container exited with code {exit_info.get('exit_code', 'unknown')}" time.sleep(1) continue # Check logs for readiness indicators result = subprocess.run( ["docker", "logs", container_name, "--tail", "100"], capture_output=True, text=True, timeout=5, encoding="utf-8", errors="replace", ) if result.returncode == 0: # Combine stdout and stderr for checking (handle None values) stdout = result.stdout or "" stderr = result.stderr or "" all_logs = (stdout + stderr).lower() # Gluetun needs both proxy listening AND VPN connected # The proxy starts before VPN is ready, so we need to wait for VPN proxy_ready = "[http proxy] listening" in all_logs vpn_ready = "initialization sequence completed" in all_logs if proxy_ready and vpn_ready: # Give a brief moment for the proxy to fully initialize time.sleep(1) duration_ms = (time.time() - start_time) * 1000 if debug_logger: debug_logger.log( level="INFO", operation="gluetun_container_ready", message=f"Gluetun container is ready: {container_name}", context={ "container_name": container_name, "proxy_ready": proxy_ready, "vpn_ready": vpn_ready, }, success=True, duration_ms=duration_ms, ) return True # Check for fatal errors that indicate VPN connection failure error_indicators = [ "fatal", "cannot connect", "authentication failed", "invalid credentials", "connection refused", "no valid servers", ] for error in error_indicators: if error in all_logs: # Extract the error line for better messaging for line in (stdout + stderr).split("\n"): if error in line.lower(): last_error = line.strip() break # Fatal errors mean we should stop waiting if "fatal" in all_logs or "invalid credentials" in all_logs: return False except subprocess.TimeoutExpired: pass time.sleep(2) # Store the last error for potential logging if last_error: self._last_wait_error = last_error # Log timeout/failure duration_ms = (time.time() - start_time) * 1000 if debug_logger: debug_logger.log( level="ERROR", operation="gluetun_container_wait_timeout", message=f"Gluetun container failed to become ready: {container_name}", context={ "container_name": container_name, "timeout": timeout, "last_error": last_error, }, success=False, duration_ms=duration_ms, ) return False def _get_container_exit_info(self, container_name: str) -> Optional[dict]: """Get exit information for a stopped container.""" try: result = subprocess.run( ["docker", "inspect", container_name, "--format", "{{.State.ExitCode}}:{{.State.Error}}"], capture_output=True, text=True, timeout=5, ) if result.returncode == 0: 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 "", } return None except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): return None def _get_container_logs(self, container_name: str, tail: int = 50) -> str: """Get recent logs from a container for error reporting.""" try: result = subprocess.run( ["docker", "logs", container_name, "--tail", str(tail)], capture_output=True, text=True, timeout=10, encoding="utf-8", errors="replace", ) return (result.stdout or "") + (result.stderr or "") except (subprocess.TimeoutExpired, FileNotFoundError): return "" def _verify_container(self, query_key: str, max_retries: int = 3): """ Verify container's VPN IP and region using ipinfo.io lookup. Uses the shared get_ip_info function with a session configured to use the Gluetun proxy. Retries with exponential backoff if the network isn't ready immediately after the VPN connects. Args: query_key: The container query key (provider:region) max_retries: Maximum number of retry attempts (default: 3) Raises: RuntimeError: If verification fails after all retries """ debug_logger = get_debug_logger() start_time = time.time() if query_key not in self.active_containers: return container = self.active_containers[query_key] proxy_url = self._build_proxy_uri(container["port"]) expected_country = container.get("country", "").upper() if debug_logger: debug_logger.log( level="DEBUG", operation="gluetun_verify_start", message=f"Verifying VPN IP for: {query_key}", context={ "query_key": query_key, "container_name": container.get("container_name"), "expected_country": expected_country, "max_retries": max_retries, }, ) last_error = None # Create a session with the proxy configured session = requests.Session() try: session.proxies = {"http": proxy_url, "https": proxy_url} # Retry with exponential backoff for attempt in range(max_retries): try: # Get external IP through the proxy using shared utility ip_info = get_ip_info(session) if ip_info: actual_country = ip_info.get("country", "").upper() # Check if country matches (if we have an expected country) # ipinfo.io returns country codes (CA), but we may have full names (Canada) # Normalize both to country codes for comparison using shared utility if expected_country: # Convert expected country name to code if it's a full name expected_code = get_country_code(expected_country) or expected_country expected_code = expected_code.upper() if actual_country != expected_code: duration_ms = (time.time() - start_time) * 1000 if debug_logger: debug_logger.log( level="ERROR", operation="gluetun_verify_mismatch", message=f"Region mismatch for {query_key}", context={ "query_key": query_key, "expected_country": expected_code, "actual_country": actual_country, "ip": ip_info.get("ip"), "city": ip_info.get("city"), "org": ip_info.get("org"), }, success=False, duration_ms=duration_ms, ) raise RuntimeError( f"Region mismatch for {container['provider']}:{container['region']}: " f"Expected '{expected_code}' but got '{actual_country}' " f"(IP: {ip_info.get('ip')}, City: {ip_info.get('city')})" ) # 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( level="INFO", operation="gluetun_verify_success", message=f"VPN IP verified for: {query_key}", context={ "query_key": query_key, "ip": ip_info.get("ip"), "country": actual_country, "city": ip_info.get("city"), "org": ip_info.get("org"), "attempts": attempt + 1, }, success=True, duration_ms=duration_ms, ) return # ip_info was None, retry last_error = "Failed to get IP info from ipinfo.io" except RuntimeError: raise # Re-raise region mismatch errors immediately except Exception as e: last_error = str(e) if debug_logger: debug_logger.log( level="DEBUG", operation="gluetun_verify_retry", message=f"Verification attempt {attempt + 1} failed, retrying", context={ "query_key": query_key, "attempt": attempt + 1, "error": last_error, }, ) # Wait before retry (exponential backoff) if attempt < max_retries - 1: wait_time = 2**attempt # 1, 2, 4 seconds time.sleep(wait_time) finally: try: session.close() except Exception: pass # All retries exhausted duration_ms = (time.time() - start_time) * 1000 if debug_logger: debug_logger.log( level="ERROR", operation="gluetun_verify_failed", message=f"VPN verification failed after {max_retries} attempts", context={ "query_key": query_key, "max_retries": max_retries, "last_error": last_error, }, success=False, duration_ms=duration_ms, ) raise RuntimeError( f"Failed to verify VPN IP for {container['provider']}:{container['region']} " f"after {max_retries} attempts. Last error: {last_error}" ) def _remove_stopped_container(self, container_name: str) -> bool: """ Remove a stopped container with the given name if it exists. This prevents "container name already in use" errors when a previous container wasn't properly cleaned up. Args: container_name: Name of the container to check and remove Returns: True if a container was removed, False otherwise """ try: # Check if container exists (running or stopped) result = subprocess.run( ["docker", "ps", "-a", "--filter", f"name=^{container_name}$", "--format", "{{.Names}}:{{.Status}}"], capture_output=True, text=True, timeout=5, ) if result.returncode != 0 or not result.stdout.strip(): return False # Parse status - format is "name:Up 2 hours" or "name:Exited (0) 2 hours ago" output = result.stdout.strip() if container_name not in output: return False # Check if container is stopped (not running) if "Exited" in output or "Created" in output or "Dead" in output: # Container exists but is stopped - remove it subprocess.run( ["docker", "rm", "-f", container_name], capture_output=True, text=True, timeout=10, ) return True return False except (subprocess.TimeoutExpired, FileNotFoundError): return False def _remove_container(self, container_name: str): """Stop and remove a Docker container.""" try: if self.auto_cleanup: # Use docker rm -f to force remove (stops and removes in one command) subprocess.run( ["docker", "rm", "-f", container_name], capture_output=True, text=True, timeout=10, ) else: # Just stop the container subprocess.run( ["docker", "stop", container_name], capture_output=True, text=True, timeout=10, ) except subprocess.TimeoutExpired: # Force kill if timeout try: subprocess.run( ["docker", "rm", "-f", container_name], capture_output=True, text=True, timeout=5, ) except subprocess.TimeoutExpired: pass def _build_proxy_uri(self, port: int) -> str: """Build HTTP proxy URI.""" if self.auth_user and self.auth_password: return f"http://{self.auth_user}:{self.auth_password}@localhost:{port}" return f"http://localhost:{port}" def __del__(self): """Cleanup containers on object destruction.""" if hasattr(self, "auto_cleanup") and self.auto_cleanup: try: if hasattr(self, "active_containers") and self.active_containers: self.cleanup() except Exception: pass