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,3 @@
"""Rotating proxy chain manager with a GOST backend."""
__version__ = "2.0.0-mac"

View File

@@ -0,0 +1,4 @@
from .app import main
if __name__ == "__main__":
main()

3074
proxy_chain_manager/app.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,218 @@
"""One-button forensic artifact wipe.
Surfaces purged:
%TEMP% — per-user temp
%SystemRoot%\\Prefetch\\*.pf (Admin only) — file-launch history
%APPDATA%\\Microsoft\\Windows\\Recent\\* — recent files
%APPDATA%\\Microsoft\\Windows\\Recent\\AutomaticDestinations\\* — Jump Lists
%APPDATA%\\Microsoft\\Windows\\Recent\\CustomDestinations\\* — Jump Lists
• HKCU\\\\Explorer\\RunMRU — Win+R history
• HKCU\\\\Explorer\\TypedPaths — Explorer typed paths
• HKCU\\\\Explorer\\WordWheelQuery — Start / Explorer search history
• HKCU\\\\Explorer\\RecentDocs — recent docs MRU
• Clipboard — current contents
Counts are reported but specific filenames are never logged (this is the
opposite of what we want to leak). All deletes use ``ignore_errors=True``
because files in use by other apps are expected.
"""
from __future__ import annotations
import ctypes
import logging
import os
import shutil
import subprocess
import winreg
from dataclasses import dataclass, field
from pathlib import Path
from .firewall import is_admin
log = logging.getLogger(__name__)
_REG_MRU_KEYS = (
r"Software\Microsoft\Windows\CurrentVersion\Explorer\RunMRU",
r"Software\Microsoft\Windows\CurrentVersion\Explorer\TypedPaths",
r"Software\Microsoft\Windows\CurrentVersion\Explorer\WordWheelQuery",
r"Software\Microsoft\Windows\CurrentVersion\Explorer\RecentDocs",
)
@dataclass
class WipeReport:
files_deleted: int = 0
bytes_freed: int = 0
folders_skipped: int = 0
registry_keys_cleared: int = 0
clipboard_cleared: bool = False
errors: list[str] = field(default_factory=list)
def summary(self) -> str:
mb = self.bytes_freed / (1024 * 1024)
return (
f"{self.files_deleted} files / {mb:.1f} MB freed, "
f"{self.registry_keys_cleared} MRU entries cleared, "
f"clipboard={'yes' if self.clipboard_cleared else 'no'}, "
f"errors={len(self.errors)}"
)
def _walk_size(path: Path) -> int:
total = 0
for p in path.rglob("*"):
try:
if p.is_file():
total += p.stat().st_size
except OSError:
pass
return total
def _purge_dir(path: Path, rep: WipeReport, keep_root: bool = True) -> None:
"""Delete contents of `path`. Preserves the directory itself when keep_root."""
if not path.exists():
return
try:
size_before = _walk_size(path)
except Exception:
size_before = 0
count = 0
for child in path.iterdir():
try:
if child.is_dir():
shutil.rmtree(child, ignore_errors=True)
else:
child.unlink(missing_ok=True)
count += 1
except OSError:
rep.folders_skipped += 1
if not keep_root:
try:
path.rmdir()
except OSError:
pass
rep.files_deleted += count
try:
size_after = _walk_size(path)
except Exception:
size_after = 0
rep.bytes_freed += max(0, size_before - size_after)
def _clear_mru_key(path: str, rep: WipeReport) -> None:
"""Remove every value under a Run/Typed/Search MRU key."""
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, path, 0, winreg.KEY_ALL_ACCESS
) as key:
count = 0
try:
while True:
name, _, _ = winreg.EnumValue(key, 0)
try:
winreg.DeleteValue(key, name)
count += 1
except OSError:
break
except OSError:
pass
# Recurse into subkeys (e.g. RecentDocs/.png)
sub_count = 0
try:
while True:
sub_count += 1
sub_name = winreg.EnumKey(key, 0)
try:
winreg.DeleteKey(key, sub_name)
except OSError:
break
if sub_count > 200: # safety cap
break
except OSError:
pass
if count or sub_count:
rep.registry_keys_cleared += 1
except OSError:
pass
def _clear_clipboard(rep: WipeReport) -> None:
try:
user32 = ctypes.windll.user32 # type: ignore[attr-defined]
if user32.OpenClipboard(None):
try:
user32.EmptyClipboard()
rep.clipboard_cleared = True
finally:
user32.CloseClipboard()
except Exception as e:
rep.errors.append(f"clipboard: {e}")
def wipe_artifacts(include_prefetch: bool = True) -> WipeReport:
rep = WipeReport()
appdata = Path(os.environ.get("APPDATA", "")) if os.environ.get("APPDATA") else None
temp = Path(os.environ.get("TEMP", "")) if os.environ.get("TEMP") else None
sysroot = Path(os.environ.get("SystemRoot", r"C:\Windows"))
# %TEMP%
if temp and temp.exists():
_purge_dir(temp, rep, keep_root=True)
# Prefetch (Admin)
if include_prefetch and is_admin():
pf = sysroot / "Prefetch"
if pf.exists():
count = 0
for f in pf.glob("*.pf"):
try:
sz = f.stat().st_size
f.unlink(missing_ok=True)
count += 1
rep.bytes_freed += sz
except OSError as e:
rep.errors.append(f"prefetch: {e}")
rep.files_deleted += count
# Recent / Jump Lists
if appdata:
recent = appdata / "Microsoft" / "Windows" / "Recent"
if recent.exists():
_purge_dir(recent / "AutomaticDestinations", rep, keep_root=True)
_purge_dir(recent / "CustomDestinations", rep, keep_root=True)
_purge_dir(recent, rep, keep_root=True)
# MRU registry keys
for path in _REG_MRU_KEYS:
_clear_mru_key(path, rep)
# Clipboard
_clear_clipboard(rep)
# Trigger Explorer's "Clear recent items" via Shell API (covers Win10/11
# Quick Access pinned ↔ recent lists not covered by raw file delete).
try:
shell32 = ctypes.windll.shell32 # type: ignore[attr-defined]
# SHCNE_ASSOCCHANGED tells Explorer to refresh its caches.
shell32.SHChangeNotify(0x08000000, 0x0000, None, None)
except Exception:
pass
# Best-effort Defender quick-scan history flush — non-fatal.
try:
subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
"Clear-RecycleBin -Force -ErrorAction SilentlyContinue"],
capture_output=True, text=True, timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
pass
return rep

View File

@@ -0,0 +1,221 @@
from __future__ import annotations
import concurrent.futures
import socket
from dataclasses import dataclass
from typing import Iterable
import httpx
# ── Site lists ────────────────────────────────────────────────────────────────
SITES_SOCIAL: tuple[tuple[str, str], ...] = (
("Google", "https://www.google.com/generate_204"),
("YouTube", "https://www.youtube.com/"),
("Facebook", "https://www.facebook.com/"),
("Instagram", "https://www.instagram.com/"),
("X / Twitter", "https://x.com/"),
("Reddit", "https://www.reddit.com/"),
("TikTok", "https://www.tiktok.com/"),
("Wikipedia", "https://www.wikipedia.org/"),
("DuckDuckGo", "https://duckduckgo.com/"),
("BBC", "https://www.bbc.com/"),
("GitHub", "https://github.com/"),
)
SITES_SHOPPING: tuple[tuple[str, str], ...] = (
("Amazon", "https://www.amazon.com/"),
("eBay", "https://www.ebay.com/"),
("Walmart", "https://www.walmart.com/"),
("Etsy", "https://www.etsy.com/"),
("AliExpress", "https://www.aliexpress.com/"),
("Best Buy", "https://www.bestbuy.com/"),
("Target", "https://www.target.com/"),
("Newegg", "https://www.newegg.com/"),
)
SITES_CRYPTO: tuple[tuple[str, str], ...] = (
("Coinbase", "https://www.coinbase.com/"),
("Binance", "https://www.binance.com/"),
("Kraken", "https://www.kraken.com/"),
("Bybit", "https://www.bybit.com/"),
("OKX", "https://www.okx.com/"),
("KuCoin", "https://www.kucoin.com/"),
("Bitfinex", "https://www.bitfinex.com/"),
("Gemini", "https://www.gemini.com/"),
)
SITES_DNS: tuple[tuple[str, str], ...] = (
("Cloudflare DNS (1.1.1.1)", "https://1.1.1.1/"),
("Google DNS (8.8.8.8)", "https://dns.google/"),
("Quad9 DNS (9.9.9.9)", "https://quad9.net/"),
("AdGuard DNS", "https://adguard-dns.io/"),
("NextDNS", "https://nextdns.io/"),
("Mullvad DNS", "https://mullvad.net/en/help/dns-over-https-and-dns-over-tls/"),
("OpenDNS", "https://www.opendns.com/"),
)
# Combined default: all categories
POPULAR_SITES: tuple[tuple[str, str], ...] = (
SITES_SOCIAL + SITES_SHOPPING + SITES_CRYPTO + SITES_DNS
)
SITE_CATEGORIES: dict[str, tuple[tuple[str, str], ...]] = {
"Social / General": SITES_SOCIAL,
"Shopping": SITES_SHOPPING,
"Crypto Exchanges": SITES_CRYPTO,
"DNS Providers": SITES_DNS,
"All": POPULAR_SITES,
}
_BANNED_STATUS_CODES = {401, 403, 407, 418, 429, 451}
_BANNED_TEXT_HINTS = (
"access denied",
"forbidden",
"temporarily blocked",
"request blocked",
"unusual traffic",
"challenge required",
"bot detected",
"suspicious activity",
"geo-blocked",
"not available in your region",
"cloudflare ray",
"ddos protection",
"attention required",
"complete the captcha",
"captcha required",
"g-recaptcha",
"hcaptcha",
)
@dataclass(frozen=True)
class BanTestResult:
site: str
url: str
status: str # ok | banned | error
code: int | None
detail: str
category: str = ""
def _looks_banned_body(body: str) -> bool:
t = (body or "").lower()
return any(h in t for h in _BANNED_TEXT_HINTS)
def _test_one(
proxy_url: str,
site: str,
url: str,
timeout_seconds: float,
category: str = "",
) -> BanTestResult:
timeout = httpx.Timeout(timeout_seconds, connect=min(8.0, timeout_seconds))
try:
with httpx.Client(
proxy=proxy_url,
timeout=timeout,
verify=False,
follow_redirects=True,
headers={
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
) as c:
r = c.get(url)
body = r.text[:2400] if r.text else ""
if r.status_code in _BANNED_STATUS_CODES or _looks_banned_body(body):
return BanTestResult(
site, url, "banned", r.status_code,
f"HTTP {r.status_code}", category,
)
if 200 <= r.status_code < 400:
return BanTestResult(site, url, "ok", r.status_code, f"HTTP {r.status_code}", category)
return BanTestResult(site, url, "error", r.status_code, f"HTTP {r.status_code}", category)
except Exception as e:
short = str(e)[:80]
return BanTestResult(site, url, "error", None, f"{type(e).__name__}: {short}", category)
def _dns_resolve_via_system(hostname: str, timeout: float = 5.0) -> str | None:
"""Resolve a hostname using system DNS (does not go through proxy — exposes leak)."""
_prev = socket.getdefaulttimeout()
try:
socket.setdefaulttimeout(timeout)
info = socket.getaddrinfo(hostname, None)
for entry in info:
addr = entry[4][0]
if addr:
return addr
except Exception:
pass
finally:
socket.setdefaulttimeout(_prev)
return None
# NOTE: removed legacy ``check_dns_leak`` / ``DnsLeakResult`` — they relied on
# the ``x-real-ip`` HTTP response header which proxy targets do not set, so
# results were meaningless. Use :func:`proxy_chain_manager.dns_leak.run_dns_leak_test`
# instead.
def run_ban_tests(
proxy_url: str,
timeout_seconds: float = 12.0,
sites: Iterable[tuple[str, str]] | None = None,
categories: Iterable[str] | None = None,
max_workers: int = 8,
) -> list[BanTestResult]:
"""
Run ban tests in parallel across requested site categories.
``categories`` accepts names from SITE_CATEGORIES (e.g. "Shopping", "Crypto Exchanges").
Defaults to all categories when neither ``sites`` nor ``categories`` is given.
"""
if sites is not None:
work = [(s, u, "") for s, u in sites]
elif categories is not None:
seen_urls: set[str] = set()
work = []
for cat in categories:
for s, u in SITE_CATEGORIES.get(cat, ()):
if u not in seen_urls:
seen_urls.add(u)
work.append((s, u, cat))
else:
work = [(s, u, cat) for cat, pairs in SITE_CATEGORIES.items() if cat != "All" for s, u in pairs]
out: list[BanTestResult] = []
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
futures = {
pool.submit(_test_one, proxy_url, site, url, timeout_seconds, cat): (site, url, cat)
for site, url, cat in work
}
for fut in concurrent.futures.as_completed(futures):
try:
out.append(fut.result())
except Exception:
site, url, cat = futures[fut]
out.append(BanTestResult(site, url, "error", None, "internal error", cat))
# Sort: category then site name
out.sort(key=lambda r: (r.category, r.site))
return out
def test_single_site(
proxy_url: str,
site: str,
url: str,
timeout_seconds: float = 10.0,
) -> BanTestResult:
"""Probe one URL through the proxy. Used by pre-flight checks."""
return _test_one(proxy_url, site, url, timeout_seconds)

View File

@@ -0,0 +1,327 @@
"""Browser identity personas + cookie policies.
The "lock everything down" approach (Firefox RFP + WebRTC off + cookies blocked)
keeps you safe from fingerprinting but makes the machine *look weird* — sites
flag you as suspicious and ban / captcha you. Worse: very few users use that
config, so RFP-on is itself a fingerprint.
Two strategies are exposed:
• **Blend-in personas** — mimic the most common Windows-Chrome-US-en setup.
JS / cookies / WebGL all work; everything looks normal; only WebRTC is
blocked (because that leaks the real IP behind the proxy). The proxy
chain itself does the actual hiding.
• **Hardened** — full RFP / blocked WebRTC / strict tracking / FPI. Stand-out
but maximum block. Same behavior as the original profile.
Cookie modes are independent of persona. Each mode expresses a complete
policy: which cookies to accept, lifetime, whether to wipe on close, and
whether to partition 3rd-party storage.
"""
from __future__ import annotations
from dataclasses import dataclass
# ── Personas ──────────────────────────────────────────────────────────────────
PERSONAS: list[str] = [
"blend_windows_chrome",
"blend_windows_chrome_laptop",
"blend_windows_firefox",
"blend_windows_edge",
"blend_mac_safari",
"blend_mac_chrome",
"blend_linux_firefox",
"hardened",
"custom",
]
PERSONA_LABELS: dict[str, str] = {
"blend_windows_chrome": "Blend — Win10 + Chrome 124 (most common, US-en)",
"blend_windows_chrome_laptop": "Blend — Win11 + Chrome 124 laptop 1366×768",
"blend_windows_firefox": "Blend — Win10 + Firefox 128 (US-en)",
"blend_windows_edge": "Blend — Win11 + Edge 124 (corporate look)",
"blend_mac_safari": "Blend — macOS 14 + Safari 17 (US-en)",
"blend_mac_chrome": "Blend — macOS 14 + Chrome 124 (US-en)",
"blend_linux_firefox": "Blend — Ubuntu + Firefox 128 (en-US)",
"hardened": "Hardened — RFP + FPI + block all (stands out)",
"custom": "Custom — use individual hardening toggles",
}
@dataclass(frozen=True)
class Persona:
"""A user-agent / locale / timezone / screen tuple that mimics a real,
common configuration."""
key: str
user_agent: str
accept_language: str
timezone: str
screen_w: int
screen_h: int
platform: str
locale: str
PERSONA_DATA: dict[str, Persona] = {
"blend_windows_chrome": Persona(
key="blend_windows_chrome",
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
accept_language="en-US,en;q=0.9",
timezone="America/New_York",
screen_w=1920, screen_h=1080,
platform="Win32",
locale="en-US",
),
"blend_windows_chrome_laptop": Persona(
key="blend_windows_chrome_laptop",
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
accept_language="en-US,en;q=0.9",
timezone="America/Chicago",
screen_w=1366, screen_h=768,
platform="Win32",
locale="en-US",
),
"blend_windows_firefox": Persona(
key="blend_windows_firefox",
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) "
"Gecko/20100101 Firefox/128.0"
),
accept_language="en-US,en;q=0.5",
timezone="America/New_York",
screen_w=1920, screen_h=1080,
platform="Win32",
locale="en-US",
),
"blend_windows_edge": Persona(
key="blend_windows_edge",
user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36 Edg/124.0.0.0"
),
accept_language="en-US,en;q=0.9",
timezone="America/Chicago",
screen_w=1920, screen_h=1080,
platform="Win32",
locale="en-US",
),
"blend_mac_safari": Persona(
key="blend_mac_safari",
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4.1 Safari/605.1.15"
),
accept_language="en-US,en;q=0.9",
timezone="America/Los_Angeles",
screen_w=1680, screen_h=1050,
platform="MacIntel",
locale="en-US",
),
"blend_mac_chrome": Persona(
key="blend_mac_chrome",
user_agent=(
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
accept_language="en-US,en;q=0.9",
timezone="America/Los_Angeles",
screen_w=1440, screen_h=900,
platform="MacIntel",
locale="en-US",
),
"blend_linux_firefox": Persona(
key="blend_linux_firefox",
user_agent=(
"Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:128.0) "
"Gecko/20100101 Firefox/128.0"
),
accept_language="en-US,en;q=0.5",
timezone="America/New_York",
screen_w=1920, screen_h=1080,
platform="Linux x86_64",
locale="en-US",
),
}
# ── Cookie policies ───────────────────────────────────────────────────────────
@dataclass(frozen=True)
class CookiePolicy:
"""
Complete Firefox cookie configuration expressed as a single composable unit.
behavior : network.cookie.cookieBehavior
0 = accept all
1 = block 3rd-party
2 = block ALL cookies
3 = block 3rd-party from unvisited sites
4 = block cross-site tracking (ETP Strict)
5 = block cross-site + social media trackers
lifetime : network.cookie.lifetimePolicy
0 = normal (respect Set-Cookie expires)
2 = session-only (all cookies expire on close)
partition : Total Cookie Protection — partition 3rd-party storage
by the top-level site (network.cookie.cookieBehavior.optInPartitioning)
clear_cookies : privacy.clearOnShutdown.cookies
clear_cache : privacy.clearOnShutdown.cache
clear_history : privacy.clearOnShutdown.history
clear_ls : privacy.clearOnShutdown.localStorage + indexedDB
clear_formdata : privacy.clearOnShutdown.formdata
sanitize : privacy.sanitize.sanitizeOnShutdown (master switch)
"""
behavior: int = 1
lifetime: int = 0
partition: bool = False
sanitize: bool = False
clear_cookies: bool = False
clear_cache: bool = False
clear_history: bool = False
clear_ls: bool = False
clear_formdata: bool = False
# Every cookie mode is a complete CookiePolicy — no guesswork in profile.py.
COOKIE_POLICIES: dict[str, CookiePolicy] = {
# ── Permissive ──────────────────────────────────────────────────────────
"accept_all": CookiePolicy(
behavior=0, lifetime=0,
# No clearing, no blocking — maximum site compatibility.
),
"accept_all_session": CookiePolicy(
behavior=0, lifetime=2,
sanitize=True, clear_cookies=True, clear_ls=True,
# Accept all cookies while browsing but wipe them when the
# browser closes. Good for signup / form-filling sessions.
),
# ── Standard 3rd-party blocking ────────────────────────────────────────
"block_third_party": CookiePolicy(
behavior=1, lifetime=0,
# Block 3rd-party, accept 1st-party with normal lifetime.
# Recommended baseline — most sites work fine.
),
"block_third_party_session": CookiePolicy(
behavior=1, lifetime=2,
sanitize=True, clear_cookies=True, clear_cache=True, clear_ls=True,
# Block 3rd-party AND treat all accepted cookies as session-only.
# Nothing persists. Use for anonymous sessions.
),
# ── Tracker-focused blocking ────────────────────────────────────────────
"block_third_party_trackers": CookiePolicy(
behavior=4, lifetime=0,
# Firefox ETP Strict mode: only blocks known trackers, not ALL
# 3rd-party cookies. Best blend-in option — same as Firefox default.
),
"block_social_trackers": CookiePolicy(
behavior=5, lifetime=0,
# Block cross-site cookies AND social-media tracking cookies
# (Facebook, Twitter pixels, etc.). Stricter than ETP Strict but
# still allows most logins.
),
"block_unvisited": CookiePolicy(
behavior=3, lifetime=0,
# Block 3rd-party from sites the user has never visited. Very light
# — almost invisible to sites; good when you need max compatibility.
),
# ── Partitioned storage (Total Cookie Protection) ───────────────────────
"partitioned_tcp": CookiePolicy(
behavior=1, lifetime=0, partition=True,
# Block 3rd-party AND enable Total Cookie Protection: each site
# gets its own isolated cookie jar so cross-site tracking via
# cookie sync is impossible. Recommended for blend-in + privacy.
),
"partitioned_session": CookiePolicy(
behavior=1, lifetime=2, partition=True,
sanitize=True, clear_cookies=True, clear_cache=True,
clear_ls=True, clear_formdata=True,
# Partitioned + session-only + full wipe on close. Leaves no
# persistent state on disk after the browser exits.
),
# ── Maximum wipe ───────────────────────────────────────────────────────
"session_only": CookiePolicy(
behavior=0, lifetime=2,
sanitize=True, clear_cookies=True,
# All cookies accepted but expire when the browser closes.
# Note: localStorage is NOT cleared here — only cookies.
),
"ghost_mode": CookiePolicy(
behavior=1, lifetime=2,
sanitize=True, clear_cookies=True, clear_cache=True,
clear_history=True, clear_ls=True, clear_formdata=True,
partition=True,
# Maximum ephemeral session: block 3rd-party, TCP partitioning,
# session-only lifetime, full wipe of cookies + cache + history +
# localStorage/IndexedDB + formdata on every close. Zero disk trace.
),
"block_all": CookiePolicy(
behavior=2, lifetime=0,
# Block ALL cookies. Most login-dependent sites will break.
# Only useful for read-only scraping sessions.
),
}
COOKIE_MODES: list[str] = list(COOKIE_POLICIES)
COOKIE_LABELS: dict[str, str] = {
"accept_all": "Accept all — maximum compatibility (cookies persist)",
"accept_all_session": "Accept all, session only — wipe cookies on close",
"block_third_party": "Block 3rd-party — recommended baseline (persists 1st-party)",
"block_third_party_session": "Block 3rd-party + session — nothing persists",
"block_third_party_trackers":"Block trackers only — ETP Strict (Firefox default-strict)",
"block_social_trackers": "Block social + cross-site trackers (Facebook pixel, etc.)",
"block_unvisited": "Block 3rd-party from unvisited sites — lightest option",
"partitioned_tcp": "Partitioned (TCP) — each site gets isolated 3rd-party jar",
"partitioned_session": "Partitioned + session — isolated jars, full wipe on close",
"session_only": "Session only — all cookies expire on close (no partition)",
"ghost_mode": "Ghost mode — partitioned + session + full disk wipe on close",
"block_all": "Block ALL cookies — breaks most logins",
}
# ── Helpers ───────────────────────────────────────────────────────────────────
def get_cookie_policy(mode: str) -> CookiePolicy:
return COOKIE_POLICIES.get(mode, COOKIE_POLICIES["block_third_party"])
# Legacy shims kept for code that still calls these directly.
def cookie_behavior_value(mode: str) -> int:
return get_cookie_policy(mode).behavior
def cookie_session_only(mode: str) -> bool:
return get_cookie_policy(mode).lifetime == 2
def get_persona(key: str) -> Persona | None:
return PERSONA_DATA.get(key)
def is_blend_persona(key: str) -> bool:
return key.startswith("blend_")

View File

@@ -0,0 +1,402 @@
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
import time
import winreg
from dataclasses import dataclass
from pathlib import Path
from .browser_profile import FirefoxHardening, ensure_firefox_profile, persona_env
from .paths import app_data_dir
from .signup_prep import SignupDraft, install_signup_extension
log = logging.getLogger(__name__)
# CREATE_NO_WINDOW suppresses the console; DETACHED_PROCESS fully separates
# the child from Python's job object so Firefox lives past Python's exit.
_CREATE_FLAGS = 0 if sys.platform != "win32" else (
getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
)
@dataclass
class BrowserConfig:
firefox_path: str = ""
profile_dir: str = ""
clear_on_close: bool = True
disposable_profile: bool = False
kill_on_chain_drop: bool = True
auto_relaunch: bool = False
lock_managed_profile: bool = True
force_proxy: bool = True
disable_webrtc: bool = True
resist_fingerprinting: bool = True
disable_telemetry: bool = True
first_party_isolation: bool = True
strict_tracking_protection: bool = True
timezone_utc: bool = True
persona: str = "blend_windows_chrome"
cookie_mode: str = "block_third_party"
def _find_firefox_via_registry() -> str:
"""Look up Firefox install path in the Windows registry."""
hives = [
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\Mozilla\Mozilla Firefox"),
(winreg.HKEY_LOCAL_MACHINE, r"SOFTWARE\WOW6432Node\Mozilla\Mozilla Firefox"),
(winreg.HKEY_CURRENT_USER, r"SOFTWARE\Mozilla\Mozilla Firefox"),
]
for hive, key_path in hives:
try:
with winreg.OpenKey(hive, key_path) as root:
version = winreg.QueryValue(root, None)
for sub in (f"{version}\\Main", "bin"):
try:
with winreg.OpenKey(root, sub) as k:
path = winreg.QueryValueEx(k, "PathToExe")[0]
if path and Path(path).is_file():
return str(path)
except OSError:
continue
except OSError:
continue
# Also try the "Uninstall" key
for hive in (winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER):
for root_path in (
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall",
r"SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
):
try:
with winreg.OpenKey(hive, root_path) as base:
i = 0
while True:
try:
sub = winreg.EnumKey(base, i)
i += 1
if "firefox" not in sub.lower():
continue
with winreg.OpenKey(base, sub) as k:
loc = winreg.QueryValueEx(k, "InstallLocation")[0]
candidate = Path(loc) / "firefox.exe"
if candidate.is_file():
return str(candidate)
except OSError:
break
except OSError:
continue
return ""
def default_firefox_path() -> str:
"""Find Firefox.exe — registry first, then well-known paths."""
if sys.platform == "darwin":
candidates = [
Path("/Applications/Firefox.app/Contents/MacOS/firefox"),
Path.home() / "Applications" / "Firefox.app" / "Contents" / "MacOS" / "firefox",
]
which = shutil.which("firefox")
if which:
candidates.insert(0, Path(which))
for c in candidates:
if c.is_file():
return str(c)
return ""
# 1) Registry
reg = _find_firefox_via_registry()
if reg:
return reg
# 2) Standard install locations
candidates: list[Path] = [
Path(r"C:\Program Files\Mozilla Firefox\firefox.exe"),
Path(r"C:\Program Files (x86)\Mozilla Firefox\firefox.exe"),
Path(os.path.expandvars(r"%LOCALAPPDATA%\Mozilla Firefox\firefox.exe")),
Path(os.path.expandvars(r"%APPDATA%\Mozilla Firefox\firefox.exe")),
]
# 3) Scoop / winget / portable in PATH
which = shutil.which("firefox") or shutil.which("firefox.exe")
if which:
candidates.insert(0, Path(which))
for c in candidates:
if c.is_file():
return str(c)
return ""
def default_profile_dir() -> str:
return str(app_data_dir() / "browser_profiles" / "firefox_hardened")
def _firefox_is_running_on_system() -> bool:
"""Check if any firefox.exe process is alive (tasklist, no admin needed)."""
if sys.platform == "darwin":
try:
r = subprocess.run(["pgrep", "-x", "firefox"], capture_output=True, text=True, timeout=6)
return r.returncode == 0 and bool((r.stdout or "").strip())
except Exception:
return False
try:
r = subprocess.run(
["tasklist", "/FI", "IMAGENAME eq firefox.exe", "/NH", "/FO", "CSV"],
capture_output=True, text=True, timeout=6,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
)
return "firefox.exe" in (r.stdout or "").lower()
except Exception:
return False
def _has_descendants(root_pid: int) -> bool:
"""True iff *root_pid* or any descendant process is currently alive.
Walks the process tree from root_pid using WMIC ProcessId/ParentProcessId
(no admin required). Falls back to checking the root PID's existence via
tasklist if WMIC is missing on the host.
"""
if not root_pid:
return False
if sys.platform == "darwin":
try:
r = subprocess.run(["ps", "-p", str(root_pid)], capture_output=True, text=True, timeout=6)
return str(root_pid) in (r.stdout or "")
except Exception:
return False
# Fast path: is the root PID itself still alive?
try:
r = subprocess.run(
["tasklist", "/FI", f"PID eq {root_pid}", "/NH", "/FO", "CSV"],
capture_output=True, text=True, timeout=6,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
)
if str(root_pid) in (r.stdout or ""):
return True
except Exception: # noqa: BLE001
pass
# Walk descendants via WMIC. Builds {parent: [child, ...]} then BFS.
try:
w = subprocess.run(
["wmic", "process", "get", "ProcessId,ParentProcessId", "/FORMAT:CSV"],
capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
)
children: dict[int, list[int]] = {}
for line in (w.stdout or "").splitlines():
parts = [p.strip() for p in line.split(",")]
# CSV header: Node,ParentProcessId,ProcessId
if len(parts) < 3 or not parts[1].isdigit() or not parts[2].isdigit():
continue
ppid, pid = int(parts[1]), int(parts[2])
children.setdefault(ppid, []).append(pid)
stack = [root_pid]
seen = {root_pid}
while stack:
cur = stack.pop()
kids = children.get(cur, [])
for k in kids:
if k in seen:
continue
seen.add(k)
# Any live descendant = our session is still alive.
return True
return False
except Exception: # noqa: BLE001
# WMIC missing (newer Windows) — be conservative and return False
# rather than the old "any firefox.exe = mine" heuristic.
return False
class BrowserSession:
def __init__(self) -> None:
self._proc: subprocess.Popen[str] | None = None
self._profile_path: Path | None = None
self._launched_at: float = 0.0
def is_running(self) -> bool:
if self._proc is None:
return False
# Firefox's initial launcher process exits quickly (code 0) while
# browser child processes carry on. Poll the parent process first.
if self._proc.poll() is None:
return True
# Parent has exited — check whether any process in our spawned tree
# (the original PID's children) is still alive. Using a PID-scoped
# WMIC query avoids the old failure mode of treating any unrelated
# firefox.exe on the machine as ours.
launched_pid = self._proc.pid
return _has_descendants(launched_pid)
def pid(self) -> int | None:
if self._proc is None:
return None
return self._proc.pid
def launch(
self,
cfg: BrowserConfig,
proxy_host: str,
proxy_port: int,
start_url: str = "",
signup_draft: SignupDraft | None = None,
) -> tuple[bool, str]:
raw_exe = (cfg.firefox_path or default_firefox_path()).strip().strip('"').strip("'")
if not raw_exe:
return (
False,
"Firefox not found. Install Firefox then set the path in the Browser tab "
"(or browse to it with the Browse button).",
)
exe = Path(raw_exe)
if not exe.is_file():
return False, (
f"Firefox executable not found: {raw_exe} "
"Use the Browse button on the Browser tab to locate firefox.exe."
)
raw_profile = (cfg.profile_dir or default_profile_dir()).strip().strip('"').strip("'")
profile_dir = Path(raw_profile)
hard = FirefoxHardening(
force_proxy=cfg.force_proxy,
disable_webrtc=cfg.disable_webrtc,
resist_fingerprinting=cfg.resist_fingerprinting,
disable_telemetry=cfg.disable_telemetry,
first_party_isolation=cfg.first_party_isolation,
strict_tracking_protection=cfg.strict_tracking_protection,
clear_on_shutdown=cfg.clear_on_close,
timezone_utc=cfg.timezone_utc,
persona_key=cfg.persona,
cookie_mode=cfg.cookie_mode,
)
try:
if cfg.lock_managed_profile:
ensure_firefox_profile(profile_dir, proxy_host, int(proxy_port), hard)
if signup_draft is not None:
install_signup_extension(profile_dir, signup_draft)
except Exception as e:
return False, f"Profile setup failed: {e!s}"
if self.is_running():
self.stop()
if cfg.lock_managed_profile:
args = [str(exe), "-no-remote", "-profile", str(profile_dir)]
else:
args = [str(exe)]
url = (start_url or "").strip()
if url:
args.append(url)
env = os.environ.copy()
env.update(persona_env(hard))
try:
self._proc = subprocess.Popen(
args,
# DETACHED_PROCESS ensures Firefox outlives Python and is not
# killed when Python's job object exits. CREATE_NO_WINDOW
# suppresses any console window. CREATE_NEW_PROCESS_GROUP
# isolates Ctrl-C handling.
creationflags=_CREATE_FLAGS,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
text=True,
env=env,
)
except Exception as e:
self._proc = None
return False, f"Launch failed: {e!s}"
self._launched_at = time.monotonic()
# Firefox's multi-process launcher exits (code 0) within ~0.5 s while
# the actual browser window loads in child processes. We therefore do
# NOT treat a quick exit of the parent as an error — we just verify
# that *some* firefox.exe appears in the process list within 3 s.
deadline = time.monotonic() + 3.0
found = False
while time.monotonic() < deadline:
time.sleep(0.3)
rc = self._proc.poll()
# If it already exited with an error code, report it.
if rc is not None and rc not in (0, None):
self._proc = None
return False, f"Firefox exited with error code {rc}."
if _firefox_is_running_on_system():
found = True
break
if not found:
# One last check — maybe the profile write was the only action needed
# and Firefox opened fast; accept success if parent is still alive.
if self._proc.poll() is None:
found = True
if not found:
self._proc = None
return False, (
"Firefox did not appear in the process list after 3 s. "
"Check that the path is correct and Firefox is not already blocked by another -profile lock."
)
self._profile_path = profile_dir if cfg.lock_managed_profile else None
pid = self._proc.pid
log.info("Launched hardened Firefox pid=%s profile=%s", pid, profile_dir)
mode = "hardened profile" if cfg.lock_managed_profile else "unlocked mode"
return True, f"Firefox started ({mode}, pid {pid})."
def stop(self, dispose: bool = False) -> tuple[bool, str]:
# Capture our PID before clearing the handle
launched_pid: int | None = self._proc.pid if self._proc else None
# Terminate the Popen handle if still alive
if self._proc and self._proc.poll() is None:
try:
self._proc.terminate()
self._proc.wait(timeout=8)
except Exception:
try:
self._proc.kill()
except Exception:
pass
finally:
self._proc = None
# Kill only the process tree we spawned (handles the detached-child
# case where the launcher process already exited naturally).
# /T kills the entire child tree; /PID scopes to our PID only.
if launched_pid is not None:
if sys.platform == "darwin":
try:
subprocess.run(["pkill", "-P", str(launched_pid)], capture_output=True, timeout=10)
except Exception:
pass
else:
try:
subprocess.run(
["taskkill", "/F", "/T", "/PID", str(launched_pid)],
capture_output=True,
timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000),
)
except Exception:
pass
self._proc = None
if dispose and self._profile_path and self._profile_path.exists():
try:
shutil.rmtree(self._profile_path, ignore_errors=True)
except Exception:
pass
self._launched_at = 0.0
return True, "Browser stopped."

