mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-12 01:19:02 +00:00
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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user