fix: address P0/P1/P2 audit findings — security, reliability, CI, docs
Some checks failed
CI / Test Python 3.10 (push) Has been cancelled
CI / Test Python 3.11 (push) Has been cancelled
CI / Test Python 3.12 (push) Has been cancelled

P0 - Critical:
- config.py: add _mask() credential-redaction helper, SETTINGS_SCHEMA_VERSION,
  plaintext-storage warning; corrupt settings.json backed up before defaults
- gost_util.py: pin SHA256 for gost_3.2.6_windows_amd64.zip; verify before
  extraction; _add_defender_exclusion now logs warning on failure
- firewall.py: add emergency_disengage() for atexit/signal use
- service.py: register fw_emergency_disengage via atexit + SIGTERM/SIGINT;
  stop() calls emergency_disengage if thread hangs past 15s timeout
- app.py: wrap main() in top-level except with CTk error dialog + log
- LICENSE: add MIT license file

P1 - Important:
- app.py: switch to RotatingFileHandler (5 MB / 3 backups)
- config.py: settings_version + migrate(); save_settings() writes .bak before
  overwrite; _is_safe_https_url() strips RFC-1918 sources/ip_check_url
- service.py: threading.Lock on _settings; _signal_handler; graceful stop
- requirements.txt: pin exact versions; add cryptography==48.0.0
- .gitignore: add settings.json, credential JSON files, screenshot noise
- tray.py, dns_leak.py, gost_util.py: replace bare except:pass with logging
- CHANGELOG.md: document all session changes

P2 - Nice to have:
- .github/workflows/test.yml: CI on Python 3.10/3.11/3.12 windows-latest
- run.py: --version / -V flag
- docs/OPERATOR_RUNBOOK.md: emergency disengage, proxy leak, GOST, settings

Tests: 47/47 passed (python -m unittest discover -s tests -v)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dr Jones
2026-05-21 23:49:02 -07:00
parent 2ba43c3ff9
commit 792b320257
15 changed files with 625 additions and 24 deletions

View File

@@ -89,11 +89,14 @@ from .vpn_detect import detect_vpn
from .windows_task import install_logon_task, task_exists, uninstall_logon_task
LOG_PATH = app_data_dir() / "proxy_chain_manager.log"
from logging.handlers import RotatingFileHandler as _RotatingFileHandler # noqa: E402
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
handlers=[
logging.FileHandler(LOG_PATH, encoding="utf-8"),
_RotatingFileHandler(
LOG_PATH, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
),
logging.StreamHandler(sys.stdout),
],
)
@@ -142,6 +145,25 @@ def _ts() -> str:
def main() -> None:
try:
_main_inner()
except Exception as _exc: # noqa: BLE001
_log = logging.getLogger(__name__)
_log.exception("Unhandled exception in main loop")
try:
import tkinter.messagebox as _mb
_mb.showerror(
"Proxy God — Fatal Error",
f"An unexpected error occurred and the application must close.\n\n"
f"{type(_exc).__name__}: {_exc}\n\n"
f"Details have been written to:\n{LOG_PATH}",
)
except Exception:
pass
raise
def _main_inner() -> None:
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("dark-blue")

View File