View File

@@ -0,0 +1,271 @@
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from .browser_identity import (
CookiePolicy,
Persona,
cookie_behavior_value,
cookie_session_only,
get_cookie_policy,
get_persona,
is_blend_persona,
)
@dataclass
class FirefoxHardening:
# Proxy & network always-on
force_proxy: bool = True
disable_webrtc: bool = True # always strongly recommended; leaks real IP
# Hardened block mode — overrides persona spoofing
resist_fingerprinting: bool = True
disable_telemetry: bool = True
first_party_isolation: bool = True
strict_tracking_protection: bool = True
clear_on_shutdown: bool = True
timezone_utc: bool = True
# Persona / cookie selectors (added in "spoof when possible" mode)
persona_key: str = "custom" # see browser_identity.PERSONAS
cookie_mode: str = "block_third_party" # see browser_identity.COOKIE_MODES
# Optional Firefox-managed user-agent override even outside of a persona
user_agent_override: str = ""
# Filled in by ``apply_persona`` when persona_key starts with "blend_".
persona: Persona | None = field(default=None, repr=False, compare=False)
def apply_persona(hard: FirefoxHardening) -> FirefoxHardening:
"""Mutate hardening flags consistent with the persona choice.
Blend-in personas relax the "stand out" toggles (RFP, FPI, strict TP) but
keep the IP-leakers off (WebRTC, geolocation, etc.). Cookie mode is
honored. The result is a profile that looks like a normal browser.
"""
if hard.persona_key == "hardened":
hard.resist_fingerprinting = True
hard.first_party_isolation = True
hard.strict_tracking_protection = True
hard.timezone_utc = True
hard.persona = None
return hard
if is_blend_persona(hard.persona_key):
hard.resist_fingerprinting = False # RFP itself is a fingerprint
hard.first_party_isolation = False # breaks logins on many sites
hard.strict_tracking_protection = False # Firefox default ETP is enough
hard.timezone_utc = False
hard.persona = get_persona(hard.persona_key)
if hard.persona and not hard.user_agent_override:
hard.user_agent_override = hard.persona.user_agent
return hard
# custom — honor the individual flags as-is.
hard.persona = None
return hard
def _bool(v: bool) -> str:
return "true" if v else "false"
def build_user_js(
proxy_host: str,
proxy_port: int,
hard: FirefoxHardening,
) -> str:
hard = apply_persona(hard)
lines: list[str] = [
"// Managed by Proxy God. Changes are overwritten on next launch.",
# Always-on baseline (no UX cost)
'user_pref("app.normandy.enabled", false);',
'user_pref("app.shield.optoutstudies.enabled", false);',
'user_pref("browser.newtabpage.activity-stream.feeds.telemetry", false);',
'user_pref("browser.newtabpage.activity-stream.telemetry", false);',
'user_pref("browser.ping-centre.telemetry", false);',
'user_pref("toolkit.telemetry.archive.enabled", false);',
'user_pref("toolkit.telemetry.bhrPing.enabled", false);',
'user_pref("toolkit.telemetry.enabled", false);',
'user_pref("toolkit.telemetry.firstShutdownPing.enabled", false);',
'user_pref("toolkit.telemetry.hybridContent.enabled", false);',
'user_pref("toolkit.telemetry.newProfilePing.enabled", false);',
'user_pref("toolkit.telemetry.shutdownPingSender.enabled", false);',
'user_pref("toolkit.telemetry.unified", false);',
'user_pref("datareporting.healthreport.uploadEnabled", false);',
'user_pref("datareporting.policy.dataSubmissionEnabled", false);',
'user_pref("network.trr.mode", 5);',
'user_pref("network.captive-portal-service.enabled", false);',
'user_pref("geo.enabled", false);',
'user_pref("media.navigator.enabled", false);',
'user_pref("dom.battery.enabled", false);',
'user_pref("dom.gamepad.enabled", false);',
'user_pref("dom.netinfo.enabled", false);',
# WebGL allowed by default (blocking it makes you stand out massively)
'user_pref("webgl.enable-debug-renderer-info", false);',
]
if hard.force_proxy:
lines.extend(
[
'user_pref("network.proxy.type", 1);',
'user_pref("network.proxy.share_proxy_settings", true);',
f'user_pref("network.proxy.http", "{proxy_host}");',
f'user_pref("network.proxy.http_port", {int(proxy_port)});',
f'user_pref("network.proxy.ssl", "{proxy_host}");',
f'user_pref("network.proxy.ssl_port", {int(proxy_port)});',
'user_pref("network.proxy.ftp", "");',
'user_pref("network.proxy.ftp_port", 0);',
'user_pref("network.proxy.socks", "");',
'user_pref("network.proxy.socks_port", 0);',
'user_pref("network.proxy.socks_version", 5);',
'user_pref("network.proxy.socks_remote_dns", false);',
'user_pref("network.proxy.no_proxies_on", "");',
]
)
if hard.disable_webrtc:
# Off regardless of persona — WebRTC leaks real IP through STUN.
lines.extend(
[
'user_pref("media.peerconnection.enabled", false);',
'user_pref("media.peerconnection.ice.default_address_only", true);',
'user_pref("media.peerconnection.ice.no_host", true);',
]
)
if hard.resist_fingerprinting:
lines.extend(
[
'user_pref("privacy.resistFingerprinting", true);',
'user_pref("privacy.resistFingerprinting.letterboxing", true);',
'user_pref("privacy.window.maxInnerWidth", 1600);',
'user_pref("privacy.window.maxInnerHeight", 900);',
]
)
if hard.first_party_isolation:
lines.append('user_pref("privacy.firstparty.isolate", true);')
if hard.disable_telemetry:
lines.append('user_pref("browser.send_pings", false);')
if hard.strict_tracking_protection:
lines.extend(
[
'user_pref("privacy.trackingprotection.enabled", true);',
'user_pref("privacy.trackingprotection.pbmode.enabled", true);',
]
)
# Persona spoofing (UA, accept-language, screen, platform)
if hard.persona is not None:
p = hard.persona
lines.extend(
[
f'user_pref("general.useragent.override", "{p.user_agent}");',
f'user_pref("intl.accept_languages", "{p.accept_language}");',
f'user_pref("general.useragent.locale", "{p.locale}");',
'user_pref("javascript.use_us_english_locale", true);',
f'user_pref("privacy.window.maxInnerWidth", {int(p.screen_w)});',
f'user_pref("privacy.window.maxInnerHeight", {int(p.screen_h)});',
# Hint timezone — Firefox honors TZ env var at launch (set by
# the launcher) but this pref nudges some site detection too.
f'user_pref("intl.locale.requested", "{p.locale}");',
]
)
elif hard.user_agent_override:
lines.append(
f'user_pref("general.useragent.override", "{hard.user_agent_override}");'
)
# ── Cookie policy ────────────────────────────────────────────────────────
cp: CookiePolicy = get_cookie_policy(hard.cookie_mode)
lines.extend([
f'user_pref("network.cookie.cookieBehavior", {cp.behavior});',
# pbmode = private-browsing mode uses same behavior
f'user_pref("network.cookie.cookieBehavior.pbmode", {cp.behavior});',
# lifetime: 0 = normal, 2 = session-only
f'user_pref("network.cookie.lifetimePolicy", {cp.lifetime});',
])
# Total Cookie Protection (TCP / Firefox partitioning)
if cp.partition:
lines.extend([
'user_pref("network.cookie.cookieBehavior.optInPartitioning", true);',
# Also isolate cache, localStorage, IndexedDB per top-level site
'user_pref("privacy.partition.serviceWorkers", true);',
'user_pref("privacy.partition.network_state", true);',
'user_pref("privacy.partition.bloburl_by_default", true);',
])
else:
lines.extend([
'user_pref("network.cookie.cookieBehavior.optInPartitioning", false);',
])
# SameSite=None cookies must be Secure (good hygiene regardless of mode)
lines.append('user_pref("network.cookie.sameSite.noneRequiresSecure", true);')
# ── Sanitize-on-shutdown ─────────────────────────────────────────────────
# The CookiePolicy sanitize flag takes priority; the hard.clear_on_shutdown
# toggle acts as an additional override for cookies+cache even when the
# cookie mode doesn't request it.
do_sanitize = cp.sanitize or hard.clear_on_shutdown
if do_sanitize:
lines.append('user_pref("privacy.sanitize.sanitizeOnShutdown", true);')
# Cookies
lines.append(f'user_pref("privacy.clearOnShutdown.cookies", {_bool(cp.clear_cookies or hard.clear_on_shutdown)});')
# Cache
lines.append(f'user_pref("privacy.clearOnShutdown.cache", {_bool(cp.clear_cache or hard.clear_on_shutdown)});')
# History
lines.append(f'user_pref("privacy.clearOnShutdown.history", {_bool(cp.clear_history or hard.clear_on_shutdown)});')
# Downloads list
lines.append('user_pref("privacy.clearOnShutdown.downloads", true);')
# Form / search bar autofill data
lines.append(f'user_pref("privacy.clearOnShutdown.formdata", {_bool(cp.clear_formdata or hard.clear_on_shutdown)});')
# Active sessions (HTTP auth, logged-in sites)
lines.append('user_pref("privacy.clearOnShutdown.sessions", true);')
# localStorage + IndexedDB
lines.append(f'user_pref("privacy.clearOnShutdown.localStorage", {_bool(cp.clear_ls or hard.clear_on_shutdown)});')
lines.append(f'user_pref("privacy.clearOnShutdown.indexedDB", {_bool(cp.clear_ls or hard.clear_on_shutdown)});')
# Service worker registrations
lines.append('user_pref("privacy.clearOnShutdown.serviceWorkers", true);')
# Offline web app cache
lines.append('user_pref("privacy.clearOnShutdown.offlineApps", true);')
else:
lines.append('user_pref("privacy.sanitize.sanitizeOnShutdown", false);')
if hard.timezone_utc:
lines.append('user_pref("privacy.resistFingerprinting.reduceTimerPrecision", true);')
lines.append("")
return "\n".join(lines)
def persona_env(hard: FirefoxHardening) -> dict[str, str]:
"""Environment overrides applied when launching Firefox under a persona.
Only the timezone needs the OS-level env var (TZ) — Firefox reads it
even when running under Windows.
"""
hard = apply_persona(hard)
if hard.persona is None:
return {}
return {"TZ": hard.persona.timezone}
def ensure_firefox_profile(
profile_dir: Path,
proxy_host: str,
proxy_port: int,
hard: FirefoxHardening,
) -> None:
profile_dir.mkdir(parents=True, exist_ok=True)
(profile_dir / "user.js").write_text(
build_user_js(proxy_host, proxy_port, hard),
encoding="utf-8",
)

View File

