fix: address P0/P1/P2 audit findings — security, reliability, CI, docs
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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user