first commit

This commit is contained in:
drjones
2026-05-23 21:58:06 -07:00
commit 1f5e63ca1f
73 changed files with 13565 additions and 0 deletions

View 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")