@@ -0,0 +1,367 @@
"""World-map visualization for proxy chains.
Renders hop arcs over the bundled neon world map image. Falls back to a
drawn landmass map if the image file is absent.
"""
from __future__ import annotations
import ipaddress
import logging
import math
import socket
from dataclasses import dataclass
from pathlib import Path
from typing import Literal
from urllib.parse import urlparse
from PIL import Image, ImageDraw, ImageFilter, ImageFont
try:
_RESAMPLE = Image.Resampling.LANCZOS
except AttributeError:
_RESAMPLE = Image.LANCZOS # type: ignore[attr-defined]
from .config import normalize_proxy_url, redact_proxy_url
from .exit_intel import ExitIntel, fetch_ip_geo
log = logging.getLogger(__name__)
ChainStatus = Literal["healthy", "connecting", "dead", "idle"]
# Theme palette
_BG = "#070713"
_GRID = "#1a1f3a"
_LAND = "#12182e"
_LAND_EDGE = "#1e2a4a"
_LINE_HEALTHY = "#00e5ff"
_LINE_CONNECTING = "#ffd000"
_LINE_DEAD = "#ff2a6d"
_LINE_IDLE = "#3052ff"
_YOU = "#00ff9c"
_HOP = "#00e5ff"
_EXIT = "#ffd000"
_LABEL = "#eaf2ff"
_LABEL_DIM = "#7a8aab"
_UNKNOWN = "#2a2f4a"
def _map_img_path() -> Path:
"""Locate world_map.png whether running from source or a frozen PyInstaller bundle."""
import sys
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS) / "proxy_chain_manager" / "world_map.png"
return Path(__file__).resolve().parent / "world_map.png"
_cached_map: Image.Image | None = None
def _load_world_map(width: int, height: int) -> Image.Image | None:
"""Return the neon world map resized to (width × height), or None if unavailable."""
global _cached_map
try:
if _cached_map is None:
_cached_map = Image.open(_map_img_path()).convert("RGB")
return _cached_map.resize((width, height), _RESAMPLE)
except Exception as exc:
log.debug("world_map load failed: %s", exc)
return None
@dataclass(frozen=True)
class MapPoint:
role: Literal["you", "hop", "exit"]
label: str
lat: float
lon: float
detail: str = ""
hop_index: int = -1
def extract_hop_host(proxy_url: str) -> str | None:
"""Return hostname or IP from a proxy URL."""
p = urlparse(normalize_proxy_url(proxy_url))
host = (p.hostname or "").strip()
return host or None
def resolve_host_ip(host: str, timeout_seconds: float = 4.0) -> str | None:
"""Resolve host to IPv4/IPv6 string. Never raises."""
h = (host or "").strip()
if not h:
return None
try:
ipaddress.ip_address(h)
return h
except ValueError:
pass
try:
prev = socket.getdefaulttimeout()
socket.setdefaulttimeout(max(1.0, float(timeout_seconds)))
try:
infos = socket.getaddrinfo(h, None, type=socket.SOCK_STREAM)
finally:
socket.setdefaulttimeout(prev)
for info in infos:
addr = info[4][0]
try:
ipaddress.ip_address(addr)
return addr
except ValueError:
continue
except Exception as e:
log.debug("resolve_host_ip(%s): %s", h, e)
return None
def _loc_label(intel: ExitIntel) -> str:
bits = [b for b in (intel.city, intel.region, intel.country) if b]
return ", ".join(bits) if bits else intel.country_code or intel.ip or ""
def _intel_to_point(
role: Literal["you", "hop", "exit"],
label: str,
intel: ExitIntel,
*,
hop_index: int = -1,
) -> MapPoint | None:
if not intel.ok or intel.lat is None or intel.lon is None:
return None
return MapPoint(
role=role,
label=label,
lat=float(intel.lat),
lon=float(intel.lon),
detail=_loc_label(intel),
hop_index=hop_index,
)
def build_chain_map_points(
hops: list[str],
*,
direct_ip: str = "",
exit_ip: str = "",
exit_intel: ExitIntel | None = None,
timeout_seconds: float = 8.0,
) -> list[MapPoint]:
"""Resolve geo for YOU → hop₁ → … → EXIT. Never raises."""
points: list[MapPoint] = []
origin_ip = (direct_ip or "").strip()
if origin_ip and origin_ip != "":
you = _intel_to_point("you", "YOU", fetch_ip_geo(origin_ip, timeout_seconds))
if you:
points.append(you)
for i, hop in enumerate(hops):
host = extract_hop_host(hop)
if not host:
continue
ip = resolve_host_ip(host, timeout_seconds=min(4.0, timeout_seconds))
if not ip:
continue
intel = fetch_ip_geo(ip, timeout_seconds)
pt = _intel_to_point("hop", f"H{i + 1}", intel, hop_index=i)
if pt:
points.append(pt)
exit_str = (exit_ip or "").strip()
if exit_str and exit_str != "":
if (
exit_intel
and exit_intel.ok
and exit_intel.ip == exit_str
and exit_intel.lat is not None
and exit_intel.lon is not None
):
pt = _intel_to_point("exit", "EXIT", exit_intel)
else:
pt = _intel_to_point("exit", "EXIT", fetch_ip_geo(exit_str, timeout_seconds))
if pt:
points.append(pt)
return points
def _project(lat: float, lon: float, width: int, height: int, margin: int) -> tuple[float, float]:
inner_w = max(1, width - margin * 2)
inner_h = max(1, height - margin * 2)
x = margin + (float(lon) + 180.0) / 360.0 * inner_w
y = margin + (90.0 - float(lat)) / 180.0 * inner_h
return x, y
def _great_circle_points(
lat1: float,
lon1: float,
lat2: float,
lon2: float,
steps: int = 32,
) -> list[tuple[float, float]]:
"""Interpolate along a great circle for curved hop arcs."""
phi1, lam1 = math.radians(lat1), math.radians(lon1)
phi2, lam2 = math.radians(lat2), math.radians(lon2)
d = 2 * math.asin(
math.sqrt(
math.sin((phi2 - phi1) / 2) ** 2
+ math.cos(phi1) * math.cos(phi2) * math.sin((lam2 - lam1) / 2) ** 2
)
)
if d < 1e-9:
return [(lat1, lon1), (lat2, lon2)]
out: list[tuple[float, float]] = []
for i in range(steps + 1):
f = i / steps
a = math.sin((1 - f) * d) / math.sin(d)
b = math.sin(f * d) / math.sin(d)
x = a * math.cos(phi1) * math.cos(lam1) + b * math.cos(phi2) * math.cos(lam2)
y = a * math.cos(phi1) * math.sin(lam1) + b * math.cos(phi2) * math.sin(lam2)
z = a * math.sin(phi1) + b * math.sin(phi2)
out.append((
math.degrees(math.atan2(z, math.sqrt(x * x + y * y))),
math.degrees(math.atan2(y, x)),
))
return out
def _line_color(status: ChainStatus) -> tuple[int, int, int]:
table = {
"healthy": (0, 229, 255),
"connecting": (255, 208, 0),
"dead": (255, 42, 109),
"idle": (48, 82, 255),
}
return table.get(status, (48, 82, 255))
def _node_color(role: str) -> tuple[int, int, int]:
table = {"you": (0, 255, 156), "hop": (0, 229, 255), "exit": (255, 208, 0)}
return table.get(role, (0, 229, 255))
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _draw_glow_line(
draw: ImageDraw.ImageDraw,
pts: list[tuple[float, float]],
color: tuple[int, int, int],
) -> None:
"""Draw neon glow arc: fat dim outer halo → bright core line."""
if len(pts) < 2:
return
r, g, b = color
# outer halo — wider, dim
halo = (r // 4, g // 4, b // 4)
draw.line(pts, fill=halo, width=7)
# mid glow
mid = (r // 2, g // 2, b // 2)
draw.line(pts, fill=mid, width=4)
# bright core
draw.line(pts, fill=color, width=2)
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for name in ("segoeui.ttf", "arial.ttf", "DejaVuSans.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
continue
return ImageFont.load_default()
def _draw_landmasses(draw: ImageDraw.ImageDraw, width: int, height: int, margin: int) -> None:
"""Fallback minimal continent silhouettes."""
blobs: list[list[tuple[float, float]]] = [
[(-168, 72), (-140, 75), (-100, 72), (-80, 50), (-75, 25), (-95, 15), (-110, 20), (-125, 48), (-168, 72)],
[(-82, 12), (-72, -5), (-55, -35), (-65, -55), (-75, -18), (-82, 12)],
[(-10, 72), (30, 70), (40, 55), (35, 30), (20, 5), (-5, 5), (-18, 28), (-10, 72)],
[(15, 35), (35, 32), (50, 12), (42, -5), (18, -35), (15, 35)],
[(40, 72), (100, 75), (140, 55), (130, 35), (110, 10), (80, 8), (60, 25), (40, 45), (40, 72)],
[(115, -12), (135, -12), (150, -25), (145, -38), (115, -38), (115, -12)],
]
for poly in blobs:
pts = [_project(lat, lon, width, height, margin) for lat, lon in poly]
draw.polygon(pts, fill=_LAND, outline=_LAND_EDGE)
def render_chain_map(
points: list[MapPoint],
*,
width: int = 960,
height: int = 220,
status: ChainStatus = "idle",
margin: int = 12,
) -> Image.Image:
"""Render chain hops over the neon world map (or a drawn fallback)."""
w = max(320, width)
h = max(120, height)
# ── background ──────────────────────────────────────────────────────────
world = _load_world_map(w, h)
if world is not None:
# Slightly darken so overlaid lines pop
bg = Image.new("RGB", (w, h), (0, 0, 0))
img = Image.blend(world, bg, alpha=0.22)
else:
img = Image.new("RGB", (w, h), _BG)
draw_bg = ImageDraw.Draw(img)
_draw_landmasses(draw_bg, w, h, margin)
# grid
for lat in range(-60, 91, 30):
y = _project(lat, 0, w, h, margin)[1]
draw_bg.line([(margin, y), (w - margin, y)], fill=_GRID, width=1)
for lon in range(-150, 181, 30):
x = _project(0, lon, w, h, margin)[0]
draw_bg.line([(x, margin), (x, h - margin)], fill=_GRID, width=1)
draw = ImageDraw.Draw(img)
if len(points) < 2:
font = _load_font(12)
msg = "Start the chain to plot hops on the map." if not points else "Need 2+ geo points to draw path."
# drop shadow
draw.text((margin + 1, h // 2 - 7), msg, fill=(0, 0, 0), font=font)
draw.text((margin, h // 2 - 8), msg, fill=_hex_to_rgb(_LABEL_DIM), font=font)
return img
line_rgb = _line_color(status)
projected = [_project(p.lat, p.lon, w, h, margin) for p in points]
# ── arc lines between consecutive hops ─────────────────────────────────
for i in range(len(projected) - 1):
arc = _great_circle_points(
points[i].lat, points[i].lon,
points[i + 1].lat, points[i + 1].lon,
)
arc_xy = [_project(lat, lon, w, h, margin) for lat, lon in arc]
_draw_glow_line(draw, arc_xy, line_rgb)
# ── nodes ───────────────────────────────────────────────────────────────
label_font = _load_font(11)
detail_font = _load_font(9)
for pt, (x, y) in zip(points, projected):
nc = _node_color(pt.role)
r = 7 if pt.role == "exit" else (6 if pt.role == "you" else 5)
# outer glow ring
glow = (nc[0] // 3, nc[1] // 3, nc[2] // 3)
draw.ellipse((x - r - 4, y - r - 4, x + r + 4, y + r + 4), fill=glow)
# filled node with dark center
draw.ellipse((x - r, y - r, x + r, y + r), fill=nc)
draw.ellipse((x - r + 2, y - r + 2, x + r - 2, y + r - 2), fill=(5, 5, 20))
tx, ty = int(x + r + 5), int(y - 8)
# text drop-shadow
draw.text((tx + 1, ty + 1), pt.label, fill=(0, 0, 0), font=label_font)
draw.text((tx, ty), pt.label, fill=_hex_to_rgb(_LABEL), font=label_font)
if pt.detail:
draw.text((tx, ty + 12), pt.detail[:30], fill=_hex_to_rgb(_LABEL_DIM), font=detail_font)
return img
def resize_map_display(pil_img: Image.Image, width: int, height: int) -> Image.Image:
"""Resize rendered map for the GUI widget."""
return pil_img.resize((max(320, int(width)), max(120, int(height))), _RESAMPLE)

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

View File

@@ -0,0 +1,335 @@
"""DNS leak detection and cache flushing.
Two levels of testing:
1. check_dns_leak_hint() — fast, no network: inspect configured DNS resolvers
2. run_dns_leak_test() — real network test: compare DNS answers seen through
the proxy vs direct, and probe resolver identity via
a dedicated leak-test API.
"""
from __future__ import annotations
import concurrent.futures
import ipaddress
import logging
import socket
import subprocess
import sys
from dataclasses import dataclass, field
import httpx
log = logging.getLogger(__name__)
# ─── DoH endpoint used to ask "what is my apparent source IP?" ──────────────
_DOH_IP_CHECK = "https://api.ipify.org?format=json"
_DNS_LEAK_API = "https://dnsleaktest.com/api/v1/start"
_DNS_LEAK_RESULT = "https://dnsleaktest.com/api/v1/results"
# Well-known public resolver IP ranges (prefix → label)
_PUBLIC_DNS_LABELS: dict[str, str] = {
"8.8.8.8": "Google", "8.8.4.4": "Google",
"1.1.1.1": "Cloudflare", "1.0.0.1": "Cloudflare",
"9.9.9.9": "Quad9", "149.112.112.112": "Quad9",
"208.67.222.222": "OpenDNS", "208.67.220.220": "OpenDNS",
"76.76.2.0": "Alternate DNS",
"94.140.14.14": "AdGuard",
}
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class DnsLeakResult:
"""Legacy single-check result kept for API compatibility."""
ok: bool
system_resolvers: list[str]
message: str
doh_ip: str | None = None
@dataclass
class DnsResolverInfo:
ip: str
hostname: str = ""
country: str = ""
isp: str = ""
label: str = "" # "Google", "Cloudflare", or ""
is_public_known: bool = False
@dataclass
class FullDnsLeakReport:
"""Comprehensive DNS leak report from run_dns_leak_test()."""
# Resolvers that answered DNS queries in this session
resolvers_seen: list[DnsResolverInfo] = field(default_factory=list)
# Resolvers configured by the OS
system_resolvers: list[str] = field(default_factory=list)
# IPs resolved for test hostnames — via proxy vs direct
proxy_resolution: dict[str, list[str]] = field(default_factory=dict)
direct_resolution: dict[str, list[str]] = field(default_factory=dict)
# True when proxy and direct give the same answers (possible leak)
resolution_matches_direct: bool = False
# Whether any configured resolver is a known public server
has_public_resolver: bool = False
# Whether any configured resolver is outside local network
has_external_resolver: bool = False
# Overall verdict
leaked: bool = False
summary: str = ""
errors: list[str] = field(default_factory=list)
# ─────────────────────────────────────────────────────────────────────────────
# System resolver detection
# ─────────────────────────────────────────────────────────────────────────────
def get_system_dns_servers() -> list[str]:
"""Return configured IPv4 DNS resolvers via PowerShell (falls back to ipconfig)."""
if sys.platform == "darwin":
try:
r = subprocess.run(["scutil", "--dns"], capture_output=True, text=True, timeout=10)
servers: list[str] = []
for line in (r.stdout or "").splitlines():
line = line.strip()
if line.startswith("nameserver["):
ip = line.split(":", 1)[-1].strip()
if ip and ip not in servers:
servers.append(ip)
return servers
except Exception:
return []
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command",
"(Get-DnsClientServerAddress -AddressFamily IPv4 | "
"Where-Object { $_.ServerAddresses } | "
"Select-Object -ExpandProperty ServerAddresses) -join ','"],
capture_output=True, text=True, timeout=12,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
raw = (r.stdout or "").strip()
if raw:
return [x.strip() for x in raw.replace(";", ",").split(",") if x.strip()]
except Exception as exc:
log.debug("PowerShell DNS server query failed (will try ipconfig): %s", exc)
# ipconfig fallback
try:
r = subprocess.run(
["ipconfig", "/all"], capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
servers: list[str] = []
for line in (r.stdout or "").splitlines():
if "DNS Servers" in line or "DNS Server" in line:
parts = line.split(":", 1)
if len(parts) == 2:
ip = parts[1].strip()
if ip:
servers.append(ip)
return servers
except Exception:
return []
def flush_dns_cache() -> tuple[bool, str]:
if sys.platform == "darwin":
try:
subprocess.run(["dscacheutil", "-flushcache"], capture_output=True, timeout=15)
subprocess.run(["killall", "-HUP", "mDNSResponder"], capture_output=True, timeout=15)
return True, "macOS DNS cache flushed"
except Exception as e:
return False, str(e)
try:
r = subprocess.run(
["ipconfig", "/flushdns"], capture_output=True, text=True, timeout=15,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
lines = (r.stdout or r.stderr or "").strip().splitlines()
msg = lines[-1] if lines else "flushed"
return r.returncode == 0, msg
except Exception as e:
return False, str(e)
# ─────────────────────────────────────────────────────────────────────────────
# Hostname resolution helpers
# ─────────────────────────────────────────────────────────────────────────────
def _resolve_direct(hostname: str, timeout: float = 4.0) -> list[str]:
"""Resolve hostname using system DNS (direct, not through proxy)."""
_prev = socket.getdefaulttimeout()
try:
socket.setdefaulttimeout(timeout)
infos = socket.getaddrinfo(hostname, None)
seen: list[str] = []
for info in infos:
addr = info[4][0]
if addr and addr not in seen:
seen.append(addr)
return seen
except Exception:
return []
finally:
socket.setdefaulttimeout(_prev)
def _resolve_via_proxy(hostname: str, proxy_url: str, timeout: float = 8.0) -> list[str]:
"""Resolve hostname by fetching a DNS-over-HTTPS endpoint through the proxy."""
# Use Google DoH JSON API through the proxy so DNS is resolved on the exit node
doh = f"https://dns.google/resolve?name={hostname}&type=A"
try:
with httpx.Client(proxy=proxy_url, timeout=timeout, verify=False, follow_redirects=True) as c:
r = c.get(doh)
if r.status_code == 200:
data = r.json()
return [ans["data"] for ans in (data.get("Answer") or [])
if ans.get("type") == 1]
except Exception as exc:
log.debug("DoH DNS leak query failed: %s", exc)
return []
def _is_private(ip: str) -> bool:
try:
return ipaddress.ip_address(ip).is_private
except Exception:
return False
# ─────────────────────────────────────────────────────────────────────────────
# Main leak test
# ─────────────────────────────────────────────────────────────────────────────
_LEAK_TEST_HOSTS = [
"google.com",
"cloudflare.com",
"github.com",
"amazon.com",
]
def run_dns_leak_test(
proxy_url: str | None = None,
timeout: float = 10.0,
) -> FullDnsLeakReport:
"""
Comprehensive DNS leak test:
- Collect system DNS resolver configuration
- Resolve test hostnames both directly (system DNS) and via proxy (DoH through chain)
- Compare answers: matching answers suggest DNS is NOT going through the proxy
- Flag known public resolvers that would bypass the chain
"""
rep = FullDnsLeakReport()
rep.system_resolvers = get_system_dns_servers()
_PRIVATE_PREFIXES = (
"127.", "10.", "192.168.", "172.16.", "172.17.", "172.18.",
"172.19.", "172.2", "172.30.", "172.31.", "169.254.",
)
for r_ip in rep.system_resolvers:
is_public_known = r_ip in _PUBLIC_DNS_LABELS
is_external = not any(r_ip.startswith(p) for p in _PRIVATE_PREFIXES)
label = _PUBLIC_DNS_LABELS.get(r_ip, "")
rep.has_public_resolver = rep.has_public_resolver or is_public_known
rep.has_external_resolver = rep.has_external_resolver or is_external
rep.resolvers_seen.append(DnsResolverInfo(
ip=r_ip,
label=label,
is_public_known=is_public_known,
))
# Resolve test hosts in parallel
all_match = True
any_resolved_via_proxy = False
def _test_host(host: str) -> tuple[str, list[str], list[str]]:
direct = _resolve_direct(host, timeout=min(4.0, timeout))
proxy = _resolve_via_proxy(host, proxy_url, timeout=timeout) if proxy_url else []
return host, direct, proxy
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool:
futs = {pool.submit(_test_host, h): h for h in _LEAK_TEST_HOSTS}
for fut in concurrent.futures.as_completed(futs, timeout=timeout + 2):
try:
host, direct, proxy = fut.result()
rep.direct_resolution[host] = direct
rep.proxy_resolution[host] = proxy
if proxy:
any_resolved_via_proxy = True
# Overlap: if proxy and direct gave the same IPs, DNS
# might not be going through the proxy (same resolver path)
direct_set = set(direct)
proxy_set = set(proxy)
if direct_set and proxy_set and direct_set == proxy_set:
pass # this host matches
else:
all_match = False
except Exception as e:
rep.errors.append(str(e))
except Exception as e:
rep.errors.append(f"Thread pool error: {e}")
rep.resolution_matches_direct = all_match and any_resolved_via_proxy
# ── Build verdict ──────────────────────────────────────────────────────
problems: list[str] = []
if rep.has_external_resolver:
external = [r for r in rep.system_resolvers
if not any(r.startswith(p) for p in _PRIVATE_PREFIXES)]
names = [f"{ip} ({_PUBLIC_DNS_LABELS[ip]})" if ip in _PUBLIC_DNS_LABELS else ip
for ip in external[:4]]
problems.append(f"OS DNS: {', '.join(names)} — DNS may bypass proxy chain")
if rep.resolution_matches_direct and proxy_url:
problems.append("Proxy DNS resolution matches direct — possible DNS leak (same upstream resolver)")
if problems:
rep.leaked = True
rep.summary = " · ".join(problems)
else:
rep.leaked = False
if proxy_url:
rep.summary = "DNS routing looks clean — proxy resolves differently from direct."
else:
resolver_str = ", ".join(rep.system_resolvers[:3]) if rep.system_resolvers else "none detected"
rep.summary = f"System resolvers: {resolver_str}"
return rep
# ─────────────────────────────────────────────────────────────────────────────
# Legacy quick hint (used by Privacy tab DNS section)
# ─────────────────────────────────────────────────────────────────────────────
def check_dns_leak_hint(local_proxy: str | None = None) -> DnsLeakResult:
"""Fast heuristic: inspect configured DNS resolvers, flag public ones."""
resolvers = get_system_dns_servers()
private_prefixes = (
"127.", "10.", "192.168.", "172.16.", "172.17.", "172.18.",
"172.19.", "172.2", "172.30.", "172.31.",
)
public = [r for r in resolvers if not any(r.startswith(p) for p in private_prefixes)]
if not resolvers:
return DnsLeakResult(ok=True, system_resolvers=[], message="No IPv4 DNS servers reported (DHCP).")
if public and local_proxy:
return DnsLeakResult(
ok=False, system_resolvers=resolvers,
message=(
f"DNS may bypass proxy chain: public resolvers {', '.join(public)}. "
"Use kill-switch + VPN, or set DNS to localhost when hardened."
),
)
if public:
return DnsLeakResult(
ok=False, system_resolvers=resolvers,
message=f"Public DNS resolvers active: {', '.join(public)}",
)
return DnsLeakResult(
ok=True, system_resolvers=resolvers,
message=f"DNS servers: {', '.join(resolvers)}",
)

View File

@@ -0,0 +1,226 @@
"""Exit IP intelligence: geo, ASN, datacenter heuristics.
Designed to be called through the local chain proxy so the lookup itself
flows over the active path (no out-of-band leak). Uses ip-api.com (free,
no key) with a fallback to ipwho.is. Both return geo + ASN.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Any
import httpx
log = logging.getLogger(__name__)
# Common datacenter / hosting ASN keywords. Used as a quick heuristic to
# warn the operator before signup (residential ASNs are far less likely to
# trip Google/Proton anti-fraud than DC ranges).
_DC_KEYWORDS = (
"amazon", "aws", "google", "microsoft", "azure", "digitalocean",
"linode", "ovh", "hetzner", "vultr", "choopa", "scaleway",
"leaseweb", "contabo", "hostinger", "godaddy", "namecheap",
"cloudflare", "fastly", "akamai", "datacamp", "m247",
"psychz", "quadranet", "colocrossing", "rackspace",
"alibaba", "tencent", "huawei", "online s.a.s",
"wholesale", "datacenter", "data center", "hosting",
"server", "cloud", "vps",
)
@dataclass
class ExitIntel:
ok: bool
ip: str = ""
country: str = ""
country_code: str = ""
city: str = ""
region: str = ""
timezone: str = ""
lat: float | None = None
lon: float | None = None
asn: str = ""
org: str = ""
isp: str = ""
is_datacenter: bool = False
is_mobile: bool = False
is_proxy_flagged: bool = False
source: str = ""
detail: str = ""
def summary(self) -> str:
if not self.ok:
return f"unknown — {self.detail}"
loc_bits = [b for b in (self.city, self.region, self.country) if b]
loc = ", ".join(loc_bits) if loc_bits else ""
tags: list[str] = []
if self.is_datacenter:
tags.append("DATACENTER")
if self.is_mobile:
tags.append("MOBILE")
if self.is_proxy_flagged:
tags.append("PROXY-FLAGGED")
tag_str = f" [{' · '.join(tags)}]" if tags else ""
asn_str = f" AS{self.asn}" if self.asn else ""
return f"{self.ip} {loc}{asn_str}{tag_str}"
def _looks_dc(org: str, isp: str) -> bool:
haystack = f"{org} {isp}".lower()
return any(k in haystack for k in _DC_KEYWORDS)
def _parse_ipapi(payload: dict[str, Any]) -> ExitIntel:
if (payload.get("status") or "").lower() != "success":
return ExitIntel(ok=False, detail=str(payload.get("message") or "ip-api error"))
asn_raw = str(payload.get("as") or "")
asn = asn_raw.split()[0].lstrip("AS").strip() if asn_raw else ""
org = str(payload.get("org") or payload.get("isp") or "")
isp = str(payload.get("isp") or "")
lat_raw, lon_raw = payload.get("lat"), payload.get("lon")
return ExitIntel(
ok=True,
ip=str(payload.get("query") or ""),
country=str(payload.get("country") or ""),
country_code=str(payload.get("countryCode") or ""),
city=str(payload.get("city") or ""),
region=str(payload.get("regionName") or ""),
timezone=str(payload.get("timezone") or ""),
lat=float(lat_raw) if lat_raw is not None else None,
lon=float(lon_raw) if lon_raw is not None else None,
asn=asn,
org=org,
isp=isp,
is_datacenter=bool(payload.get("hosting")) or _looks_dc(org, isp),
is_mobile=bool(payload.get("mobile")),
is_proxy_flagged=bool(payload.get("proxy")),
source="ip-api.com",
detail="ok",
)
def _parse_ipwhois(payload: dict[str, Any]) -> ExitIntel:
if not payload.get("success", True):
return ExitIntel(ok=False, detail=str(payload.get("message") or "ipwho.is error"))
conn = payload.get("connection") or {}
asn_val = conn.get("asn")
asn = str(asn_val) if asn_val is not None else ""
org = str(conn.get("org") or "")
isp = str(conn.get("isp") or "")
lat_raw, lon_raw = payload.get("latitude"), payload.get("longitude")
return ExitIntel(
ok=True,
ip=str(payload.get("ip") or ""),
country=str(payload.get("country") or ""),
country_code=str(payload.get("country_code") or ""),
city=str(payload.get("city") or ""),
region=str(payload.get("region") or ""),
timezone=str((payload.get("timezone") or {}).get("id") or ""),
lat=float(lat_raw) if lat_raw is not None else None,
lon=float(lon_raw) if lon_raw is not None else None,
asn=asn,
org=org,
isp=isp,
is_datacenter=_looks_dc(org, isp),
is_mobile=False,
is_proxy_flagged=False,
source="ipwho.is",
detail="ok",
)
_IPAPI_FIELDS = (
"status,message,country,countryCode,regionName,city,lat,lon,timezone,"
"isp,org,as,mobile,proxy,hosting,query"
)
def _fetch_intel(
*,
proxy_url: str | None,
timeout_seconds: float,
) -> ExitIntel:
"""Shared ip-api / ipwho.is lookup. ``proxy_url=None`` uses a direct connection."""
timeout = httpx.Timeout(timeout_seconds, connect=min(6.0, timeout_seconds))
sources = [
(f"http://ip-api.com/json/?fields={_IPAPI_FIELDS}", _parse_ipapi),
("https://ipwho.is/", _parse_ipwhois),
]
last_err = ""
try:
with httpx.Client(
proxy=proxy_url,
timeout=timeout,
verify=False,
follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0"},
) as c:
for url, parser in sources:
try:
r = c.get(url)
if r.status_code != 200 or not r.content:
last_err = f"{url} -> HTTP {r.status_code}"
continue
data = r.json()
out = parser(data)
if out.ok:
return out
last_err = out.detail
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
continue
except Exception as e:
return ExitIntel(ok=False, detail=f"{type(e).__name__}: {e}")
return ExitIntel(ok=False, detail=last_err or "no source responded")
def fetch_exit_intel(proxy_url: str, timeout_seconds: float = 12.0) -> ExitIntel:
"""Look up exit IP geo/ASN/datacenter through the given proxy.
Returns an ExitIntel with ok=False and detail set on failure. Never raises.
"""
return _fetch_intel(proxy_url=proxy_url, timeout_seconds=timeout_seconds)
def fetch_ip_geo(ip: str, timeout_seconds: float = 8.0) -> ExitIntel:
"""Direct geo lookup for a specific IP (used for hop / origin mapping).
Never raises.
"""
target = (ip or "").strip()
if not target:
return ExitIntel(ok=False, detail="empty ip")
timeout = httpx.Timeout(timeout_seconds, connect=min(5.0, timeout_seconds))
sources = [
(
f"http://ip-api.com/json/{target}?fields={_IPAPI_FIELDS}",
_parse_ipapi,
),
(f"https://ipwho.is/{target}", _parse_ipwhois),
]
last_err = ""
try:
with httpx.Client(
timeout=timeout,
verify=False,
follow_redirects=True,
headers={"User-Agent": "Mozilla/5.0"},
) as c:
for url, parser in sources:
try:
r = c.get(url)
if r.status_code != 200 or not r.content:
last_err = f"{url} -> HTTP {r.status_code}"
continue
data = r.json()
out = parser(data)
if out.ok:
return out
last_err = out.detail
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
continue
except Exception as e:
return ExitIntel(ok=False, detail=f"{type(e).__name__}: {e}")
return ExitIntel(ok=False, detail=last_err or "no source responded")

View File

@@ -0,0 +1,37 @@
from __future__ import annotations
import logging
from typing import Any
import httpx
log = logging.getLogger(__name__)
def fetch_proxy_json(url: str, timeout: float = 45.0) -> list[dict[str, Any]]:
log.debug("fetch_proxy_json: GET %s", (url[:100] + "") if len(url) > 100 else url)
with httpx.Client(timeout=timeout, follow_redirects=True) as c:
r = c.get(url)
r.raise_for_status()
data = r.json()
if not isinstance(data, list):
return []
return [x for x in data if isinstance(x, dict)]
def normalize_entries(rows: list[dict[str, Any]], prefer_elite: bool) -> list[str]:
out: list[str] = []
for row in rows:
if prefer_elite and str(row.get("anonymity", "")).lower() != "elite":
continue
p = row.get("proxy")
if isinstance(p, str) and "://" in p:
out.append(p.strip())
# de-dupe preserving order
seen: set[str] = set()
uniq: list[str] = []
for u in out:
if u not in seen:
seen.add(u)
uniq.append(u)
return uniq

View File

@@ -0,0 +1,416 @@
"""Device fingerprint audit and OS-level hardening helpers."""
from __future__ import annotations
import logging
import platform
import random
import re
import string
import subprocess
import winreg
from dataclasses import dataclass, field
from .firewall import is_admin
from .mac_spoof import list_nics
from .win_compat import probe as _win_probe
log = logging.getLogger(__name__)
_WEBRTC_CHROME = r"SOFTWARE\Policies\Google\Chrome"
_WEBRTC_EDGE = r"SOFTWARE\Policies\Microsoft\Edge"
# Legacy DWORD key — value 3 = disable_non_proxied_udp (value 2 was wrong: public+private only)
_WEBRTC_VALUE = "DefaultWebRtcIpHandlingPolicy"
_WEBRTC_DISABLE = 3 # disable_non_proxied_udp (correct Chrome/Edge DWORD)
# Modern REG_SZ key required by Chrome 114+ Group Policy
_WEBRTC_VALUE_STR = "WebRtcIPHandling"
_WEBRTC_DISABLE_STR = "disable_non_proxied_udp"
@dataclass
class FingerprintAudit:
lines: list[str] = field(default_factory=list)
hostname: str = ""
machine_guid: str = ""
username: str = ""
macs: list[str] = field(default_factory=list)
def _run_ps(script: str, timeout: float = 15.0) -> str:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command", script],
capture_output=True,
text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return (r.stdout or "").strip()
except Exception as e:
log.debug("fingerprint ps: %s", e)
return ""
def get_computer_name() -> str:
try:
import os
return os.environ.get("COMPUTERNAME", "") or platform.node() or ""
except Exception:
return ""
def get_machine_guid() -> str:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Cryptography",
) as key:
val, _ = winreg.QueryValueEx(key, "MachineGuid")
return str(val)
except OSError:
return ""
def random_hostname(prefix: str = "PC") -> str:
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=7))
return f"{prefix}-{suffix}"[:15]
def set_computer_name(name: str) -> tuple[bool, str]:
"""Set NetBIOS / computer name (Admin). Reboot may be required for all apps."""
if not is_admin():
return False, "Administrator required to change computer name."
name = re.sub(r"[^A-Za-z0-9\-]", "", name)[:15]
if len(name) < 1:
return False, "Invalid hostname."
code = subprocess.run(
["powershell", "-NoProfile", "-Command",
f'Rename-Computer -NewName "{name}" -Force -ErrorAction Stop'],
capture_output=True,
text=True,
timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
).returncode
if code != 0:
# NetBIOS name via WMI (often works without full rename)
wmi = (
f'$n = Get-WmiObject Win32_ComputerSystem; '
f'$r = $n.Rename("{name}"); if ($r.ReturnValue -ne 0) {{ exit $r.ReturnValue }}'
)
code = subprocess.run(
["powershell", "-NoProfile", "-Command", wmi],
capture_output=True,
text=True,
timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
).returncode
if code == 0:
return True, f"Computer name set to {name} (some apps need reconnect/reboot)."
return False, "Could not change computer name."
def disable_ipv6_on_adapters() -> tuple[list[str], list[str]]:
"""Disable IPv6 binding on up physical adapters. Returns (adapter names, log lines)."""
if not is_admin():
return [], ["IPv6 disable skipped (not Admin)."]
if not _win_probe().has_net_cmdlets:
return [], [
"IPv6 disable skipped — requires PowerShell 3.0+ "
"(Windows 8 / Server 2012+). Detected legacy PowerShell."
]
script = (
"Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | "
"ForEach-Object { $_.Name }"
)
names = [n.strip() for n in _run_ps(script).splitlines() if n.strip()]
logs: list[str] = []
changed: list[str] = []
skip = ("virtual", "vmware", "hyper-v", "loopback", "bluetooth")
for name in names:
if any(h in name.lower() for h in skip):
continue
cmd = (
f'Disable-NetAdapterBinding -Name "{name}" -ComponentID ms_tcpip6 -Confirm:$false '
f'-ErrorAction SilentlyContinue'
)
subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
changed.append(name)
logs.append(f"IPv6 disabled on {name}")
return changed, logs
def enable_ipv6_on_adapters(adapters: list[str]) -> list[str]:
if not is_admin() or not adapters:
return []
if not _win_probe().has_net_cmdlets:
return []
logs: list[str] = []
for name in adapters:
cmd = (
f'Enable-NetAdapterBinding -Name "{name}" -ComponentID ms_tcpip6 -Confirm:$false '
f'-ErrorAction SilentlyContinue'
)
subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
timeout=20,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
logs.append(f"IPv6 re-enabled on {name}")
return logs
def apply_webrtc_hardening(enable: bool) -> tuple[bool, str]:
"""Chrome/Edge: disable WebRTC non-proxied UDP (Admin, HKLM policies).
Writes both the legacy DWORD key (DefaultWebRtcIpHandlingPolicy=3) and
the modern REG_SZ key (WebRtcIPHandling=disable_non_proxied_udp) so that
all Chrome/Edge versions are covered.
"""
if not is_admin():
return False, "Administrator required for browser WebRTC policy."
paths = [_WEBRTC_CHROME, _WEBRTC_EDGE]
try:
for path in paths:
if enable:
try:
key = winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
)
except OSError:
continue
with key:
winreg.SetValueEx(key, _WEBRTC_VALUE, 0, winreg.REG_DWORD, _WEBRTC_DISABLE)
winreg.SetValueEx(key, _WEBRTC_VALUE_STR, 0, winreg.REG_SZ, _WEBRTC_DISABLE_STR)
else:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
) as key:
for val_name in (_WEBRTC_VALUE, _WEBRTC_VALUE_STR):
try:
winreg.DeleteValue(key, val_name)
except OSError:
pass
except OSError:
pass
return True, (
"WebRTC hardened (Chrome/Edge: non-proxied UDP disabled)."
if enable
else "WebRTC policy removed."
)
except OSError as e:
return False, str(e)
def _get_os_info() -> str:
try:
return f"{platform.system()} {platform.release()} {platform.machine()}"
except Exception:
return "unknown"
def _get_timezone() -> str:
try:
import datetime
tz = datetime.datetime.now(datetime.timezone.utc).astimezone()
name = str(tz.tzname() or "")
offset = tz.utcoffset()
h = int(offset.total_seconds() // 3600) if offset else 0
return f"{name} (UTC{h:+d})" if name else f"UTC{h:+d}"
except Exception:
return "unknown"
def _get_screen_resolution() -> str:
if platform.system() == "Darwin":
try:
r = subprocess.run(
["system_profiler", "SPDisplaysDataType"],
capture_output=True,
text=True,
timeout=8,
)
for line in (r.stdout or "").splitlines():
if "Resolution:" in line:
return line.split("Resolution:", 1)[1].strip()
except Exception:
return "unknown"
try:
r = subprocess.run(
["powershell", "-NoProfile", "-Command",
"Add-Type -AssemblyName System.Windows.Forms;"
"[System.Windows.Forms.Screen]::PrimaryScreen.Bounds | "
"ForEach-Object { \"$($_.Width)x$($_.Height)\" }"],
capture_output=True, text=True, timeout=8,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return (r.stdout or "").strip() or "unknown"
except Exception:
return "unknown"
def check_browser_fingerprint_consistency(profile_dir: "Path") -> list[str]:
"""
Verify that the managed Firefox user.js is internally consistent and
doesn't mix prefs that would expose the browser as fingerprint-hardened
while still advertising a normal user-agent.
Returns a list of warning strings (empty = consistent).
"""
from pathlib import Path
user_js = Path(profile_dir) / "user.js"
if not user_js.is_file():
return ["user.js not found — profile not yet initialized"]
try:
content = user_js.read_text(encoding="utf-8", errors="replace")
except Exception as e:
return [f"Cannot read user.js: {e}"]
warnings: list[str] = []
def _has(pref: str, value: str) -> bool:
return f'"{pref}", {value}' in content
# 1. RFP + custom UA is a contradiction (RFP overrides UA)
if _has("privacy.resistFingerprinting", "true") and 'general.useragent.override' in content:
warnings.append(
"RFP + UA override conflict: privacy.resistFingerprinting=true overrides "
"general.useragent.override — the spoofed UA is ignored"
)
# 2. proxy not set but force_proxy claimed
if _has("network.proxy.type", "1"):
if 'network.proxy.http",' not in content:
warnings.append("proxy.type=1 but network.proxy.http is missing — browsers will error")
# 3. FPI on + third-party cookies allowed = inconsistent privacy posture
if _has("privacy.firstparty.isolate", "true") and _has("network.cookie.cookieBehavior", "0"):
warnings.append(
"FPI enabled but cookieBehavior=0 (accept all) — cookies are isolated but not blocked"
)
# 4. sanitizeOnShutdown without clearing history = incomplete wipe
if _has("privacy.sanitize.sanitizeOnShutdown", "true"):
if not _has("privacy.clearOnShutdown.history", "true"):
warnings.append(
"sanitizeOnShutdown=true but clearOnShutdown.history not set — "
"history may survive session"
)
# 5. WebRTC peerconnection disabled is good — flag if missing
if not _has("media.peerconnection.enabled", "false"):
warnings.append("WebRTC not disabled in profile — real IP can leak via STUN")
# 6. network.trr.mode=5 (DoH off) is expected when using proxy DNS
if not _has("network.trr.mode", "5"):
warnings.append(
"network.trr.mode is not 5 — Firefox may use its own DoH resolver, "
"bypassing the proxy chain DNS path"
)
return warnings
def audit_device() -> FingerprintAudit:
"""Collect OS-level identifiers and consistency notes."""
import getpass
import os
from pathlib import Path
host = get_computer_name()
guid = get_machine_guid()
user = getpass.getuser()
nics = list_nics()
macs = [f"{n.name}: {n.mac}" for n in nics]
os_ = _get_os_info()
tz_ = _get_timezone()
res_ = _get_screen_resolution()
lines: list[str] = [
"── OS / Identity ──────────────────────────────────",
f" OS : {os_}",
f" Computer name: {host or ''}",
f" Username : {user}",
f" MachineGuid : " + (f"{guid[:8]}{guid[-4:]}" if len(guid) > 12 else guid or ""),
f" Timezone : {tz_}",
f" Screen res : {res_}",
"",
"── Network adapters ───────────────────────────────",
]
lines.extend([f"{m}" for m in macs[:10]] or [" • (none detected)"])
if len(macs) > 10:
lines.append(f" … and {len(macs) - 10} more")
lines += [
"",
"── Browser fingerprint note ───────────────────────",
" Canvas, WebGL, font metrics, audio context, and screen dimensions",
" are NOT changed at the OS level by this app.",
" → Use 'blend_windows_chrome' persona OR 'hardened' mode to control",
" what the browser reports to sites.",
" → RFP (Resist Fingerprinting) makes Firefox stand out as hardened.",
" → Blend persona spoofs UA/platform to look like a normal Windows Chrome.",
"",
"── Recommendations ────────────────────────────────",
]
# Quick consistency checks — WebRTC IP-handling policy (Chrome / Edge)
# Accept either: DWORD DefaultWebRtcIpHandlingPolicy==3
# or REG_SZ WebRtcIPHandling=="disable_non_proxied_udp"
webrtc_ok = False
for hive_root in (_WEBRTC_CHROME, _WEBRTC_EDGE):
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, hive_root, 0, winreg.KEY_QUERY_VALUE
) as k:
try:
v = int(winreg.QueryValueEx(k, _WEBRTC_VALUE)[0])
if v == _WEBRTC_DISABLE:
webrtc_ok = True
break
except OSError:
pass
try:
v_str = str(winreg.QueryValueEx(k, _WEBRTC_VALUE_STR)[0])
if v_str == _WEBRTC_DISABLE_STR:
webrtc_ok = True
break
except OSError:
pass
except OSError:
continue
if webrtc_ok:
lines.append(" ✓ Chrome/Edge WebRTC IP-handling policy is set (disable_non_proxied_udp)")
else:
lines.append(" ⚠ Chrome/Edge WebRTC policy not set — enable in Privacy tab")
# Check IPv6
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
"Get-NetAdapterBinding -ComponentID ms_tcpip6 | Where-Object { $_.Enabled } | Measure-Object | Select-Object -ExpandProperty Count"],
capture_output=True, text=True, timeout=8,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
count = int((r.stdout or "0").strip() or "0")
if count > 0:
lines.append(f" ⚠ IPv6 active on {count} adapter(s) — disable in Privacy tab for full v4-only chain")
else:
lines.append(" ✓ IPv6 disabled on all adapters")
except Exception:
lines.append(" ? IPv6 status unknown")
return FingerprintAudit(
lines=lines,
hostname=host,
machine_guid=guid,
username=user,
macs=[n.mac for n in nics],
)

View File

@@ -0,0 +1,191 @@
"""
Windows Firewall enforcement — kill-switch style.
When engaged:
- Default outbound policy → BLOCK (all profiles)
- Allow rules for: GOST, NordVPN stack, this app, loopback, DHCP
- Everything else is denied outbound — apps that don't go through the
local proxy simply can't reach the internet.
When disengaged:
- Remove our rules, restore default outbound → ALLOW
"""
from __future__ import annotations
import ctypes
import logging
import subprocess
import sys
from pathlib import Path
from .paths import gost_exe_path
from .vpn_detect import expand_vpn_executables
log = logging.getLogger(__name__)
RULE_PREFIX = "PCM_"
def is_admin() -> bool:
try:
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore[attr-defined]
except Exception:
return False
def request_admin_relaunch() -> bool:
"""Re-launch the current process elevated. Returns True if launched, False on error."""
if is_admin():
return False
try:
exe = sys.executable
args = " ".join(f'"{a}"' for a in sys.argv)
ret = ctypes.windll.shell32.ShellExecuteW( # type: ignore[attr-defined]
None, "runas", exe, args, None, 1
)
return ret > 32
except Exception:
return False
def _run(args: list[str], check: bool = False) -> subprocess.CompletedProcess[str]:
try:
return subprocess.run(
args, capture_output=True, text=True, timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except FileNotFoundError as exc:
return subprocess.CompletedProcess(args, 127, "", str(exc))
def _add_rule(name: str, **kw: str) -> bool:
full = RULE_PREFIX + name
args = ["netsh", "advfirewall", "firewall", "add", "rule", f"name={full}"]
for k, v in kw.items():
args.append(f"{k}={v}")
r = _run(args)
ok = r.returncode == 0
if not ok:
log.warning("Firewall rule %s failed: %s", full, (r.stdout or "") + (r.stderr or ""))
return ok
def _delete_rules() -> None:
r = _run(["netsh", "advfirewall", "firewall", "show", "rule", "name=all", "dir=out"])
lines = (r.stdout or "").splitlines()
names: set[str] = set()
for ln in lines:
if ln.startswith("Rule Name:"):
n = ln.split(":", 1)[1].strip()
if n.startswith(RULE_PREFIX):
names.add(n)
for n in names:
_run(["netsh", "advfirewall", "firewall", "delete", "rule", f"name={n}"])
log.info("Deleted %d PCM firewall rules", len(names))
def _set_outbound_policy(action: str) -> None:
"""action = 'blockinbound,blockoutbound' or 'blockinbound,allowoutbound'."""
for profile in ("domainprofile", "privateprofile", "publicprofile"):
_run(["netsh", "advfirewall", "set", profile, "firewallpolicy", action])
def _resolve_self_exe() -> Path:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve()
return Path(sys.executable).resolve()
def engage(gost_path: Path | None = None) -> tuple[bool, str]:
"""Activate kill-switch firewall. Returns (success, message)."""
if sys.platform != "win32":
return False, "Kill-switch is Windows-only; skipped on macOS."
if not is_admin():
return False, "Need admin privileges for firewall enforcement."
gost = gost_path or gost_exe_path()
self_exe = _resolve_self_exe()
vpn_exes = expand_vpn_executables()
_delete_rules()
# Allow loopback (apps → GOST on 127.0.0.1)
_add_rule("Loopback", dir="out", action="allow",
remoteip="127.0.0.0/8", protocol="any")
# Allow GOST outbound (it connects to the proxy hops)
_add_rule("GOST", dir="out", action="allow",
program=f'"{gost}"', protocol="any")
# Allow this application outbound (fetches proxy lists, validates)
_add_rule("Self", dir="out", action="allow",
program=f'"{self_exe}"', protocol="any")
# If running from python.exe, also allow pythonw.exe
if self_exe.name.lower() in ("python.exe", "pythonw.exe"):
for sibling in ("python.exe", "pythonw.exe"):
p = self_exe.with_name(sibling)
if p.is_file():
_add_rule(f"Python_{sibling}", dir="out", action="allow",
program=f'"{p}"', protocol="any")
# Allow VPN client executables (Nord, WireGuard, OpenVPN, etc.)
for i, vpath in enumerate(vpn_exes):
_add_rule(f"VPN_{i}", dir="out", action="allow",
program=f'"{vpath}"', protocol="any")
# Allow DHCP (or you lose your adapter)
_add_rule("DHCP", dir="out", action="allow",
protocol="udp", remoteport="67,68")
# Allow DNS (GOST needs to resolve proxy hostnames)
_add_rule("DNS_UDP", dir="out", action="allow",
protocol="udp", remoteport="53")
_add_rule("DNS_TCP", dir="out", action="allow",
protocol="tcp", remoteport="53")
# Set default outbound to BLOCK
_set_outbound_policy("blockinbound,blockoutbound")
log.info("Firewall kill-switch engaged. %d VPN exes whitelisted.", len(vpn_exes))
return True, f"Kill-switch ON. {len(vpn_exes)} VPN client(s) whitelisted."
def disengage() -> tuple[bool, str]:
"""Remove kill-switch, restore normal outbound."""
if sys.platform != "win32":
return True, "Kill-switch is Windows-only; nothing to remove on macOS."
if not is_admin():
return False, "Need admin to remove firewall rules."
_set_outbound_policy("blockinbound,allowoutbound")
_delete_rules()
log.info("Firewall kill-switch disengaged.")
return True, "Kill-switch OFF. Normal outbound restored."
def is_engaged() -> bool:
"""Quick check: are our rules present?"""
if sys.platform != "win32":
return False
r = _run(["netsh", "advfirewall", "firewall", "show", "rule", f"name={RULE_PREFIX}GOST", "dir=out"])
return RULE_PREFIX in (r.stdout or "")
def emergency_disengage() -> None:
"""Best-effort kill-switch removal for atexit/signal handlers.
Unlike ``disengage()``, this never raises and does not require prior
admin check — it simply tries, logs the outcome, and returns. Safe to
call from atexit or signal handlers where exceptions must not propagate.
No-op when our rules are not present (avoids touching firewall policy on
every process exit).
"""
if not is_engaged():
return
try:
_set_outbound_policy("blockinbound,allowoutbound")
_delete_rules()
log.info("emergency_disengage: firewall rules removed.")
except Exception as exc: # noqa: BLE001
log.warning("emergency_disengage failed: %s", exc)

View File

@@ -0,0 +1,289 @@
from __future__ import annotations
import hashlib
import io
import logging
import platform
import shutil
import subprocess
import sys
import tarfile
import zipfile
from pathlib import Path
from typing import Any
import httpx
from .paths import app_data_dir, gost_exe_path
log = logging.getLogger(__name__)
GOST_VERSION = "3.2.6"
GOST_RELEASE_ZIP = (
"https://github.com/go-gost/gost/releases/download/v3.2.6/"
"gost_3.2.6_windows_amd64.zip"
)
# Pinned SHA256 of the release zip. Update this constant when bumping GOST_RELEASE_ZIP.
# Verified 2026-05-21 against https://github.com/go-gost/gost/releases/download/v3.2.6/
GOST_RELEASE_ZIP_SHA256 = "32f4edf3d94b622e67f1979f6f5de82dac62abc0977772cf96215dd199ef7e7b"
# Pinned SHA256 of the extracted ``gost.exe`` binary inside the v3.2.6 zip.
# Recorded the first time the zip is unpacked and verified on every reuse, so
# a tampered or partially-overwritten exe on disk forces a clean re-download.
GOST_EXE_SHA256_FILE = "gost.sha256"
_DARWIN_ARCHIVE_SHA256 = {
"arm64": "e54f6c22e81c00650adfbbb23317c74a4dca9b9b73fa28cfa150f5559cc3ff2e",
"x86_64": "0892485bd94e37b67a1f1d0d2372ed12d7dc0f1bc763d56177a0c0ee734855e6",
"amd64": "0892485bd94e37b67a1f1d0d2372ed12d7dc0f1bc763d56177a0c0ee734855e6",
}
def _release_asset() -> tuple[str, str, str]:
"""Return (url, archive_sha256, binary_name) for this platform."""
if sys.platform == "darwin":
machine = platform.machine().lower()
asset_arch = "arm64" if machine == "arm64" else "amd64"
digest = _DARWIN_ARCHIVE_SHA256["arm64" if asset_arch == "arm64" else "amd64"]
name = f"gost_{GOST_VERSION}_darwin_{asset_arch}.tar.gz"
return f"https://github.com/go-gost/gost/releases/download/v{GOST_VERSION}/{name}", digest, "gost"
return GOST_RELEASE_ZIP, GOST_RELEASE_ZIP_SHA256, "gost.exe"
def _add_defender_exclusion(path: Path) -> None:
"""Add Windows Defender exclusion so GOST is not quarantined."""
if sys.platform != "win32":
return
try:
# ExclusionPath covers gost.exe under this folder; avoid invalid combined params on older builds.
folder = str(path.parent).replace("'", "''")
subprocess.run(
[
"powershell", "-NoProfile", "-NonInteractive", "-Command",
f"Add-MpPreference -ExclusionPath '{folder}' -ErrorAction SilentlyContinue",
],
capture_output=True,
timeout=30,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
log.info("Defender exclusion added for %s", path.parent)
except Exception as exc:
log.warning("Defender exclusion failed (non-fatal): %s", exc)
def _verify_zip_sha256(data: bytes, expected: str) -> None:
"""Raise RuntimeError if SHA256 of *data* does not match *expected* (hex)."""
actual = hashlib.sha256(data).hexdigest().lower()
if actual != expected.lower():
raise RuntimeError(
f"GOST zip SHA256 mismatch — possible supply-chain attack!\n"
f" expected: {expected.lower()}\n"
f" actual: {actual}\n"
"Delete the downloaded zip and retry, or update GOST_RELEASE_ZIP_SHA256."
)
log.info("GOST zip SHA256 OK: %s", actual)
def _hash_file(path: Path) -> str:
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(64 * 1024), b""):
h.update(chunk)
return h.hexdigest().lower()
def _read_pinned_exe_hash(exe: Path) -> str | None:
sidecar = exe.with_suffix(exe.suffix + ".sha256")
try:
return sidecar.read_text(encoding="utf-8").strip().lower() or None
except OSError:
return None
def _write_pinned_exe_hash(exe: Path, digest: str) -> None:
sidecar = exe.with_suffix(exe.suffix + ".sha256")
try:
sidecar.write_text(digest, encoding="utf-8")
except OSError as exc:
log.warning("Could not write gost.exe hash sidecar: %s", exc)
def _bundled_gost_paths() -> tuple[Path | None, Path | None]:
"""Return (exe_path, sha_path) inside the PyInstaller bundle, or (None, None)."""
base = getattr(sys, "_MEIPASS", None)
if not base:
# Source checkout — look next to the package
base = str(Path(__file__).resolve().parent.parent)
binary_name = "gost.exe" if sys.platform == "win32" else "gost"
bundle_exe = Path(base) / "proxy_chain_manager" / "_bundled" / binary_name
bundle_sha = bundle_exe.with_suffix(bundle_exe.suffix + ".sha256")
if bundle_exe.is_file():
return bundle_exe, (bundle_sha if bundle_sha.is_file() else None)
return None, None
def _install_from_bundle(exe: Path) -> bool:
"""Copy the bundled gost.exe into *exe* and pin its hash. Returns True
on success. Verifies the bundle hash if a sidecar shipped with it."""
src, sha_src = _bundled_gost_paths()
if not src:
return False
try:
exe.parent.mkdir(parents=True, exist_ok=True)
_add_defender_exclusion(exe)
shutil.copy2(src, exe)
_add_defender_exclusion(exe)
digest = _hash_file(exe)
if sha_src:
expected = sha_src.read_text(encoding="utf-8").strip().lower()
if expected and expected != digest:
exe.unlink(missing_ok=True)
log.warning(
"Bundled gost.exe SHA mismatch (bundle=%s actual=%s) — "
"falling back to network download.", expected, digest,
)
return False
_write_pinned_exe_hash(exe, digest)
log.info("GOST installed from bundle at %s", exe)
return True
except Exception as exc: # noqa: BLE001
log.warning("Bundle install failed: %s", exc)
return False
def ensure_gost(target: Path | None = None) -> Path:
exe = target or gost_exe_path()
if exe.is_file() and exe.stat().st_size > 10_000:
# Re-verify the on-disk binary against the sidecar pin so a tampered
# exe cannot persist across launches.
pinned = _read_pinned_exe_hash(exe)
if pinned:
actual = _hash_file(exe)
if actual == pinned:
return exe
log.warning(
"gost.exe SHA256 mismatch on reuse — re-installing.\n"
" pinned: %s\n actual: %s",
pinned, actual,
)
try:
exe.unlink()
except OSError as exc:
log.warning("Could not remove tampered gost.exe: %s", exc)
else:
# First reuse after older versions: record the current hash so the
# next run can verify. Mark as trusted-on-first-use only when zip
# SHA verification has already happened earlier in the session.
_write_pinned_exe_hash(exe, _hash_file(exe))
return exe
# Prefer the bundled binary so the first run works fully offline.
if _install_from_bundle(exe):
return exe
exe.parent.mkdir(parents=True, exist_ok=True)
# Add exclusion BEFORE downloading so Defender doesn't nuke it on write
_add_defender_exclusion(exe)
url, archive_sha, binary_name = _release_asset()
log.info("Downloading GOST %s", url)
with httpx.Client(timeout=120.0, follow_redirects=True) as c:
r = c.get(url)
r.raise_for_status()
data = r.content
_verify_zip_sha256(data, archive_sha)
if url.endswith(".zip"):
if len(data) < 64 or data[:2] != b"PK":
raise RuntimeError("Downloaded GOST zip looks invalid (not a zip).")
with zipfile.ZipFile(io.BytesIO(data), "r") as z:
names = [n for n in z.namelist() if n.lower().endswith(binary_name)]
if not names:
raise RuntimeError(f"{binary_name} not found in release zip")
with z.open(names[0]) as src, open(exe, "wb") as dst:
shutil.copyfileobj(src, dst)
else:
with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz") as tar:
members = [m for m in tar.getmembers() if Path(m.name).name == binary_name and m.isfile()]
if not members:
raise RuntimeError(f"{binary_name} not found in release tarball")
src = tar.extractfile(members[0])
if src is None:
raise RuntimeError(f"{binary_name} could not be extracted")
with src, open(exe, "wb") as dst:
shutil.copyfileobj(src, dst)
try:
exe.chmod(0o755)
except OSError:
pass
# Add exclusion again after write in case Defender scanned during extraction
_add_defender_exclusion(exe)
# Pin the freshly-extracted exe so subsequent launches can re-verify it.
_write_pinned_exe_hash(exe, _hash_file(exe))
log.info("GOST installed at %s", exe)
return exe
def build_gost_cmd(gost: Path, listen_http: str, forwards: list[str]) -> list[str]:
args = [str(gost), "-L", f"http://{listen_http}"]
for f in forwards:
args.extend(["-F", f])
return args
def gost_log_path() -> Path:
return app_data_dir() / "gost.log"
def popen_no_window(args: list[str]) -> subprocess.Popen:
# stderr → rolling log file (not a pipe — pipes deadlock if unread; files never block).
# stdout is also captured to the same file since GOST v3 mixes output channels.
cr = getattr(subprocess, "CREATE_NO_WINDOW", 0)
log_path = gost_log_path()
try:
_rotate_gost_log(log_path)
stderr_dest: Any = open(log_path, "ab") # noqa: WPS515 — intentional long-lived file handle
except OSError:
stderr_dest = subprocess.DEVNULL
return subprocess.Popen(
args,
stdin=subprocess.DEVNULL,
stdout=stderr_dest,
stderr=stderr_dest,
creationflags=cr,
)
def _rotate_gost_log(path: Path, max_bytes: int = 512 * 1024) -> None:
"""Keep gost.log under max_bytes by truncating the oldest half when it grows too large."""
try:
if path.is_file() and path.stat().st_size > max_bytes:
data = path.read_bytes()
path.write_bytes(data[len(data) // 2:])
except OSError:
pass
def read_gost_log_tail(lines: int = 40) -> str:
"""Return the last N lines of gost.log for display in the UI."""
try:
text = gost_log_path().read_text(encoding="utf-8", errors="replace")
return "\n".join(text.splitlines()[-lines:])
except OSError:
return "(gost.log not found)"
def terminate_process(proc: subprocess.Popen | None) -> None:
if proc is None:
return
if proc.poll() is not None:
return
try:
proc.terminate()
proc.wait(timeout=5)
except Exception as exc:
log.debug("GOST terminate failed (%s) — escalating to kill.", exc)
try:
proc.kill()
except Exception as kill_exc:
log.warning("GOST kill also failed: %s", kill_exc)

View File

@@ -0,0 +1,41 @@
"""Fast parallel proxy checks for the GUI (per-hop callbacks)."""
from __future__ import annotations
import asyncio
from typing import Callable
from .validator import _check_one
# High concurrency for interactive chain tests
GUI_VALIDATE_CONCURRENCY = 48
async def validate_each_parallel(
urls: list[str],
check_url: str,
concurrency: int,
timeout_seconds: float,
on_result: Callable[[str, bool], None],
) -> None:
if not urls:
return
sem = asyncio.Semaphore(max(1, concurrency))
async def one(u: str) -> None:
async with sem:
ok = await _check_one(u, check_url, timeout_seconds)
on_result(u, ok)
await asyncio.gather(*(one(u) for u in urls))
def run_validate_each_sync(
urls: list[str],
check_url: str,
timeout_seconds: float,
on_result: Callable[[str, bool], None],
concurrency: int = GUI_VALIDATE_CONCURRENCY,
) -> None:
asyncio.run(
validate_each_parallel(urls, check_url, concurrency, timeout_seconds, on_result)
)

View File

@@ -0,0 +1,313 @@
"""Consolidated live leak audit.
Used by the Privacy tab "Run audit" button. Probes every surface that can
deanonymize the host even when the chain is healthy:
• IP — exit IP through chain vs direct IP (subnet leak)
• DNS — system resolvers, whether they're public
• IPv6 binding — adapters with IPv6 active
• WPAD/PAC — leftover AutoConfigURL or AutoDetect
• Policy locks — Group Policy proxy keys that beat our settings
• PerUser flag — Windows Server ProxySettingsPerUser
• LAN broadcast — LLMNR / NetBIOS / mDNS status
• VPN — adapter presence
• WebRTC — Chrome/Edge policy presence
• System proxy — what registry says we're set to
• TLS exit — quick categorisation hint (datacenter / residential)
Each check has its own short timeout so a slow probe never blocks the rest.
"""
from __future__ import annotations
import asyncio
import logging
import subprocess
import winreg
from dataclasses import dataclass, field
from .dns_leak import get_system_dns_servers
from .firewall import is_admin
from .privacy_lan import lan_status
from .sysproxy import detect_policy_overrides, is_system_proxy_set
from .validator import check_chain_exit_ip, get_direct_ip
from .vpn_detect import detect_vpn
log = logging.getLogger(__name__)
@dataclass
class AuditFinding:
name: str
ok: bool
value: str
note: str = ""
@dataclass
class AuditReport:
findings: list[AuditFinding] = field(default_factory=list)
overall_ok: bool = True
def add(self, name: str, ok: bool, value: str, note: str = "") -> None:
self.findings.append(AuditFinding(name, ok, value, note))
if not ok:
self.overall_ok = False
def _check_ipv6_active() -> tuple[bool, str]:
"""True/'string' if any 'Up' adapter has IPv6 binding enabled.
PowerShell 3.0+ path; falls back to ipconfig parsing (locale-tolerant
enough — looks for hex colons).
"""
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
"Get-NetAdapterBinding -ComponentID ms_tcpip6 | "
"Where-Object { $_.Enabled } | "
"Select-Object -ExpandProperty Name"],
capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
names = [n.strip() for n in (r.stdout or "").splitlines() if n.strip()]
if names:
return True, ", ".join(names[:3]) + (f" +{len(names)-3}" if len(names) > 3 else "")
# Empty stdout could mean PS3 cmdlet missing — try ipconfig fallback.
if r.returncode != 0:
raise RuntimeError("PS3 net cmdlets unavailable")
return False, "no adapters bound to IPv6"
except Exception:
try:
r = subprocess.run(
["ipconfig"], capture_output=True, text=True, timeout=8,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
has_v6 = any(
"IPv6" in ln and ":" in ln.split(":", 1)[1]
for ln in (r.stdout or "").splitlines()
)
return has_v6, ("IPv6 address present" if has_v6 else "no IPv6 address")
except Exception as e:
return False, f"unknown ({e})"
def _check_wpad() -> tuple[bool, str]:
path = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
autoconf = ""
autodet = 0
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, path, 0, winreg.KEY_QUERY_VALUE
) as key:
try:
autoconf = str(winreg.QueryValueEx(key, "AutoConfigURL")[0] or "")
except OSError:
pass
try:
autodet = int(winreg.QueryValueEx(key, "AutoDetect")[0])
except OSError:
autodet = 0
except OSError:
pass
leaking = bool(autoconf) or autodet == 1
if leaking:
bits = []
if autoconf:
bits.append(f"AutoConfigURL={autoconf}")
if autodet:
bits.append(f"AutoDetect={autodet}")
return False, ", ".join(bits)
return True, "no WPAD/PAC override"
def _check_per_user_flag() -> tuple[bool, str]:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings",
0, winreg.KEY_QUERY_VALUE,
) as key:
v = int(winreg.QueryValueEx(key, "ProxySettingsPerUser")[0])
if v == 0:
return False, "ProxySettingsPerUser=0 (HKCU IGNORED)"
return True, "ProxySettingsPerUser=1"
except OSError:
return True, "unset (default → HKCU honored)"
def _check_webrtc_policy() -> tuple[bool, str]:
"""OK means the policy IS set (browsers won't leak non-proxied UDP)."""
paths = (
r"SOFTWARE\Policies\Google\Chrome",
r"SOFTWARE\Policies\Microsoft\Edge",
)
_DWORD_DISABLE = 3 # disable_non_proxied_udp
_STR_KEY = "WebRtcIPHandling"
_STR_DISABLE = "disable_non_proxied_udp"
found = []
for p in paths:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, p, 0, winreg.KEY_QUERY_VALUE
) as key:
ok = False
try:
v = int(winreg.QueryValueEx(key, "DefaultWebRtcIpHandlingPolicy")[0])
if v == _DWORD_DISABLE:
ok = True
except OSError:
pass
try:
v_str = str(winreg.QueryValueEx(key, _STR_KEY)[0]).strip().lower()
if v_str == _STR_DISABLE:
ok = True
except OSError:
pass
if ok:
found.append(p.split("\\")[-1])
except OSError:
continue
if found:
return True, "policy set: " + ", ".join(found)
return False, "no WebRTC policy — Chrome/Edge may leak local IPs"
def _categorize_exit(ip: str | None) -> str:
"""Cheap categorization hint based on common RIR / ASN heuristics.
No external lookup — just shape-based hints. Real classification can be
added later via offline GeoIP DBs.
"""
if not ip:
return "unknown"
try:
a = int(ip.split(".")[0])
except Exception:
return "non-IPv4"
if a in (10,) or ip.startswith(("192.168.", "172.16.", "172.17.", "172.18.",
"172.19.", "172.2", "172.30.", "172.31.")):
return "PRIVATE — chain broken"
if a == 127:
return "LOOPBACK — chain broken"
return "public"
async def run_audit(
listen_proxy: str,
ip_check_url: str,
timeout_seconds: float = 10.0,
) -> AuditReport:
"""Run every check in parallel where safe; return structured report."""
rep = AuditReport()
chain_running = is_system_proxy_set()
# Network probes in parallel
direct_task = asyncio.create_task(get_direct_ip(ip_check_url, timeout_seconds))
chain_task = (
asyncio.create_task(
check_chain_exit_ip(listen_proxy, ip_check_url, timeout_seconds, chain_hops=1)
)
if chain_running else None
)
direct_ip = await direct_task
exit_ip = await chain_task if chain_task else None
if direct_ip:
rep.add("Direct IP (no chain)", True, direct_ip, "")
else:
rep.add("Direct IP (no chain)", False, "unreachable", "no internet?")
if chain_running:
if exit_ip:
cat = _categorize_exit(exit_ip)
same = bool(direct_ip and exit_ip == direct_ip)
rep.add(
"Chain exit IP",
not same and cat == "public",
f"{exit_ip} ({cat})",
"matches direct IP — chain not forwarding" if same else "",
)
else:
rep.add("Chain exit IP", False, "unreachable through chain", "")
else:
rep.add("Chain exit IP", True, "chain not running", "skipped")
# DNS resolvers
dns_servers = get_system_dns_servers()
private_prefixes = ("127.", "10.", "192.168.", "172.16.", "172.17.", "172.18.",
"172.19.", "172.2", "172.30.", "172.31.")
public_dns = [d for d in dns_servers if not d.startswith(private_prefixes)]
if not dns_servers:
rep.add("DNS resolvers", True, "none reported (DHCP)", "")
elif public_dns:
rep.add(
"DNS resolvers",
False,
", ".join(dns_servers),
f"{len(public_dns)} public — DNS may bypass chain",
)
else:
rep.add("DNS resolvers", True, ", ".join(dns_servers), "all private/local")
# IPv6 binding
v6_on, v6_msg = _check_ipv6_active()
rep.add("IPv6 binding", not v6_on, v6_msg, "IPv6 active = potential leak past v4 proxies" if v6_on else "")
# WPAD
wpad_ok, wpad_msg = _check_wpad()
rep.add("WPAD / PAC", wpad_ok, wpad_msg)
# Per-User flag
pu_ok, pu_msg = _check_per_user_flag()
rep.add("ProxySettingsPerUser", pu_ok, pu_msg,
"needs Admin fix on this box" if not pu_ok else "")
# Policy locks
pol = detect_policy_overrides()
if pol:
rep.add("Group Policy proxy locks", False, f"{len(pol)} entries",
"policy keys override our proxy")
else:
rep.add("Group Policy proxy locks", True, "none")
# LAN-scope broadcasts
lan = lan_status()
lan_clean = "OFF" in lan["llmnr"] and "OFF" in lan["mdns"] and "NICs with NetBIOS disabled" in lan["netbios"]
rep.add(
"LAN broadcast (LLMNR/NetBIOS/mDNS)",
lan_clean,
f"LLMNR={lan['llmnr']} NetBIOS={lan['netbios']} mDNS={lan['mdns']}",
"hostname leaks to local network" if not lan_clean else "",
)
# VPN
vpn = detect_vpn()
rep.add(
"VPN adapter",
True,
f"{vpn.label}" + (f" ({vpn.adapter})" if vpn.adapter else ""),
"informational",
)
# WebRTC policy
rtc_ok, rtc_msg = _check_webrtc_policy()
rep.add("Browser WebRTC policy", rtc_ok, rtc_msg)
# Admin status (a lot of fixes require it)
rep.add(
"Administrator privileges",
is_admin(),
"Yes" if is_admin() else "No",
"MAC/IPv6/LAN/HKLM fixes need elevation" if not is_admin() else "",
)
return rep
def run_audit_sync(
listen_proxy: str,
ip_check_url: str,
timeout_seconds: float = 10.0,
) -> AuditReport:
return asyncio.run(run_audit(listen_proxy, ip_check_url, timeout_seconds))

View File

@@ -0,0 +1,64 @@
"""VPN-aware chain leak detection."""
from __future__ import annotations
def is_same_subnet(ip_a: str | None, ip_b: str | None, prefix_len: int = 16) -> bool:
"""True if two IPv4 addresses share the same /prefix_len subnet."""
if not ip_a or not ip_b:
return False
try:
a_parts = [int(x) for x in ip_a.split(".")]
b_parts = [int(x) for x in ip_b.split(".")]
if len(a_parts) != 4 or len(b_parts) != 4:
return False
def to_int(parts: list[int]) -> int:
return (parts[0] << 24) | (parts[1] << 16) | (parts[2] << 8) | parts[3]
mask = (0xFFFFFFFF << (32 - prefix_len)) & 0xFFFFFFFF
return (to_int(a_parts) & mask) == (to_int(b_parts) & mask)
except Exception:
return False
def is_chain_leak(exit_ip: str | None, real_ip: str | None, vpn_active: bool) -> bool:
"""True when the chain is not forwarding (traffic still looks like direct/VPN exit).
With VPN: compare /16 — VPN IPs rotate but stay in-provider ranges.
Without VPN: exact IP match only (avoid false positives on same ISP /16).
**Fail-closed**: when ``real_ip`` is unknown we cannot prove the chain is
safe, so we conservatively return ``True``. The service loop applies a
short warm-up grace period before this verdict kicks in so a transient
direct-IP lookup failure doesn't cause perpetual rotation.
"""
if not exit_ip:
return True
if not real_ip:
# Fail-closed: README promises "fail closed" when trust dies.
return True
if exit_ip == real_ip:
return True
if vpn_active:
return is_same_subnet(exit_ip, real_ip)
return False
def leak_reason(exit_ip: str | None, real_ip: str | None, vpn_active: bool) -> str:
if not exit_ip:
return "exit IP unreachable"
if not real_ip:
return (
"direct IP unknown — cannot prove the chain is forwarding "
"(fail-closed). Check internet connectivity and ip_check_url."
)
if exit_ip == real_ip:
if vpn_active:
return f"exit {exit_ip} equals VPN/direct IP (chain not forwarding)"
return f"exit {exit_ip} equals your real IP (no anonymization)"
if vpn_active and is_same_subnet(exit_ip, real_ip):
return (
f"exit {exit_ip} shares /16 with direct {real_ip} "
"(likely exiting via VPN tunnel, not proxy chain)"
)
return "ok"

View File

@@ -0,0 +1,158 @@
"""Randomize and restore NIC MAC addresses (Admin required)."""
from __future__ import annotations
import logging
import random
import re
import subprocess
from dataclasses import dataclass
from .firewall import is_admin
from .win_compat import probe as _win_probe
log = logging.getLogger(__name__)
_MAC_RE = re.compile(r"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$")
@dataclass
class NicMac:
name: str
mac: str
description: str = ""
def _run(args: list[str], timeout: float = 20.0) -> tuple[int, str, str]:
try:
r = subprocess.run(
args,
capture_output=True,
text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as e:
return 1, "", str(e)
def list_nics() -> list[NicMac]:
"""Physical/up adapters with current MAC.
Falls back to ``wmic nic`` for hosts without PowerShell 3.0 (Server 2008 R2).
"""
compat = _win_probe()
if not compat.has_net_cmdlets:
return _list_nics_wmic()
script = (
"Get-NetAdapter | Where-Object { $_.Status -ne 'Disabled' } | "
"Select-Object Name, MacAddress, InterfaceDescription | "
"ConvertTo-Json -Compress"
)
code, out, _ = _run(["powershell", "-NoProfile", "-Command", script])
if code != 0 or not out.strip():
return _list_nics_wmic()
import json
try:
data = json.loads(out)
except json.JSONDecodeError:
return []
rows = data if isinstance(data, list) else [data]
nics: list[NicMac] = []
for row in rows:
if not isinstance(row, dict):
continue
name = str(row.get("Name", "")).strip()
mac = str(row.get("MacAddress", "")).strip().replace("-", ":")
desc = str(row.get("InterfaceDescription", "")).strip()
if name and mac and mac != "00:00:00:00:00:00":
nics.append(NicMac(name=name, mac=mac, description=desc))
return nics
def random_mac() -> str:
"""Locally administered unicast MAC."""
b = [random.randint(0, 255) for _ in range(6)]
b[0] = (b[0] | 0x02) & 0xFE
return ":".join(f"{x:02X}" for x in b)
def _list_nics_wmic() -> list[NicMac]:
"""Server 2008 R2 / PowerShell 2.0 fallback via wmic."""
code, out, _ = _run([
"wmic", "nic", "where", "NetEnabled=true",
"get", "NetConnectionID,MACAddress,Name", "/format:list",
])
if code != 0 or not out:
return []
nics: list[NicMac] = []
cur = {"id": "", "mac": "", "name": ""}
for ln in out.splitlines():
s = ln.strip()
if not s:
if cur["id"] and cur["mac"]:
mac = cur["mac"].replace("-", ":")
if mac and mac != "00:00:00:00:00:00":
nics.append(NicMac(name=cur["id"], mac=mac, description=cur["name"]))
cur = {"id": "", "mac": "", "name": ""}
continue
if s.startswith("NetConnectionID="):
cur["id"] = s.split("=", 1)[1].strip()
elif s.startswith("MACAddress="):
cur["mac"] = s.split("=", 1)[1].strip()
elif s.startswith("Name="):
cur["name"] = s.split("=", 1)[1].strip()
if cur["id"] and cur["mac"]:
mac = cur["mac"].replace("-", ":")
if mac and mac != "00:00:00:00:00:00":
nics.append(NicMac(name=cur["id"], mac=mac, description=cur["name"]))
return nics
def set_mac(adapter: str, mac: str) -> tuple[bool, str]:
if not is_admin():
return False, "Administrator required to change MAC."
if not _win_probe().has_net_cmdlets:
return False, (
"MAC change requires PowerShell 3.0+ (Windows 8 / Server 2012+). "
"Detected legacy PowerShell — feature unavailable on this host."
)
mac = mac.replace("-", ":").upper()
if not _MAC_RE.match(mac.replace(":", "-")):
return False, f"Invalid MAC: {mac}"
ps = (
f'$a = Get-NetAdapter -Name "{adapter}" -ErrorAction Stop; '
f'Set-NetAdapter -Name $a.Name -MacAddress "{mac}" -Confirm:$false'
)
code, _, err = _run(["powershell", "-NoProfile", "-Command", ps])
if code != 0:
return False, err or "Set-NetAdapter failed"
log.info("MAC set %s%s", adapter, mac)
return True, f"{adapter}{mac}"
def spoof_all_physical(snapshot: dict[str, str] | None = None) -> tuple[dict[str, str], list[str]]:
"""Randomize MAC on non-virtual adapters. Returns (original_map, log lines)."""
originals: dict[str, str] = dict(snapshot or {})
logs: list[str] = []
skip_hints = ("virtual", "vmware", "hyper-v", "loopback", "bluetooth", "wan miniport")
for nic in list_nics():
low = (nic.description + nic.name).lower()
if any(h in low for h in skip_hints):
continue
if nic.name not in originals:
originals[nic.name] = nic.mac
new_mac = random_mac()
ok, msg = set_mac(nic.name, new_mac)
logs.append(msg if ok else f"{nic.name}: {msg}")
return originals, logs
def restore_macs(originals: dict[str, str]) -> list[str]:
logs: list[str] = []
for name, mac in originals.items():
ok, msg = set_mac(name, mac)
logs.append(msg if ok else f"{name}: {msg}")
return logs

View File

@@ -0,0 +1,22 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
def app_data_dir() -> Path:
if sys.platform == "darwin":
base = Path.home() / "Library" / "Application Support"
elif os.environ.get("LOCALAPPDATA"):
base = Path(os.environ["LOCALAPPDATA"])
else:
base = Path.home() / "AppData" / "Local"
d = base / "ProxyChainManager"
d.mkdir(parents=True, exist_ok=True)
return d
def gost_exe_path() -> Path:
suffix = ".exe" if sys.platform == "win32" else ""
return app_data_dir() / f"gost{suffix}"

View File

@@ -0,0 +1,106 @@
"""Fetch proxy lists for UI picker and shared pool building."""
from __future__ import annotations
import asyncio
import logging
import random
from concurrent.futures import ThreadPoolExecutor
from typing import Callable
from .config import Settings
from .fetcher import fetch_proxy_json, normalize_entries
from .validator import validate_proxies
log = logging.getLogger(__name__)
_POOL_EXEC = ThreadPoolExecutor(max_workers=6, thread_name_prefix="pool_ops")
PICKER_DISPLAY_CAP = 200
class ProxySourceCache:
"""Cache full source lists; each UI pull shows a new random batch."""
def __init__(self) -> None:
self._full: list[str] = []
self.total_cached: int = 0
def clear(self) -> None:
self._full.clear()
self.total_cached = 0
@property
def loaded(self) -> bool:
return bool(self._full)
def load(self, sources: list[str], prefer_elite: bool,
on_status: Callable[[str], None] | None = None) -> int:
self._full = fetch_raw_proxies(sources, prefer_elite, on_status=on_status)
self.total_cached = len(self._full)
return self.total_cached
def random_batch(self, cap: int = PICKER_DISPLAY_CAP) -> list[str]:
if not self._full:
return []
k = min(cap, len(self._full))
return random.sample(self._full, k)
def fetch_raw_proxies(
sources: list[str],
prefer_elite: bool,
on_status: Callable[[str], None] | None = None,
) -> list[str]:
"""Blocking fetch from all JSON sources; deduped, not validated."""
if not sources:
sources = list(Settings().sources)
seen: set[str] = set()
out: list[str] = []
for url in sources:
if on_status:
on_status(f"Fetching {url[:70]}")
try:
rows = fetch_proxy_json(url, timeout=45.0)
entries = normalize_entries(rows, prefer_elite)
if on_status:
on_status(f"{len(entries)} proxies")
for u in entries:
if u not in seen:
seen.add(u)
out.append(u)
except Exception as e:
log.warning("fetch_raw_proxies %s: %s", url[:60], e)
if on_status:
on_status(f" → error: {e!s}")
return out
async def validate_proxy_subset(
urls: list[str],
settings: Settings,
target: int = 0,
on_progress: Callable[[int, int], None] | None = None,
) -> list[str]:
if not urls:
return []
sample = list(urls)
random.shuffle(sample)
if len(sample) > settings.max_candidates:
sample = sample[: settings.max_candidates]
tgt = target or settings.min_pool_size
return await validate_proxies(
sample,
settings.ip_check_url,
settings.validation_concurrency,
settings.validation_timeout_seconds,
on_progress=on_progress,
target=tgt,
)
def run_validate_sync(
urls: list[str],
settings: Settings,
target: int = 0,
on_progress: Callable[[int, int], None] | None = None,
) -> list[str]:
return asyncio.run(validate_proxy_subset(urls, settings, target=target, on_progress=on_progress))

View File

@@ -0,0 +1,291 @@
"""LAN-scope hostname leak lockdown.
Windows by default broadcasts the local hostname on every adapter via:
- LLMNR (UDP 5355) — link-local multicast name resolution
- NetBIOS over TCP/IP — broadcast name registration on the subnet
- mDNS (UDP 5353) — Bonjour-style name advertising on newer Windows
Anyone on the same Wi-Fi / VLAN can grab the hostname and tie it to the MAC
address. Killing these is a paranoid-mode no-brainer.
All three are reversible. We snapshot original state on engage and restore on
disengage.
"""
from __future__ import annotations
import json
import logging
import subprocess
from dataclasses import dataclass, field
import winreg
from .firewall import is_admin
from .win_compat import probe as _win_probe
log = logging.getLogger(__name__)
_LLMNR_POLICY_PATH = r"SOFTWARE\Policies\Microsoft\Windows NT\DNSClient"
_LLMNR_VALUE = "EnableMulticast"
_MDNS_DNSCLIENT = r"SYSTEM\CurrentControlSet\Services\Dnscache\Parameters"
_MDNS_VALUE = "EnableMDNS"
@dataclass
class LanSnapshot:
netbios_per_interface: dict[str, int] = field(default_factory=dict)
llmnr_present: bool = False
llmnr_prev_value: int | None = None
mdns_present: bool = False
mdns_prev_value: int | None = None
def _run(args: list[str], timeout: float = 12.0) -> tuple[int, str, str]:
try:
r = subprocess.run(
args,
capture_output=True,
text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as e:
return 1, "", str(e)
def _list_netbios_via_wmic() -> dict[str, int]:
"""Per-NIC NetBIOS setting via wmic (works back to Server 2008 R2).
Returns: { settings_id : tcpip_netbios_options }
0 = use DHCP, 1 = enabled, 2 = disabled
"""
code, out, _ = _run([
"wmic", "nicconfig", "where", "IPEnabled=true",
"get", "SettingID,TcpipNetbiosOptions", "/format:list",
])
if code != 0 or not out:
return {}
settings: dict[str, int] = {}
cur_id = ""
cur_val: int | None = None
for raw in out.splitlines():
line = raw.strip()
if not line:
if cur_id and cur_val is not None:
settings[cur_id] = cur_val
cur_id, cur_val = "", None
continue
if line.startswith("SettingID="):
cur_id = line.split("=", 1)[1].strip()
elif line.startswith("TcpipNetbiosOptions="):
try:
cur_val = int(line.split("=", 1)[1].strip())
except ValueError:
cur_val = None
if cur_id and cur_val is not None:
settings[cur_id] = cur_val
return settings
def _set_netbios_via_wmic(settings_id: str, mode: int) -> bool:
"""mode: 0=DHCP, 1=enabled, 2=disabled."""
code, _, _ = _run([
"wmic", "nicconfig", "where",
f"SettingID='{settings_id}'",
"call", "SetTcpipNetbios", str(mode),
])
return code == 0
def _llmnr_read() -> tuple[bool, int | None]:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_QUERY_VALUE
) as key:
val, _ = winreg.QueryValueEx(key, _LLMNR_VALUE)
return True, int(val)
except OSError:
return False, None
def _llmnr_write(value: int) -> bool:
try:
with winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, _LLMNR_VALUE, 0, winreg.REG_DWORD, value)
return True
except OSError:
return False
def _llmnr_delete() -> None:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _LLMNR_POLICY_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.DeleteValue(key, _LLMNR_VALUE)
except OSError:
pass
def _mdns_read() -> tuple[bool, int | None]:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_QUERY_VALUE
) as key:
val, _ = winreg.QueryValueEx(key, _MDNS_VALUE)
return True, int(val)
except OSError:
return False, None
def _mdns_write(value: int) -> bool:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, _MDNS_VALUE, 0, winreg.REG_DWORD, value)
return True
except OSError:
return False
def _restart_dnscache() -> None:
"""Bounce the DNS Client service so the LLMNR/mDNS toggles take effect.
Server hardening baselines sometimes mark this service "denied" — failure
is logged and ignored; the change still applies on next reboot.
"""
_run(["net", "stop", "Dnscache", "/y"], timeout=20)
_run(["net", "start", "Dnscache"], timeout=20)
def lan_status() -> dict[str, str]:
"""Human-readable current state. Returned even when not Admin."""
nb = _list_netbios_via_wmic()
if nb:
disabled = sum(1 for v in nb.values() if v == 2)
nb_summary = f"{disabled}/{len(nb)} NICs with NetBIOS disabled"
else:
nb_summary = "unavailable"
llmnr_present, llmnr_v = _llmnr_read()
if not llmnr_present:
llmnr_summary = "default (ON)"
else:
llmnr_summary = "OFF" if llmnr_v == 0 else f"ON (policy={llmnr_v})"
mdns_present, mdns_v = _mdns_read()
if not mdns_present:
mdns_summary = "default (ON on Win10 1903+)"
else:
mdns_summary = "OFF" if mdns_v == 0 else f"ON (EnableMDNS={mdns_v})"
return {
"netbios": nb_summary,
"llmnr": llmnr_summary,
"mdns": mdns_summary,
}
def engage_lan_lockdown() -> tuple[LanSnapshot | None, list[str]]:
"""Disable LLMNR / NetBIOS / mDNS. Returns (snapshot for restore, log lines).
snapshot is None when the operation was refused (not Admin, etc.).
"""
logs: list[str] = []
if not is_admin():
return None, ["LAN privacy lockdown skipped — needs Administrator."]
snap = LanSnapshot()
snap.netbios_per_interface = _list_netbios_via_wmic()
snap.llmnr_present, snap.llmnr_prev_value = _llmnr_read()
snap.mdns_present, snap.mdns_prev_value = _mdns_read()
if snap.netbios_per_interface:
changed = 0
for sid in snap.netbios_per_interface:
if _set_netbios_via_wmic(sid, 2):
changed += 1
logs.append(f"NetBIOS over TCP/IP disabled on {changed}/{len(snap.netbios_per_interface)} NICs.")
else:
logs.append("NetBIOS: could not enumerate adapters (wmic missing?).")
if _llmnr_write(0):
logs.append("LLMNR (UDP 5355) disabled via DNSClient policy.")
else:
logs.append("LLMNR disable failed (policy write rejected).")
if _mdns_write(0):
logs.append("mDNS (UDP 5353) disabled via Dnscache parameters.")
else:
logs.append("mDNS disable failed (Dnscache parameters not writable).")
_restart_dnscache()
logs.append("DNS Client service bounced — LAN lockdown active.")
return snap, logs
def restore_lan(snap: LanSnapshot | None) -> list[str]:
"""Roll back to the snapshot captured by engage_lan_lockdown."""
if snap is None or not is_admin():
return []
logs: list[str] = []
if snap.netbios_per_interface:
restored = 0
for sid, prev in snap.netbios_per_interface.items():
if _set_netbios_via_wmic(sid, prev):
restored += 1
logs.append(f"NetBIOS restored on {restored}/{len(snap.netbios_per_interface)} NICs.")
if snap.llmnr_present and snap.llmnr_prev_value is not None:
_llmnr_write(snap.llmnr_prev_value)
logs.append(f"LLMNR policy restored to {snap.llmnr_prev_value}.")
else:
_llmnr_delete()
logs.append("LLMNR policy removed (was default).")
if snap.mdns_present and snap.mdns_prev_value is not None:
_mdns_write(snap.mdns_prev_value)
logs.append(f"mDNS restored (EnableMDNS={snap.mdns_prev_value}).")
else:
# If mDNS key was absent originally, delete the value we created.
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _MDNS_DNSCLIENT, 0, winreg.KEY_SET_VALUE
) as key:
winreg.DeleteValue(key, _MDNS_VALUE)
logs.append("mDNS toggle removed (was default).")
except OSError:
pass
_restart_dnscache()
return logs
def snapshot_to_json(snap: LanSnapshot | None) -> str:
if snap is None:
return ""
return json.dumps({
"netbios": snap.netbios_per_interface,
"llmnr_present": snap.llmnr_present,
"llmnr_prev_value": snap.llmnr_prev_value,
"mdns_present": snap.mdns_present,
"mdns_prev_value": snap.mdns_prev_value,
})
def snapshot_from_json(raw: str) -> LanSnapshot | None:
if not raw:
return None
try:
d = json.loads(raw)
return LanSnapshot(
netbios_per_interface={k: int(v) for k, v in (d.get("netbios") or {}).items()},
llmnr_present=bool(d.get("llmnr_present")),
llmnr_prev_value=d.get("llmnr_prev_value"),
mdns_present=bool(d.get("mdns_present")),
mdns_prev_value=d.get("mdns_prev_value"),
)
except Exception:
return None

View File

@@ -0,0 +1,201 @@
"""Modal dialog: random batch from cached sources, multi-select."""
from __future__ import annotations
import threading
from typing import Any, Callable
import customtkinter as ctk
from .config import Settings, redact_proxy_url
from .pool_ops import PICKER_DISPLAY_CAP, ProxySourceCache
BG = "#0d0d1a"
CARD = "#12122a"
PANEL = "#1a1a3e"
ACCENT = "#1e3a8a"
ACCENT2 = "#2563eb"
GREEN = "#00e676"
RED = "#ff1744"
YELLOW = "#ffab00"
DIM = "#4a5568"
TEXT = "#e2e8f0"
TEXT2 = "#94a3b8"
FONT = "Segoe UI"
_UI_CHUNK = 50
# One cache per app session — avoids re-downloading 800+ URLs every open
_SESSION_CACHE = ProxySourceCache()
def show_proxy_picker(
parent: Any,
settings: Settings,
prefer_elite: bool,
on_done: Callable[[list[str], str], None],
) -> None:
"""``on_done(urls, mode)`` where mode is ``append`` or ``replace``."""
win = ctk.CTkToplevel(parent)
win.title("Browse proxy lists")
win.geometry("700x500")
win.configure(fg_color=BG)
win.transient(parent)
win.grab_set()
status = ctk.CTkLabel(
win,
text=f"Pull loads sources once, then shows {PICKER_DISPLAY_CAP} random proxies per batch.",
font=(FONT, 11),
text_color=TEXT2,
)
status.pack(fill="x", padx=12, pady=(10, 4))
filter_var = ctk.StringVar(value="")
filt_row = ctk.CTkFrame(win, fg_color="transparent")
filt_row.pack(fill="x", padx=12, pady=4)
ctk.CTkLabel(filt_row, text="Filter:", font=(FONT, 10), text_color=TEXT2).pack(side="left")
ctk.CTkEntry(
filt_row, textvariable=filter_var, width=180, height=28,
fg_color=BG, border_color=ACCENT,
).pack(side="left", padx=6)
proto_var = ctk.StringVar(value="all")
for label, val in [("All", "all"), ("HTTP", "http"), ("SOCKS5", "socks5")]:
ctk.CTkRadioButton(
filt_row, text=label, variable=proto_var, value=val,
font=(FONT, 10), fg_color=ACCENT2, text_color=TEXT,
).pack(side="left", padx=4)
scroll = ctk.CTkScrollableFrame(win, fg_color=CARD, height=300,
scrollbar_button_color=ACCENT)
scroll.pack(fill="both", expand=True, padx=12, pady=6)
display_proxies: list[str] = []
check_vars: dict[str, ctk.BooleanVar] = {}
_build_gen = [0]
def _filtered_urls() -> list[str]:
q = filter_var.get().strip().lower()
pv = proto_var.get()
out: list[str] = []
for u in display_proxies:
if pv == "http" and not u.startswith("http://"):
continue
if pv == "socks5" and not u.startswith("socks5://"):
continue
if q and q not in u.lower():
continue
out.append(u)
return out
def _rebuild_list() -> None:
_build_gen[0] += 1
gen = _build_gen[0]
for w in scroll.winfo_children():
w.destroy()
urls = _filtered_urls()
if not urls:
status.configure(
text="No proxies match filter — pull a batch or change filter.",
text_color=TEXT2,
)
return
def _chunk(start: int) -> None:
if gen != _build_gen[0]:
return
end = min(start + _UI_CHUNK, len(urls))
for u in urls[start:end]:
if u not in check_vars:
check_vars[u] = ctk.BooleanVar(value=False)
fr = ctk.CTkFrame(scroll, fg_color=PANEL, corner_radius=4)
fr.pack(fill="x", pady=1)
ctk.CTkCheckBox(
fr, text=redact_proxy_url(u), variable=check_vars[u],
font=("Consolas", 10), fg_color=ACCENT2, text_color=TEXT,
).pack(anchor="w", padx=8, pady=2)
if end < len(urls):
win.after(1, lambda: _chunk(end))
else:
cached = _SESSION_CACHE.total_cached
status.configure(
text=f"Showing {len(urls)} of batch ({cached} cached in memory). Select → OK.",
text_color=GREEN,
)
_chunk(0)
def _select_all(on: bool) -> None:
for u in _filtered_urls():
if u in check_vars:
check_vars[u].set(on)
def _pull(force_reload: bool = False) -> None:
pull_btn.configure(state="disabled")
reload_btn.configure(state="disabled")
status.configure(text="Loading sources…", text_color=YELLOW)
def work() -> None:
def stat(msg: str) -> None:
win.after(0, lambda m=msg: status.configure(text=m, text_color=TEXT2))
if force_reload:
_SESSION_CACHE.clear()
if not _SESSION_CACHE.loaded:
_SESSION_CACHE.load(list(settings.sources), prefer_elite, on_status=stat)
batch = _SESSION_CACHE.random_batch(PICKER_DISPLAY_CAP)
def finish() -> None:
nonlocal display_proxies
display_proxies = batch
check_vars.clear()
for u in batch:
check_vars[u] = ctk.BooleanVar(value=False)
pull_btn.configure(state="normal")
reload_btn.configure(state="normal")
_rebuild_list()
win.after(0, finish)
threading.Thread(target=work, daemon=True).start()
def _selected() -> list[str]:
return [u for u, v in check_vars.items() if v.get()]
def _ok(mode: str) -> None:
sel = _selected()
if not sel:
status.configure(text="Select at least one proxy.", text_color=RED)
return
win.grab_release()
win.destroy()
on_done(sel, mode)
btn_row = ctk.CTkFrame(win, fg_color="transparent")
btn_row.pack(fill="x", padx=12, pady=(4, 12))
pull_btn = ctk.CTkButton(
btn_row, text="↻ Random batch", command=lambda: _pull(False),
width=120, fg_color=ACCENT, hover_color=ACCENT2,
)
pull_btn.pack(side="left", padx=2)
reload_btn = ctk.CTkButton(
btn_row, text="Reload sources", command=lambda: _pull(True),
width=110, fg_color=DIM, hover_color=ACCENT,
)
reload_btn.pack(side="left", padx=2)
ctk.CTkButton(btn_row, text="Select all", command=lambda: _select_all(True),
width=72, fg_color=DIM).pack(side="left", padx=2)
ctk.CTkButton(btn_row, text="Clear", command=lambda: _select_all(False),
width=52, fg_color=DIM).pack(side="left", padx=2)
ctk.CTkButton(btn_row, text="Cancel",
command=lambda: (win.grab_release(), win.destroy()),
width=64, fg_color="#7f1d1d").pack(side="right", padx=2)
ctk.CTkButton(btn_row, text="Add to chain", command=lambda: _ok("append"),
width=100, fg_color=GREEN, hover_color=ACCENT2).pack(side="right", padx=2)
ctk.CTkButton(btn_row, text="Replace chain", command=lambda: _ok("replace"),
width=100, fg_color=ACCENT2).pack(side="right", padx=2)
filter_var.trace_add("write", lambda *_: _rebuild_list())
proto_var.trace_add("write", lambda *_: _rebuild_list())
_pull(False)

View File

@@ -0,0 +1,160 @@
"""Windows DPAPI-based secret storage.
Provides ``encrypt(plaintext: str) -> str`` and ``decrypt(token: str) -> str``
backed by ``CryptProtectData`` / ``CryptUnprotectData``.
Why DPAPI:
- Built into Windows; no third-party crypto dependency.
- Key material is tied to the *current user account* (or the machine, when
``CRYPTPROTECT_LOCAL_MACHINE`` is used) — no passphrase to remember.
- Data encrypted on disk cannot be decrypted by another user / machine.
Token format: ``DPAPI:v1:<base64-blob>``
Plain strings that don't start with ``DPAPI:`` are returned unchanged by
``decrypt()`` so the loader can safely walk old plaintext files and re-encrypt
them on the next save (migration is free).
Falls back gracefully on non-Windows or when DPAPI is unavailable: ``encrypt``
returns the original string verbatim (logged once).
"""
from __future__ import annotations
import base64
import ctypes
import logging
import sys
from ctypes import wintypes
log = logging.getLogger(__name__)
_TOKEN_PREFIX = "DPAPI:v1:"
# ── Windows API binding ──────────────────────────────────────────────────────
class _DATA_BLOB(ctypes.Structure):
_fields_ = [
("cbData", wintypes.DWORD),
("pbData", ctypes.POINTER(ctypes.c_char)),
]
def _blob(data: bytes) -> _DATA_BLOB:
buf = ctypes.create_string_buffer(data, len(data))
return _DATA_BLOB(len(data), ctypes.cast(buf, ctypes.POINTER(ctypes.c_char)))
def _is_windows() -> bool:
return sys.platform == "win32"
_warned_unavailable = False
def _warn_unavailable(reason: str) -> None:
global _warned_unavailable
if not _warned_unavailable:
log.warning("DPAPI secret store unavailable: %s — secrets will be stored in plaintext.", reason)
_warned_unavailable = True
# ── Public API ───────────────────────────────────────────────────────────────
def is_available() -> bool:
"""Return True if the current process can use DPAPI."""
if not _is_windows():
return False
try:
ctypes.windll.crypt32 # noqa: B018 — just confirm the DLL is importable
return True
except Exception: # noqa: BLE001
return False
def encrypt(plaintext: str) -> str:
"""Encrypt *plaintext* with current-user DPAPI.
Returns ``DPAPI:v1:<base64>`` on success. Returns the original plaintext
on any failure (callers can therefore always trust the return value as the
string to persist).
"""
if not plaintext:
return ""
# Already encrypted — pass through.
if plaintext.startswith(_TOKEN_PREFIX):
return plaintext
if not is_available():
_warn_unavailable("not running on Windows")
return plaintext
try:
data = plaintext.encode("utf-8")
in_blob = _blob(data)
out_blob = _DATA_BLOB()
ok = ctypes.windll.crypt32.CryptProtectData(
ctypes.byref(in_blob),
"ProxyGodSecret", # description (ignored by us)
None, # optional entropy
None, # reserved
None, # prompt struct
0, # flags
ctypes.byref(out_blob),
)
if not ok:
err = ctypes.get_last_error()
_warn_unavailable(f"CryptProtectData failed (err={err})")
return plaintext
try:
enc = ctypes.string_at(out_blob.pbData, out_blob.cbData)
finally:
ctypes.windll.kernel32.LocalFree(out_blob.pbData)
return _TOKEN_PREFIX + base64.b64encode(enc).decode("ascii")
except Exception as exc: # noqa: BLE001
_warn_unavailable(f"unexpected error: {exc}")
return plaintext
def decrypt(token: str) -> str:
"""Decrypt a token produced by :func:`encrypt`.
Plain strings (not starting with the ``DPAPI:`` prefix) are returned
verbatim — this is intentional, it lets the loader transparently read
legacy plaintext files and re-encrypt on save.
"""
if not token:
return ""
if not token.startswith(_TOKEN_PREFIX):
return token # legacy plaintext
if not is_available():
_warn_unavailable("not running on Windows — cannot decrypt")
return ""
try:
b64 = token[len(_TOKEN_PREFIX):]
data = base64.b64decode(b64.encode("ascii"))
in_blob = _blob(data)
out_blob = _DATA_BLOB()
ok = ctypes.windll.crypt32.CryptUnprotectData(
ctypes.byref(in_blob),
None, # ppszDataDescr
None, # entropy
None, # reserved
None, # prompt struct
0, # flags
ctypes.byref(out_blob),
)
if not ok:
err = ctypes.get_last_error()
log.warning("CryptUnprotectData failed (err=%s) — secret stayed encrypted.", err)
return ""
try:
plain = ctypes.string_at(out_blob.pbData, out_blob.cbData)
finally:
ctypes.windll.kernel32.LocalFree(out_blob.pbData)
return plain.decode("utf-8", errors="replace")
except Exception as exc: # noqa: BLE001
log.warning("DPAPI decrypt error: %s", exc)
return ""
def is_encrypted(token: str) -> bool:
return bool(token) and token.startswith(_TOKEN_PREFIX)

View File

@@ -0,0 +1,949 @@
from __future__ import annotations
import atexit
import asyncio
import logging
import random
import signal
import threading
import time
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from typing import Any
from .config import (
Settings,
load_settings,
normalize_proxy_url,
redact_proxy_url,
save_settings,
)
from .dns_leak import flush_dns_cache
from .fetcher import fetch_proxy_json, normalize_entries
from .fingerprint import (
apply_webrtc_hardening,
disable_ipv6_on_adapters,
enable_ipv6_on_adapters,
get_computer_name,
random_hostname,
set_computer_name,
)
from .firewall import disengage as fw_disengage, emergency_disengage as fw_emergency_disengage, engage as fw_engage, is_admin
from .gost_util import build_gost_cmd, ensure_gost, popen_no_window, read_gost_log_tail, terminate_process
from .leak_detect import is_chain_leak, leak_reason
from .mac_spoof import restore_macs, spoof_all_physical
from .privacy_lan import (
LanSnapshot,
engage_lan_lockdown,
restore_lan,
)
from .telemetry_kill import (
TelemetrySnapshot,
engage_telemetry_kill,
restore_telemetry,
)
from .sysproxy import (
clear_system_proxy,
detect_policy_overrides,
diagnose_system_proxy,
is_system_proxy_set,
set_system_proxy,
)
from .validator import (
check_chain_exit_ip,
check_https_tunnel,
get_direct_ip,
validate_proxies,
)
from .vpn_detect import VpnStatus, detect_vpn
from .win_compat import probe as _win_probe
log = logging.getLogger(__name__)
Notify = Callable[[dict[str, Any]], None]
# Thread pool for blocking I/O (proxy list fetching) so we don't stall asyncio
_FETCH_POOL = ThreadPoolExecutor(max_workers=8, thread_name_prefix="fetcher")
# Back-compat for tests
from .leak_detect import is_same_subnet as _is_same_network # noqa: F401
async def _probe_tcp(host: str, port: int, timeout: float = 6.0) -> bool:
"""Open and close a TCP socket; True if the handshake completes within *timeout*."""
try:
reader, writer = await asyncio.wait_for(
asyncio.open_connection(host, port), timeout=timeout,
)
writer.close()
try:
await writer.wait_closed()
except Exception: # noqa: BLE001
pass
return True
except Exception: # noqa: BLE001
return False
class ChainService:
"""Background rotating proxy chain using GOST."""
def __init__(self, notify: Notify) -> None:
self._notify = notify
self._stop = threading.Event()
self._force_rotate = threading.Event()
self._thread: threading.Thread | None = None
self._proc = None
self._settings_lock = threading.Lock()
self._settings = load_settings()
self._current_chain: list[str] = []
# Sticky-exit: while time.monotonic() < self._sticky_until, leak-based
# auto-rotation is skipped so signup flows keep the same egress IP.
self._sticky_until: float = 0.0
# Shuffle-and-drain pool state — never repeat a proxy within a cycle
self._available: list[str] = [] # proxies not yet used this cycle
self._used: set[str] = set() # proxies used this cycle
# Per-session blacklist: proxies that crashed GOST immediately
self._blacklist: set[str] = set()
self._vpn: VpnStatus = VpnStatus()
self._mac_originals: dict[str, str] = {}
self._hostname_original: str | None = None
self._ipv6_adapters: list[str] = []
self._webrtc_was_applied: bool = False
self._lan_snap: LanSnapshot | None = None
self._telemetry_snap: TelemetrySnapshot | None = None
# Register emergency firewall disengage so a crash can't leave the
# kill-switch permanently engaged.
atexit.register(fw_emergency_disengage)
for _sig in (signal.SIGTERM, signal.SIGINT):
try:
signal.signal(_sig, self._signal_handler)
except (OSError, ValueError):
pass # not on main thread or unsupported on this platform
def _signal_handler(self, signum: int, frame: Any) -> None:
"""Received SIGTERM/SIGINT — stop cleanly and ensure firewall is disengaged."""
log.warning("Signal %s received — stopping ChainService.", signum)
self._stop.set()
terminate_process(self._proc)
fw_emergency_disengage()
def _manual_exit_url(self) -> str | None:
with self._settings_lock:
u = normalize_proxy_url(self._settings.manual_exit_proxy)
return u if u else None
@property
def settings(self) -> Settings:
with self._settings_lock:
return self._settings
@property
def current_chain(self) -> list[str]:
return list(self._current_chain)
def update_settings(self, s: Settings) -> None:
with self._settings_lock:
self._settings = s
save_settings(s)
def start(self) -> None:
if self._thread and self._thread.is_alive():
return
compat = _win_probe()
self._notify({"type": "log", "text": f"Host: {compat.summary()}"})
if not compat.has_net_cmdlets:
self._notify({
"type": "log",
"text": (
"Legacy PowerShell detected — MAC spoof / IPv6 disable / "
"hostname spoof unavailable on this host. Proxy chain + "
"kill-switch + WebRTC policy still work."
),
})
self._stop.clear()
self._thread = threading.Thread(
target=self._run_thread, name="ChainService", daemon=True
)
self._thread.start()
def stop(self) -> None:
self._stop.set()
terminate_process(self._proc)
self._proc = None
if self._thread:
self._thread.join(timeout=15)
if self._thread.is_alive():
log.warning("ChainService thread did not exit in 15s — forcing firewall disengage.")
fw_emergency_disengage()
self._teardown_network()
self._notify({"type": "state", "running": False})
def rotate_now(self) -> None:
self._force_rotate.set()
def set_sticky(self, seconds: float) -> None:
"""Hold current exit for ``seconds`` (suppresses leak-driven rotation).
Manual rotation still works. Pass 0 to clear sticky mode immediately.
"""
if seconds <= 0:
self._sticky_until = 0.0
return
self._sticky_until = time.monotonic() + float(seconds)
def sticky_remaining(self) -> float:
"""Seconds remaining on sticky-exit hold (0.0 if not sticky)."""
rem = self._sticky_until - time.monotonic()
return rem if rem > 0 else 0.0
# ─────────────────────────────────────────────────────────────────────────
def _teardown_network(self) -> None:
clear_system_proxy()
self._notify({"type": "log", "text": "System proxy cleared."})
self._restore_privacy()
with self._settings_lock:
ks_enabled = self._settings.kill_switch_enabled
if is_admin() and ks_enabled:
ok, msg = fw_disengage()
self._notify({"type": "log", "text": msg})
self._notify({"type": "firewall", "engaged": False})
def _apply_privacy(self) -> None:
s = self._settings
if s.mac_spoof_enabled and is_admin():
self._mac_originals, logs = spoof_all_physical(self._mac_originals or None)
for ln in logs:
self._notify({"type": "log", "text": f"MAC: {ln}"})
elif s.mac_spoof_enabled:
self._notify({"type": "log", "text": "MAC spoof enabled but not Admin — skipped."})
if s.spoof_hostname_enabled and is_admin():
self._hostname_original = get_computer_name()
new_name = random_hostname()
ok, msg = set_computer_name(new_name)
self._notify({"type": "log", "text": f"Hostname: {msg}"})
elif s.spoof_hostname_enabled:
self._notify({"type": "log", "text": "Hostname spoof enabled but not Admin — skipped."})
if s.disable_ipv6_while_active and is_admin():
self._ipv6_adapters, logs = disable_ipv6_on_adapters()
for ln in logs:
self._notify({"type": "log", "text": ln})
elif s.disable_ipv6_while_active:
self._notify({"type": "log", "text": "IPv6 disable enabled but not Admin — skipped."})
if s.harden_webrtc_enabled and is_admin():
ok, msg = apply_webrtc_hardening(True)
self._webrtc_was_applied = ok
self._notify({"type": "log", "text": msg})
elif s.harden_webrtc_enabled:
self._notify({"type": "log", "text": "WebRTC hardening enabled but not Admin — skipped."})
if s.lan_lockdown_enabled and is_admin():
snap, logs = engage_lan_lockdown()
self._lan_snap = snap
for ln in logs:
self._notify({"type": "log", "text": f"LAN: {ln}"})
elif s.lan_lockdown_enabled:
self._notify({"type": "log", "text": "LAN lockdown enabled but not Admin — skipped."})
if s.telemetry_kill_enabled and is_admin():
tsnap, tlogs = engage_telemetry_kill()
self._telemetry_snap = tsnap
for ln in tlogs[:8]:
self._notify({"type": "log", "text": f"Telemetry: {ln}"})
if len(tlogs) > 8:
self._notify({"type": "log", "text": f"Telemetry: … +{len(tlogs) - 8} more changes."})
elif s.telemetry_kill_enabled:
self._notify({"type": "log", "text": "Telemetry kill enabled but not Admin — skipped."})
def _restore_privacy(self) -> None:
if self._mac_originals:
for ln in restore_macs(self._mac_originals):
self._notify({"type": "log", "text": f"MAC restore: {ln}"})
self._mac_originals.clear()
if self._hostname_original and is_admin():
ok, msg = set_computer_name(self._hostname_original)
self._notify({"type": "log", "text": f"Hostname restore: {msg}"})
self._hostname_original = None
if self._ipv6_adapters:
for ln in enable_ipv6_on_adapters(self._ipv6_adapters):
self._notify({"type": "log", "text": ln})
self._ipv6_adapters.clear()
if self._webrtc_was_applied and is_admin():
apply_webrtc_hardening(False)
self._webrtc_was_applied = False
self._notify({"type": "log", "text": "WebRTC policy restored."})
if self._lan_snap is not None and is_admin():
for ln in restore_lan(self._lan_snap):
self._notify({"type": "log", "text": f"LAN: {ln}"})
self._lan_snap = None
if self._telemetry_snap is not None and is_admin():
for ln in restore_telemetry(self._telemetry_snap)[:8]:
self._notify({"type": "log", "text": f"Telemetry: {ln}"})
self._telemetry_snap = None
def _run_thread(self) -> None:
try:
asyncio.run(self._async_main())
except Exception:
log.exception("service thread failed")
self._notify({"type": "log", "text": "Fatal error in service thread (see log)."})
finally:
terminate_process(self._proc)
self._proc = None
self._teardown_network()
self._notify({"type": "state", "running": False})
# ─────────────────────────────────────────────────────────────────────────
# MAIN ASYNC LOOP
# ─────────────────────────────────────────────────────────────────────────
async def _async_main(self) -> None:
self._notify({"type": "state", "running": True})
log.info(
"Service start: chain_length=%d mode=%s sources=%d pinned=%s kill_switch=%s",
self._settings.chain_length,
self._settings.obfuscation_mode,
len(self._settings.sources),
self._settings.use_pinned_chain,
self._settings.kill_switch_enabled,
)
# ── GOST setup ───────────────────────────────────────────────────────
try:
gost = ensure_gost()
self._notify({"type": "log", "text": f"GOST ready: {gost}"})
except Exception as e:
self._notify({"type": "log", "text": f"GOST setup failed: {e}"})
return
if gost.stat().st_size < 10_000:
self._notify({"type": "log", "text": "GOST appears quarantined. Re-downloading..."})
gost.unlink(missing_ok=True)
try:
gost = ensure_gost()
except Exception as e:
self._notify({"type": "log", "text": f"GOST re-download failed: {e}"})
return
# ── VPN + direct IP ───────────────────────────────────────────────────
self._vpn = detect_vpn()
mode = "VPN-aware (/16)" if self._vpn.active else "strict (exact IP)"
self._notify({
"type": "vpn",
"active": self._vpn.active,
"label": self._vpn.label,
"adapter": self._vpn.adapter,
"leak_mode": mode,
})
self._notify({
"type": "log",
"text": (
f"VPN: {self._vpn.label}"
+ (f" ({self._vpn.adapter})" if self._vpn.adapter else "")
+ f" — leak check: {mode}"
),
})
# Warm-up: retry the direct-IP lookup a few times so a transient
# network blip during startup doesn't put us in permanent fail-closed.
real_ip = None
for attempt in range(3):
real_ip = await get_direct_ip(self._settings.ip_check_url)
if real_ip:
break
if attempt < 2:
await asyncio.sleep(2.0)
if real_ip:
self._notify({"type": "real_ip", "ip": real_ip})
label = "direct/VPN IP" if self._vpn.active else "your real IP"
self._notify({"type": "log", "text": f"{label.capitalize()}: {real_ip}"})
else:
# Leak detection now FAILS CLOSED on unknown real_ip — any chain
# whose exit IP can't be compared against a baseline will be
# treated as a leak and rotated. Make the operator aware up front.
self._notify({"type": "log", "text": (
"Could not determine direct IP after 3 attempts — leak detection "
"will FAIL CLOSED (rotate on any chain whose exit IP cannot be "
"verified). Fix internet / ip_check_url to restore."
)})
self._apply_privacy()
# ── Firewall kill-switch ──────────────────────────────────────────────
if self._settings.kill_switch_enabled:
if is_admin():
ok, msg = fw_engage(gost)
self._notify({"type": "log", "text": msg})
self._notify({"type": "firewall", "engaged": ok})
else:
self._notify({"type": "log", "text": "Kill-switch skipped (not Admin)."})
self._notify({"type": "firewall", "engaged": False})
else:
self._notify({"type": "log", "text": "Kill-switch disabled in settings."})
self._notify({"type": "firewall", "engaged": False})
log.debug(
"Listener %s | refresh=%ds health=%ds max_candidates=%d concurrency=%d",
self._settings.listen_addr(),
int(self._settings.full_refresh_seconds),
int(self._settings.health_check_seconds),
int(self._settings.max_candidates),
int(self._settings.validation_concurrency),
)
# ── Main rotation loop ────────────────────────────────────────────────
full_pool: list[str] = []
last_full = 0.0
rotation_num = 0
while not self._stop.is_set():
now = time.monotonic()
need_refresh = (
not full_pool
or now - last_full >= float(self._settings.full_refresh_seconds)
)
log.debug(
"Loop tick: full_pool=%d available=%d used=%d blacklist=%d need_refresh=%s age=%.0fs",
len(full_pool),
len(self._available),
len(self._used),
len(self._blacklist),
need_refresh,
(now - last_full) if full_pool else 0.0,
)
# Pinned (manual) chain mode — skip pool management
if self._settings.use_pinned_chain and not self._settings.pinned_chain:
self._notify({
"type": "log",
"text": (
"Pinned chain enabled but list is empty — add hops in Chain Builder "
"or disable 'Use pinned chain'."
),
})
if self._settings.use_pinned_chain and self._settings.pinned_chain:
chain = list(self._settings.pinned_chain)
# Optional per-hop TCP probe so a known-dead pinned chain
# doesn't loop forever on the 10 s retry below.
if self._settings.validate_pinned_on_start:
from urllib.parse import urlparse as _urlparse
dead = []
for hop in chain:
try:
p = _urlparse(hop if "://" in hop else "http://" + hop)
if p.hostname and p.port:
if not await _probe_tcp(p.hostname, int(p.port), 6.0):
dead.append(hop)
except Exception: # noqa: BLE001
dead.append(hop)
if dead:
self._notify({"type": "log", "text": (
f"Pinned chain: {len(dead)}/{len(chain)} hop(s) failed TCP "
f"probe — proceeding anyway (will retry on failure)."
)})
rotation_num += 1
self._notify({"type": "rotation", "n": rotation_num})
log.info("Pinned chain run: %d hops", len(chain))
ok = await self._run_chain(gost, chain, real_ip)
if not ok:
await self._sleep_interruptible(10)
if self._stop.is_set():
break
continue
# Fixed exit only: hop count = 1 → chain is only the manual exit (no pool)
mex = self._manual_exit_url()
if mex and not self._settings.use_pinned_chain and self._settings.chain_length == 1:
rotation_num += 1
self._notify({"type": "rotation", "n": rotation_num})
ok = await self._run_chain(gost, [mex], real_ip)
if not ok:
await self._sleep_interruptible(15)
if self._stop.is_set():
break
continue
# ── Pool refresh ─────────────────────────────────────────────────
if need_refresh:
self._notify({"type": "phase", "phase": "fetch"})
log.info("Pool refresh: fetching %d source(s)…", len(self._settings.sources))
full_pool = await self._build_pool()
last_full = time.monotonic()
self._available = list(full_pool)
random.shuffle(self._available)
self._used.clear()
self._notify({"type": "pool", "count": len(full_pool)})
if not full_pool:
self._notify({"type": "log", "text": "Empty pool — retrying in 60s..."})
await self._sleep_interruptible(60)
last_full = 0.0
continue
# ── Pick next chain (shuffle-and-drain, no repeats per cycle) ────
chain = self._pick_chain()
if not chain:
# Pool exhausted for this cycle — reshuffle and restart
self._notify({"type": "log", "text": "Pool cycle complete — reshuffling for next round."})
self._available = list(full_pool)
random.shuffle(self._available)
self._used.clear()
chain = self._pick_chain()
if not chain:
await self._sleep_interruptible(15)
continue
rotation_num += 1
self._notify({"type": "rotation", "n": rotation_num})
log.info(
"Auto chain #%d: %d hops | obfuscation=%s | pool_remain=%d",
rotation_num,
len(chain),
self._settings.obfuscation_mode,
len(self._available),
)
await self._run_chain(gost, chain, real_ip)
if self._stop.is_set():
break
log.info("Main loop exit (stop requested or fatal).")
terminate_process(self._proc)
self._proc = None
# ─────────────────────────────────────────────────────────────────────────
# CHAIN RUNNER
# ─────────────────────────────────────────────────────────────────────────
async def _run_chain(
self, gost: Any, chain: list[str], real_ip: str | None
) -> bool:
"""Start GOST with chain, verify exit IP, monitor until rotation/stop.
Returns True if chain ran successfully, False if it immediately failed."""
self._current_chain = list(chain)
self._notify({"type": "hops", "hops": chain, "status": "connecting"})
self._notify({"type": "phase", "phase": "gost_start"})
if self._settings.flush_dns_on_rotate:
ok, msg = flush_dns_cache()
if ok:
log.debug("DNS cache flushed before chain run")
# Per-rotation MAC re-randomization (only if mac_spoof is also on, since
# without spoof there's no original snapshot we own to mutate).
if (
self._settings.mac_rotate_on_chain_rotate
and self._settings.mac_spoof_enabled
and is_admin()
and self._mac_originals
):
_, logs = spoof_all_physical(self._mac_originals)
for ln in logs:
self._notify({"type": "log", "text": f"MAC rotate: {ln}"})
listen = self._settings.listen_addr()
cmd = build_gost_cmd(gost, listen, chain)
self._notify({
"type": "log",
"text": f"Chain #{len(self._used) // max(1, self._settings.chain_length)}: "
+ "".join(self._short(h) for h in chain),
})
red = " | ".join(redact_proxy_url(h) for h in chain)
log.debug("GOST listen=http://%s | forwards (redacted): %s", listen, red)
log.debug("GOST argv: %s … (%d args)", cmd[0], len(cmd))
terminate_process(self._proc)
self._proc = popen_no_window(cmd)
self._notify({"type": "log", "text": "GOST started — warming up (2s)…"})
for _ in range(4):
if self._stop.is_set():
terminate_process(self._proc)
self._proc = None
return False
await asyncio.sleep(0.5)
if self._proc.poll() is not None:
# GOST died immediately — blacklist pool proxies (never blacklist user fixed exit)
fixed = self._manual_exit_url()
for h in chain:
if fixed and h == fixed:
continue
self._blacklist.add(h)
self._available = [x for x in self._available if x != h]
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
self._notify({"type": "log", "text": "GOST exited immediately — proxies blacklisted."})
tail = read_gost_log_tail(20)
if tail.strip():
for ln in tail.splitlines()[-8:]:
if ln.strip():
self._notify({"type": "log", "text": f" GOST> {ln.rstrip()}"})
return False
local_proxy = f"http://{listen}"
timeout = min(30.0, self._settings.validation_timeout_seconds + 12.0)
self._notify({"type": "phase", "phase": "verify_chain"})
self._notify({
"type": "log",
"text": f"Exit IP check through local proxy (≤{int(timeout * 2 + 5)}s)…",
})
t0 = time.monotonic()
exit_ip = await check_chain_exit_ip(
local_proxy, self._settings.ip_check_url, timeout, chain_hops=len(chain)
)
log.debug("Initial exit IP check took %.2fs → %s", time.monotonic() - t0, exit_ip or "none")
if not exit_ip:
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": None})
self._notify({"type": "log", "text": "Chain IP check failed. Rotating."})
terminate_process(self._proc)
self._proc = None
return False
if is_chain_leak(exit_ip, real_ip, self._vpn.active):
reason = leak_reason(exit_ip, real_ip, self._vpn.active)
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"Leak detected — {reason}. Rotating."})
log.warning("Chain leak: exit=%s real=%s vpn=%s%s", exit_ip, real_ip, self._vpn.active, reason)
# Blacklist the whole chain so we don't reuse broken proxies
fixed = self._manual_exit_url()
for h in chain:
if fixed and h == fixed:
continue
self._blacklist.add(h)
self._available = [x for x in self._available if x != h]
terminate_process(self._proc)
self._proc = None
return False
# HTTPS CONNECT must work before the chain is marked healthy.
https_ok, https_msg = await check_https_tunnel(local_proxy, timeout)
if not https_ok:
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({"type": "log", "text": (
"HTTPS tunnel FAILED — chain forwards plain HTTP but refuses CONNECT. "
"Browsers will time out on HTTPS sites. Rotating. " + https_msg
)})
fixed = self._manual_exit_url()
for h in chain:
if fixed and h == fixed:
continue
self._blacklist.add(h)
self._available = [x for x in self._available if x != h]
terminate_process(self._proc)
self._proc = None
return False
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"✓ Chain healthy — Exit IP: {exit_ip}"})
self._notify({"type": "log", "text": f"✓ HTTPS tunnel OK — {https_msg}"})
self._notify({"type": "phase", "phase": "running"})
# Pre-flight: surface Group Policy locks (they will override us).
pol_before = detect_policy_overrides()
if pol_before:
self._notify({"type": "log", "text": (
f"Group Policy proxy lock detected ({len(pol_before)} entries) — "
"these BEAT our settings. Browsers will keep the policy proxy "
"(or DIRECT) until those keys are removed."
)})
for p in pol_before[:3]:
self._notify({"type": "log", "text": f" ! {p}"})
set_system_proxy(
self._settings.local_host,
self._settings.local_port,
self._settings.proxy_bypass,
)
applied = is_system_proxy_set()
self._notify({
"type": "log",
"text": (
f"System proxy → {self._settings.listen_addr()} "
f"(HKCU + HKLM(adm) + Connections + WinHTTP) "
f"{'OK' if applied else 'FAILED — registry write rejected'}"
),
})
# Post-flight diagnostic — every layer's actual state.
for ln in diagnose_system_proxy():
self._notify({"type": "log", "text": f" proxy: {ln}"})
# ── Health monitor loop ───────────────────────────────────────────────
hc = int(self._settings.health_check_seconds)
while not self._stop.is_set():
log.debug("Health sleep: %ds until next exit check", hc)
result = await self._wait_health_interval()
if result in ("stop", "rotate"):
log.debug("Health loop break: %s", result)
break
if self._proc is None or self._proc.poll() is not None:
self._notify({"type": "log", "text": "GOST process died — rebuilding."})
break
self._notify({"type": "log", "text": "Health check..."})
t1 = time.monotonic()
exit_ip = await check_chain_exit_ip(
local_proxy, self._settings.ip_check_url, timeout, chain_hops=len(chain)
)
log.debug("Periodic exit IP check %.2fs → %s", time.monotonic() - t1, exit_ip or "none")
if is_chain_leak(exit_ip, real_ip, self._vpn.active):
reason = leak_reason(exit_ip, real_ip, self._vpn.active)
rem = self.sticky_remaining()
if rem > 0:
self._notify({
"type": "log",
"text": (
f"Health check flagged ({reason}) — STICKY EXIT active "
f"({int(rem)}s left), skipping rotation."
),
})
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
continue
self._notify({"type": "hops", "hops": chain, "status": "dead", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"Health check failed ({reason}) — rotating."})
break
self._notify({"type": "hops", "hops": chain, "status": "healthy", "exit_ip": exit_ip})
self._notify({"type": "log", "text": f"✓ Still healthy — Exit IP: {exit_ip}"})
terminate_process(self._proc)
self._proc = None
return True
# ─────────────────────────────────────────────────────────────────────────
# POOL MANAGEMENT
# ─────────────────────────────────────────────────────────────────────────
@staticmethod
def _apply_mode_filter(candidates: list[str], mode: str) -> list[str]:
if mode == "http_only":
return [u for u in candidates if u.startswith("http://")]
if mode == "socks5_only":
return [u for u in candidates if u.startswith("socks5://")]
if mode == "random_mix":
out = list(candidates)
random.shuffle(out)
return out
return list(candidates)
def _pick_chain(self) -> list[str]:
"""Pick chain_length hops: optional fixed last hop + random prefix from pool."""
s = self._settings
chain = self._pick_chain_for_mode(s.obfuscation_mode)
if chain:
log.debug(
"Picked chain len=%d mode=%s available_left=%d",
len(chain),
s.obfuscation_mode,
len(self._available),
)
return chain
if s.obfuscation_mode != "auto" and not s.use_pinned_chain:
log.debug("Pick empty under mode=%s — retrying as auto", s.obfuscation_mode)
self._notify({
"type": "log",
"text": "Obfuscation filter left nothing usable — retrying this pick with ALL protocols (auto).",
})
ch2 = self._pick_chain_for_mode("auto")
if ch2:
log.debug("Auto retry picked len=%d", len(ch2))
return ch2
log.debug("Pick chain returned empty (pool exhausted?)")
return []
def _pick_chain_for_mode(self, mode: str) -> list[str]:
"""Build one chain using given mode (http_only / socks5_only / random_mix / auto)."""
s = self._settings
k = max(1, s.chain_length)
manual = self._manual_exit_url()
if manual and not s.use_pinned_chain:
mid_need = k - 1
base = [
u for u in self._available
if u not in self._blacklist and u != manual
]
candidates = self._apply_mode_filter(base, mode)
if mid_need == 0:
return [manual]
if len(candidates) < mid_need:
return []
prefix = candidates[:mid_need]
pset = set(prefix)
self._available = [u for u in self._available if u not in pset]
self._used.update(prefix)
self._used.add(manual)
return prefix + [manual]
base = [u for u in self._available if u not in self._blacklist]
candidates = self._apply_mode_filter(base, mode)
if len(candidates) < k:
return []
picked = candidates[:k]
picked_set = set(picked)
self._available = [u for u in self._available if u not in picked_set]
self._used.update(picked)
return picked
async def _build_pool(self) -> list[str]:
"""Fetch and validate proxies. Runs blocking I/O in thread pool."""
s = self._settings
loop = asyncio.get_running_loop()
log.info("_build_pool: %d source URL(s), prefer_elite=%s", len(s.sources), s.prefer_elite)
# Fetch all sources concurrently in thread pool (they are blocking)
async def _fetch_one(url: str) -> list[str]:
try:
rows = await asyncio.wait_for(
loop.run_in_executor(
_FETCH_POOL,
lambda u=url: fetch_proxy_json(u, timeout=50.0),
),
timeout=55.0,
)
entries = normalize_entries(rows, s.prefer_elite)
if s.prefer_elite and rows and not entries:
self._notify({
"type": "log",
"text": (
f"Source returned {len(rows)} proxies but none marked “elite” — all skipped. "
"Turn off “Elite proxies only” in Chain Builder if every fetch is empty."
),
})
self._notify({"type": "log", "text": f"Fetched {len(entries)} from source."})
return entries
except asyncio.TimeoutError:
self._notify({"type": "log", "text": f"Fetch timed out (55s): {url[:60]}..."})
return []
except Exception as e:
self._notify({"type": "log", "text": f"Fetch error: {e!s}"})
return []
src_urls = list(s.sources) if s.sources else list(Settings().sources)
if not s.sources:
self._notify({
"type": "log",
"text": "Settings had no proxy sources — using built-in defaults (save Settings to persist).",
})
if not src_urls:
self._notify({"type": "log", "text": "No proxy source URLs configured — cannot build a pool."})
return []
results = await asyncio.gather(*(_fetch_one(u) for u in src_urls))
raw_urls: list[str] = []
for chunk in results:
raw_urls.extend(chunk)
if not raw_urls:
self._notify({
"type": "log",
"text": "No proxies fetched from any source (check network, URLs, or turn off “Elite only” if every list is filtered empty).",
})
return []
# Deduplicate, remove blacklisted, shuffle before capping
seen: set[str] = set()
unique: list[str] = []
for u in raw_urls:
if u not in seen and u not in self._blacklist:
seen.add(u)
unique.append(u)
random.shuffle(unique)
if len(unique) > s.max_candidates:
unique = unique[: s.max_candidates]
log.debug("Unique candidates after dedupe/cap: %d (max_candidates=%d)", len(unique), s.max_candidates)
self._notify({"type": "phase", "phase": "validate"})
self._notify({"type": "log", "text": f"Validating {len(unique)} candidates..."})
def on_prog(done: int, total: int) -> None:
self._notify({"type": "validate_progress", "done": done, "total": total})
target = max(s.min_pool_size, s.chain_length * 3)
good = await validate_proxies(
unique,
s.ip_check_url,
s.validation_concurrency,
s.validation_timeout_seconds,
on_progress=on_prog,
target=target,
)
random.shuffle(good)
mex = normalize_proxy_url(s.manual_exit_proxy)
if mex and not s.use_pinned_chain:
self._notify({"type": "phase", "phase": "exit_check"})
self._notify({"type": "log", "text": "Validating fixed exit proxy…"})
v = await validate_proxies(
[mex],
s.ip_check_url,
1,
s.validation_timeout_seconds,
on_progress=None,
)
if v:
self._notify({"type": "log", "text": "Fixed exit proxy: OK (reachable)."})
else:
self._notify({
"type": "log",
"text": "Fixed exit proxy: validation failed — will still attempt; check URL/credentials.",
})
self._notify({"type": "log", "text": f"Valid proxies: {len(good)} / {len(unique)}"})
log.info("_build_pool done: valid=%d / tested=%d", len(good), len(unique))
return good
# ─────────────────────────────────────────────────────────────────────────
# UTILITIES
# ─────────────────────────────────────────────────────────────────────────
async def _wait_health_interval(self) -> str | None:
"""Sleep for health_check_seconds. Returns 'stop', 'rotate', or None."""
total = float(self._settings.health_check_seconds)
end = time.monotonic() + total
while time.monotonic() < end:
if self._stop.is_set():
return "stop"
if self._force_rotate.is_set():
self._force_rotate.clear()
self._notify({"type": "log", "text": "Manual rotate triggered."})
if self._settings.flush_dns_on_rotate:
ok, msg = flush_dns_cache()
self._notify({"type": "log", "text": f"DNS flush: {msg}" if ok else f"DNS flush failed: {msg}"})
return "rotate"
remaining = end - time.monotonic()
self._notify({"type": "countdown", "secs": max(0, int(remaining))})
await asyncio.sleep(1.0)
self._notify({"type": "countdown", "secs": 0})
return None
async def _sleep_interruptible(self, seconds: float) -> None:
end = time.monotonic() + seconds
while time.monotonic() < end:
if self._stop.is_set():
return
await asyncio.sleep(0.2)
@staticmethod
def _short(url: str) -> str:
url = redact_proxy_url(url)
url = (
url.replace("http://", "")
.replace("socks5://", "s5://")
.replace("socks4://", "s4://")
.replace("https://", "")
)
return url[:30] + "" if len(url) > 32 else url

View File

@@ -0,0 +1,153 @@
(function () {
"use strict";
var cfg = null;
var filledKeys = {};
var startedAt = Date.now();
var MAX_MS = 120000;
function loadConfig(cb) {
try {
var rt = typeof browser !== "undefined" ? browser : chrome;
var url = rt.runtime.getURL("autofill_config.json");
fetch(url, { cache: "no-store" })
.then(function (r) { return r.json(); })
.then(function (j) { cb(j || null); })
.catch(function () { cb(null); });
} catch (e) {
cb(null);
}
}
function hostMatches(host, patterns) {
if (!patterns || !patterns.length) return true;
host = (host || "").toLowerCase();
for (var i = 0; i < patterns.length; i++) {
var p = String(patterns[i] || "").toLowerCase();
if (!p) continue;
if (p.indexOf("*") >= 0) {
var re = new RegExp("^" + p.replace(/\./g, "\\.").replace(/\*/g, ".*") + "$");
if (re.test(host)) return true;
} else if (host === p || host.endsWith("." + p)) {
return true;
}
}
return false;
}
function setValue(el, value) {
if (!el || value == null || value === "") return false;
try {
el.focus();
el.value = value;
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
return true;
} catch (e) {
return false;
}
}
function trySelectors(selectors, value) {
if (!selectors || !value) return false;
for (var i = 0; i < selectors.length; i++) {
var nodes = document.querySelectorAll(selectors[i]);
for (var j = 0; j < nodes.length; j++) {
var el = nodes[j];
if (!el || el.disabled || el.readOnly) continue;
if (el.type === "hidden") continue;
if (setValue(el, value)) return true;
}
}
return false;
}
function runFill() {
if (!cfg || !cfg.fields) return;
if (Date.now() - startedAt > MAX_MS) return;
var host = location.hostname || "";
if (cfg.hosts && cfg.hosts.length && !hostMatches(host, cfg.hosts)) return;
var fields = cfg.fields;
var keys = Object.keys(fields);
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var val = fields[key];
if (!val) continue;
if (filledKeys[key]) continue;
if (trySelectors(fields[key + "_selectors"] || defaultSelectors(key), val)) {
filledKeys[key] = true;
}
}
}
function defaultSelectors(key) {
var map = {
email: [
'input[type="email"]',
'input[name="email"]',
'input[name="Email"]',
'input[id="email"]',
'input[autocomplete="email"]',
'input[autocomplete="username"]',
'input[name="identifier"]'
],
password: [
'input[type="password"]:not([name*="Confirm"]):not([name*="confirm"]):not([name*="Again"]):not([name*="again"])',
'input[name="password"]',
'input[name="Passwd"]',
'input[id="password"]',
'input[autocomplete="new-password"]'
],
password_confirm: [
'input[name="PasswdAgain"]',
'input[name="password_confirm"]',
'input[name="confirmPassword"]',
'input[name="passwordConfirmation"]',
'input[autocomplete="new-password"]:nth-of-type(2)'
],
first_name: [
'input[name="firstName"]',
'input[name="firstname"]',
'input[name="first_name"]',
'input[autocomplete="given-name"]'
],
last_name: [
'input[name="lastName"]',
'input[name="lastname"]',
'input[name="last_name"]',
'input[autocomplete="family-name"]'
],
username: [
'input[name="username"]',
'input[name="Username"]',
'input[id="username"]',
'input[autocomplete="username"]'
]
};
return map[key] || [];
}
function boot() {
loadConfig(function (j) {
cfg = j;
if (!cfg) return;
runFill();
var obs = new MutationObserver(function () { runFill(); });
obs.observe(document.documentElement || document.body, {
childList: true,
subtree: true
});
var timer = setInterval(function () {
runFill();
if (Date.now() - startedAt > MAX_MS) clearInterval(timer);
}, 900);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();

View File

@@ -0,0 +1,21 @@
{
"manifest_version": 2,
"name": "Proxy God Signup Autofill",
"version": "1.0.0",
"description": "Autofills signup forms from Proxy God (manual submit + captcha).",
"applications": {
"gecko": {
"id": "signup-autofill@proxygod"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": true
}
],
"permissions": ["<all_urls>"],
"web_accessible_resources": ["autofill_config.json"]
}

View File

@@ -0,0 +1,444 @@
from __future__ import annotations
import json
import secrets
import shutil
import string
import uuid
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from .paths import app_data_dir
_EXT_ID = "signup-autofill@proxygod"
def _ext_src_path() -> Path:
"""Locate the signup_extension dir whether running from source or frozen."""
import sys
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
return Path(sys._MEIPASS) / "proxy_chain_manager" / "signup_extension"
return Path(__file__).resolve().parent / "signup_extension"
@dataclass(frozen=True)
class SignupSitePreset:
key: str
label: str
url: str
hosts: tuple[str, ...]
extra_selectors: dict[str, list[str]] = field(default_factory=dict)
SIGNUP_PRESETS: dict[str, SignupSitePreset] = {
"google": SignupSitePreset(
key="google",
label="Google",
url="https://accounts.google.com/signup/v2/createaccount?flowName=GlifWebSignIn&flowEntry=SignUp",
hosts=("accounts.google.com", "google.com"),
extra_selectors={
"first_name": ['input[name="firstName"]'],
"last_name": ['input[name="lastName"]'],
"email": ['input[name="Username"]', 'input[type="email"]'],
"password": ['input[name="Passwd"]'],
"password_confirm": ['input[name="PasswdAgain"]'],
},
),
"protonmail": SignupSitePreset(
key="protonmail",
label="Proton Mail",
url="https://account.proton.me/signup",
hosts=("account.proton.me", "proton.me"),
extra_selectors={
"email": ['input[id="email"]', 'input[name="email"]', 'input[type="email"]'],
"password": ['input[id="password"]', 'input[name="password"]'],
"password_confirm": ['input[id="password-confirm"]', 'input[name="passwordConfirmation"]'],
},
),
"hydraproxy": SignupSitePreset(
key="hydraproxy",
label="HydraProxy",
url="https://dashboard.hydraproxy.com/register",
hosts=("dashboard.hydraproxy.com", "hydraproxy.com"),
extra_selectors={
"email": ['input[name="email"]', 'input[type="email"]', 'input[id="email"]'],
"password": ['input[name="password"]', 'input[type="password"]'],
"password_confirm": ['input[name="password_confirmation"]', 'input[name="confirmPassword"]'],
"username": ['input[name="username"]', 'input[name="name"]'],
},
),
"outlook": SignupSitePreset(
key="outlook",
label="Outlook / Microsoft",
url="https://signup.live.com/signup",
hosts=("signup.live.com", "login.live.com", "account.microsoft.com"),
extra_selectors={
"email": ['input[name="MemberName"]', 'input[type="email"]'],
"password": ['input[name="Password"]', 'input[name="PasswordInput"]'],
"first_name": ['input[name="FirstName"]'],
"last_name": ['input[name="LastName"]'],
},
),
"github": SignupSitePreset(
key="github",
label="GitHub",
url="https://github.com/signup",
hosts=("github.com",),
extra_selectors={
"email": ['input[name="user[email]"]', 'input[autocomplete="email"]'],
"password": ['input[name="user[password]"]', 'input[autocomplete="new-password"]'],
"username": ['input[name="user[login]"]'],
},
),
"reddit": SignupSitePreset(
key="reddit",
label="Reddit",
url="https://www.reddit.com/register",
hosts=("www.reddit.com", "reddit.com"),
extra_selectors={
"email": ['input[name="email"]', 'input[type="email"]'],
"username": ['input[name="username"]'],
"password": ['input[name="password"]'],
},
),
"discord": SignupSitePreset(
key="discord",
label="Discord",
url="https://discord.com/register",
hosts=("discord.com",),
extra_selectors={
"email": ['input[name="email"]', 'input[type="email"]'],
"username": ['input[name="username"]'],
"password": ['input[name="password"]'],
},
),
"x_twitter": SignupSitePreset(
key="x_twitter",
label="X / Twitter",
url="https://x.com/i/flow/signup",
hosts=("x.com", "twitter.com"),
extra_selectors={
"email": ['input[name="email"]', 'input[type="email"]'],
"username": ['input[name="username"]'],
"password": ['input[name="password"]'],
"first_name": ['input[name="name"]'],
},
),
"telegram": SignupSitePreset(
key="telegram",
label="Telegram Web",
url="https://web.telegram.org/",
hosts=("web.telegram.org", "telegram.org"),
),
"tutanota": SignupSitePreset(
key="tutanota",
label="Tuta Mail",
url="https://app.tuta.com/signup",
hosts=("app.tuta.com", "tutanota.com", "tuta.com"),
extra_selectors={
"email": ['input[id="mailAddress"]', 'input[name="mailAddress"]'],
"password": ['input[id="password1"]', 'input[type="password"]'],
"password_confirm": ['input[id="password2"]'],
},
),
"mailcom": SignupSitePreset(
key="mailcom",
label="mail.com",
url="https://signup.mail.com/",
hosts=("signup.mail.com", "mail.com"),
extra_selectors={
"username": ['input[name="email_user"]', 'input[name="username"]'],
"password": ['input[name="password"]'],
"first_name": ['input[name="firstName"]'],
"last_name": ['input[name="lastName"]'],
},
),
}
# Public site-key list in display order. Useful for building dropdowns.
SIGNUP_PRESET_KEYS: tuple[str, ...] = (
"google", "protonmail", "outlook", "tutanota", "mailcom",
"github", "reddit", "discord", "x_twitter", "telegram",
"hydraproxy",
)
_FIRST_NAMES: tuple[str, ...] = (
"Alex", "Jordan", "Casey", "Morgan", "Taylor", "Riley", "Cameron",
"Sam", "Jamie", "Quinn", "Avery", "Drew", "Reese", "Skyler", "Logan",
"Hayden", "Parker", "Rowan", "Sage", "Emerson", "Finley", "Harper",
"Kai", "Micah", "Nico", "Phoenix", "River", "Shawn", "Sutton", "Wren",
)
_LAST_NAMES: tuple[str, ...] = (
"Reed", "Hayes", "Brooks", "Bennett", "Carter", "Foster", "Gray",
"Hughes", "Jensen", "Knight", "Lambert", "Mason", "Nash", "Owens",
"Porter", "Quincy", "Reyes", "Sutton", "Tate", "Underwood", "Vance",
"Walsh", "Yates", "Zimmerman", "Abbott", "Barker", "Cohen", "Dixon",
"Ellis", "Fitch",
)
def random_first_name() -> str:
return secrets.choice(_FIRST_NAMES)
def random_last_name() -> str:
return secrets.choice(_LAST_NAMES)
def random_full_name() -> tuple[str, str]:
return random_first_name(), random_last_name()
def random_username(first: str = "", last: str = "") -> str:
"""Generate a plausible-looking username.
If a name is provided we mix it with digits; otherwise we use two short
name fragments. Always lowercase, always 8-18 chars.
"""
f = (first or random_first_name()).lower()
l = (last or random_last_name()).lower()
seps = ("", ".", "_", "")
sep = secrets.choice(seps)
digits = "".join(secrets.choice(string.digits) for _ in range(secrets.choice([2, 3, 4])))
style = secrets.randbelow(4)
if style == 0:
base = f + sep + l + digits
elif style == 1:
base = f + digits
elif style == 2:
base = l + sep + f[0] + digits
else:
base = f[0] + sep + l + digits
base = base.strip(".-_")
return base[:18]
@dataclass
class SignupDraft:
site_key: str = "google"
custom_url: str = ""
email: str = ""
password: str = ""
username: str = ""
first_name: str = ""
last_name: str = ""
notes: str = ""
@dataclass
class AccountRecord:
id: str
site: str
url: str
email: str
password: str
username: str = ""
first_name: str = ""
last_name: str = ""
exit_ip: str = ""
exit_hop: str = ""
notes: str = ""
status: str = "created" # pending | created
created_at: str = ""
def accounts_path() -> Path:
return app_data_dir() / "signup_accounts.json"
def draft_path() -> Path:
return app_data_dir() / "signup_draft.json"
def generate_password(length: int = 18) -> str:
n = max(12, min(64, int(length)))
alphabet = string.ascii_letters + string.digits + "!@#$%^&*-_=+"
while True:
pw = "".join(secrets.choice(alphabet) for _ in range(n))
if (
any(c.islower() for c in pw)
and any(c.isupper() for c in pw)
and any(c.isdigit() for c in pw)
):
return pw
def preset_for(key: str) -> SignupSitePreset | None:
return SIGNUP_PRESETS.get(key)
def resolve_signup_url(draft: SignupDraft) -> str:
if draft.site_key == "custom":
return (draft.custom_url or "").strip()
p = preset_for(draft.site_key)
return p.url if p else ""
def load_draft() -> SignupDraft:
from . import secrets_store as _ss
p = draft_path()
if not p.is_file():
return SignupDraft()
try:
raw = json.loads(p.read_text(encoding="utf-8"))
kwargs = {k: raw.get(k, "") for k in SignupDraft.__dataclass_fields__}
if kwargs.get("password"):
kwargs["password"] = _ss.decrypt(kwargs["password"])
return SignupDraft(**kwargs)
except (OSError, json.JSONDecodeError, TypeError):
return SignupDraft()
def save_draft(draft: SignupDraft) -> None:
from . import secrets_store as _ss
d = asdict(draft)
if d.get("password"):
d["password"] = _ss.encrypt(d["password"])
draft_path().write_text(json.dumps(d, indent=2), encoding="utf-8")
def load_accounts() -> list[AccountRecord]:
from . import secrets_store as _ss
p = accounts_path()
if not p.is_file():
return []
try:
raw = json.loads(p.read_text(encoding="utf-8"))
if not isinstance(raw, list):
return []
out: list[AccountRecord] = []
for item in raw:
if not isinstance(item, dict):
continue
fields = AccountRecord.__dataclass_fields__
kwargs = {k: item.get(k, "") for k in fields}
if not kwargs.get("id"):
kwargs["id"] = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S%f")
# Transparent DPAPI decrypt; plaintext passes through.
if kwargs.get("password"):
kwargs["password"] = _ss.decrypt(kwargs["password"])
out.append(AccountRecord(**kwargs))
return out
except (OSError, json.JSONDecodeError, TypeError):
return []
def save_accounts(rows: list[AccountRecord]) -> None:
from . import secrets_store as _ss
serialized = []
for r in rows:
d = asdict(r)
# Encrypt sensitive fields at rest using Windows DPAPI when available.
# Legacy plaintext entries are migrated transparently on first save.
if d.get("password"):
d["password"] = _ss.encrypt(d["password"])
serialized.append(d)
accounts_path().write_text(
json.dumps(serialized, indent=2),
encoding="utf-8",
)
def append_account(record: AccountRecord) -> None:
rows = load_accounts()
rows.insert(0, record)
save_accounts(rows)
def build_autofill_config(draft: SignupDraft) -> dict[str, Any]:
preset = preset_for(draft.site_key)
hosts = list(preset.hosts) if preset else []
selectors: dict[str, list[str]] = {}
if preset:
selectors.update(preset.extra_selectors)
fields: dict[str, str] = {}
if draft.first_name:
fields["first_name"] = draft.first_name
if draft.last_name:
fields["last_name"] = draft.last_name
if draft.username:
fields["username"] = draft.username
if draft.email:
fields["email"] = draft.email
if draft.password:
fields["password"] = draft.password
fields["password_confirm"] = draft.password
out_fields: dict[str, Any] = {}
for key, val in fields.items():
out_fields[key] = val
sel_key = f"{key}_selectors"
if key in selectors:
out_fields[sel_key] = selectors[key]
return {
"site": draft.site_key,
"hosts": hosts,
"fields": out_fields,
"updated_at": datetime.now(timezone.utc).isoformat(),
}
def _extensions_json_entry(ext_dir_name: str) -> dict[str, Any]:
now = int(datetime.now(timezone.utc).timestamp()) * 1000
return {
"id": _EXT_ID,
"syncGUID": str(uuid.uuid4()),
"version": "1.0.0",
"type": "extension",
"location": {
"appProfile": {
"root": "app-profile",
"path": f"extensions/{ext_dir_name}",
}
},
"internalName": None,
"updateURL": None,
"webExtensionConverted": False,
"webExtensionId": _EXT_ID,
"active": True,
"userDisabled": False,
"appDisabled": False,
"installDate": now,
"scope": 1,
}
def install_signup_extension(profile_dir: Path, draft: SignupDraft) -> None:
"""Copy autofill WebExtension into the Firefox profile and write current field values."""
profile_dir.mkdir(parents=True, exist_ok=True)
ext_name = _EXT_ID
dest = profile_dir / "extensions" / ext_name
if dest.exists():
shutil.rmtree(dest, ignore_errors=True)
shutil.copytree(_ext_src_path(), dest)
cfg = build_autofill_config(draft)
(dest / "autofill_config.json").write_text(json.dumps(cfg, indent=2), encoding="utf-8")
ext_json_path = profile_dir / "extensions.json"
entry = _extensions_json_entry(ext_name)
data: dict[str, Any]
if ext_json_path.is_file():
try:
data = json.loads(ext_json_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {"schemaVersion": 35, "addons": []}
else:
data = {"schemaVersion": 35, "addons": []}
addons = data.get("addons")
if not isinstance(addons, list):
addons = []
addons = [a for a in addons if isinstance(a, dict) and a.get("id") != _EXT_ID]
addons.append(entry)
data["addons"] = addons
ext_json_path.write_text(json.dumps(data, indent=2), encoding="utf-8")

View File

@@ -0,0 +1,574 @@
"""Set / clear the Windows system-wide HTTP proxy.
Layers configured so the proxy actually applies system-wide:
1. ``HKCU\\\\Internet Settings`` — simple values WinINet reads first
2. ``Internet Settings\\Connections\\DefaultConnectionSettings`` binary blob —
overrides #1 for any process that opens the per-connection structure.
3. ``HKLM\\\\Internet Settings`` mirror (Admin only) — required on Server when
``ProxySettingsPerUser=0`` is set by Group Policy / baseline image.
4. ``netsh winhttp set proxy`` — .NET, Windows Update, services and
headless browser components that bypass WinINet.
We also actively detect and warn about:
- ``ProxySettingsPerUser=0`` → HKCU is ignored; we fix it when Admin.
- Group Policy proxy keys → those override us; we surface the path so the
user can verify or remove them.
- ``AutoConfigURL`` / ``AutoDetect`` (WPAD) → wiped on engage.
"""
from __future__ import annotations
import ctypes
import logging
import sys
import struct
import subprocess
import winreg
log = logging.getLogger(__name__)
_KEY_PATH = r"Software\Microsoft\Windows\CurrentVersion\Internet Settings"
_CONN_KEY_PATH = (
r"Software\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"
)
_HKLM_INET_PATH = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings"
_HKLM_CONN_PATH = (
r"SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections"
)
_POLICY_PATHS = (
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings"),
(winreg.HKEY_CURRENT_USER,
r"SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings"),
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Internet Explorer\Control Panel"),
)
_SETTINGS_CHANGED = 39
_REFRESH = 37
_FLAG_DIRECT = 0x01
_FLAG_MANUAL_PROXY = 0x02
_FLAG_AUTO_CONFIG = 0x04
_FLAG_AUTO_DETECT = 0x08
def _mac_network_services() -> list[str]:
if sys.platform != "darwin":
return []
try:
r = subprocess.run(
["networksetup", "-listallnetworkservices"],
capture_output=True,
text=True,
timeout=12,
)
except Exception:
return []
services: list[str] = []
for line in (r.stdout or "").splitlines():
name = line.strip()
if not name or name.startswith("An asterisk"):
continue
services.append(name.lstrip("*").strip())
return services
def _mac_run_networksetup(args: list[str]) -> None:
try:
subprocess.run(["networksetup", *args], capture_output=True, text=True, timeout=20)
except Exception as exc:
log.debug("networksetup failed (%s): %s", args, exc)
def _mac_set_system_proxy(host: str, port: int, bypass: str) -> None:
bypass_hosts = [
h.strip().replace("*", "")
for h in bypass.replace(";", ",").split(",")
if h.strip() and h.strip() != "<local>"
]
for service in _mac_network_services():
_mac_run_networksetup(["-setwebproxy", service, host, str(port)])
_mac_run_networksetup(["-setsecurewebproxy", service, host, str(port)])
_mac_run_networksetup(["-setwebproxystate", service, "on"])
_mac_run_networksetup(["-setsecurewebproxystate", service, "on"])
if bypass_hosts:
_mac_run_networksetup(["-setproxybypassdomains", service, *bypass_hosts])
log.info("macOS system proxy set to %s:%s for %d service(s)", host, port, len(_mac_network_services()))
def _mac_clear_system_proxy() -> None:
for service in _mac_network_services():
_mac_run_networksetup(["-setwebproxystate", service, "off"])
_mac_run_networksetup(["-setsecurewebproxystate", service, "off"])
log.info("macOS system proxy cleared for %d service(s)", len(_mac_network_services()))
def _mac_system_proxy_set() -> bool:
for service in _mac_network_services():
try:
r = subprocess.run(
["networksetup", "-getwebproxy", service],
capture_output=True,
text=True,
timeout=8,
)
if "Enabled: Yes" in (r.stdout or ""):
return True
except Exception:
continue
return False
def _is_admin() -> bool:
try:
return ctypes.windll.shell32.IsUserAnAdmin() != 0 # type: ignore[attr-defined]
except Exception:
return False
def _broadcast() -> None:
"""Tell running apps (browsers, etc.) that proxy settings changed."""
try:
inet = ctypes.windll.wininet # type: ignore[attr-defined]
inet.InternetSetOptionW(0, _SETTINGS_CHANGED, 0, 0)
inet.InternetSetOptionW(0, _REFRESH, 0, 0)
except Exception:
pass
def _bump_counter(prev: bytes | None) -> int:
if not prev or len(prev) < 8:
return 1
try:
return int.from_bytes(prev[4:8], "little") + 1
except Exception:
return 1
def _build_blob(
proxy: str,
bypass: str,
auto_config_url: str = "",
counter: int = 1,
) -> bytes:
"""REG_BINARY layout for DefaultConnectionSettings (little-endian)."""
flags = _FLAG_DIRECT
if proxy:
flags |= _FLAG_MANUAL_PROXY
if auto_config_url:
flags |= _FLAG_AUTO_CONFIG
p = proxy.encode("utf-8")
b = bypass.encode("utf-8")
a = auto_config_url.encode("utf-8")
return (
struct.pack("<III", 0x46, counter, flags)
+ struct.pack("<I", len(p)) + p
+ struct.pack("<I", len(b)) + b
+ struct.pack("<I", len(a)) + a
+ b"\x00" * 32
)
def _read_blob_counter(hive: int, path: str) -> int:
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
val, _ = winreg.QueryValueEx(key, "DefaultConnectionSettings")
return _bump_counter(val)
except OSError:
return 1
def _write_conn_blob(hive: int, path: str, proxy: str, bypass: str) -> bool:
counter = _read_blob_counter(hive, path)
blob = _build_blob(proxy, bypass, "", counter)
try:
key = winreg.CreateKeyEx(hive, path, 0, winreg.KEY_SET_VALUE)
with key:
winreg.SetValueEx(key, "DefaultConnectionSettings", 0, winreg.REG_BINARY, blob)
winreg.SetValueEx(key, "SavedLegacySettings", 0, winreg.REG_BINARY, blob)
return True
except OSError as e:
log.debug("Connections blob write failed (%s): %s", path, e)
return False
def _clear_conn_blob(hive: int, path: str) -> None:
counter = _read_blob_counter(hive, path)
blob = _build_blob("", "", "", counter)
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_SET_VALUE) as key:
winreg.SetValueEx(key, "DefaultConnectionSettings", 0, winreg.REG_BINARY, blob)
winreg.SetValueEx(key, "SavedLegacySettings", 0, winreg.REG_BINARY, blob)
except OSError:
pass
def _wipe_autoconfig(key: winreg.HKEYType) -> None:
for name in ("AutoConfigURL", "AutoDetect"):
try:
winreg.DeleteValue(key, name)
except OSError:
pass
def _set_winhttp_proxy(host: str, port: int, bypass: str) -> None:
try:
bypass_list = (
bypass.replace(";", " ").replace("<local>", "<local>").strip()
) or "<local>"
subprocess.run(
["netsh", "winhttp", "set", "proxy",
f"{host}:{port}", f"bypass-list={bypass_list}"],
capture_output=True, text=True, timeout=15,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception as e:
log.debug("netsh winhttp set proxy failed: %s", e)
def _reset_winhttp_proxy() -> None:
try:
subprocess.run(
["netsh", "winhttp", "reset", "proxy"],
capture_output=True, text=True, timeout=15,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
pass
# ── browser-policy proxy (Chrome / Edge) ─────────────────────────────────────
#
# Why this exists: when you set WebRTC policy on Chrome/Edge, the browser sees
# itself as "managed". Some managed-Chrome builds then *ignore* the system
# proxy unless an explicit ``ProxyServer`` policy is also set. They also keep
# trying HTTP/3 over QUIC (UDP/443), which bypasses HTTP proxies entirely —
# under the kill-switch those UDP packets get dropped and pages hang forever.
#
# Setting these policy values pins the browser to our chain (HTTP proxy) and
# forces it off QUIC. Cleared cleanly on disengage.
_BROWSER_POLICY_PATHS = (
r"SOFTWARE\Policies\Google\Chrome",
r"SOFTWARE\Policies\Microsoft\Edge",
r"SOFTWARE\Policies\Chromium",
)
def _set_browser_proxy_policy(host: str, port: int, bypass: str) -> int:
"""Pin Chromium-based browsers (Chrome, Edge, Chromium) to our local proxy
via Group Policy registry, and disable QUIC. Returns count of keys updated.
Requires Admin to write under HKLM\\SOFTWARE\\Policies. Silently skips when
not elevated — the user gets a warning in the service log instead.
"""
if not _is_admin():
return 0
proxy_value = f"http={host}:{port};https={host}:{port}"
bypass_value = bypass.replace(";", ",")
written = 0
for path in _BROWSER_POLICY_PATHS:
try:
with winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyMode", 0, winreg.REG_SZ, "fixed_servers")
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, proxy_value)
winreg.SetValueEx(key, "ProxyBypassList", 0, winreg.REG_SZ, bypass_value)
winreg.SetValueEx(key, "QuicAllowed", 0, winreg.REG_DWORD, 0)
written += 1
except OSError:
continue
return written
def _clear_browser_proxy_policy() -> None:
if not _is_admin():
return
for path in _BROWSER_POLICY_PATHS:
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_SET_VALUE
) as key:
for name in ("ProxyMode", "ProxyServer", "ProxyBypassList", "QuicAllowed"):
try:
winreg.DeleteValue(key, name)
except OSError:
pass
except OSError:
continue
def _read_proxy_settings_per_user() -> int | None:
"""``HKLM\\\\Internet Settings\\ProxySettingsPerUser``.
When 0, Windows ignores HKCU proxy values and reads only HKLM. Set on many
Server images and AD policy baselines — the #1 cause of "set the proxy but
the browser still goes direct" on Server.
"""
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_QUERY_VALUE
) as key:
val, _ = winreg.QueryValueEx(key, "ProxySettingsPerUser")
return int(val)
except OSError:
return None
def _fix_proxy_settings_per_user() -> bool:
"""Force ``ProxySettingsPerUser=1`` (per-user proxy honored). Admin only."""
if not _is_admin():
return False
try:
with winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxySettingsPerUser", 0, winreg.REG_DWORD, 1)
log.info("HKLM\\\\ProxySettingsPerUser → 1 (was %s)", _read_proxy_settings_per_user())
return True
except OSError as e:
log.warning("Could not fix ProxySettingsPerUser: %s", e)
return False
def _set_hklm_root(proxy: str, bypass: str) -> bool:
"""Mirror the per-user keys at HKLM so machine-scope WinINet reads honor us."""
if not _is_admin():
return False
try:
with winreg.CreateKeyEx(
winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 1)
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, proxy)
winreg.SetValueEx(key, "ProxyOverride", 0, winreg.REG_SZ, bypass)
_wipe_autoconfig(key)
return True
except OSError as e:
log.warning("HKLM root proxy write failed: %s", e)
return False
def _clear_hklm_root() -> None:
if not _is_admin():
return
try:
with winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 0)
_wipe_autoconfig(key)
except OSError:
pass
def detect_policy_overrides() -> list[str]:
if sys.platform != "win32":
return []
"""Return human-readable paths of Group Policy proxy locks that beat us.
Anything we find here will silently override our manual proxy. We do *not*
delete policy keys (admins set those deliberately) — we just surface them.
"""
hits: list[str] = []
proxy_value_names = ("ProxyServer", "ProxyEnable", "AutoConfigURL", "ProxyOverride")
for hive, path in _POLICY_PATHS:
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
for n in proxy_value_names:
try:
winreg.QueryValueEx(key, n)
hive_name = "HKLM" if hive == winreg.HKEY_LOCAL_MACHINE else "HKCU"
hits.append(f"{hive_name}\\{path}\\{n}")
except OSError:
continue
except OSError:
continue
return hits
def _read_str(hive: int, path: str, name: str) -> str:
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
val, _ = winreg.QueryValueEx(key, name)
return str(val)
except OSError:
return ""
def _read_dword(hive: int, path: str, name: str) -> int | None:
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
val, _ = winreg.QueryValueEx(key, name)
return int(val)
except OSError:
return None
def _read_winhttp_proxy() -> str:
try:
r = subprocess.run(
["netsh", "winhttp", "show", "proxy"],
capture_output=True, text=True, timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
for ln in (r.stdout or "").splitlines():
if "proxy" in ln.lower() and ":" in ln:
return ln.strip()
return (r.stdout or "").strip().splitlines()[-1] if r.stdout else ""
except Exception:
return ""
def diagnose_system_proxy() -> list[str]:
if sys.platform == "darwin":
out = []
for service in _mac_network_services()[:8]:
try:
r = subprocess.run(
["networksetup", "-getwebproxy", service],
capture_output=True,
text=True,
timeout=8,
)
first = " ".join((r.stdout or "").splitlines()[:3])
except Exception as exc:
first = str(exc)
out.append(f"macOS {service}: {first}")
return out or ["macOS network services not detected"]
"""Return a list of human-readable lines covering every place proxy can
be configured. Logged on each engage so "chain green but browser leaks"
is debuggable without RegEdit gymnastics.
"""
lines: list[str] = []
per_user = _read_proxy_settings_per_user()
per_user_msg = (
"1 (HKCU honored)" if per_user == 1
else "0 (HKCU IGNORED — Server lock!)" if per_user == 0
else "(unset → defaults to HKCU)"
)
lines.append(f"ProxySettingsPerUser = {per_user_msg}")
en_u = _read_dword(winreg.HKEY_CURRENT_USER, _KEY_PATH, "ProxyEnable")
ps_u = _read_str(winreg.HKEY_CURRENT_USER, _KEY_PATH, "ProxyServer")
lines.append(f"HKCU Enable={en_u} Server='{ps_u}'")
en_m = _read_dword(winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, "ProxyEnable")
ps_m = _read_str(winreg.HKEY_LOCAL_MACHINE, _HKLM_INET_PATH, "ProxyServer")
lines.append(f"HKLM Enable={en_m} Server='{ps_m}'")
ac_u = _read_str(winreg.HKEY_CURRENT_USER, _KEY_PATH, "AutoConfigURL")
ad_u = _read_dword(winreg.HKEY_CURRENT_USER, _KEY_PATH, "AutoDetect")
if ac_u or ad_u:
lines.append(f"WPAD/PAC: AutoConfigURL='{ac_u}' AutoDetect={ad_u}")
winhttp = _read_winhttp_proxy()
if winhttp:
lines.append(f"WinHTTP: {winhttp}")
pol = detect_policy_overrides()
if pol:
lines.append(f"Policy overrides (these BEAT us): {len(pol)} entries")
for p in pol[:5]:
lines.append(f" ! {p}")
if len(pol) > 5:
lines.append(f" ! …and {len(pol) - 5} more")
else:
lines.append("Policy overrides: none")
return lines
def set_system_proxy(
host: str,
port: int,
bypass: str = "localhost;127.*;10.*;192.168.*;<local>",
) -> None:
if sys.platform == "darwin":
_mac_set_system_proxy(host, port, bypass)
return
proxy = f"{host}:{port}"
# HKCU root
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 1)
winreg.SetValueEx(key, "ProxyServer", 0, winreg.REG_SZ, proxy)
winreg.SetValueEx(key, "ProxyOverride", 0, winreg.REG_SZ, bypass)
_wipe_autoconfig(key)
except OSError:
log.exception("Failed to set system proxy (HKCU root)")
return
_write_conn_blob(winreg.HKEY_CURRENT_USER, _CONN_KEY_PATH, proxy, bypass)
# If ProxySettingsPerUser=0 (Server / GPO baseline), HKCU is ignored.
# When Admin, fix the flag AND mirror to HKLM so the proxy actually applies.
per_user = _read_proxy_settings_per_user()
if per_user == 0:
log.warning(
"ProxySettingsPerUser=0 detected — Windows is ignoring per-user "
"proxy. Attempting Admin fix (HKLM mirror)."
)
if _is_admin():
_fix_proxy_settings_per_user()
_set_hklm_root(proxy, bypass)
_write_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH, proxy, bypass)
else:
log.warning(
"Not running as Admin — cannot fix ProxySettingsPerUser. "
"Re-launch elevated for proxy to apply on this Server image."
)
# On Admin runs always mirror HKLM root too. Harmless when PerUser=1 and
# correct when PerUser=0 or absent.
if _is_admin():
_set_hklm_root(proxy, bypass)
_write_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH, proxy, bypass)
_set_winhttp_proxy(host, port, bypass)
n_browser = _set_browser_proxy_policy(host, port, bypass)
_broadcast()
log.info(
"System proxy set %s (bypass=%s) — HKCU + HKLM(adm) + Connections + WinHTTP + %d browser policy",
proxy, bypass, n_browser,
)
def clear_system_proxy() -> None:
if sys.platform == "darwin":
_mac_clear_system_proxy()
return
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_SET_VALUE
) as key:
winreg.SetValueEx(key, "ProxyEnable", 0, winreg.REG_DWORD, 0)
_wipe_autoconfig(key)
except OSError:
log.exception("Failed to clear system proxy")
_clear_conn_blob(winreg.HKEY_CURRENT_USER, _CONN_KEY_PATH)
if _is_admin():
_clear_hklm_root()
_clear_conn_blob(winreg.HKEY_LOCAL_MACHINE, _HKLM_CONN_PATH)
_reset_winhttp_proxy()
_clear_browser_proxy_policy()
_broadcast()
log.info("System proxy cleared — HKCU + HKLM(adm) + Connections + WinHTTP + browser policy")
def is_system_proxy_set() -> bool:
if sys.platform == "darwin":
return _mac_system_proxy_set()
try:
with winreg.OpenKey(
winreg.HKEY_CURRENT_USER, _KEY_PATH, 0, winreg.KEY_QUERY_VALUE
) as key:
val, _ = winreg.QueryValueEx(key, "ProxyEnable")
return val == 1
except OSError:
return False

