first commit
This commit is contained in:
3
proxy_chain_manager/__init__.py
Normal file
3
proxy_chain_manager/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Rotating proxy chain manager with a GOST backend."""
|
||||
|
||||
__version__ = "2.0.0-mac"
|
||||
4
proxy_chain_manager/__main__.py
Normal file
4
proxy_chain_manager/__main__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .app import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
3074
proxy_chain_manager/app.py
Normal file
3074
proxy_chain_manager/app.py
Normal file
File diff suppressed because it is too large
Load Diff
218
proxy_chain_manager/artifact_wipe.py
Normal file
218
proxy_chain_manager/artifact_wipe.py
Normal 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
|
||||
221
proxy_chain_manager/ban_tester.py
Normal file
221
proxy_chain_manager/ban_tester.py
Normal 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)
|
||||
327
proxy_chain_manager/browser_identity.py
Normal file
327
proxy_chain_manager/browser_identity.py
Normal 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_")
|
||||
402
proxy_chain_manager/browser_launcher.py
Normal file
402
proxy_chain_manager/browser_launcher.py
Normal 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."
|
||||
271
proxy_chain_manager/browser_profile.py
Normal file
271
proxy_chain_manager/browser_profile.py
Normal 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",
|
||||
)
|
||||
367
proxy_chain_manager/chain_map.py
Normal file
367
proxy_chain_manager/chain_map.py
Normal 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)
|
||||
470
proxy_chain_manager/config.py
Normal file
470
proxy_chain_manager/config.py
Normal file
@@ -0,0 +1,470 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, unquote, urlparse, urlunparse
|
||||
|
||||
from .paths import app_data_dir
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# ── Settings schema version ───────────────────────────────────────────────────
|
||||
# Bump this whenever a breaking field rename / type change is made so
|
||||
# migrate() can upgrade old settings.json files safely.
|
||||
SETTINGS_SCHEMA_VERSION = 1
|
||||
|
||||
# !! SECURITY NOTE !!
|
||||
# settings.json is stored in plaintext at %LOCALAPPDATA%\ProxyChainManager\.
|
||||
# It may contain proxy credentials (URLs with user:password). Do NOT log
|
||||
# raw settings dicts — use _mask() to redact credential fields before logging.
|
||||
|
||||
|
||||
def _mask(d: dict) -> dict:
|
||||
"""Return a copy of settings dict with credential-bearing fields redacted."""
|
||||
redact_keys = {"manual_exit_proxy", "pinned_chain", "sources"}
|
||||
out: dict = {}
|
||||
for k, v in d.items():
|
||||
if k in redact_keys:
|
||||
if isinstance(v, list):
|
||||
out[k] = [redact_proxy_url(str(x)) for x in v]
|
||||
else:
|
||||
out[k] = redact_proxy_url(str(v))
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
|
||||
|
||||
def _host_port_for_url(host: str, port: int | None) -> str:
|
||||
"""``host`` and optional ``port`` as used in proxy URLs (bracket IPv6 literals)."""
|
||||
if not host:
|
||||
return ""
|
||||
if ":" in host and "." not in host:
|
||||
inner = f"[{host}]"
|
||||
else:
|
||||
inner = host
|
||||
return f"{inner}:{port}" if port else inner
|
||||
|
||||
|
||||
# Always bind the local forwarder to loopback. VM clones and NIC/IP changes (DHCP)
|
||||
# do not affect 127.0.0.1; avoid storing LAN IPs or hostnames for the listener.
|
||||
LISTEN_HOST = "127.0.0.1"
|
||||
|
||||
|
||||
def normalize_proxy_url(raw: str) -> str:
|
||||
"""Strip, add http:// if missing. Empty input → empty string."""
|
||||
t = (raw or "").strip()
|
||||
if not t:
|
||||
return ""
|
||||
if "://" not in t:
|
||||
t = "http://" + t
|
||||
return t.rstrip("/")
|
||||
|
||||
|
||||
def split_proxy_for_edit(raw: str) -> tuple[str, str, str]:
|
||||
"""Split stored proxy URL into (url_without_credentials, username, password).
|
||||
|
||||
Used to populate fixed-exit / form fields. Credentials are percent-decoded.
|
||||
"""
|
||||
t = normalize_proxy_url((raw or "").strip())
|
||||
if not t:
|
||||
return "", "", ""
|
||||
p = urlparse(t)
|
||||
if not p.hostname:
|
||||
return t, "", ""
|
||||
host = p.hostname
|
||||
port = p.port
|
||||
scheme = p.scheme or "http"
|
||||
base = f"{scheme}://{_host_port_for_url(host, port)}"
|
||||
u = unquote(p.username) if p.username else ""
|
||||
pw = unquote(p.password) if p.password else ""
|
||||
return base, u, pw
|
||||
|
||||
|
||||
def merge_proxy_credentials(raw_url: str, username: str = "", password: str = "") -> str:
|
||||
"""Build a single proxy URL: host/port from ``raw_url`` (any embedded auth ignored) plus optional auth.
|
||||
|
||||
User and password are URL-encoded for ``@`` and ``:`` inside values.
|
||||
"""
|
||||
base = normalize_proxy_url((raw_url or "").strip())
|
||||
if not base:
|
||||
return ""
|
||||
p = urlparse(base)
|
||||
if not p.hostname:
|
||||
return base
|
||||
host = p.hostname
|
||||
port = p.port
|
||||
scheme = p.scheme or "http"
|
||||
user = (username or "").strip()
|
||||
pw = (password or "").strip()
|
||||
if not user and not pw:
|
||||
# Leave credentials embedded in URL when form fields are blank (paste user:pass@host).
|
||||
return base
|
||||
hp = _host_port_for_url(host, port)
|
||||
netloc = f"{quote(user, safe='')}:{quote(pw, safe='')}@{hp}"
|
||||
return urlunparse((scheme, netloc, "", "", "", "")).rstrip("/")
|
||||
|
||||
|
||||
def parse_exit_proxy_input(
|
||||
raw: str, default_scheme: str = "socks5"
|
||||
) -> tuple[str, str, str]:
|
||||
"""Parse any sane paste format into ``(base_url, user, password)``.
|
||||
|
||||
Accepts (whitespace-trimmed, default scheme = SOCKS5 when none given):
|
||||
|
||||
``host:port``
|
||||
``host:port:user:password``
|
||||
``host:port:user:password:session_or_extra`` (extras folded into password)
|
||||
``host:port user password`` (tab/space-separated)
|
||||
``user:password@host:port``
|
||||
``scheme://host:port``
|
||||
``scheme://user:password@host:port``
|
||||
|
||||
User and password may contain ``@``, ``:`` and arbitrary "words" — those
|
||||
are URL-encoded automatically when ``merge_proxy_credentials`` rebuilds
|
||||
the URL, so pasting plain text is safe.
|
||||
"""
|
||||
t = (raw or "").strip()
|
||||
if not t:
|
||||
return "", "", ""
|
||||
|
||||
if "://" in t:
|
||||
# Already a full URL — defer to the existing splitter.
|
||||
return split_proxy_for_edit(t)
|
||||
|
||||
if "@" in t:
|
||||
return split_proxy_for_edit(f"{default_scheme}://{t}")
|
||||
|
||||
if "\t" in t or any(c in t for c in (" ",)):
|
||||
# "host:port user pass" or "host:port\tuser\tpass"
|
||||
parts = [p for p in t.replace("\t", " ").split() if p]
|
||||
if len(parts) >= 3:
|
||||
hp, user, pw = parts[0], parts[1], " ".join(parts[2:])
|
||||
return f"{default_scheme}://{hp}".rstrip("/"), user, pw
|
||||
if len(parts) == 2:
|
||||
return f"{default_scheme}://{parts[0]}".rstrip("/"), "", ""
|
||||
t = parts[0]
|
||||
|
||||
bits = t.split(":")
|
||||
if len(bits) >= 4:
|
||||
host, port, user = bits[0], bits[1], bits[2]
|
||||
password = ":".join(bits[3:]) # passwords containing ":" survive
|
||||
return f"{default_scheme}://{host}:{port}", user, password
|
||||
if len(bits) == 2:
|
||||
return f"{default_scheme}://{bits[0]}:{bits[1]}", "", ""
|
||||
# 1 token, or 3-token oddity — best-effort; treat as host[:port] only.
|
||||
return f"{default_scheme}://{t}".rstrip("/"), "", ""
|
||||
|
||||
|
||||
def redact_proxy_url(url: str) -> str:
|
||||
"""Same URL shape for logs/UI, with credentials replaced by ``***``."""
|
||||
t = (url or "").strip()
|
||||
if not t:
|
||||
return ""
|
||||
if "://" not in t:
|
||||
t = "http://" + t
|
||||
p = urlparse(t.rstrip("/"))
|
||||
if not p.hostname:
|
||||
return t
|
||||
has_auth = bool(p.username) or bool(p.password)
|
||||
if not has_auth:
|
||||
return t.rstrip("/")
|
||||
scheme = p.scheme or "http"
|
||||
hp = _host_port_for_url(p.hostname, p.port)
|
||||
netloc = f"***:***@{hp}"
|
||||
return urlunparse((scheme, netloc, "", "", "", "")).rstrip("/")
|
||||
|
||||
|
||||
OBFUSCATION_MODES = ["auto", "http_only", "socks5_only", "random_mix"]
|
||||
OBFUSCATION_LABELS = {
|
||||
"auto": "Auto (best available)",
|
||||
"http_only": "HTTP only",
|
||||
"socks5_only": "SOCKS5 only",
|
||||
"random_mix": "Random Mix (max noise)",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
# Schema version — used by migrate() to upgrade old settings.json files.
|
||||
settings_version: int = SETTINGS_SCHEMA_VERSION
|
||||
|
||||
# ── network ──────────────────────────────────────────────────────────
|
||||
local_host: str = LISTEN_HOST
|
||||
local_port: int = 18888
|
||||
|
||||
# ── chain ─────────────────────────────────────────────────────────────
|
||||
chain_length: int = 3 # number of hops (2-8)
|
||||
obfuscation_mode: str = "auto" # see OBFUSCATION_MODES
|
||||
use_pinned_chain: bool = True # use manually ordered chain from Chain Builder
|
||||
pinned_chain: list[str] = field(default_factory=list) # user-ordered hop list
|
||||
validate_pinned_on_start: bool = True # quick TCP probe of each pinned hop before chaining
|
||||
max_browser_relaunches: int = 5 # cap auto-relaunch attempts so a broken profile doesn't loop
|
||||
# Fixed last hop only (ignored when use_pinned_chain is True — full manual chain wins)
|
||||
manual_exit_proxy: str = ""
|
||||
|
||||
# ── timing ────────────────────────────────────────────────────────────
|
||||
health_check_seconds: int = 180 # how often to re-verify exit IP
|
||||
full_refresh_seconds: int = 1800 # how often to re-fetch/re-validate pool
|
||||
|
||||
# ── validation ────────────────────────────────────────────────────────
|
||||
validation_concurrency: int = 64
|
||||
max_candidates: int = 200 # cap before validation (shuffled, so random sample)
|
||||
min_pool_size: int = 20 # stop validating once this many good proxies found
|
||||
validation_timeout_seconds: float = 12.0
|
||||
prefer_elite: bool = False
|
||||
|
||||
# ── security ──────────────────────────────────────────────────────────
|
||||
kill_switch_enabled: bool = sys.platform == "win32" # Windows Firewall kill-switch when available
|
||||
proxy_bypass: str = "localhost;127.*;10.*;192.168.*;<local>"
|
||||
|
||||
# ── privacy / device hardening (Privacy tab) ───────────────────────────
|
||||
mac_spoof_enabled: bool = False # randomize NIC MAC while chain runs (Admin)
|
||||
mac_rotate_on_chain_rotate: bool = False # re-randomize MAC on every chain rotation
|
||||
spoof_hostname_enabled: bool = False # temporary computer name while chain runs (Admin)
|
||||
flush_dns_on_rotate: bool = True # ipconfig /flushdns on each rotation
|
||||
disable_ipv6_while_active: bool = False # disable IPv6 bindings while chain runs (Admin)
|
||||
harden_webrtc_enabled: bool = False # Chrome/Edge WebRTC policy (Admin)
|
||||
lan_lockdown_enabled: bool = False # kill LLMNR/NetBIOS/mDNS while chain runs
|
||||
telemetry_kill_enabled: bool = False # disable DiagTrack + Activity History (Admin)
|
||||
|
||||
# ── hardened browser ────────────────────────────────────────────────────
|
||||
firefox_path: str = ""
|
||||
firefox_profile_dir: str = ""
|
||||
browser_clear_on_close: bool = True
|
||||
browser_disposable_profile: bool = False
|
||||
browser_kill_on_chain_drop: bool = True
|
||||
browser_auto_relaunch: bool = False
|
||||
browser_lock_managed_profile: bool = True
|
||||
browser_force_proxy: bool = True
|
||||
browser_disable_webrtc: bool = True
|
||||
browser_resist_fingerprinting: bool = True
|
||||
browser_disable_telemetry: bool = True
|
||||
browser_first_party_isolation: bool = True
|
||||
browser_strict_tracking_protection: bool = True
|
||||
browser_timezone_utc: bool = True
|
||||
browser_persona: str = "blend_windows_chrome"
|
||||
browser_cookie_mode: str = "block_third_party"
|
||||
|
||||
# ── sources ───────────────────────────────────────────────────────────
|
||||
sources: list[str] = field(
|
||||
default_factory=lambda: [
|
||||
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/http/data.json",
|
||||
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/socks5/data.json",
|
||||
"https://cdn.jsdelivr.net/gh/proxifly/free-proxy-list@main/proxies/protocols/https/data.json",
|
||||
]
|
||||
)
|
||||
ip_check_url: str = "https://api.ipify.org?format=json"
|
||||
|
||||
def listen_addr(self) -> str:
|
||||
return f"{self.local_host}:{self.local_port}"
|
||||
|
||||
def settings_path(self) -> Path:
|
||||
return app_data_dir() / "settings.json"
|
||||
|
||||
|
||||
def _is_safe_https_url(url: str) -> bool:
|
||||
"""Return True for HTTPS URLs pointing at public hosts (not RFC-1918/link-local)."""
|
||||
try:
|
||||
p = urlparse(url)
|
||||
if p.scheme != "https":
|
||||
return False
|
||||
host = p.hostname or ""
|
||||
if not host:
|
||||
return False
|
||||
import ipaddress as _ip
|
||||
try:
|
||||
addr = _ip.ip_address(host)
|
||||
return not (addr.is_private or addr.is_loopback or addr.is_link_local)
|
||||
except ValueError:
|
||||
pass
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def migrate(raw: dict) -> dict:
|
||||
"""Upgrade a raw settings dict from any schema version to the current one.
|
||||
|
||||
Each version block transforms ``raw`` in-place and bumps
|
||||
``settings_version``. New fields are left absent so the dataclass
|
||||
default takes effect.
|
||||
"""
|
||||
ver = int(raw.get("settings_version", 0))
|
||||
# v0 → v1: no structural changes; just stamp the version
|
||||
if ver < 1:
|
||||
raw["settings_version"] = 1
|
||||
return raw
|
||||
|
||||
|
||||
def sanitize_settings(s: Settings) -> tuple[Settings, bool]:
|
||||
"""Clamp invalid values from hand-edited JSON. Returns (settings, changed)."""
|
||||
changed = False
|
||||
try:
|
||||
cl = int(s.chain_length)
|
||||
if not (1 <= cl <= 8):
|
||||
s.chain_length = max(1, min(8, cl))
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.chain_length = 3
|
||||
changed = True
|
||||
try:
|
||||
p = int(s.local_port)
|
||||
if not (1 <= p <= 65535):
|
||||
s.local_port = max(1, min(65535, p))
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.local_port = 18888
|
||||
changed = True
|
||||
try:
|
||||
hc = int(s.health_check_seconds)
|
||||
if hc < 10:
|
||||
s.health_check_seconds = 10
|
||||
changed = True
|
||||
elif hc > 3600:
|
||||
s.health_check_seconds = 3600
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.health_check_seconds = 180
|
||||
changed = True
|
||||
try:
|
||||
fr = int(s.full_refresh_seconds)
|
||||
if fr < 60:
|
||||
s.full_refresh_seconds = 60
|
||||
changed = True
|
||||
elif fr > 86400:
|
||||
s.full_refresh_seconds = 86400
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.full_refresh_seconds = 1800
|
||||
changed = True
|
||||
try:
|
||||
if int(s.validation_concurrency) < 1:
|
||||
s.validation_concurrency = 1
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.validation_concurrency = 64
|
||||
changed = True
|
||||
try:
|
||||
if int(s.max_candidates) < 10:
|
||||
s.max_candidates = 10
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.max_candidates = 200
|
||||
changed = True
|
||||
try:
|
||||
mps = int(s.min_pool_size)
|
||||
if mps < 3:
|
||||
s.min_pool_size = 3
|
||||
changed = True
|
||||
elif mps > 500:
|
||||
s.min_pool_size = 500
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.min_pool_size = 20
|
||||
changed = True
|
||||
if not hasattr(s, "min_pool_size"):
|
||||
s.min_pool_size = 20
|
||||
changed = True
|
||||
try:
|
||||
vt = float(s.validation_timeout_seconds)
|
||||
if vt < 2.0:
|
||||
s.validation_timeout_seconds = 2.0
|
||||
changed = True
|
||||
elif vt > 120.0:
|
||||
s.validation_timeout_seconds = 120.0
|
||||
changed = True
|
||||
except (TypeError, ValueError):
|
||||
s.validation_timeout_seconds = 12.0
|
||||
changed = True
|
||||
# Empty list survives "all(isinstance…)" — would skip all fetches and yield an empty pool.
|
||||
if not isinstance(s.sources, list) or not all(isinstance(x, str) for x in s.sources) or len(s.sources) == 0:
|
||||
s.sources = list(Settings().sources)
|
||||
changed = True
|
||||
else:
|
||||
safe = [u for u in s.sources if _is_safe_https_url(u)]
|
||||
if len(safe) < len(s.sources):
|
||||
log.warning("Removed %d unsafe source URL(s) from settings.", len(s.sources) - len(safe))
|
||||
s.sources = safe if safe else list(Settings().sources)
|
||||
changed = True
|
||||
if s.ip_check_url and not _is_safe_https_url(s.ip_check_url):
|
||||
log.warning("ip_check_url looks unsafe (%s) — reverting to default.", s.ip_check_url)
|
||||
s.ip_check_url = Settings().ip_check_url
|
||||
changed = True
|
||||
return s, changed
|
||||
|
||||
|
||||
def normalize_listen_host(s: Settings) -> tuple[Settings, bool]:
|
||||
"""Force loopback bind so LAN/DHCP IP changes (e.g. cloned VMs) never break the listener."""
|
||||
if s.local_host == LISTEN_HOST:
|
||||
return s, False
|
||||
s.local_host = LISTEN_HOST
|
||||
return s, True
|
||||
|
||||
|
||||
def load_settings() -> Settings:
|
||||
p = app_data_dir() / "settings.json"
|
||||
if not p.is_file():
|
||||
s = Settings()
|
||||
if sys.platform != "win32":
|
||||
s.kill_switch_enabled = False
|
||||
s.mac_spoof_enabled = False
|
||||
s.mac_rotate_on_chain_rotate = False
|
||||
s.spoof_hostname_enabled = False
|
||||
s.disable_ipv6_while_active = False
|
||||
s.harden_webrtc_enabled = False
|
||||
s.lan_lockdown_enabled = False
|
||||
s.telemetry_kill_enabled = False
|
||||
return s
|
||||
try:
|
||||
raw = json.loads(p.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
corrupt = p.with_suffix(".json.corrupt")
|
||||
log.warning("settings.json unreadable (%s) — backing up to %s and using defaults.", exc, corrupt)
|
||||
try:
|
||||
shutil.copy2(p, corrupt)
|
||||
except OSError:
|
||||
pass
|
||||
return Settings()
|
||||
raw = migrate(raw)
|
||||
s = Settings()
|
||||
for k, v in raw.items():
|
||||
if hasattr(s, k):
|
||||
setattr(s, k, v)
|
||||
if (
|
||||
not isinstance(s.sources, list)
|
||||
or not all(isinstance(x, str) for x in s.sources)
|
||||
or len(s.sources) == 0
|
||||
):
|
||||
s.sources = list(Settings().sources)
|
||||
if s.obfuscation_mode not in OBFUSCATION_MODES:
|
||||
s.obfuscation_mode = "auto"
|
||||
if not isinstance(s.pinned_chain, list):
|
||||
s.pinned_chain = []
|
||||
if not hasattr(s, "manual_exit_proxy") or not isinstance(s.manual_exit_proxy, str):
|
||||
s.manual_exit_proxy = ""
|
||||
s, ch1 = sanitize_settings(s)
|
||||
s, ch2 = normalize_listen_host(s)
|
||||
changed = ch1 or ch2
|
||||
if changed:
|
||||
try:
|
||||
p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
return s
|
||||
|
||||
|
||||
def save_settings(s: Settings) -> None:
|
||||
s, _ = sanitize_settings(s)
|
||||
s, _ = normalize_listen_host(s)
|
||||
p = app_data_dir() / "settings.json"
|
||||
# Backup before overwriting so a crash mid-write doesn't corrupt settings.
|
||||
if p.is_file():
|
||||
try:
|
||||
shutil.copy2(p, p.with_suffix(".json.bak"))
|
||||
except OSError as exc:
|
||||
log.warning("Could not back up settings.json: %s", exc)
|
||||
p.write_text(json.dumps(asdict(s), indent=2), encoding="utf-8")
|
||||
335
proxy_chain_manager/dns_leak.py
Normal file
335
proxy_chain_manager/dns_leak.py
Normal 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)}",
|
||||
)
|
||||
226
proxy_chain_manager/exit_intel.py
Normal file
226
proxy_chain_manager/exit_intel.py
Normal 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")
|
||||
37
proxy_chain_manager/fetcher.py
Normal file
37
proxy_chain_manager/fetcher.py
Normal 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
|
||||
416
proxy_chain_manager/fingerprint.py
Normal file
416
proxy_chain_manager/fingerprint.py
Normal 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],
|
||||
)
|
||||
191
proxy_chain_manager/firewall.py
Normal file
191
proxy_chain_manager/firewall.py
Normal 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)
|
||||
289
proxy_chain_manager/gost_util.py
Normal file
289
proxy_chain_manager/gost_util.py
Normal 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)
|
||||
41
proxy_chain_manager/gui_validate.py
Normal file
41
proxy_chain_manager/gui_validate.py
Normal 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)
|
||||
)
|
||||
313
proxy_chain_manager/leak_audit.py
Normal file
313
proxy_chain_manager/leak_audit.py
Normal 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))
|
||||
64
proxy_chain_manager/leak_detect.py
Normal file
64
proxy_chain_manager/leak_detect.py
Normal 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"
|
||||
158
proxy_chain_manager/mac_spoof.py
Normal file
158
proxy_chain_manager/mac_spoof.py
Normal 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
|
||||
22
proxy_chain_manager/paths.py
Normal file
22
proxy_chain_manager/paths.py
Normal 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}"
|
||||
106
proxy_chain_manager/pool_ops.py
Normal file
106
proxy_chain_manager/pool_ops.py
Normal 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))
|
||||
291
proxy_chain_manager/privacy_lan.py
Normal file
291
proxy_chain_manager/privacy_lan.py
Normal 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
|
||||
201
proxy_chain_manager/proxy_picker.py
Normal file
201
proxy_chain_manager/proxy_picker.py
Normal 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)
|
||||
160
proxy_chain_manager/secrets_store.py
Normal file
160
proxy_chain_manager/secrets_store.py
Normal 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)
|
||||
949
proxy_chain_manager/service.py
Normal file
949
proxy_chain_manager/service.py
Normal 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
|
||||
153
proxy_chain_manager/signup_extension/content.js
Normal file
153
proxy_chain_manager/signup_extension/content.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
21
proxy_chain_manager/signup_extension/manifest.json
Normal file
21
proxy_chain_manager/signup_extension/manifest.json
Normal 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"]
|
||||
}
|
||||
444
proxy_chain_manager/signup_prep.py
Normal file
444
proxy_chain_manager/signup_prep.py
Normal 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")
|
||||
574
proxy_chain_manager/sysproxy.py
Normal file
574
proxy_chain_manager/sysproxy.py
Normal 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
|
||||
292
proxy_chain_manager/telemetry_kill.py
Normal file
292
proxy_chain_manager/telemetry_kill.py
Normal 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
160
proxy_chain_manager/tray.py
Normal 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()
|
||||
277
proxy_chain_manager/validator.py
Normal file
277
proxy_chain_manager/validator.py
Normal 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"
|
||||
233
proxy_chain_manager/vpn_detect.py
Normal file
233
proxy_chain_manager/vpn_detect.py
Normal 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
|
||||
231
proxy_chain_manager/webrtc_check.py
Normal file
231
proxy_chain_manager/webrtc_check.py
Normal 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
|
||||
107
proxy_chain_manager/win_compat.py
Normal file
107
proxy_chain_manager/win_compat.py
Normal 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
|
||||
84
proxy_chain_manager/windows_task.py
Normal file
84
proxy_chain_manager/windows_task.py
Normal 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
|
||||
BIN
proxy_chain_manager/world_map.png
Normal file
BIN
proxy_chain_manager/world_map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
Reference in New Issue
Block a user