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
This commit is contained in:
Andy
2026-02-07 19:22:13 -07:00
parent 774b9ba96c
commit a04f1ad4db

View File

@@ -2,7 +2,9 @@ import atexit
import logging import logging
import os import os
import re import re
import stat
import subprocess import subprocess
import tempfile
import threading import threading
import time import time
from typing import Optional from typing import Optional
@@ -750,7 +752,8 @@ class Gluetun(Proxy):
# Debug log environment variables (redact sensitive values) # Debug log environment variables (redact sensitive values)
if debug_logger: if debug_logger:
safe_env = {k: ("***" if "KEY" in k or "PASSWORD" in k else v) for k, v in env_vars.items()} 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( debug_logger.log(
level="DEBUG", level="DEBUG",
operation="gluetun_env_vars", operation="gluetun_env_vars",
@@ -771,23 +774,62 @@ class Gluetun(Proxy):
f"127.0.0.1:{port}:8888/tcp", f"127.0.0.1:{port}:8888/tcp",
] ]
# Add environment variables # Avoid exposing credentials in process listings by using --env-file instead of many "-e KEY=VALUE".
for key, value in env_vars.items(): env_file_path: str | None = None
cmd.extend(["-e", f"{key}={value}"])
# Add Gluetun image
cmd.append("qmcgaw/gluetun:latest")
# Execute docker run
try: try:
result = subprocess.run( fd, env_file_path = tempfile.mkstemp(prefix=f"unshackle-{container_name}-", suffix=".env")
cmd, try:
capture_output=True, # Best-effort restrictive permissions.
text=True, if os.name != "nt":
timeout=30, if hasattr(os, "fchmod"):
encoding="utf-8", os.fchmod(fd, 0o600)
errors="replace", 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: if result.returncode != 0:
error_msg = result.stderr or "unknown error" error_msg = result.stderr or "unknown error"
@@ -826,29 +868,51 @@ class Gluetun(Proxy):
success=True, success=True,
duration_ms=duration_ms, duration_ms=duration_ms,
) )
finally:
except subprocess.TimeoutExpired: if env_file_path:
if debug_logger: # Best-effort "secure delete": overwrite then unlink (not guaranteed on all filesystems).
debug_logger.log( try:
level="ERROR", with open(env_file_path, "r+b") as f:
operation="gluetun_container_create_timeout", try:
message=f"Docker run timed out for {container_name}", f.seek(0, os.SEEK_END)
context={"container_name": container_name}, length = f.tell()
success=False, f.seek(0)
duration_ms=(time.time() - start_time) * 1000, if length > 0:
) f.write(b"\x00" * length)
raise RuntimeError("Docker run command timed out") 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: def _is_container_running(self, container_name: str) -> bool:
"""Check if a Docker container is running.""" """Check if a Docker container is running."""
try: try:
result = subprocess.run( result = subprocess.run(
["docker", "ps", "--filter", f"name={container_name}", "--format", "{{.Names}}"], [
"docker",
"ps",
"--filter",
f"name=^{re.escape(container_name)}$",
"--format",
"{{.Names}}",
],
capture_output=True, capture_output=True,
text=True, text=True,
timeout=5, timeout=5,
) )
return result.returncode == 0 and container_name in result.stdout 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): except (subprocess.TimeoutExpired, FileNotFoundError):
return False return False
@@ -1132,98 +1196,104 @@ class Gluetun(Proxy):
# Create a session with the proxy configured # Create a session with the proxy configured
session = requests.Session() session = requests.Session()
session.proxies = {"http": proxy_url, "https": proxy_url} try:
session.proxies = {"http": proxy_url, "https": proxy_url}
# Retry with exponential backoff # Retry with exponential backoff
for attempt in range(max_retries): for attempt in range(max_retries):
try: try:
# Get external IP through the proxy using shared utility # Get external IP through the proxy using shared utility
ip_info = get_ip_info(session) ip_info = get_ip_info(session)
if ip_info: if ip_info:
actual_country = ip_info.get("country", "").upper() actual_country = ip_info.get("country", "").upper()
# Check if country matches (if we have an expected country) # Check if country matches (if we have an expected country)
# ipinfo.io returns country codes (CA), but we may have full names (Canada) # ipinfo.io returns country codes (CA), but we may have full names (Canada)
# Normalize both to country codes for comparison using shared utility # Normalize both to country codes for comparison using shared utility
if expected_country: if expected_country:
# Convert expected country name to code if it's a full name # Convert expected country name to code if it's a full name
expected_code = get_country_code(expected_country) or expected_country expected_code = get_country_code(expected_country) or expected_country
expected_code = expected_code.upper() expected_code = expected_code.upper()
if actual_country != expected_code: if actual_country != expected_code:
duration_ms = (time.time() - start_time) * 1000 duration_ms = (time.time() - start_time) * 1000
if debug_logger: if debug_logger:
debug_logger.log( debug_logger.log(
level="ERROR", level="ERROR",
operation="gluetun_verify_mismatch", operation="gluetun_verify_mismatch",
message=f"Region mismatch for {query_key}", message=f"Region mismatch for {query_key}",
context={ context={
"query_key": query_key, "query_key": query_key,
"expected_country": expected_code, "expected_country": expected_code,
"actual_country": actual_country, "actual_country": actual_country,
"ip": ip_info.get("ip"), "ip": ip_info.get("ip"),
"city": ip_info.get("city"), "city": ip_info.get("city"),
"org": ip_info.get("org"), "org": ip_info.get("org"),
}, },
success=False, success=False,
duration_ms=duration_ms, duration_ms=duration_ms,
) )
raise RuntimeError( raise RuntimeError(
f"Region mismatch for {container['provider']}:{container['region']}: " f"Region mismatch for {container['provider']}:{container['region']}: "
f"Expected '{expected_code}' but got '{actual_country}' " f"Expected '{expected_code}' but got '{actual_country}' "
f"(IP: {ip_info.get('ip')}, City: {ip_info.get('city')})" f"(IP: {ip_info.get('ip')}, City: {ip_info.get('city')})"
) )
# Verification successful - store IP info in container record # Verification successful - store IP info in container record
if query_key in self.active_containers: if query_key in self.active_containers:
self.active_containers[query_key]["public_ip"] = ip_info.get("ip") 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_country"] = actual_country
self.active_containers[query_key]["ip_city"] = ip_info.get("city") self.active_containers[query_key]["ip_city"] = ip_info.get("city")
self.active_containers[query_key]["ip_org"] = ip_info.get("org") self.active_containers[query_key]["ip_org"] = ip_info.get("org")
duration_ms = (time.time() - start_time) * 1000 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: if debug_logger:
debug_logger.log( debug_logger.log(
level="INFO", level="DEBUG",
operation="gluetun_verify_success", operation="gluetun_verify_retry",
message=f"VPN IP verified for: {query_key}", message=f"Verification attempt {attempt + 1} failed, retrying",
context={ context={
"query_key": query_key, "query_key": query_key,
"ip": ip_info.get("ip"), "attempt": attempt + 1,
"country": actual_country, "error": last_error,
"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 # Wait before retry (exponential backoff)
last_error = "Failed to get IP info from ipinfo.io" if attempt < max_retries - 1:
wait_time = 2**attempt # 1, 2, 4 seconds
except RuntimeError: time.sleep(wait_time)
raise # Re-raise region mismatch errors immediately finally:
except Exception as e: try:
last_error = str(e) session.close()
if debug_logger: except Exception:
debug_logger.log( pass
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)
# All retries exhausted # All retries exhausted
duration_ms = (time.time() - start_time) * 1000 duration_ms = (time.time() - start_time) * 1000