@@ -1,12 +1,41 @@
from __future__ import annotations
import json
import logging
import shutil
from dataclasses import asdict, dataclass, field
from pathlib import Path
from urllib.parse import quote, unquote, urlparse, urlunparse
from .paths import app_data_dir
log = logging.getLogger(__name__)
# ── Settings schema version ───────────────────────────────────────────────────
# Bump this whenever a breaking field rename / type change is made so
# migrate() can upgrade old settings.json files safely.
SETTINGS_SCHEMA_VERSION = 1
# !! SECURITY NOTE !!
# settings.json is stored in plaintext at %LOCALAPPDATA%\ProxyChainManager\.
# It may contain proxy credentials (URLs with user:password). Do NOT log
# raw settings dicts — use _mask() to redact credential fields before logging.
def _mask(d: dict) -> dict:
"""Return a copy of settings dict with credential-bearing fields redacted."""
redact_keys = {"manual_exit_proxy", "pinned_chain", "sources"}
out: dict = {}
for k, v in d.items():
if k in redact_keys:
if isinstance(v, list):
out[k] = [redact_proxy_url(str(x)) for x in v]
else:
out[k] = redact_proxy_url(str(v))
else:
out[k] = v
return out
def _host_port_for_url(host: str, port: int | None) -> str:
"""``host`` and optional ``port`` as used in proxy URLs (bracket IPv6 literals)."""
@@ -159,6 +188,9 @@ OBFUSCATION_LABELS = {
@dataclass
class Settings:
# Schema version — used by migrate() to upgrade old settings.json files.
settings_version: int = SETTINGS_SCHEMA_VERSION
# ── network ──────────────────────────────────────────────────────────
local_host: str = LISTEN_HOST
local_port: int = 18888
@@ -231,6 +263,40 @@ class Settings:
return app_data_dir() / "settings.json"
def _is_safe_https_url(url: str) -> bool:
"""Return True for HTTPS URLs pointing at public hosts (not RFC-1918/link-local)."""
try:
p = urlparse(url)
if p.scheme not in ("http", "https"):
return False
host = p.hostname or ""
if not host:
return False
import ipaddress as _ip
try:
addr = _ip.ip_address(host)
return not (addr.is_private or addr.is_loopback or addr.is_link_local)
except ValueError:
pass
return True
except Exception:
return False
def migrate(raw: dict) -> dict:
"""Upgrade a raw settings dict from any schema version to the current one.
Each version block transforms ``raw`` in-place and bumps
``settings_version``. New fields are left absent so the dataclass
default takes effect.
"""
ver = int(raw.get("settings_version", 0))
# v0 → v1: no structural changes; just stamp the version
if ver < 1:
raw["settings_version"] = 1
return raw
def sanitize_settings(s: Settings) -> tuple[Settings, bool]:
"""Clamp invalid values from hand-edited JSON. Returns (settings, changed)."""
changed = False
@@ -315,6 +381,16 @@ def sanitize_settings(s: Settings) -> tuple[Settings, bool]:
if not isinstance(s.sources, list) or not all(isinstance(x, str) for x in s.sources) or len(s.sources) == 0:
s.sources = list(Settings().sources)
changed = True
else:
safe = [u for u in s.sources if _is_safe_https_url(u)]
if len(safe) < len(s.sources):
log.warning("Removed %d unsafe source URL(s) from settings.", len(s.sources) - len(safe))
s.sources = safe if safe else list(Settings().sources)
changed = True
if s.ip_check_url and not _is_safe_https_url(s.ip_check_url):
log.warning("ip_check_url looks unsafe (%s) — reverting to default.", s.ip_check_url)
s.ip_check_url = Settings().ip_check_url
changed = True
return s, changed
@@ -332,8 +408,15 @@ def load_settings() -> Settings:
return Settings()
try:
raw = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
except (OSError, json.JSONDecodeError) as exc:
corrupt = p.with_suffix(".json.corrupt")
log.warning("settings.json unreadable (%s) — backing up to %s and using defaults.", exc, corrupt)
try:
shutil.copy2(p, corrupt)
except OSError:
pass
return Settings()
raw = migrate(raw)
s = Settings()
for k, v in raw.items():
if hasattr(s, k):
@@ -365,4 +448,10 @@ def save_settings(s: Settings) -> None:
s, _ = sanitize_settings(s)
s, _ = normalize_listen_host(s)
p = app_data_dir() / "settings.json"
# Backup before overwriting so a crash mid-write doesn't corrupt settings.
if p.is_file():
try:
shutil.copy2(p, p.with_suffix(".json.bak"))
except OSError as exc:
log.warning("Could not back up settings.json: %s", exc)
p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8")

View File

@@ -95,8 +95,8 @@ def get_system_dns_servers() -> list[str]:
raw = (r.stdout or "").strip()
if raw:
return [x.strip() for x in raw.replace(";", ",").split(",") if x.strip()]
except Exception:
pass
except Exception as exc:
log.debug("PowerShell DNS server query failed (will try ipconfig): %s", exc)
# ipconfig fallback
try:
@@ -162,8 +162,8 @@ def _resolve_via_proxy(hostname: str, proxy_url: str, timeout: float = 8.0) -> l
data = r.json()
return [ans["data"] for ans in (data.get("Answer") or [])
if ans.get("type") == 1]
except Exception:
pass
except Exception as exc:
log.debug("DoH DNS leak query failed: %s", exc)
return []

View File

@@ -160,3 +160,18 @@ def is_engaged() -> bool:
"""Quick check: are our rules present?"""
r = _run(["netsh", "advfirewall", "firewall", "show", "rule", f"name={RULE_PREFIX}GOST", "dir=out"])
return RULE_PREFIX in (r.stdout or "")
def emergency_disengage() -> None:
"""Best-effort kill-switch removal for atexit/signal handlers.
Unlike ``disengage()``, this never raises and does not require prior
admin check — it simply tries, logs the outcome, and returns. Safe to
call from atexit or signal handlers where exceptions must not propagate.
"""
try:
_set_outbound_policy("blockinbound,allowoutbound")
_delete_rules()
log.info("emergency_disengage: firewall rules removed.")
except Exception as exc: # noqa: BLE001
log.warning("emergency_disengage failed: %s", exc)

View File

@@ -1,5 +1,6 @@
from __future__ import annotations
import hashlib
import io
import logging
import shutil
@@ -19,6 +20,11 @@ GOST_RELEASE_ZIP = (
"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"
def _add_defender_exclusion(path: Path) -> None:
"""Add Windows Defender exclusion so GOST is not quarantined."""
@@ -35,8 +41,21 @@ def _add_defender_exclusion(path: Path) -> None:
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
log.info("Defender exclusion added for %s", path.parent)
except Exception:
pass # non-fatal
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 ensure_gost(target: Path | None = None) -> Path:
@@ -53,6 +72,8 @@ def ensure_gost(target: Path | None = None) -> Path:
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:
@@ -122,8 +143,9 @@ def terminate_process(proc: subprocess.Popen | None) -> None:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
except Exception as exc:
log.debug("GOST terminate failed (%s) — escalating to kill.", exc)
try:
proc.kill()
except Exception:
pass
except Exception as kill_exc:
log.warning("GOST kill also failed: %s", kill_exc)

View File

@@ -1,8 +1,10 @@
from __future__ import annotations
import atexit
import asyncio
import logging
import random
import signal
import threading
import time
from collections.abc import Callable
@@ -26,7 +28,7 @@ from .fingerprint import (
random_hostname,
set_computer_name,
)
from .firewall import disengage as fw_disengage, engage as fw_engage, is_admin
from .firewall import disengage as fw_disengage, emergency_disengage as fw_emergency_disengage, engage as fw_engage, is_admin
from .gost_util import build_gost_cmd, ensure_gost, popen_no_window, read_gost_log_tail, terminate_process
from .leak_detect import is_chain_leak, leak_reason
from .mac_spoof import restore_macs, spoof_all_physical
@@ -77,6 +79,7 @@ class ChainService:
self._force_rotate = threading.Event()
self._thread: threading.Thread | None = None
self._proc = None
self._settings_lock = threading.Lock()
self._settings = load_settings()
self._current_chain: list[str] = []
# Sticky-exit: while time.monotonic() < self._sticky_until, leak-based
@@ -97,20 +100,39 @@ class ChainService:
self._lan_snap: LanSnapshot | None = None
self._telemetry_snap: TelemetrySnapshot | None = None
# Register emergency firewall disengage so a crash can't leave the
# kill-switch permanently engaged.
atexit.register(fw_emergency_disengage)
for _sig in (signal.SIGTERM, signal.SIGINT):
try:
signal.signal(_sig, self._signal_handler)
except (OSError, ValueError):
pass # not on main thread or unsupported on this platform
def _signal_handler(self, signum: int, frame: Any) -> None:
"""Received SIGTERM/SIGINT — stop cleanly and ensure firewall is disengaged."""
log.warning("Signal %s received — stopping ChainService.", signum)
self._stop.set()
terminate_process(self._proc)
fw_emergency_disengage()
def _manual_exit_url(self) -> str | None:
u = normalize_proxy_url(self._settings.manual_exit_proxy)
with self._settings_lock:
u = normalize_proxy_url(self._settings.manual_exit_proxy)
return u if u else None
@property
def settings(self) -> Settings:
return self._settings
with self._settings_lock:
return self._settings
@property
def current_chain(self) -> list[str]:
return list(self._current_chain)
def update_settings(self, s: Settings) -> None:
self._settings = s
with self._settings_lock:
self._settings = s
save_settings(s)
def start(self) -> None:
@@ -139,6 +161,9 @@ class ChainService:
self._proc = None
if self._thread:
self._thread.join(timeout=15)
if self._thread.is_alive():
log.warning("ChainService thread did not exit in 15s — forcing firewall disengage.")
fw_emergency_disengage()
self._teardown_network()
self._notify({"type": "state", "running": False})
@@ -166,7 +191,9 @@ class ChainService:
clear_system_proxy()
self._notify({"type": "log", "text": "System proxy cleared."})
self._restore_privacy()
if is_admin() and self._settings.kill_switch_enabled:
with self._settings_lock:
ks_enabled = self._settings.kill_switch_enabled
if is_admin() and ks_enabled:
ok, msg = fw_disengage()
self._notify({"type": "log", "text": msg})
self._notify({"type": "firewall", "engaged": False})

View File

@@ -1,9 +1,12 @@
"""System tray: LED-style proxy on/off + hover tooltip with exit IP."""
from __future__ import annotations
import logging
import threading
from typing import Any, Callable
log = logging.getLogger(__name__)
import pystray
from PIL import Image, ImageDraw, ImageFilter
@@ -108,8 +111,8 @@ class TrayIcon:
if self._icon:
try:
self._icon.stop()
except Exception:
pass
except Exception as exc:
log.warning("Tray icon stop failed: %s", exc)
def set_state(self, state: str, exit_ip: str | None = None) -> None:
"""state: green (on), red (error), yellow (connecting), gray (stopped).