Files
unshackle-SeFree/unshackle/core/proxies/gluetun.py
Andy a04f1ad4db fix(gluetun): stop leaking proxy/vpn secrets to process list
- Switch docker run to use a temporary --env-file instead of per-var -e flags\n- Ensure temp env file is always removed (best-effort overwrite + unlink)\n- Tighten _is_container_running to exact-name matching via anchored docker filter\n- Close requests.Session used for IP verification to release connections\n- Redact more secret-like env keys in debug logs\n
2026-02-07 19:22:13 -07:00

1409 lines
55 KiB
Python

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