View File

@@ -0,0 +1,292 @@
"""Reversibly disable Windows telemetry / data collection.
Targets, in order of leak severity:
• DiagTrack service — primary telemetry pipeline ("Connected User
Experiences and Telemetry"). Phones home every
few minutes.
• dmwappushservice — WAP Push routing (DSMS), historically tied to
DiagTrack. Disabled when present.
• AllowTelemetry (HKLM) — DataCollection policy. 0 = lowest tier.
• Activity History — EnableActivityFeed / Upload / PublishUserActivities.
• Advertising ID — HKCU\\\\AdvertisingInfo\\Enabled.
• Cortana Bing search — HKCU\\\\Search\\BingSearchEnabled.
• Scheduled tasks — Compatibility Appraiser + CEIP Consolidator
+ UsbCeip.
Every change is snapshotted before mutation; ``restore_telemetry`` rolls back.
"""
from __future__ import annotations
import json
import logging
import subprocess
import winreg
from dataclasses import dataclass, field
from .firewall import is_admin
log = logging.getLogger(__name__)
_SERVICES = ("DiagTrack", "dmwappushservice")
_SCHED_TASKS = (
r"\Microsoft\Windows\Application Experience\Microsoft Compatibility Appraiser",
r"\Microsoft\Windows\Customer Experience Improvement Program\Consolidator",
r"\Microsoft\Windows\Customer Experience Improvement Program\UsbCeip",
)
# (hive, path, value_name, dword_when_killed)
_REG_KILLS = (
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\DataCollection",
"AllowTelemetry", 0),
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\System",
"EnableActivityFeed", 0),
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\System",
"PublishUserActivities", 0),
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\System",
"UploadUserActivities", 0),
(winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\CloudContent",
"DisableWindowsConsumerFeatures", 1),
(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\AdvertisingInfo",
"Enabled", 0),
(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Search",
"BingSearchEnabled", 0),
(winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Search",
"CortanaConsent", 0),
)
@dataclass
class TelemetrySnapshot:
service_state: dict[str, str] = field(default_factory=dict) # name -> 'auto'|'manual'|'disabled'|'absent'
sched_tasks_state: dict[str, bool] = field(default_factory=dict) # task -> was_enabled
reg_values: list[dict] = field(default_factory=list)
def _run(args: list[str], timeout: float = 12.0) -> tuple[int, str, str]:
try:
r = subprocess.run(
args, capture_output=True, text=True, timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return r.returncode, r.stdout or "", r.stderr or ""
except Exception as e:
return 1, "", str(e)
# ── service helpers ──────────────────────────────────────────────────────────
def _service_start_type(name: str) -> str:
code, out, _ = _run(["sc", "qc", name])
if code != 0:
return "absent"
for ln in out.splitlines():
s = ln.strip().lower()
if "start_type" in s:
if "2" in s or "auto" in s:
return "auto"
if "3" in s or "demand" in s or "manual" in s:
return "manual"
if "4" in s or "disabled" in s:
return "disabled"
return "absent"
def _set_service_disabled(name: str) -> bool:
_run(["sc", "stop", name], timeout=15)
code, _, _ = _run(["sc", "config", name, "start=", "disabled"])
return code == 0
def _set_service_start(name: str, want: str) -> bool:
start_value = {"auto": "auto", "manual": "demand", "disabled": "disabled"}.get(want)
if not start_value:
return False
_run(["sc", "stop", name], timeout=10)
code, _, _ = _run(["sc", "config", name, "start=", start_value])
if want == "auto":
_run(["sc", "start", name], timeout=15)
return code == 0
# ── scheduled-task helpers ──────────────────────────────────────────────────
def _task_is_enabled(name: str) -> bool | None:
code, out, _ = _run(["schtasks", "/Query", "/TN", name])
if code != 0:
return None
for ln in out.splitlines():
s = ln.strip().lower()
if s.startswith("status:") or "ready" in s or "disabled" in s:
if "disabled" in s:
return False
if "ready" in s or "running" in s:
return True
return True # assume ready when query succeeded
def _set_task_state(name: str, enable: bool) -> bool:
code, _, _ = _run([
"schtasks", "/Change", "/TN", name,
"/Enable" if enable else "/Disable",
])
return code == 0
# ── registry helpers ────────────────────────────────────────────────────────
def _read_reg_dword(hive: int, path: str, name: str) -> tuple[bool, int | None]:
"""Returns (present, value)."""
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_QUERY_VALUE) as key:
v, _ = winreg.QueryValueEx(key, name)
return True, int(v)
except OSError:
return False, None
def _write_reg_dword(hive: int, path: str, name: str, value: int) -> bool:
try:
with winreg.CreateKeyEx(hive, path, 0, winreg.KEY_SET_VALUE) as key:
winreg.SetValueEx(key, name, 0, winreg.REG_DWORD, value)
return True
except OSError:
return False
def _delete_reg_value(hive: int, path: str, name: str) -> None:
try:
with winreg.OpenKey(hive, path, 0, winreg.KEY_SET_VALUE) as key:
winreg.DeleteValue(key, name)
except OSError:
pass
# ── public API ──────────────────────────────────────────────────────────────
def telemetry_status() -> dict[str, str]:
"""Read-only snapshot for the audit panel."""
out: dict[str, str] = {}
for svc in _SERVICES:
out[f"svc.{svc}"] = _service_start_type(svc)
pres, val = _read_reg_dword(
winreg.HKEY_LOCAL_MACHINE,
r"SOFTWARE\Policies\Microsoft\Windows\DataCollection",
"AllowTelemetry",
)
out["AllowTelemetry"] = "0 (killed)" if pres and val == 0 else (
f"{val}" if pres else "unset"
)
pres, val = _read_reg_dword(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\AdvertisingInfo",
"Enabled",
)
out["AdvertisingID"] = "off" if pres and val == 0 else (
"on" if pres and val == 1 else "unset"
)
return out
def engage_telemetry_kill() -> tuple[TelemetrySnapshot | None, list[str]]:
logs: list[str] = []
if not is_admin():
return None, ["Telemetry kill skipped — needs Administrator."]
snap = TelemetrySnapshot()
for svc in _SERVICES:
st = _service_start_type(svc)
snap.service_state[svc] = st
if st == "absent":
continue
if _set_service_disabled(svc):
logs.append(f"Service '{svc}' stopped + disabled (was {st}).")
else:
logs.append(f"Service '{svc}' could not be disabled.")
for task in _SCHED_TASKS:
was = _task_is_enabled(task)
if was is None:
continue
snap.sched_tasks_state[task] = was
if was:
if _set_task_state(task, enable=False):
logs.append(f"Scheduled task disabled: {task}")
for hive, path, name, kill_val in _REG_KILLS:
present, prev = _read_reg_dword(hive, path, name)
snap.reg_values.append({
"hive": "HKLM" if hive == winreg.HKEY_LOCAL_MACHINE else "HKCU",
"path": path,
"name": name,
"present": present,
"prev": prev,
})
if _write_reg_dword(hive, path, name, kill_val):
logs.append(f"reg: {('HKLM' if hive == winreg.HKEY_LOCAL_MACHINE else 'HKCU')}\\{path}\\{name}{kill_val}")
else:
logs.append(f"reg write failed: {path}\\{name}")
return snap, logs
def restore_telemetry(snap: TelemetrySnapshot | None) -> list[str]:
if snap is None or not is_admin():
return []
logs: list[str] = []
for svc, prev in snap.service_state.items():
if prev == "absent":
continue
if _set_service_start(svc, prev):
logs.append(f"Service '{svc}' restored → {prev}.")
for task, was in snap.sched_tasks_state.items():
if was:
_set_task_state(task, enable=True)
logs.append(f"Scheduled task re-enabled: {task}")
for entry in snap.reg_values:
hive = winreg.HKEY_LOCAL_MACHINE if entry["hive"] == "HKLM" else winreg.HKEY_CURRENT_USER
path = entry["path"]
name = entry["name"]
if entry["present"]:
_write_reg_dword(hive, path, name, int(entry["prev"]))
logs.append(f"reg restore: {entry['hive']}\\{path}\\{name}{entry['prev']}")
else:
_delete_reg_value(hive, path, name)
logs.append(f"reg removed (was absent): {entry['hive']}\\{path}\\{name}")
return logs
def snapshot_to_json(snap: TelemetrySnapshot | None) -> str:
if snap is None:
return ""
return json.dumps({
"services": snap.service_state,
"tasks": snap.sched_tasks_state,
"reg": snap.reg_values,
})
def snapshot_from_json(raw: str) -> TelemetrySnapshot | None:
if not raw:
return None
try:
d = json.loads(raw)
return TelemetrySnapshot(
service_state=dict(d.get("services") or {}),
sched_tasks_state={k: bool(v) for k, v in (d.get("tasks") or {}).items()},
reg_values=list(d.get("reg") or []),
)
except Exception:
return None

