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,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
|
||||||
|
|||||||
Reference in New Issue
Block a user