mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-03-11 17:09:00 +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 os
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional
|
||||
@@ -750,7 +752,8 @@ class Gluetun(Proxy):
|
||||
|
||||
# Debug log environment variables (redact sensitive values)
|
||||
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(
|
||||
level="DEBUG",
|
||||
operation="gluetun_env_vars",
|
||||
@@ -771,12 +774,40 @@ class Gluetun(Proxy):
|
||||
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():
|
||||
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
|
||||
cmd.append("qmcgaw/gluetun:latest")
|
||||
cmd.append(gluetun_image)
|
||||
|
||||
# Execute docker run
|
||||
try:
|
||||
@@ -788,6 +819,17 @@ class Gluetun(Proxy):
|
||||
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"
|
||||
@@ -826,29 +868,51 @@ class Gluetun(Proxy):
|
||||
success=True,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
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")
|
||||
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={container_name}", "--format", "{{.Names}}"],
|
||||
[
|
||||
"docker",
|
||||
"ps",
|
||||
"--filter",
|
||||
f"name=^{re.escape(container_name)}$",
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
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):
|
||||
return False
|
||||
|
||||
@@ -1132,6 +1196,7 @@ class Gluetun(Proxy):
|
||||
|
||||
# Create a session with the proxy configured
|
||||
session = requests.Session()
|
||||
try:
|
||||
session.proxies = {"http": proxy_url, "https": proxy_url}
|
||||
|
||||
# Retry with exponential backoff
|
||||
@@ -1224,6 +1289,11 @@ class Gluetun(Proxy):
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user