VPN-aware leak detection, GOST stderr logging

- _is_same_network: /16 subnet comparison catches NordVPN IP rotation
  (exit_ip != real_ip exact-match missed same-VPN exits on rotated IPs)
- Leak now blacklists the entire dead chain, not just rotate
- GOST stderr/stdout -> gost.log (file, never deadlocks vs pipe)
  with 512KB rotation; last 8 lines shown in UI when GOST dies
- read_gost_log_tail helper for live debugging
- Health check uses same subnet check for consistency
- 5 new subnet tests

Made-with: Cursor
This commit is contained in:
Dr Jones
2026-04-16 01:23:00 -07:00
parent 0509d4104d
commit 8ffde94d48
3 changed files with 112 additions and 10 deletions

View File

@@ -6,10 +6,11 @@ import shutil
import subprocess
import zipfile
from pathlib import Path
from typing import Any
import httpx
from .paths import gost_exe_path
from .paths import app_data_dir, gost_exe_path
log = logging.getLogger(__name__)
@@ -71,19 +72,48 @@ def build_gost_cmd(gost: Path, listen_http: str, forwards: list[str]) -> list[st
return args
def gost_log_path() -> Path:
return app_data_dir() / "gost.log"
def popen_no_window(args: list[str]) -> subprocess.Popen:
# Use DEVNULL for both pipesGOST writes logs to stderr, leaving pipes
# unread would fill the OS buffer and deadlock the child process.
# stderr → rolling log file (not a pipe — pipes deadlock if unread; files never block).
# stdout is also captured to the same file since GOST v3 mixes output channels.
cr = getattr(subprocess, "CREATE_NO_WINDOW", 0)
log_path = gost_log_path()
try:
_rotate_gost_log(log_path)
stderr_dest: Any = open(log_path, "ab") # noqa: WPS515 — intentional long-lived file handle
except OSError:
stderr_dest = subprocess.DEVNULL
return subprocess.Popen(
args,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdout=stderr_dest,
stderr=stderr_dest,
creationflags=cr,
)
def _rotate_gost_log(path: Path, max_bytes: int = 512 * 1024) -> None:
"""Keep gost.log under max_bytes by truncating the oldest half when it grows too large."""
try:
if path.is_file() and path.stat().st_size > max_bytes:
data = path.read_bytes()
path.write_bytes(data[len(data) // 2:])
except OSError:
pass
def read_gost_log_tail(lines: int = 40) -> str:
"""Return the last N lines of gost.log for display in the UI."""
try:
text = gost_log_path().read_text(encoding="utf-8", errors="replace")
return "\n".join(text.splitlines()[-lines:])
except OSError:
return "(gost.log not found)"
def terminate_process(proc: subprocess.Popen | None) -> None:
if proc is None:
return