160
proxy_chain_manager/tray.py Normal file
View File

@@ -0,0 +1,160 @@
"""System tray: LED-style proxy on/off + hover tooltip with exit IP."""
from __future__ import annotations
import logging
import sys
import threading
from typing import Any, Callable
log = logging.getLogger(__name__)
try:
import pystray
except Exception as exc: # noqa: BLE001
pystray = None # type: ignore[assignment]
log.warning("System tray unavailable: %s", exc)
from PIL import Image, ImageDraw, ImageFilter
_SIZE = 64
def _hex_rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _led_icon(led: str, glow: str, rim: str = "#2d3748") -> Image.Image:
"""Traffic-light style icon: dark housing + glowing LED."""
base = Image.new("RGBA", (_SIZE, _SIZE), (0, 0, 0, 0))
draw = ImageDraw.Draw(base)
cx, cy = _SIZE // 2, _SIZE // 2
# Outer housing (rounded rect feel via ellipse)
draw.ellipse([4, 4, _SIZE - 4, _SIZE - 4], fill="#0d0d1a", outline=rim, width=2)
draw.ellipse([10, 10, _SIZE - 10, _SIZE - 10], fill="#12122a", outline="#1a1a3e", width=1)
glow_layer = Image.new("RGBA", (_SIZE, _SIZE), (0, 0, 0, 0))
g = ImageDraw.Draw(glow_layer)
lr, lg, lb = _hex_rgb(glow)
for radius, alpha in ((22, 35), (16, 70), (11, 120)):
g.ellipse(
[cx - radius, cy - radius, cx + radius, cy + radius],
fill=(lr, lg, lb, alpha),
)
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=2))
base = Image.alpha_composite(base, glow_layer)
draw = ImageDraw.Draw(base)
dr, dg, db = _hex_rgb(led)
draw.ellipse([cx - 9, cy - 9, cx + 9, cy + 9], fill=(dr, dg, db, 255))
draw.ellipse([cx - 5, cy - 6, cx + 2, cy - 1], fill=(255, 255, 255, 90))
return base
ICONS = {
"green": _led_icon("#00e676", "#00e676"),
"red": _led_icon("#ff1744", "#ff1744"),
"yellow": _led_icon("#ffab00", "#ffab00"),
"gray": _led_icon("#4a5568", "#3d4a5c", rim="#374151"),
}
_STATE_LABEL = {
"green": "PROXY ON",
"red": "PROXY OFF / ERROR",
"yellow": "CONNECTING…",
"gray": "STOPPED",
}
def _tooltip(state: str, exit_ip: str | None) -> str:
label = _STATE_LABEL.get(state, "Proxy God")
if exit_ip:
return f"Proxy God — {label}\nExit IP: {exit_ip}"
if state == "green":
return f"Proxy God — {label}\nExit IP: (checking…)"
return f"Proxy God — {label}\nExit IP: —"
class TrayIcon:
def __init__(
self,
on_show: Callable[[], None],
on_quit: Callable[[], None],
on_rotate: Callable[[], None],
) -> None:
self._on_show = on_show
self._on_quit = on_quit
self._on_rotate = on_rotate
self._icon: pystray.Icon | None = None
self._thread: threading.Thread | None = None
self._state = "gray"
self._exit_ip: str | None = None
self._lock = threading.Lock()
def start(self) -> None:
if sys.platform == "darwin":
log.info("Tray icon disabled on macOS; use the main window controls.")
return
if pystray is None:
return
if self._thread and self._thread.is_alive():
return
menu = pystray.Menu(
pystray.MenuItem("Show Proxy God", self._show, default=True),
pystray.MenuItem("Rotate chain now", self._rotate),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Quit", self._quit),
)
with self._lock:
tip = _tooltip(self._state, self._exit_ip)
self._icon = pystray.Icon(
"ProxyGod",
icon=ICONS[self._state],
title=tip,
menu=menu,
)
self._thread = threading.Thread(target=self._icon.run, daemon=True)
self._thread.start()
def stop(self) -> None:
if sys.platform == "darwin":
return
if pystray is None:
return
if self._icon:
try:
self._icon.stop()
except Exception as exc:
log.warning("Tray icon stop failed: %s", exc)
def set_state(self, state: str, exit_ip: str | None = None) -> None:
"""state: green (on), red (error), yellow (connecting), gray (stopped).
Pass exit_ip to refresh the hover tooltip. Use exit_ip=None to keep the last IP.
"""
if state not in ICONS:
state = "gray"
with self._lock:
self._state = state
if exit_ip is not None:
self._exit_ip = exit_ip.strip() if exit_ip else None
if self._icon:
self._icon.icon = ICONS[state]
self._icon.title = _tooltip(state, self._exit_ip)
def set_exit_ip(self, exit_ip: str | None) -> None:
"""Update tooltip only (e.g. after health check, same LED color)."""
with self._lock:
self._exit_ip = exit_ip.strip() if exit_ip else None
if self._icon:
self._icon.title = _tooltip(self._state, self._exit_ip)
def _show(self, icon: Any = None, item: Any = None) -> None:
self._on_show()
def _rotate(self, icon: Any = None, item: Any = None) -> None:
self._on_rotate()
def _quit(self, icon: Any = None, item: Any = None) -> None:
self._on_quit()

