Browser: identity persona dropdown (blend Windows-Chrome / Firefox / mac-Safari / hardened / custom) + cookie policy dropdown. Blend personas relax RFP/FPI/strict-TP so the machine looks normal while still hiding behind the chain. Persona TZ applied via env var; UA / language / locale / screen via user.js. WebRTC still forced off (it leaks real IP). Privacy: Telemetry kill toggle (DiagTrack, Activity History, ad ID, Cortana, scheduled tasks) with full snapshot/restore. One-button forensic artifact wipe (TEMP, Recent, Jump Lists, Prefetch, MRU, clipboard). UI: neon palette, glow card borders, gradient header banner. Co-authored-by: Cursor <cursoragent@cursor.com>
369 lines
14 KiB
Python
369 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
from urllib.parse import quote, unquote, urlparse, urlunparse
|
|
|
|
from .paths import app_data_dir
|
|
|
|
|
|
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:
|
|
# ── 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
|
|
# 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 = True # engage Windows Firewall kill-switch when running
|
|
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 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
|
|
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():
|
|
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)
|
|
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"
|
|
p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8")
|