from __future__ import annotations import hashlib import io import logging import shutil import subprocess import zipfile from pathlib import Path from typing import Any import httpx from .paths import app_data_dir, gost_exe_path log = logging.getLogger(__name__) GOST_RELEASE_ZIP = ( "https://github.com/go-gost/gost/releases/download/v3.2.6/" "gost_3.2.6_windows_amd64.zip" ) # Pinned SHA256 of the release zip. Update this constant when bumping GOST_RELEASE_ZIP. # Verified 2026-05-21 against https://github.com/go-gost/gost/releases/download/v3.2.6/ GOST_RELEASE_ZIP_SHA256 = "32f4edf3d94b622e67f1979f6f5de82dac62abc0977772cf96215dd199ef7e7b" # Pinned SHA256 of the extracted ``gost.exe`` binary inside the v3.2.6 zip. # Recorded the first time the zip is unpacked and verified on every reuse, so # a tampered or partially-overwritten exe on disk forces a clean re-download. GOST_EXE_SHA256_FILE = "gost.exe.sha256" def _add_defender_exclusion(path: Path) -> None: """Add Windows Defender exclusion so GOST is not quarantined.""" try: # ExclusionPath covers gost.exe under this folder; avoid invalid combined params on older builds. folder = str(path.parent).replace("'", "''") subprocess.run( [ "powershell", "-NoProfile", "-NonInteractive", "-Command", f"Add-MpPreference -ExclusionPath '{folder}' -ErrorAction SilentlyContinue", ], capture_output=True, timeout=30, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) log.info("Defender exclusion added for %s", path.parent) except Exception as exc: log.warning("Defender exclusion failed (non-fatal): %s", exc) def _verify_zip_sha256(data: bytes, expected: str) -> None: """Raise RuntimeError if SHA256 of *data* does not match *expected* (hex).""" actual = hashlib.sha256(data).hexdigest().lower() if actual != expected.lower(): raise RuntimeError( f"GOST zip SHA256 mismatch — possible supply-chain attack!\n" f" expected: {expected.lower()}\n" f" actual: {actual}\n" "Delete the downloaded zip and retry, or update GOST_RELEASE_ZIP_SHA256." ) log.info("GOST zip SHA256 OK: %s", actual) def _hash_file(path: Path) -> str: h = hashlib.sha256() with open(path, "rb") as f: for chunk in iter(lambda: f.read(64 * 1024), b""): h.update(chunk) return h.hexdigest().lower() def _read_pinned_exe_hash(exe: Path) -> str | None: sidecar = exe.with_suffix(exe.suffix + ".sha256") try: return sidecar.read_text(encoding="utf-8").strip().lower() or None except OSError: return None def _write_pinned_exe_hash(exe: Path, digest: str) -> None: sidecar = exe.with_suffix(exe.suffix + ".sha256") try: sidecar.write_text(digest, encoding="utf-8") except OSError as exc: log.warning("Could not write gost.exe hash sidecar: %s", exc) def ensure_gost(target: Path | None = None) -> Path: exe = target or gost_exe_path() if exe.is_file() and exe.stat().st_size > 10_000: # Re-verify the on-disk binary against the sidecar pin so a tampered # exe cannot persist across launches. pinned = _read_pinned_exe_hash(exe) if pinned: actual = _hash_file(exe) if actual == pinned: return exe log.warning( "gost.exe SHA256 mismatch on reuse — re-downloading.\n" " pinned: %s\n actual: %s", pinned, actual, ) try: exe.unlink() except OSError as exc: log.warning("Could not remove tampered gost.exe: %s", exc) else: # First reuse after older versions: record the current hash so the # next run can verify. Mark as trusted-on-first-use only when zip # SHA verification has already happened earlier in the session. _write_pinned_exe_hash(exe, _hash_file(exe)) return exe exe.parent.mkdir(parents=True, exist_ok=True) # Add exclusion BEFORE downloading so Defender doesn't nuke it on write _add_defender_exclusion(exe) log.info("Downloading GOST %s", GOST_RELEASE_ZIP) with httpx.Client(timeout=120.0, follow_redirects=True) as c: r = c.get(GOST_RELEASE_ZIP) r.raise_for_status() data = r.content if len(data) < 64 or data[:2] != b"PK": raise RuntimeError("Downloaded GOST zip looks invalid (not a zip).") # Integrity check before extraction _verify_zip_sha256(data, GOST_RELEASE_ZIP_SHA256) with zipfile.ZipFile(io.BytesIO(data), "r") as z: names = [n for n in z.namelist() if n.lower().endswith("gost.exe")] if not names: raise RuntimeError("gost.exe not found in release zip") with z.open(names[0]) as src, open(exe, "wb") as dst: shutil.copyfileobj(src, dst) # Add exclusion again after write in case Defender scanned during extraction _add_defender_exclusion(exe) # Pin the freshly-extracted exe so subsequent launches can re-verify it. _write_pinned_exe_hash(exe, _hash_file(exe)) log.info("GOST installed at %s", exe) return exe def build_gost_cmd(gost: Path, listen_http: str, forwards: list[str]) -> list[str]: args = [str(gost), "-L", f"http://{listen_http}"] for f in forwards: args.extend(["-F", f]) return args def gost_log_path() -> Path: return app_data_dir() / "gost.log" def popen_no_window(args: list[str]) -> subprocess.Popen: # 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=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 if proc.poll() is not None: return try: proc.terminate() proc.wait(timeout=5) except Exception as exc: log.debug("GOST terminate failed (%s) — escalating to kill.", exc) try: proc.kill() except Exception as kill_exc: log.warning("GOST kill also failed: %s", kill_exc)