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

@@ -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})