first commit
This commit is contained in:
470
proxy_chain_manager/config.py
Normal file
470
proxy_chain_manager/config.py
Normal file
@@ -0,0 +1,470 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import sys
|
||||
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)."""
|
||||
if not host:
|
||||
return ""
|
||||
if ":" in host and "." not in host:
|
||||
inner = f"[{host}]"
|
||||
else:
|
||||
inner = host
|
||||
return f"{inner}:{port}" if port else inner
|
||||
|
||||
|
||||
# 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("/")
|
||||
|
||||
|
||||
def split_proxy_for_edit(raw: str) -> tuple[str, str, str]:
|
||||
"""Split stored proxy URL into (url_without_credentials, username, password).
|
||||
|
||||
Used to populate fixed-exit / form fields. Credentials are percent-decoded.
|
||||
"""
|
||||
t = normalize_proxy_url((raw or "").strip())
|
||||
if not t:
|
||||
return "", "", ""
|
||||
p = urlparse(t)
|
||||
if not p.hostname:
|
||||
return t, "", ""
|
||||
host = p.hostname
|
||||
port = p.port
|
||||
scheme = p.scheme or "http"
|
||||
base = f"{scheme}://{_host_port_for_url(host, port)}"
|
||||
u = unquote(p.username) if p.username else ""
|
||||
pw = unquote(p.password) if p.password else ""
|
||||
return base, u, pw
|
||||
|
||||
|
||||
def merge_proxy_credentials(raw_url: str, username: str = "", password: str = "") -> str:
|
||||
"""Build a single proxy URL: host/port from ``raw_url`` (any embedded auth ignored) plus optional auth.
|
||||
|
||||
User and password are URL-encoded for ``@`` and ``:`` inside values.
|
||||
"""
|
||||
base = normalize_proxy_url((raw_url or "").strip())
|
||||
if not base:
|
||||
return ""
|
||||
p = urlparse(base)
|
||||
if not p.hostname:
|
||||
return base
|
||||
host = p.hostname
|
||||
port = p.port
|
||||
scheme = p.scheme or "http"
|
||||
user = (username or "").strip()
|
||||
pw = (password or "").strip()
|
||||
if not user and not pw:
|
||||
# Leave credentials embedded in URL when form fields are blank (paste user:pass@host).
|
||||
return base
|
||||
hp = _host_port_for_url(host, port)
|
||||
netloc = f"{quote(user, safe='')}:{quote(pw, safe='')}@{hp}"
|
||||
return urlunparse((scheme, netloc, "", "", "", "")).rstrip("/")
|
||||
|
||||
|
||||
def parse_exit_proxy_input(
|
||||
raw: str, default_scheme: str = "socks5"
|
||||
) -> tuple[str, str, str]:
|
||||
"""Parse any sane paste format into ``(base_url, user, password)``.
|
||||
|
||||
Accepts (whitespace-trimmed, default scheme = SOCKS5 when none given):
|
||||
|
||||
``host:port``
|
||||
``host:port:user:password``
|
||||
``host:port:user:password:session_or_extra`` (extras folded into password)
|
||||
``host:port user password`` (tab/space-separated)
|
||||
``user:password@host:port``
|
||||
``scheme://host:port``
|
||||
``scheme://user:password@host:port``
|
||||
|
||||
User and password may contain ``@``, ``:`` and arbitrary "words" — those
|
||||
are URL-encoded automatically when ``merge_proxy_credentials`` rebuilds
|
||||
the URL, so pasting plain text is safe.
|
||||
"""
|
||||
t = (raw or "").strip()
|
||||
if not t:
|
||||
return "", "", ""
|
||||
|
||||
if "://" in t:
|
||||
# Already a full URL — defer to the existing splitter.
|
||||
return split_proxy_for_edit(t)
|
||||
|
||||
if "@" in t:
|
||||
return split_proxy_for_edit(f"{default_scheme}://{t}")
|
||||
|
||||
if "\t" in t or any(c in t for c in (" ",)):
|
||||
# "host:port user pass" or "host:port\tuser\tpass"
|
||||
parts = [p for p in t.replace("\t", " ").split() if p]
|
||||
if len(parts) >= 3:
|
||||
hp, user, pw = parts[0], parts[1], " ".join(parts[2:])
|
||||
return f"{default_scheme}://{hp}".rstrip("/"), user, pw
|
||||
if len(parts) == 2:
|
||||
return f"{default_scheme}://{parts[0]}".rstrip("/"), "", ""
|
||||
t = parts[0]
|
||||
|
||||
bits = t.split(":")
|
||||
if len(bits) >= 4:
|
||||
host, port, user = bits[0], bits[1], bits[2]
|
||||
password = ":".join(bits[3:]) # passwords containing ":" survive
|
||||
return f"{default_scheme}://{host}:{port}", user, password
|
||||
if len(bits) == 2:
|
||||
return f"{default_scheme}://{bits[0]}:{bits[1]}", "", ""
|
||||
# 1 token, or 3-token oddity — best-effort; treat as host[:port] only.
|
||||
return f"{default_scheme}://{t}".rstrip("/"), "", ""
|
||||
|
||||
|
||||
def redact_proxy_url(url: str) -> str:
|
||||
"""Same URL shape for logs/UI, with credentials replaced by ``***``."""
|
||||
t = (url or "").strip()
|
||||
if not t:
|
||||
return ""
|
||||
if "://" not in t:
|
||||
t = "http://" + t
|
||||
p = urlparse(t.rstrip("/"))
|
||||
if not p.hostname:
|
||||
return t
|
||||
has_auth = bool(p.username) or bool(p.password)
|
||||
if not has_auth:
|
||||
return t.rstrip("/")
|
||||
scheme = p.scheme or "http"
|
||||
hp = _host_port_for_url(p.hostname, p.port)
|
||||
netloc = f"***:***@{hp}"
|
||||
return urlunparse((scheme, netloc, "", "", "", "")).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:
|
||||
# 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
|
||||
|
||||
# ── chain ─────────────────────────────────────────────────────────────
|
||||
chain_length: int = 3 # number of hops (2-8)
|
||||
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
|
||||
use_pinned_chain: bool = True # use manually ordered chain from Chain Builder
|
||||
pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list
|
||||
validate_pinned_on_start: bool = True # quick TCP probe of each pinned hop before chaining
|
||||
max_browser_relaunches: int = 5 # cap auto-relaunch attempts so a broken profile doesn't loop
|
||||
# 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 = 200 # cap before validation (shuffled, so random sample)
|
||||
min_pool_size: int = 20 # stop validating once this many good proxies found
|
||||
validation_timeout_seconds: float = 12.0
|
||||
prefer_elite: bool = False
|
||||
|
||||
# ── security ──────────────────────────────────────────────────────────
|
||||
kill_switch_enabled: bool = sys.platform == "win32" # Windows Firewall kill-switch when available
|
||||
proxy_bypass: str = "localhost;127.*;10.*;192.168.*;<local>"
|
||||
|
||||
# ── privacy / device hardening (Privacy tab) ───────────────────────────
|
||||
mac_spoof_enabled: bool = False # randomize NIC MAC while chain runs (Admin)
|
||||
mac_rotate_on_chain_rotate: bool = False # re-randomize MAC on every chain rotation
|
||||
spoof_hostname_enabled: bool = False # temporary computer name while chain runs (Admin)
|
||||
flush_dns_on_rotate: bool = True # ipconfig /flushdns on each rotation
|
||||
disable_ipv6_while_active: bool = False # disable IPv6 bindings while chain runs (Admin)
|
||||
harden_webrtc_enabled: bool = False # Chrome/Edge WebRTC policy (Admin)
|
||||
lan_lockdown_enabled: bool = False # kill LLMNR/NetBIOS/mDNS while chain runs
|
||||
telemetry_kill_enabled: bool = False # disable DiagTrack + Activity History (Admin)
|
||||
|
||||
# ── hardened browser ────────────────────────────────────────────────────
|
||||
firefox_path: str = ""
|
||||
firefox_profile_dir: str = ""
|
||||
browser_clear_on_close: bool = True
|
||||
browser_disposable_profile: bool = False
|
||||
browser_kill_on_chain_drop: bool = True
|
||||
browser_auto_relaunch: bool = False
|
||||
browser_lock_managed_profile: bool = True
|
||||
browser_force_proxy: bool = True
|
||||
browser_disable_webrtc: bool = True
|
||||
browser_resist_fingerprinting: bool = True
|
||||
browser_disable_telemetry: bool = True
|
||||
browser_first_party_isolation: bool = True
|
||||
browser_strict_tracking_protection: bool = True
|
||||
browser_timezone_utc: bool = True
|
||||
browser_persona: str = "blend_windows_chrome"
|
||||
browser_cookie_mode: str = "block_third_party"
|
||||
|
||||
# ── 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 _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 != "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
|
||||
try:
|
||||
cl = int(s.chain_length)
|
||||
if not (1 <= cl <= 8):
|
||||
s.chain_length = max(1, min(8, cl))
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.chain_length = 3
|
||||
changed = True
|
||||
try:
|
||||
p = int(s.local_port)
|
||||
if not (1 <= p <= 65535):
|
||||
s.local_port = max(1, min(65535, p))
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.local_port = 18888
|
||||
changed = True
|
||||
try:
|
||||
hc = int(s.health_check_seconds)
|
||||
if hc < 10:
|
||||
s.health_check_seconds = 10
|
||||
changed = True
|
||||
elif hc > 3600:
|
||||
s.health_check_seconds = 3600
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.health_check_seconds = 180
|
||||
changed = True
|
||||
try:
|
||||
fr = int(s.full_refresh_seconds)
|
||||
if fr < 60:
|
||||
s.full_refresh_seconds = 60
|
||||
changed = True
|
||||
elif fr > 86400:
|
||||
s.full_refresh_seconds = 86400
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.full_refresh_seconds = 1800
|
||||
changed = True
|
||||
try:
|
||||
if int(s.validation_concurrency) < 1:
|
||||
s.validation_concurrency = 1
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.validation_concurrency = 64
|
||||
changed = True
|
||||
try:
|
||||
if int(s.max_candidates) < 10:
|
||||
s.max_candidates = 10
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.max_candidates = 200
|
||||
changed = True
|
||||
try:
|
||||
mps = int(s.min_pool_size)
|
||||
if mps < 3:
|
||||
s.min_pool_size = 3
|
||||
changed = True
|
||||
elif mps > 500:
|
||||
s.min_pool_size = 500
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.min_pool_size = 20
|
||||
changed = True
|
||||
if not hasattr(s, "min_pool_size"):
|
||||
s.min_pool_size = 20
|
||||
changed = True
|
||||
try:
|
||||
vt = float(s.validation_timeout_seconds)
|
||||
if vt < 2.0:
|
||||
s.validation_timeout_seconds = 2.0
|
||||
changed = True
|
||||
elif vt > 120.0:
|
||||
s.validation_timeout_seconds = 120.0
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.validation_timeout_seconds = 12.0
|
||||
changed = True
|
||||
# Empty list survives "all(isinstance…)" — would skip all fetches and yield an empty pool.
|
||||
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
|
||||
|
||||
|
||||
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():
|
||||
s = Settings()
|
||||
if sys.platform != "win32":
|
||||
s.kill_switch_enabled = False
|
||||
s.mac_spoof_enabled = False
|
||||
s.mac_rotate_on_chain_rotate = False
|
||||
s.spoof_hostname_enabled = False
|
||||
s.disable_ipv6_while_active = False
|
||||
s.harden_webrtc_enabled = False
|
||||
s.lan_lockdown_enabled = False
|
||||
s.telemetry_kill_enabled = False
|
||||
return s
|
||||
try:
|
||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||
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):
|
||||
setattr(s, k, v)
|
||||
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)
|
||||
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, ch1 = sanitize_settings(s)
|
||||
s, ch2 = normalize_listen_host(s)
|
||||
changed = ch1 or ch2
|
||||
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, _ = 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")
|
||||
Reference in New Issue
Block a user