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,12 +774,40 @@ 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".
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(): for key, value in env_vars.items():
cmd.extend(["-e", f"{key}={value}"]) 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 # Add Gluetun image
cmd.append("qmcgaw/gluetun:latest") cmd.append(gluetun_image)
# Execute docker run # Execute docker run
try: try:
@@ -788,6 +819,17 @@ class Gluetun(Proxy):
encoding="utf-8", encoding="utf-8",
errors="replace", 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,6 +1196,7 @@ class Gluetun(Proxy):
# Create a session with the proxy configured # Create a session with the proxy configured
session = requests.Session() session = requests.Session()
try:
session.proxies = {"http": proxy_url, "https": proxy_url} session.proxies = {"http": proxy_url, "https": proxy_url}
# Retry with exponential backoff # Retry with exponential backoff
@@ -1224,6 +1289,11 @@ class Gluetun(Proxy):
if attempt < max_retries - 1: if attempt < max_retries - 1:
wait_time = 2**attempt # 1, 2, 4 seconds wait_time = 2**attempt # 1, 2, 4 seconds
time.sleep(wait_time) time.sleep(wait_time)
finally:
try:
session.close()
except Exception:
pass
# All retries exhausted # All retries exhausted
duration_ms = (time.time() - start_time) * 1000 duration_ms = (time.time() - start_time) * 1000