from __future__ import annotations import json from dataclasses import asdict, dataclass, field from pathlib import Path from .paths import app_data_dir # Always bind the local forwarder to loopback. VM clones and NIC/IP changes (DHCP) # do not affect 127.0.0.1; avoid storing LAN IPs or hostnames for the listener. LISTEN_HOST = "127.0.0.1" def normalize_proxy_url(raw: str) -> str: """Strip, add http:// if missing. Empty input → empty string.""" t = (raw or "").strip() if not t: return "" if "://" not in t: t = "http://" + t return t.rstrip("/") OBFUSCATION_MODES = ["auto", "http_only", "socks5_only", "random_mix"] OBFUSCATION_LABELS = { "auto": "Auto (best available)", "http_only": "HTTP only", "socks5_only": "SOCKS5 only", "random_mix": "Random Mix (max noise)", } @dataclass class Settings: # ── network ────────────────────────────────────────────────────────── local_host: str = LISTEN_HOST local_port: int = 18888 # ── chain ───────────────────────────────────────────────────────────── chain_length: int = 3 # number of hops (2-8) obfuscation_mode: str = "auto" # see OBFUSCATION_MODES use_pinned_chain: bool = False # use manually ordered chain pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list # Fixed last hop only (ignored when use_pinned_chain is True — full manual chain wins) manual_exit_proxy: str = "" # ── timing ──────────────────────────────────────────────────────────── health_check_seconds: int = 180 # how often to re-verify exit IP full_refresh_seconds: int = 1800 # how often to re-fetch/re-validate pool # ── validation ──────────────────────────────────────────────────────── validation_concurrency: int = 64 max_candidates: int = 400 validation_timeout_seconds: float = 12.0 prefer_elite: bool = False # ── security ────────────────────────────────────────────────────────── kill_switch_enabled: bool = True # engage Windows Firewall kill-switch when running proxy_bypass: str = "localhost;127.*;10.*;192.168.*;" # ── sources ─────────────────────────────────────────────────────────── sources: list[str] = field( default_factory=lambda: [ "https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/http/data.json", "https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/socks5/data.json", "https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/https/data.json", ] ) ip_check_url: str = "https://api.ipify.org?format=json" def listen_addr(self) -> str: return f"{self.local_host}:{self.local_port}" def settings_path(self) -> Path: return app_data_dir() / "settings.json" def normalize_listen_host(s: Settings) -> tuple[Settings, bool]: """Force loopback bind so LAN/DHCP IP changes (e.g. cloned VMs) never break the listener.""" if s.local_host == LISTEN_HOST: return s, False s.local_host = LISTEN_HOST return s, True def load_settings() -> Settings: p = app_data_dir() / "settings.json" if not p.is_file(): return Settings() try: raw = json.loads(p.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return Settings() s = Settings() for k, v in raw.items(): if hasattr(s, k): setattr(s, k, v) if not isinstance(s.sources, list) or not all(isinstance(x, str) for x in s.sources): s.sources = Settings().sources if s.obfuscation_mode not in OBFUSCATION_MODES: s.obfuscation_mode = "auto" if not isinstance(s.pinned_chain, list): s.pinned_chain = [] if not hasattr(s, "manual_exit_proxy") or not isinstance(s.manual_exit_proxy, str): s.manual_exit_proxy = "" s, changed = normalize_listen_host(s) if changed: try: p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8") except OSError: pass return s def save_settings(s: Settings) -> None: s, _ = normalize_listen_host(s) p = app_data_dir() / "settings.json" p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8")