View File

@@ -0,0 +1,277 @@
from __future__ import annotations
import asyncio
import json
import logging
import time
from typing import Callable
import httpx
log = logging.getLogger(__name__)
# Secondary fallback endpoints for IP resolution.
# HTTP endpoints come first: many free proxies and older Windows Server
# environments fail HTTPS CONNECT through chained hops. Plain HTTP IP-check
# succeeds even when only forward-proxying (no CONNECT) works, so the chain's
# "last leg" exit-IP verification doesn't fail just because TLS can't tunnel.
_IP_FALLBACKS = [
"http://api.ipify.org/?format=json",
"http://checkip.amazonaws.com/",
"http://ip-api.com/json/",
"http://ifconfig.me/ip",
"https://api.ipify.org?format=json",
"https://httpbin.org/ip",
"https://ifconfig.me/ip",
]
def _parse_ip(text: str) -> str | None:
"""Parse an IP string from JSON or plain-text response body."""
body = text.strip()
if not body:
return None
if body.startswith("{"):
try:
d = json.loads(body)
ip = d.get("ip") or d.get("origin") or d.get("query")
return str(ip).split(",")[0].strip() if ip else None
except Exception:
return None
# Plain-text: must look like an IP (short, no HTML)
if len(body) < 50 and "<" not in body:
candidate = body.split()[0].strip()
if candidate.count(".") >= 3 or ":" in candidate:
return candidate
return None
async def validate_proxies(
proxy_urls: list[str],
check_url: str,
concurrency: int,
timeout_seconds: float,
on_progress: Callable[[int, int], None] | None = None,
target: int = 0,
) -> list[str]:
"""Validate proxies concurrently.
Args:
target: Stop as soon as this many valid proxies are found (0 = test all).
"""
if not proxy_urls:
return []
sem = asyncio.Semaphore(max(1, concurrency))
ok: list[str] = []
lock = asyncio.Lock()
total = len(proxy_urls)
done = 0
c = max(1, concurrency)
waves = (total + c - 1) // c
cap = min(600.0, float(waves) * float(timeout_seconds) + 45.0)
cap = max(90.0, cap)
log.info(
"validate_proxies: n=%d concurrency=%d per_timeout=%.1fs wall_cap~=%.0fs target=%s check=%s",
total,
c,
timeout_seconds,
cap,
target if target else "all",
(check_url[:70] + "") if len(check_url) > 70 else check_url,
)
last_prog_t = 0.0
prog_step = max(1, total // 120)
enough = False # set True once target reached; tasks skip HTTP call and drain fast
async def one(u: str) -> None:
nonlocal done, last_prog_t, enough
# Skip cost of the HTTP check once we already have enough
if enough:
async with lock:
done += 1
if on_progress and done == total:
on_progress(done, total)
return
async with sem:
if enough: # re-check after potentially waiting on semaphore
good = False
else:
good = await _check_one(u, check_url, timeout_seconds)
async with lock:
done += 1
if good:
ok.append(u)
if target and len(ok) >= target:
enough = True
log.info("validate_proxies: target %d reached after %d tested", target, done)
if on_progress:
now = time.monotonic()
if (
done == total
or done == 1
or enough
or done % prog_step == 0
or (now - last_prog_t) >= 0.1
):
last_prog_t = now
on_progress(done, total)
async def _run_all() -> None:
await asyncio.gather(*(one(u) for u in proxy_urls))
try:
await asyncio.wait_for(_run_all(), timeout=cap)
except asyncio.TimeoutError:
log.warning(
"validate_proxies wall-clock cap %.0fs exceeded (%d URLs) — returning partial results",
cap,
total,
)
log.info("validate_proxies: finished ok=%d / %d tested", len(ok), done)
return ok
async def _check_one(proxy_url: str, check_url: str, timeout_seconds: float) -> bool:
"""Check a single proxy by fetching check_url through it. True = live and returns 200."""
t = httpx.Timeout(timeout_seconds, connect=min(8.0, timeout_seconds))
try:
async with httpx.AsyncClient(
proxy=proxy_url,
timeout=t,
verify=False,
follow_redirects=True,
) as c:
r = await c.get(check_url)
return r.status_code == 200 and len(r.content) > 0
except Exception:
return False
async def check_chain_exit_ip(
listen_proxy: str,
check_url: str,
timeout_seconds: float,
chain_hops: int = 3,
) -> str | None:
"""Query the IP-check URL through the local chain proxy.
Bounded total time — never stacks one slow request per fallback URL forever.
Per-request timeout scales mildly with chain length so 5+ hop chains on
slower hardware (older Windows Server, low-spec VPS) don't time out on
cumulative TLS/CONNECT handshakes.
"""
hops = max(1, int(chain_hops))
scale = 1.0 + max(0, hops - 3) * 0.35
per = max(5.0, min(30.0, float(timeout_seconds) * scale))
budget = max(20.0, min(90.0, float(timeout_seconds) * 2 * scale + 5.0))
# Try more endpoints with HTTP first; if any hop blocks CONNECT, HTTPS
# IP-check would fail and the whole chain looks "dead" at the last step.
urls = [check_url] + [u for u in _IP_FALLBACKS if u != check_url][:4]
log.debug(
"check_chain_exit_ip: hops=%d budget=%.1fs per_req=%.1fs fallback_count=%d",
hops,
budget,
per,
len(urls),
)
async def _run() -> str | None:
t = httpx.Timeout(per, connect=min(8.0, per))
try:
async with httpx.AsyncClient(
proxy=listen_proxy,
timeout=t,
verify=False,
follow_redirects=True,
) as c:
for url in urls:
try:
r = await c.get(url)
if r.status_code == 200:
ip = _parse_ip(r.text)
if ip:
return ip
except Exception:
continue
except Exception:
pass
return None
try:
return await asyncio.wait_for(_run(), timeout=budget)
except asyncio.TimeoutError:
log.debug("check_chain_exit_ip timed out after %.1fs", budget)
return None
async def get_direct_ip(check_url: str, timeout_seconds: float = 15.0) -> str | None:
"""Get our own exit IP without the proxy chain (so we can compare)."""
per = max(5.0, min(15.0, float(timeout_seconds)))
budget = max(12.0, min(45.0, float(timeout_seconds) * 2))
urls = [check_url] + [u for u in _IP_FALLBACKS if u != check_url][:2]
async def _run() -> str | None:
t = httpx.Timeout(per)
try:
async with httpx.AsyncClient(
timeout=t,
verify=False,
follow_redirects=True,
) as c:
for url in urls:
try:
r = await c.get(url)
if r.status_code == 200:
ip = _parse_ip(r.text)
if ip:
return ip
except Exception:
continue
except Exception:
pass
return None
try:
return await asyncio.wait_for(_run(), timeout=budget)
except asyncio.TimeoutError:
log.debug("get_direct_ip timed out after %.1fs", budget)
return None
async def check_https_tunnel(
listen_proxy: str,
timeout_seconds: float = 12.0,
) -> tuple[bool, str]:
"""Return (ok, message). True means the chain successfully tunneled HTTPS.
Many free proxies pass plain HTTP but refuse HTTP ``CONNECT`` (the method
required to tunnel TLS). The chain can look healthy on an IP-check via
plain HTTP yet break every HTTPS page a browser tries to load. This probe
catches that case at start time so the user gets a clear warning instead
of a mystery "browser broken".
"""
per = max(5.0, min(20.0, float(timeout_seconds)))
t = httpx.Timeout(per, connect=min(8.0, per))
# Use a tiny, fast, very-common HTTPS endpoint.
targets = [
"https://www.google.com/generate_204",
"https://www.cloudflare.com/cdn-cgi/trace",
"https://duckduckgo.com/",
]
last_err = ""
try:
async with httpx.AsyncClient(
proxy=listen_proxy, timeout=t, verify=False, follow_redirects=True,
) as c:
for url in targets:
try:
r = await c.get(url)
if 200 <= r.status_code < 400:
return True, f"HTTPS OK via {url} ({r.status_code})"
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
continue
except Exception as e:
last_err = f"{type(e).__name__}: {e}"
return False, last_err or "all HTTPS targets failed"

View File

@@ -0,0 +1,233 @@
"""Detect whether a VPN tunnel is active on Windows (any provider)."""
from __future__ import annotations
import glob
import logging
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
log = logging.getLogger(__name__)
# Adapter description / name substrings (case-insensitive)
_VPN_ADAPTER_HINTS = (
"nordlynx", "nordvpn", "openvpn", "wireguard", "wintun", "tap-windows",
"tailscale", "zerotier", "cisco anyconnect", "fortinet", "pulse secure",
"globalprotect", "softether", "proton", "mullvad", "expressvpn",
"surfshark", "private internet", "pia ", "windscribe", "hotspot shield",
)
_NON_VPN_HINTS = (
"wan miniport",
"microsoft kernel debug",
"isatap",
"teredo",
"loopback",
"pseudo-interface",
)
# Executables to whitelist in kill-switch when present
_VPN_EXE_GLOBS: list[str] = [
r"C:\Program Files\NordVPN\*.exe",
r"C:\Program Files\NordUpdater\*.exe",
r"C:\Program Files\NordVPN\NordSec ThreatProtection\*.exe",
r"C:\Program Files\OpenVPN\bin\*.exe",
r"C:\Program Files\OpenVPN Connect\*.exe",
r"C:\Program Files\WireGuard\*.exe",
r"C:\Program Files\Proton\VPN\*.exe",
r"C:\Program Files\Mullvad VPN\*.exe",
r"C:\Program Files\ExpressVPN\*.exe",
r"C:\Program Files\Surfshark\*.exe",
r"C:\Program Files\Private Internet Access\*.exe",
r"C:\Program Files\Tailscale\*.exe",
r"C:\Program Files\ZeroTier\One\*.exe",
]
_PROVIDER_FROM_ADAPTER: list[tuple[str, str]] = [
("nordlynx", "NordVPN"),
("nordvpn", "NordVPN"),
("wireguard", "WireGuard"),
("wintun", "WireGuard"),
("openvpn", "OpenVPN"),
("proton", "Proton VPN"),
("mullvad", "Mullvad"),
("expressvpn", "ExpressVPN"),
("surfshark", "Surfshark"),
("tailscale", "Tailscale"),
("zerotier", "ZeroTier"),
("tap-windows", "OpenVPN/TAP"),
("globalprotect", "GlobalProtect"),
("fortinet", "FortiClient"),
("cisco", "Cisco VPN"),
]
@dataclass
class VpnStatus:
active: bool = False
label: str = "Direct (no VPN)"
adapter: str = ""
adapters: list[str] = field(default_factory=list)
def short_label(self) -> str:
if not self.active:
return "Direct"
return self.label
def _run_ps(script: str, timeout: float = 12.0) -> str:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command", script],
capture_output=True,
text=True,
timeout=timeout,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return (r.stdout or "").strip()
except Exception as e:
log.debug("vpn_detect powershell failed: %s", e)
return ""
def _match_provider(name: str) -> str:
low = name.lower()
for hint, label in _PROVIDER_FROM_ADAPTER:
if hint in low:
return label
if "vpn" in low or "tunnel" in low:
return "VPN"
return "VPN"
def detect_vpn() -> VpnStatus:
"""Inspect up network adapters for VPN/tunnel interfaces."""
if sys.platform == "darwin":
try:
r = subprocess.run(["ifconfig"], capture_output=True, text=True, timeout=8)
names: list[str] = []
for line in (r.stdout or "").splitlines():
if line and not line.startswith("\t") and ":" in line:
name = line.split(":", 1)[0].strip()
if name:
names.append(name)
hits = [n for n in names if n.startswith(("utun", "tun", "tap", "wg"))]
if not hits:
return VpnStatus(active=False, label="Direct (no VPN)", adapters=names)
primary = hits[0]
return VpnStatus(active=True, label=_match_provider(primary), adapter=primary, adapters=names)
except Exception:
return VpnStatus()
out = _run_ps(
"Get-NetAdapter | Where-Object { $_.Status -eq 'Up' } | "
"ForEach-Object { \"$($_.Name)|$($_.InterfaceDescription)\" }"
)
if not out:
# Fallback for PowerShell 2.0 / older Windows Server: parse "wmic nic"
# then "netsh interface show interface". Both are locale-tolerant.
try:
r = subprocess.run(
["wmic", "nic", "where", "NetEnabled=true",
"get", "NetConnectionID,Name", "/format:list"],
capture_output=True,
text=True,
timeout=12,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
blob = (r.stdout or "")
names: list[str] = []
cur_id = ""
cur_name = ""
for ln in blob.splitlines():
ln = ln.strip()
if not ln:
if cur_id:
names.append(f"{cur_id}|{cur_name}")
cur_id, cur_name = "", ""
continue
if ln.startswith("NetConnectionID="):
cur_id = ln.split("=", 1)[1].strip()
elif ln.startswith("Name="):
cur_name = ln.split("=", 1)[1].strip()
if cur_id:
names.append(f"{cur_id}|{cur_name}")
out = "\n".join(names)
except Exception:
out = ""
if not out:
# Last-ditch: netsh. Locale-tolerant — match the connected/enabled state
# by extracting the interface name from the rightmost column. Older
# localized Server SKUs (es, de, fr, etc.) don't print literal "Enabled".
try:
r = subprocess.run(
["netsh", "interface", "show", "interface"],
capture_output=True,
text=True,
timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
raw = (r.stdout or "")
names = []
for ln in raw.splitlines():
s = ln.rstrip()
if not s or s.startswith("-") or ":" in s.split(" ")[0]:
continue
parts = re.split(r"\s{2,}", s.strip())
if len(parts) >= 4:
nm = parts[-1].strip()
if nm and nm.lower() not in ("interface name", "nombre de interfaz"):
names.append(f"{nm}|")
out = "\n".join(names)
except Exception:
return VpnStatus()
adapters: list[str] = []
adapter_blob: list[str] = []
for line in out.splitlines():
raw = line.strip()
if not raw:
continue
if "|" in raw:
name, desc = raw.split("|", 1)
else:
name, desc = raw, ""
name = name.strip()
desc = desc.strip()
if name:
adapters.append(name)
adapter_blob.append((name + " " + desc).strip())
hits: list[str] = []
for i, name in enumerate(adapters):
low = adapter_blob[i].lower()
if any(h in low for h in _NON_VPN_HINTS):
continue
if any(h in low for h in _VPN_ADAPTER_HINTS):
hits.append(name)
if not hits:
return VpnStatus(active=False, label="Direct (no VPN)", adapters=adapters)
primary = hits[0]
return VpnStatus(
active=True,
label=_match_provider(primary),
adapter=primary,
adapters=adapters,
)
def expand_vpn_executables() -> list[str]:
"""Paths to VPN client binaries for firewall allow rules."""
seen: set[str] = set()
out: list[str] = []
for pattern in _VPN_EXE_GLOBS:
for p in glob.glob(pattern):
rp = str(Path(p).resolve())
if rp not in seen:
seen.add(rp)
out.append(rp)
return out

View File

@@ -0,0 +1,231 @@
"""WebRTC leak detection and hardening checks.
WebRTC leaks expose your real IP through STUN/ICE even when a proxy is set.
This module checks every controllable surface:
1. OS-level Chrome / Edge Group Policy (prevents non-proxied UDP)
2. Firefox managed profile prefs (user.js must disable PeerConnection)
3. STUN UDP reachability (if STUN is reachable, WebRTC CAN leak)
4. Firefox process prefs (confirm our user.js was accepted)
"""
from __future__ import annotations
import logging
import socket
import subprocess
import winreg
from dataclasses import dataclass, field
from pathlib import Path
log = logging.getLogger(__name__)
# STUN servers commonly used by WebRTC
_STUN_HOSTS: list[tuple[str, int]] = [
("stun.l.google.com", 19302),
("stun1.l.google.com", 19302),
("stun.cloudflare.com", 3478),
("stun.stunprotocol.org", 3478),
]
# Firefox user.js prefs that block WebRTC
_REQUIRED_WEBRTC_PREFS: dict[str, str] = {
"media.peerconnection.enabled": "false",
"media.peerconnection.ice.default_address_only": "true",
"media.peerconnection.ice.no_host": "true",
}
# Chrome / Edge HKLM policy keys
_CHROMIUM_POLICY_PATHS = (
r"SOFTWARE\Policies\Google\Chrome",
r"SOFTWARE\Policies\Microsoft\Edge",
r"SOFTWARE\Policies\Chromium",
)
_WEBRTC_POLICY_VALUE = "DefaultWebRtcIpHandlingPolicy"
_WEBRTC_BLOCK_VALUE = 3 # disable_non_proxied_udp (was 2 = public+private only — wrong)
_WEBRTC_POLICY_STR_KEY = "WebRtcIPHandling"
_WEBRTC_BLOCK_STR_VALUE = "disable_non_proxied_udp"
@dataclass
class WebRtcCheckResult:
"""Aggregated WebRTC leak surface scan."""
# Policy / config checks
chrome_edge_policy_set: bool = False
chrome_edge_policy_detail: str = ""
firefox_prefs_ok: bool = False
firefox_prefs_detail: str = ""
# Network reachability
stun_reachable: bool = False
stun_detail: str = ""
# Convenience flags
any_leak_risk: bool = True
issues: list[str] = field(default_factory=list)
checks: list[tuple[str, bool, str]] = field(default_factory=list) # (name, ok, detail)
def add(self, name: str, ok: bool, detail: str) -> None:
self.checks.append((name, ok, detail))
if not ok:
self.issues.append(f"{name}: {detail}")
# ─────────────────────────────────────────────────────────────────────────────
# 1. Chrome / Edge Group Policy
# ─────────────────────────────────────────────────────────────────────────────
def check_chromium_webrtc_policy() -> tuple[bool, str]:
"""Return (policy_set, detail_string).
Accepts either the legacy DWORD DefaultWebRtcIpHandlingPolicy==3
OR the modern REG_SZ WebRtcIPHandling=="disable_non_proxied_udp".
"""
found: list[str] = []
missing: list[str] = []
for path in _CHROMIUM_POLICY_PATHS:
try:
with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path, 0, winreg.KEY_QUERY_VALUE) as k:
name = path.split("\\")[-1]
dword_ok = False
str_ok = False
# Check legacy DWORD
try:
val = int(winreg.QueryValueEx(k, _WEBRTC_POLICY_VALUE)[0])
dword_ok = val == _WEBRTC_BLOCK_VALUE
if not dword_ok:
missing.append(f"{name} DWORD={val} (need {_WEBRTC_BLOCK_VALUE})")
except OSError:
pass
# Check modern REG_SZ
try:
val_str = str(winreg.QueryValueEx(k, _WEBRTC_POLICY_STR_KEY)[0])
str_ok = val_str == _WEBRTC_BLOCK_STR_VALUE
if not str_ok:
missing.append(f"{name} REG_SZ={val_str!r} (need {_WEBRTC_BLOCK_STR_VALUE!r})")
except OSError:
pass
if dword_ok or str_ok:
found.append(name)
elif not dword_ok and not str_ok:
missing.append(f"{name}: no WebRTC policy keys present")
except OSError:
continue # key not present at all — browser not installed or not policy-managed
if found:
return True, "Policy set: " + ", ".join(found)
if missing:
return False, "Not set: " + "; ".join(missing)
return False, "No Chrome/Edge/Chromium policy keys found (browsers may not be installed)"
# ─────────────────────────────────────────────────────────────────────────────
# 2. Firefox managed profile prefs
# ─────────────────────────────────────────────────────────────────────────────
def check_firefox_webrtc_prefs(profile_dir: Path) -> tuple[bool, str]:
"""Verify our written user.js contains the required WebRTC-disabling prefs."""
user_js = profile_dir / "user.js"
if not user_js.is_file():
return False, f"user.js not found at {profile_dir} — profile not yet initialized"
try:
content = user_js.read_text(encoding="utf-8", errors="replace")
except Exception as e:
return False, f"Cannot read user.js: {e}"
missing: list[str] = []
for pref, expected_val in _REQUIRED_WEBRTC_PREFS.items():
# Look for: user_pref("pref.name", value);
needle = f'"{pref}", {expected_val}'
if needle not in content:
missing.append(pref)
if missing:
return False, "Missing in user.js: " + ", ".join(missing)
return True, "All WebRTC prefs present in managed profile"
# ─────────────────────────────────────────────────────────────────────────────
# 3. STUN server UDP reachability
# ─────────────────────────────────────────────────────────────────────────────
def _probe_stun_udp(host: str, port: int, timeout: float = 2.5) -> bool:
"""
Send a minimal STUN Binding Request over UDP and wait for a response.
Returns True if we get *any* response (STUN or ICMP unreachable).
"""
# Minimal STUN Binding Request (RFC 5389)
msg = (
b"\x00\x01" # Message Type: Binding Request
b"\x00\x00" # Message Length: 0
b"\x21\x12\xa4\x42" # Magic Cookie
+ b"\x00" * 12 # Transaction ID (12 bytes)
)
try:
ip = socket.gethostbyname(host)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(timeout)
s.sendto(msg, (ip, port))
data, _ = s.recvfrom(512)
s.close()
return len(data) > 0
except socket.timeout:
return False
except Exception:
return False
def check_stun_reachability(timeout: float = 3.0) -> tuple[bool, str]:
"""
Returns (reachable, detail).
reachable = True means STUN servers ARE reachable → WebRTC COULD leak.
"""
reachable: list[str] = []
for host, port in _STUN_HOSTS[:3]:
try:
if _probe_stun_udp(host, port, timeout):
reachable.append(f"{host}:{port}")
except Exception:
continue
if reachable:
return True, "STUN reachable: " + ", ".join(reachable) + " — WebRTC UDP is NOT blocked"
return False, "STUN servers unreachable — WebRTC UDP appears blocked (good)"
# ─────────────────────────────────────────────────────────────────────────────
# Composite scan
# ─────────────────────────────────────────────────────────────────────────────
def run_webrtc_check(profile_dir: Path | None = None) -> WebRtcCheckResult:
"""
Run all WebRTC leak surface checks.
profile_dir: path to the managed Firefox profile (to verify user.js).
"""
res = WebRtcCheckResult()
# 1. Chrome/Edge policy
policy_ok, policy_detail = check_chromium_webrtc_policy()
res.chrome_edge_policy_set = policy_ok
res.chrome_edge_policy_detail = policy_detail
res.add("Chrome/Edge WebRTC policy (HKLM)", policy_ok, policy_detail)
# 2. Firefox profile prefs
if profile_dir is not None:
fx_ok, fx_detail = check_firefox_webrtc_prefs(profile_dir)
res.firefox_prefs_ok = fx_ok
res.firefox_prefs_detail = fx_detail
res.add("Firefox managed profile WebRTC prefs", fx_ok, fx_detail)
else:
res.add("Firefox managed profile WebRTC prefs", False,
"Profile not specified — not checked")
# 3. STUN UDP reachability
stun_reachable, stun_detail = check_stun_reachability()
res.stun_reachable = stun_reachable
res.stun_detail = stun_detail
# STUN reachable = risk; NOT reachable = good (firewall is blocking it)
res.add("STUN UDP reachability", not stun_reachable, stun_detail)
# Overall verdict
res.any_leak_risk = bool(res.issues)
return res

View File

@@ -0,0 +1,107 @@
"""Windows version / PowerShell capability probe.
Used by privacy features to log a single clear reason when a function silently
no-ops on older Windows Server hosts (Server 2008 R2 ships PowerShell 2.0 which
does not have ``Get-NetAdapter`` / ``Set-NetAdapter`` / ``Disable-NetAdapterBinding``
/ ``Get-DnsClientServerAddress`` / ``Rename-Computer``).
"""
from __future__ import annotations
import logging
import platform
import subprocess
import sys
from dataclasses import dataclass
from functools import lru_cache
log = logging.getLogger(__name__)
@dataclass(frozen=True)
class WinCompat:
release: str # e.g. "10", "2012ServerR2"
build: int # major build number
powershell_major: int # 0 = PowerShell not detected
has_net_cmdlets: bool # Get-NetAdapter etc. (PS 3.0+ on Server 2012+)
has_defender: bool # Add-MpPreference cmdlet exists
def summary(self) -> str:
if sys.platform != "win32":
return (
f"{platform.system()} release={self.release} "
f"PowerShell={self.powershell_major}.x "
"Windows-only privacy controls disabled"
)
return (
f"Windows release={self.release} build={self.build} "
f"PowerShell={self.powershell_major}.x "
f"NetAdapter cmdlets={'yes' if self.has_net_cmdlets else 'no'} "
f"Defender={'yes' if self.has_defender else 'no'}"
)
def _powershell_major() -> int:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
"$PSVersionTable.PSVersion.Major"],
capture_output=True,
text=True,
timeout=12,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
out = (r.stdout or "").strip().splitlines()[-1] if r.stdout else ""
return int(out) if out.isdigit() else 0
except Exception:
return 0
def _has_cmdlet(name: str) -> bool:
try:
r = subprocess.run(
["powershell", "-NoProfile", "-NonInteractive", "-Command",
f"if (Get-Command {name} -ErrorAction SilentlyContinue) "
f"{{ 'yes' }} else {{ 'no' }}"],
capture_output=True,
text=True,
timeout=10,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
return "yes" in (r.stdout or "").lower()
except Exception:
return False
@lru_cache(maxsize=1)
def probe() -> WinCompat:
"""Cached host probe. Cheap on subsequent calls."""
if sys.platform != "win32":
info = WinCompat(
release=platform.release(),
build=0,
powershell_major=0,
has_net_cmdlets=False,
has_defender=False,
)
log.info("Compat probe: %s", info.summary())
return info
release = platform.release()
try:
build = int(platform.version().split(".")[-1])
except Exception:
build = 0
ps = _powershell_major()
# PowerShell >= 3.0 is the gate for the modern Net* cmdlets that all the
# privacy features rely on. Anything older (Server 2008 R2 RTM) only has
# PS 2.0 and needs WMI / netsh / ipconfig fallbacks.
has_net = ps >= 3 and _has_cmdlet("Get-NetAdapter")
has_def = _has_cmdlet("Add-MpPreference")
info = WinCompat(
release=release,
build=build,
powershell_major=ps,
has_net_cmdlets=has_net,
has_defender=has_def,
)
log.info("WinCompat probe: %s", info.summary())
return info

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from .paths import app_data_dir
TASK_NAME = "ProxyChainManagerAutoStart"
def _write_launcher_cmd() -> Path:
"""Create a stable launcher script so Task Scheduler does not need fragile quoting."""
if getattr(sys, "frozen", False):
exe = Path(sys.executable).resolve()
body = f'@echo off\r\nstart "" /B "{exe}"\r\n'
else:
py = Path(sys.executable).resolve()
if py.name.lower() == "python.exe":
cand = py.with_name("pythonw.exe")
if cand.is_file():
py = cand
root = Path(__file__).resolve().parent.parent
run_py = (root / "run.py").resolve()
body = f'@echo off\r\n"{py}" "{run_py}"\r\n'
p = app_data_dir() / "launch_proxy_chain_manager.cmd"
p.write_text(body, encoding="utf-8")
return p
def install_logon_task() -> tuple[bool, str]:
launcher = _write_launcher_cmd()
tr = str(launcher)
try:
r = subprocess.run(
[
"schtasks",
"/Create",
"/F",
"/TN",
TASK_NAME,
"/TR",
tr,
"/SC",
"ONLOGON",
"/RL",
"LIMITED",
],
capture_output=True,
text=True,
timeout=60,
)
out = (r.stdout or "") + (r.stderr or "")
return r.returncode == 0, out.strip() or ("OK" if r.returncode == 0 else "Failed")
except Exception as e:
return False, str(e)
def uninstall_logon_task() -> tuple[bool, str]:
try:
r = subprocess.run(
["schtasks", "/Delete", "/F", "/TN", TASK_NAME],
capture_output=True,
text=True,
timeout=60,
)
out = (r.stdout or "") + (r.stderr or "")
return r.returncode == 0, out.strip() or ("OK" if r.returncode == 0 else "Failed")
except Exception as e:
return False, str(e)
def task_exists() -> bool:
try:
r = subprocess.run(
["schtasks", "/Query", "/TN", TASK_NAME],
capture_output=True,
text=True,
timeout=30,
)
return r.returncode == 0
except Exception:
return False

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB