104 lines
4.5 KiB
Python
104 lines
4.5 KiB
Python
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"
|
|
|
|
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
|
|
|
|
# ── 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.*;<local>"
|
|
|
|
# ── 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 = []
|
|
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")
|