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:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user