feat: exhaustive feature expansion — cookie modes, DNS/WebRTC testers, map, Firefox fix
Cookie system: - Expand from 5 to 12 fully-specified CookiePolicy modes in browser_identity.py - Add CookiePolicy dataclass: behavior, lifetime, TCP partitioning, clearOnShutdown.* - browser_profile.py emits full cookie pref set from policy object - UI dropdown widened to 680px with live description label per mode Firefox launch fix: - Detect Firefox via Windows registry, AppData, and shutil.which - Use DETACHED_PROCESS|CREATE_NO_WINDOW|CREATE_NEW_PROCESS_GROUP flags - Wait up to 3s for firefox.exe in tasklist instead of polling parent pid - taskkill on stop() to terminate all firefox.exe processes DNS leak tester: - dns_leak.py: FullDnsLeakReport dataclass, run_dns_leak_test comparing proxy DoH resolution vs direct system DNS WebRTC tester: - webrtc_check.py: STUN UDP probe, registry policy check, user.js pref check Ban tester: - Added SITES_SHOPPING, SITES_CRYPTO, SITES_DNS categories - Parallel execution via ThreadPoolExecutor - Expanded banned-text hint keywords Fingerprint audit: - OS identity checks: hostname, MAC, GUID, OS version, timezone, screen res - Browser consistency analysis of user.js Neon world map: - world_map.png bundled; chain_map.py renders hop arcs over it with glow effect Signup prep: - Auto-save account on Open & Autofill; Copy Email / Copy Pass buttons - Auto-fill custom URL when preset site selected - PyInstaller-safe path resolution for signup_extension Spec: - Bundle signup_extension dir and world_map.png as PyInstaller data files Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
# -*- mode: python ; coding: utf-8 -*-
|
# -*- mode: python ; coding: utf-8 -*-
|
||||||
from PyInstaller.utils.hooks import collect_all
|
from PyInstaller.utils.hooks import collect_all
|
||||||
|
|
||||||
datas = []
|
datas = [
|
||||||
|
# Signup autofill WebExtension (manifest + content.js)
|
||||||
|
('proxy_chain_manager/signup_extension', 'proxy_chain_manager/signup_extension'),
|
||||||
|
# Neon world-map background image for the chain map widget
|
||||||
|
('proxy_chain_manager/world_map.png', 'proxy_chain_manager'),
|
||||||
|
]
|
||||||
binaries = []
|
binaries = []
|
||||||
hiddenimports = ['pystray._win32']
|
hiddenimports = ['pystray._win32']
|
||||||
tmp_ret = collect_all('customtkinter')
|
tmp_ret = collect_all('customtkinter')
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ from .firewall import (
|
|||||||
request_admin_relaunch,
|
request_admin_relaunch,
|
||||||
)
|
)
|
||||||
from .artifact_wipe import wipe_artifacts
|
from .artifact_wipe import wipe_artifacts
|
||||||
from .ban_tester import BanTestResult, run_ban_tests, test_single_site
|
from .ban_tester import BanTestResult, SITE_CATEGORIES, run_ban_tests, test_single_site
|
||||||
from .exit_intel import ExitIntel, fetch_exit_intel
|
from .exit_intel import ExitIntel, fetch_exit_intel
|
||||||
from .chain_map import build_chain_map_points, render_chain_map, resize_map_display
|
from .chain_map import build_chain_map_points, render_chain_map, resize_map_display
|
||||||
from .signup_prep import (
|
from .signup_prep import (
|
||||||
@@ -64,8 +64,14 @@ from .browser_identity import (
|
|||||||
PERSONA_LABELS,
|
PERSONA_LABELS,
|
||||||
PERSONAS,
|
PERSONAS,
|
||||||
)
|
)
|
||||||
from .dns_leak import check_dns_leak_hint, flush_dns_cache
|
from .dns_leak import (
|
||||||
|
FullDnsLeakReport,
|
||||||
|
check_dns_leak_hint,
|
||||||
|
flush_dns_cache,
|
||||||
|
run_dns_leak_test,
|
||||||
|
)
|
||||||
from .leak_audit import AuditReport, run_audit_sync
|
from .leak_audit import AuditReport, run_audit_sync
|
||||||
|
from .webrtc_check import WebRtcCheckResult, run_webrtc_check
|
||||||
from .browser_launcher import (
|
from .browser_launcher import (
|
||||||
BrowserConfig,
|
BrowserConfig,
|
||||||
BrowserSession,
|
BrowserSession,
|
||||||
@@ -1240,20 +1246,49 @@ def main() -> None:
|
|||||||
persona_menu.pack(side="left", padx=(4, 0))
|
persona_menu.pack(side="left", padx=(4, 0))
|
||||||
|
|
||||||
cookie_row = ctk.CTkFrame(persona_card, fg_color="transparent")
|
cookie_row = ctk.CTkFrame(persona_card, fg_color="transparent")
|
||||||
cookie_row.pack(fill="x", padx=12, pady=(0, 10))
|
cookie_row.pack(fill="x", padx=12, pady=(0, 4))
|
||||||
ctk.CTkLabel(cookie_row, text="Cookies:", font=(FONT, 11), text_color=TEXT,
|
ctk.CTkLabel(cookie_row, text="Cookies:", font=(FONT, 11), text_color=TEXT,
|
||||||
width=80, anchor="w").pack(side="left")
|
width=80, anchor="w").pack(side="left")
|
||||||
cookie_var = ctk.StringVar(
|
_default_cookie_label = COOKIE_LABELS.get(
|
||||||
value=COOKIE_LABELS.get(s.browser_cookie_mode, COOKIE_LABELS["block_third_party"])
|
s.browser_cookie_mode, COOKIE_LABELS["block_third_party"]
|
||||||
)
|
)
|
||||||
|
cookie_var = ctk.StringVar(value=_default_cookie_label)
|
||||||
|
|
||||||
|
# Cookie detail sub-labels (key → short description of side effects)
|
||||||
|
_COOKIE_NOTES: dict[str, str] = {
|
||||||
|
"accept_all": "Everything works · cookies persist to disk",
|
||||||
|
"accept_all_session": "Full compat while browsing · cookies wiped on close",
|
||||||
|
"block_third_party": "Blocks cross-site trackers · most sites work · cookies persist",
|
||||||
|
"block_third_party_session": "Blocks 3rd-party + nothing persists after close",
|
||||||
|
"block_third_party_trackers":"ETP Strict — same as Firefox enhanced tracking protection",
|
||||||
|
"block_social_trackers": "Blocks Facebook pixel, Twitter widgets, social SSO badges",
|
||||||
|
"block_unvisited": "Very light — only blocks sites you haven't visited",
|
||||||
|
"partitioned_tcp": "Total Cookie Protection — each site gets isolated 3rd-party jar",
|
||||||
|
"partitioned_session": "Isolated jars + full disk wipe on close · zero cross-site state",
|
||||||
|
"session_only": "All cookies expire on close · 1st-party allowed while browsing",
|
||||||
|
"ghost_mode": "Isolated + session + wipes cache/history/localStorage/formdata",
|
||||||
|
"block_all": "Breaks most logins · read-only / scraping use only",
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie_note_lbl = ctk.CTkLabel(
|
||||||
|
persona_card, text=_COOKIE_NOTES.get(s.browser_cookie_mode, ""),
|
||||||
|
font=(FONT, 9), text_color=TEXT2, anchor="w", wraplength=820,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_cookie_change(_choice: str = "") -> None:
|
||||||
|
key = _cookie_key()
|
||||||
|
cookie_note_lbl.configure(text=_COOKIE_NOTES.get(key, ""))
|
||||||
|
|
||||||
cookie_menu = ctk.CTkOptionMenu(
|
cookie_menu = ctk.CTkOptionMenu(
|
||||||
cookie_row,
|
cookie_row,
|
||||||
values=[COOKIE_LABELS[k] for k in COOKIE_MODES],
|
values=[COOKIE_LABELS[k] for k in COOKIE_MODES],
|
||||||
variable=cookie_var,
|
variable=cookie_var,
|
||||||
fg_color=BG, button_color=ACCENT2, button_hover_color=ACCENT,
|
fg_color=BG, button_color=ACCENT2, button_hover_color=ACCENT,
|
||||||
text_color=TEXT, font=(FONT, 11), width=500,
|
text_color=TEXT, font=(FONT, 11), width=680,
|
||||||
|
command=_on_cookie_change,
|
||||||
)
|
)
|
||||||
cookie_menu.pack(side="left", padx=(4, 0))
|
cookie_menu.pack(side="left", padx=(4, 0))
|
||||||
|
cookie_note_lbl.pack(anchor="w", padx=(92, 12), pady=(0, 10))
|
||||||
|
|
||||||
def _persona_key() -> str:
|
def _persona_key() -> str:
|
||||||
for k in PERSONAS:
|
for k in PERSONAS:
|
||||||
@@ -1415,68 +1450,81 @@ def main() -> None:
|
|||||||
ban_head = ctk.CTkFrame(ban_scroll, fg_color=CARD, corner_radius=10, border_width=1, border_color=GLOW)
|
ban_head = ctk.CTkFrame(ban_scroll, fg_color=CARD, corner_radius=10, border_width=1, border_color=GLOW)
|
||||||
ban_head.pack(fill="x", padx=8, pady=(6, 8))
|
ban_head.pack(fill="x", padx=8, pady=(6, 8))
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
ban_head,
|
ban_head, text="Ban / Reachability Tester",
|
||||||
text="Ban List Tester",
|
font=(FONT, 13, "bold"), text_color=CYAN,
|
||||||
font=(FONT, 13, "bold"),
|
|
||||||
text_color=CYAN,
|
|
||||||
).pack(anchor="w", padx=12, pady=(10, 2))
|
).pack(anchor="w", padx=12, pady=(10, 2))
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
ban_head,
|
ban_head,
|
||||||
text="Tests high-traffic sites through your ACTIVE chain to detect likely exit-IP bans.",
|
text=(
|
||||||
font=(FONT, 10),
|
"Tests social, shopping, crypto exchanges, and DNS providers through your ACTIVE chain. "
|
||||||
text_color=TEXT2,
|
"Detects exit-IP bans and geo-blocks."
|
||||||
).pack(anchor="w", padx=12, pady=(0, 8))
|
),
|
||||||
|
font=(FONT, 10), text_color=TEXT2, wraplength=880, justify="left",
|
||||||
|
).pack(anchor="w", padx=12, pady=(0, 6))
|
||||||
|
|
||||||
|
# Category selector
|
||||||
|
ban_cat_row = ctk.CTkFrame(ban_head, fg_color="transparent")
|
||||||
|
ban_cat_row.pack(fill="x", padx=12, pady=(0, 6))
|
||||||
|
ctk.CTkLabel(ban_cat_row, text="Category:", font=(FONT, 10), text_color=TEXT2, width=70,
|
||||||
|
anchor="w").pack(side="left")
|
||||||
|
_ban_cat_keys = list(SITE_CATEGORIES.keys()) # "Social / General", "Shopping", etc.
|
||||||
|
ban_cat_var = ctk.StringVar(value="All")
|
||||||
|
ban_cat_menu = ctk.CTkOptionMenu(
|
||||||
|
ban_cat_row, values=_ban_cat_keys, variable=ban_cat_var,
|
||||||
|
fg_color=BG, button_color=ACCENT2, button_hover_color=ACCENT,
|
||||||
|
text_color=TEXT, width=200,
|
||||||
|
)
|
||||||
|
ban_cat_menu.pack(side="left", padx=(0, 8))
|
||||||
|
|
||||||
ban_status_var = ctk.StringVar(value="Status: idle")
|
ban_status_var = ctk.StringVar(value="Status: idle")
|
||||||
ctk.CTkLabel(ban_head, textvariable=ban_status_var, font=(FONT, 11, "bold"), text_color=TEXT).pack(
|
ctk.CTkLabel(ban_head, textvariable=ban_status_var, font=(FONT, 11, "bold"), text_color=TEXT).pack(
|
||||||
anchor="w", padx=12, pady=(0, 8)
|
anchor="w", padx=12, pady=(0, 6)
|
||||||
)
|
)
|
||||||
|
|
||||||
ban_results_frame = ctk.CTkFrame(
|
ban_results_frame = ctk.CTkFrame(
|
||||||
ban_scroll,
|
ban_scroll, fg_color=CARD, corner_radius=10, border_width=1, border_color=GLOW,
|
||||||
fg_color=CARD,
|
|
||||||
corner_radius=10,
|
|
||||||
border_width=1,
|
|
||||||
border_color=GLOW,
|
|
||||||
)
|
)
|
||||||
ban_results_frame.pack(fill="both", expand=True, padx=8, pady=(0, 8))
|
ban_results_frame.pack(fill="both", expand=True, padx=8, pady=(0, 8))
|
||||||
|
|
||||||
ban_rows_wrap = ctk.CTkFrame(ban_results_frame, fg_color="transparent")
|
ban_rows_wrap = ctk.CTkFrame(ban_results_frame, fg_color="transparent")
|
||||||
ban_rows_wrap.pack(fill="both", expand=True, padx=8, pady=8)
|
ban_rows_wrap.pack(fill="both", expand=True, padx=8, pady=8)
|
||||||
|
|
||||||
def _ban_row(site: str, status: str, detail: str, color: str) -> None:
|
def _ban_row(site: str, status: str, detail: str, color: str, cat_header: str = "") -> None:
|
||||||
|
if cat_header:
|
||||||
|
ctk.CTkLabel(
|
||||||
|
ban_rows_wrap, text=f" {cat_header}",
|
||||||
|
font=(FONT, 10, "bold"), text_color=CYAN, anchor="w",
|
||||||
|
).pack(fill="x", padx=4, pady=(6, 1))
|
||||||
row = ctk.CTkFrame(ban_rows_wrap, fg_color=PANEL, corner_radius=6, height=30)
|
row = ctk.CTkFrame(ban_rows_wrap, fg_color=PANEL, corner_radius=6, height=30)
|
||||||
row.pack(fill="x", pady=2)
|
row.pack(fill="x", pady=2)
|
||||||
row.pack_propagate(False)
|
row.pack_propagate(False)
|
||||||
ctk.CTkLabel(row, text=site, font=(FONT, 11, "bold"), text_color=TEXT, width=140, anchor="w").pack(
|
ctk.CTkLabel(row, text=site, font=(FONT, 11, "bold"), text_color=TEXT,
|
||||||
side="left", padx=(8, 4)
|
width=160, anchor="w").pack(side="left", padx=(8, 4))
|
||||||
)
|
ctk.CTkLabel(row, text=status, font=(FONT, 10, "bold"), text_color=color,
|
||||||
ctk.CTkLabel(row, text=status, font=(FONT, 10, "bold"), text_color=color, width=80).pack(
|
width=72).pack(side="left", padx=4)
|
||||||
side="left", padx=4
|
ctk.CTkLabel(row, text=detail, font=("Consolas", 10), text_color=TEXT2,
|
||||||
)
|
anchor="w").pack(side="left", fill="x", expand=True, padx=(6, 8))
|
||||||
ctk.CTkLabel(row, text=detail, font=("Consolas", 10), text_color=TEXT2, anchor="w").pack(
|
|
||||||
side="left", fill="x", expand=True, padx=(6, 8)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _render_ban_results(results: list[BanTestResult]) -> None:
|
def _render_ban_results(results: list[BanTestResult]) -> None:
|
||||||
for w in list(ban_rows_wrap.winfo_children()):
|
for child in list(ban_rows_wrap.winfo_children()):
|
||||||
w.destroy()
|
child.destroy()
|
||||||
if not results:
|
if not results:
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(ban_rows_wrap, text="No results yet.", font=(FONT, 11),
|
||||||
ban_rows_wrap,
|
text_color=TEXT2).pack(anchor="w", padx=8, pady=8)
|
||||||
text="No results yet.",
|
|
||||||
font=(FONT, 11),
|
|
||||||
text_color=TEXT2,
|
|
||||||
).pack(anchor="w", padx=8, pady=8)
|
|
||||||
return
|
return
|
||||||
|
seen_cats: set[str] = set()
|
||||||
for r in results:
|
for r in results:
|
||||||
|
cat = r.category or ""
|
||||||
|
header = cat if cat and cat not in seen_cats else ""
|
||||||
|
if cat:
|
||||||
|
seen_cats.add(cat)
|
||||||
if r.status == "ok":
|
if r.status == "ok":
|
||||||
st, color = "OK", GREEN
|
st, color = "OK", GREEN
|
||||||
elif r.status == "banned":
|
elif r.status == "banned":
|
||||||
st, color = "BANNED", ORANGE
|
st, color = "BANNED", ORANGE
|
||||||
else:
|
else:
|
||||||
st, color = "ERROR", RED
|
st, color = "ERROR", RED
|
||||||
_ban_row(r.site, st, r.detail, color)
|
_ban_row(r.site, st, r.detail, color, cat_header=header)
|
||||||
|
|
||||||
_render_ban_results([])
|
_render_ban_results([])
|
||||||
|
|
||||||
@@ -1489,23 +1537,24 @@ def main() -> None:
|
|||||||
_log("Ban tester: start the chain first, then run tests.")
|
_log("Ban tester: start the chain first, then run tests.")
|
||||||
return
|
return
|
||||||
proxy_url = f"http://{svc.settings.listen_addr()}"
|
proxy_url = f"http://{svc.settings.listen_addr()}"
|
||||||
|
chosen_cat = ban_cat_var.get()
|
||||||
|
# "All" means every category
|
||||||
|
cats = None if chosen_cat == "All" else [chosen_cat]
|
||||||
ban_status_var.set("Status: running…")
|
ban_status_var.set("Status: running…")
|
||||||
ban_start_btn.configure(state="disabled")
|
ban_start_btn.configure(state="disabled")
|
||||||
_render_ban_results([])
|
_render_ban_results([])
|
||||||
|
|
||||||
def work() -> None:
|
def work() -> None:
|
||||||
timeout = min(18.0, max(8.0, float(svc.settings.validation_timeout_seconds) + 3.0))
|
timeout = min(18.0, max(8.0, float(svc.settings.validation_timeout_seconds) + 3.0))
|
||||||
results = run_ban_tests(proxy_url, timeout_seconds=timeout)
|
results = run_ban_tests(proxy_url, timeout_seconds=timeout, categories=cats)
|
||||||
ok_n = sum(1 for r in results if r.status == "ok")
|
ok_n = sum(1 for r in results if r.status == "ok")
|
||||||
banned_n = sum(1 for r in results if r.status == "banned")
|
banned_n = sum(1 for r in results if r.status == "banned")
|
||||||
err_n = len(results) - ok_n - banned_n
|
err_n = len(results) - ok_n - banned_n
|
||||||
|
|
||||||
def done() -> None:
|
def done() -> None:
|
||||||
_render_ban_results(results)
|
_render_ban_results(results)
|
||||||
ban_status_var.set(f"Status: done | ok={ok_n} banned={banned_n} errors={err_n}")
|
ban_status_var.set(f"Status: done — ok={ok_n} banned={banned_n} errors={err_n}")
|
||||||
_log(
|
_log(f"Ban tester done via {proxy_url} — ok={ok_n}, banned={banned_n}, errors={err_n}.")
|
||||||
f"Ban tester done via {proxy_url} — ok={ok_n}, banned={banned_n}, errors={err_n}."
|
|
||||||
)
|
|
||||||
ban_start_btn.configure(state="normal")
|
ban_start_btn.configure(state="normal")
|
||||||
|
|
||||||
root.after(0, done)
|
root.after(0, done)
|
||||||
@@ -1513,13 +1562,8 @@ def main() -> None:
|
|||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
ban_start_btn = _btn(
|
ban_start_btn = _btn(
|
||||||
ban_action,
|
ban_action, "Start test", _start_ban_test,
|
||||||
"Start ban test",
|
w=120, h=30, fg_color=ACCENT2, hover_color=ACCENT,
|
||||||
_start_ban_test,
|
|
||||||
w=120,
|
|
||||||
h=30,
|
|
||||||
fg_color=ACCENT2,
|
|
||||||
hover_color=ACCENT,
|
|
||||||
)
|
)
|
||||||
ban_start_btn.pack(side="left")
|
ban_start_btn.pack(side="left")
|
||||||
|
|
||||||
@@ -1561,11 +1605,12 @@ def main() -> None:
|
|||||||
signup_site_labels[0],
|
signup_site_labels[0],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
signup_site_menu = ctk.CTkOptionMenu(
|
custom_url_row = ctk.CTkFrame(signup_head, fg_color="transparent")
|
||||||
site_row, values=signup_site_labels, variable=signup_site_var,
|
custom_url_row.pack(fill="x", padx=12, pady=(0, 4))
|
||||||
fg_color=BG, button_color=ACCENT2, button_hover_color=ACCENT, text_color=TEXT, width=260,
|
ctk.CTkLabel(custom_url_row, text="URL", width=70, anchor="w", font=(FONT, 10), text_color=TEXT2).pack(side="left")
|
||||||
)
|
signup_custom_url = ctk.CTkEntry(custom_url_row, height=28, fg_color=BG, border_color=ACCENT)
|
||||||
signup_site_menu.pack(side="left", padx=(0, 8))
|
signup_custom_url.pack(side="left", fill="x", expand=True)
|
||||||
|
signup_custom_url.insert(0, signup_draft.custom_url or "")
|
||||||
|
|
||||||
def _signup_site_key() -> str:
|
def _signup_site_key() -> str:
|
||||||
label = signup_site_var.get()
|
label = signup_site_var.get()
|
||||||
@@ -1574,12 +1619,22 @@ def main() -> None:
|
|||||||
return signup_site_keys[i]
|
return signup_site_keys[i]
|
||||||
return signup_site_keys[0]
|
return signup_site_keys[0]
|
||||||
|
|
||||||
custom_url_row = ctk.CTkFrame(signup_head, fg_color="transparent")
|
def _on_signup_site_change(_choice: str = "") -> None:
|
||||||
custom_url_row.pack(fill="x", padx=12, pady=(0, 4))
|
key = _signup_site_key()
|
||||||
ctk.CTkLabel(custom_url_row, text="Custom URL", width=70, anchor="w", font=(FONT, 10), text_color=TEXT2).pack(side="left")
|
preset = SIGNUP_PRESETS.get(key)
|
||||||
signup_custom_url = ctk.CTkEntry(custom_url_row, height=28, fg_color=BG, border_color=ACCENT)
|
if preset and preset.url:
|
||||||
signup_custom_url.pack(side="left", fill="x", expand=True)
|
signup_custom_url.delete(0, "end")
|
||||||
signup_custom_url.insert(0, signup_draft.custom_url or "")
|
signup_custom_url.insert(0, preset.url)
|
||||||
|
|
||||||
|
signup_site_menu = ctk.CTkOptionMenu(
|
||||||
|
site_row, values=signup_site_labels, variable=signup_site_var,
|
||||||
|
fg_color=BG, button_color=ACCENT2, button_hover_color=ACCENT, text_color=TEXT, width=260,
|
||||||
|
command=_on_signup_site_change,
|
||||||
|
)
|
||||||
|
signup_site_menu.pack(side="left", padx=(0, 8))
|
||||||
|
|
||||||
|
# Populate URL field for the initially-selected site
|
||||||
|
_on_signup_site_change()
|
||||||
|
|
||||||
def _signup_field(parent: Any, label: str, show: str = "") -> ctk.CTkEntry:
|
def _signup_field(parent: Any, label: str, show: str = "") -> ctk.CTkEntry:
|
||||||
row = ctk.CTkFrame(parent, fg_color="transparent")
|
row = ctk.CTkFrame(parent, fg_color="transparent")
|
||||||
@@ -1695,34 +1750,48 @@ def main() -> None:
|
|||||||
# ── Pre-flight checklist ─────────────────────────────────────────────────
|
# ── Pre-flight checklist ─────────────────────────────────────────────────
|
||||||
preflight_card = _signup_card(
|
preflight_card = _signup_card(
|
||||||
"Pre-flight check",
|
"Pre-flight check",
|
||||||
"Verifies chain, target site reachability, and exit-IP intel before you sign up.",
|
"Exhaustive readiness check: chain, exit IP, geo/ASN, DNS leak, WebRTC, "
|
||||||
|
"IPv6, fingerprint, target site reachability, and direct-IP comparison.",
|
||||||
)
|
)
|
||||||
pf_rows: dict[str, dict[str, ctk.StringVar | ctk.CTkLabel]] = {}
|
pf_rows: dict[str, dict[str, ctk.StringVar | ctk.CTkLabel]] = {}
|
||||||
|
|
||||||
|
_PF_CHECKS = [
|
||||||
|
("chain", "Chain running"),
|
||||||
|
("exit_ip", "Exit IP (via chain)"),
|
||||||
|
("direct_ip", "Direct IP (no chain)"),
|
||||||
|
("leak_compare","IP leak check"),
|
||||||
|
("intel", "Geo / ASN intel"),
|
||||||
|
("datacenter", "Residential ASN"),
|
||||||
|
("dns", "DNS routing"),
|
||||||
|
("ipv6", "IPv6 binding"),
|
||||||
|
("webrtc", "WebRTC policy"),
|
||||||
|
("https_tunnel","HTTPS tunnel (CONNECT)"),
|
||||||
|
("target", "Target site reachable"),
|
||||||
|
("firefox", "Firefox path"),
|
||||||
|
]
|
||||||
|
|
||||||
def _pf_row(key: str, label: str) -> None:
|
def _pf_row(key: str, label: str) -> None:
|
||||||
row = ctk.CTkFrame(preflight_card, fg_color=PANEL, corner_radius=6)
|
row = ctk.CTkFrame(preflight_card, fg_color=PANEL, corner_radius=6)
|
||||||
row.pack(fill="x", padx=12, pady=2)
|
row.pack(fill="x", padx=12, pady=2)
|
||||||
dot = ctk.CTkLabel(row, text="●", font=(FONT, 14), text_color=DIM, width=18)
|
dot = ctk.CTkLabel(row, text="●", font=(FONT, 14), text_color=DIM, width=18)
|
||||||
dot.pack(side="left", padx=(8, 4))
|
dot.pack(side="left", padx=(8, 4))
|
||||||
ctk.CTkLabel(row, text=label, font=(FONT, 11, "bold"), text_color=TEXT,
|
ctk.CTkLabel(row, text=label, font=(FONT, 11, "bold"), text_color=TEXT,
|
||||||
width=160, anchor="w").pack(side="left")
|
width=180, anchor="w").pack(side="left")
|
||||||
var = ctk.StringVar(value="not run")
|
var = ctk.StringVar(value="not run")
|
||||||
ctk.CTkLabel(row, textvariable=var, font=(FONT, 10), text_color=TEXT2,
|
ctk.CTkLabel(row, textvariable=var, font=(FONT, 10), text_color=TEXT2,
|
||||||
anchor="w").pack(side="left", fill="x", expand=True, padx=(6, 8), pady=4)
|
anchor="w").pack(side="left", fill="x", expand=True, padx=(6, 8), pady=4)
|
||||||
pf_rows[key] = {"var": var, "dot": dot}
|
pf_rows[key] = {"var": var, "dot": dot}
|
||||||
|
|
||||||
_pf_row("chain", "Chain running")
|
for _k, _lbl in _PF_CHECKS:
|
||||||
_pf_row("exit_ip", "Exit IP")
|
_pf_row(_k, _lbl)
|
||||||
_pf_row("intel", "IP intel (geo/ASN)")
|
|
||||||
_pf_row("target", "Target site reachable")
|
|
||||||
|
|
||||||
def _pf_set(key: str, status: str, detail: str) -> None:
|
def _pf_set(key: str, status: str, detail: str) -> None:
|
||||||
r = pf_rows.get(key)
|
r = pf_rows.get(key)
|
||||||
if not r:
|
if not r:
|
||||||
return
|
return
|
||||||
color = {"ok": GREEN, "warn": ORANGE, "fail": RED, "running": YELLOW}.get(status, DIM)
|
color = {"ok": GREEN, "warn": ORANGE, "fail": RED, "running": YELLOW}.get(status, DIM)
|
||||||
r["dot"].configure(text_color=color)
|
r["dot"].configure(text_color=color) # type: ignore[union-attr]
|
||||||
r["var"].set(detail)
|
r["var"].set(detail) # type: ignore[union-attr]
|
||||||
|
|
||||||
pf_summary_var = ctk.StringVar(value="Not run.")
|
pf_summary_var = ctk.StringVar(value="Not run.")
|
||||||
ctk.CTkLabel(preflight_card, textvariable=pf_summary_var, font=(FONT, 10, "bold"),
|
ctk.CTkLabel(preflight_card, textvariable=pf_summary_var, font=(FONT, 10, "bold"),
|
||||||
@@ -1739,72 +1808,189 @@ def main() -> None:
|
|||||||
pf_summary_var.set("Pick a site or enter a custom URL first.")
|
pf_summary_var.set("Pick a site or enter a custom URL first.")
|
||||||
return
|
return
|
||||||
pf_running[0] = True
|
pf_running[0] = True
|
||||||
for k in ("chain", "exit_ip", "intel", "target"):
|
for k, _ in _PF_CHECKS:
|
||||||
_pf_set(k, "running", "checking…")
|
_pf_set(k, "running", "checking…")
|
||||||
pf_summary_var.set("Running pre-flight…")
|
pf_summary_var.set("Running pre-flight…")
|
||||||
|
|
||||||
|
# Immediate sync checks ──────────────────────────────────────────────
|
||||||
chain_ok = service_running[0] and bool(svc.current_chain)
|
chain_ok = service_running[0] and bool(svc.current_chain)
|
||||||
if not chain_ok:
|
if chain_ok:
|
||||||
_pf_set("chain", "fail", "chain not running")
|
_pf_set("chain", "ok", f"{len(svc.current_chain)} hop(s) active")
|
||||||
_pf_set("exit_ip", "fail", "n/a")
|
else:
|
||||||
_pf_set("intel", "fail", "n/a")
|
_pf_set("chain", "fail", "chain not running — start first")
|
||||||
_pf_set("target", "fail", "n/a")
|
|
||||||
pf_summary_var.set("Start the chain, then re-run pre-flight.")
|
# Firefox path check
|
||||||
pf_running[0] = False
|
ff_path = firefox_path_var.get().strip() or default_firefox_path()
|
||||||
return
|
from pathlib import Path as _Path
|
||||||
_pf_set("chain", "ok", f"{len(svc.current_chain)} hop(s) up")
|
if ff_path and _Path(ff_path).is_file():
|
||||||
|
_pf_set("firefox", "ok", ff_path)
|
||||||
|
else:
|
||||||
|
_pf_set("firefox", "fail", f"not found: {ff_path or '(no path set)'}")
|
||||||
|
|
||||||
proxy_url = f"http://{svc.settings.listen_addr()}"
|
proxy_url = f"http://{svc.settings.listen_addr()}"
|
||||||
target_host = urlparse(url).hostname or "(unknown)"
|
target_host = urlparse(url).hostname or "(unknown)"
|
||||||
|
|
||||||
def work() -> None:
|
def work() -> None:
|
||||||
timeout = min(15.0, max(8.0, float(svc.settings.validation_timeout_seconds) + 2.0))
|
timeout = min(15.0, max(8.0, float(svc.settings.validation_timeout_seconds) + 2.0))
|
||||||
intel = fetch_exit_intel(proxy_url, timeout_seconds=timeout)
|
warns: list[str] = []
|
||||||
site_res = test_single_site(proxy_url, target_host, url, timeout_seconds=timeout)
|
|
||||||
|
|
||||||
def done() -> None:
|
# ── 1. Direct IP (bypass chain) ──────────────────────────────────
|
||||||
ip = (v_exit_ip.get() or "—").strip()
|
from .validator import get_direct_ip as _get_direct_ip
|
||||||
if ip and ip != "—":
|
import asyncio as _asyncio
|
||||||
_pf_set("exit_ip", "ok", ip)
|
try:
|
||||||
|
direct_ip = _asyncio.run(_get_direct_ip(svc.settings.ip_check_url, timeout / 2))
|
||||||
|
except Exception:
|
||||||
|
direct_ip = None
|
||||||
|
|
||||||
|
def _set_direct(ip: str | None) -> None:
|
||||||
|
if ip:
|
||||||
|
_pf_set("direct_ip", "ok", ip)
|
||||||
else:
|
else:
|
||||||
_pf_set("exit_ip", "warn", "no IP reported yet")
|
_pf_set("direct_ip", "warn", "could not reach check URL directly")
|
||||||
if intel.ok:
|
|
||||||
_apply_intel(intel)
|
root.after(0, lambda ip=direct_ip: _set_direct(ip))
|
||||||
if intel.is_proxy_flagged:
|
|
||||||
_pf_set("intel", "fail", f"proxy-flagged · {intel.org or intel.isp}")
|
# ── 2. Exit IP via chain ─────────────────────────────────────────
|
||||||
elif intel.is_datacenter:
|
if chain_ok:
|
||||||
_pf_set("intel", "warn", f"datacenter ASN · {intel.org or intel.isp}")
|
intel = fetch_exit_intel(proxy_url, timeout_seconds=timeout)
|
||||||
|
exit_ip_str = intel.ip if intel.ok else (v_exit_ip.get() or "").strip()
|
||||||
|
|
||||||
|
def _set_exit(ip_: str, intel_: Any) -> None:
|
||||||
|
if ip_ and ip_ not in ("—", ""):
|
||||||
|
_pf_set("exit_ip", "ok", ip_)
|
||||||
else:
|
else:
|
||||||
loc = ", ".join([b for b in (intel.city, intel.country) if b]) or "—"
|
_pf_set("exit_ip", "warn", "no exit IP yet")
|
||||||
_pf_set("intel", "ok", f"{loc} · AS{intel.asn} {intel.org or intel.isp}")
|
# IP leak compare
|
||||||
else:
|
if direct_ip and ip_ and ip_ not in ("—", ""):
|
||||||
_pf_set("intel", "warn", intel.detail or "lookup failed")
|
if direct_ip == ip_:
|
||||||
if site_res.status == "ok":
|
_pf_set("leak_compare", "fail",
|
||||||
_pf_set("target", "ok", f"{target_host} {site_res.detail}")
|
f"exit == direct ({ip_}) — chain not working!")
|
||||||
elif site_res.status == "banned":
|
warns.append("exit IP matches direct IP (chain not routing)")
|
||||||
_pf_set("target", "fail", f"likely banned · {site_res.detail}")
|
else:
|
||||||
else:
|
_pf_set("leak_compare", "ok",
|
||||||
_pf_set("target", "warn", site_res.detail)
|
f"exit {ip_} ≠ direct {direct_ip} ✓")
|
||||||
|
else:
|
||||||
|
_pf_set("leak_compare", "warn", "cannot compare — missing IPs")
|
||||||
|
# Geo/ASN
|
||||||
|
if intel_.ok:
|
||||||
|
_apply_intel(intel_)
|
||||||
|
loc = ", ".join([b for b in (intel_.city, intel_.country) if b]) or "—"
|
||||||
|
asn_str = f"AS{intel_.asn}" if intel_.asn else ""
|
||||||
|
if intel_.is_proxy_flagged:
|
||||||
|
_pf_set("intel", "fail", f"proxy-flagged · {intel_.org or intel_.isp}")
|
||||||
|
warns.append("exit IP is proxy-flagged")
|
||||||
|
elif intel_.is_datacenter:
|
||||||
|
_pf_set("intel", "warn", f"datacenter · {loc} {asn_str}")
|
||||||
|
_pf_set("datacenter", "warn", f"DC ASN — {intel_.org or intel_.isp}")
|
||||||
|
warns.append("exit IP is datacenter ASN")
|
||||||
|
else:
|
||||||
|
_pf_set("intel", "ok", f"{loc} {asn_str}")
|
||||||
|
_pf_set("datacenter", "ok", f"residential/ISP — {intel_.org or intel_.isp}")
|
||||||
|
else:
|
||||||
|
_pf_set("intel", "warn", intel_.detail or "lookup failed")
|
||||||
|
_pf_set("datacenter", "warn", "intel unavailable")
|
||||||
|
|
||||||
warns: list[str] = []
|
root.after(0, lambda i=exit_ip_str, d=intel: _set_exit(i, d))
|
||||||
if intel.ok and intel.is_proxy_flagged:
|
else:
|
||||||
warns.append("exit IP is proxy-flagged")
|
for k in ("exit_ip", "leak_compare", "intel", "datacenter"):
|
||||||
if intel.ok and intel.is_datacenter:
|
root.after(0, lambda k_=k: _pf_set(k_, "fail", "chain not running"))
|
||||||
warns.append("exit IP is a datacenter ASN")
|
|
||||||
if site_res.status == "banned":
|
# ── 3. DNS routing check ─────────────────────────────────────────
|
||||||
warns.append("target site likely blocks this IP")
|
try:
|
||||||
if not warns:
|
dns_rep = run_dns_leak_test(
|
||||||
pf_summary_var.set("All checks passed — good to sign up.")
|
proxy_url=proxy_url if chain_ok else None,
|
||||||
else:
|
timeout=min(10.0, timeout),
|
||||||
pf_summary_var.set("Warnings: " + "; ".join(warns))
|
)
|
||||||
|
def _set_dns(r: Any) -> None:
|
||||||
|
if r.leaked:
|
||||||
|
_pf_set("dns", "fail", r.summary[:80])
|
||||||
|
warns.append("DNS leak detected")
|
||||||
|
elif r.has_external_resolver:
|
||||||
|
_pf_set("dns", "warn", r.summary[:80])
|
||||||
|
else:
|
||||||
|
_pf_set("dns", "ok", r.summary[:80])
|
||||||
|
root.after(0, lambda r_=dns_rep: _set_dns(r_))
|
||||||
|
except Exception as e:
|
||||||
|
root.after(0, lambda: _pf_set("dns", "warn", f"check error: {e}"))
|
||||||
|
|
||||||
|
# ── 4. IPv6 binding ──────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
from .leak_audit import _check_ipv6_active
|
||||||
|
v6_on, v6_msg = _check_ipv6_active()
|
||||||
|
def _set_v6(on_: bool, msg_: str) -> None:
|
||||||
|
if on_:
|
||||||
|
_pf_set("ipv6", "warn", f"IPv6 active: {msg_}")
|
||||||
|
warns.append("IPv6 enabled (possible leak path)")
|
||||||
|
else:
|
||||||
|
_pf_set("ipv6", "ok", msg_)
|
||||||
|
root.after(0, lambda on=v6_on, msg=v6_msg: _set_v6(on, msg))
|
||||||
|
except Exception as e:
|
||||||
|
root.after(0, lambda: _pf_set("ipv6", "warn", str(e)))
|
||||||
|
|
||||||
|
# ── 5. WebRTC ────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
profile = _Path(firefox_profile_var.get().strip() or default_profile_dir())
|
||||||
|
rtc_rep = run_webrtc_check(profile_dir=profile)
|
||||||
|
def _set_rtc(r: Any) -> None:
|
||||||
|
if r.any_leak_risk:
|
||||||
|
issues_short = "; ".join(r.issues[:2])[:70]
|
||||||
|
_pf_set("webrtc", "warn", issues_short)
|
||||||
|
else:
|
||||||
|
_pf_set("webrtc", "ok", "all surfaces protected")
|
||||||
|
root.after(0, lambda r_=rtc_rep: _set_rtc(r_))
|
||||||
|
except Exception as e:
|
||||||
|
root.after(0, lambda: _pf_set("webrtc", "warn", str(e)))
|
||||||
|
|
||||||
|
# ── 6. HTTPS tunnel probe ─────────────────────────────────────────
|
||||||
|
if chain_ok:
|
||||||
|
try:
|
||||||
|
from .validator import check_https_tunnel
|
||||||
|
import asyncio as _as2
|
||||||
|
tun_ok, tun_msg = _as2.run(
|
||||||
|
check_https_tunnel(proxy_url, timeout)
|
||||||
|
)
|
||||||
|
def _set_tun(ok_: bool, msg_: str) -> None:
|
||||||
|
if ok_:
|
||||||
|
_pf_set("https_tunnel", "ok", msg_)
|
||||||
|
else:
|
||||||
|
_pf_set("https_tunnel", "fail", msg_)
|
||||||
|
warns.append("HTTPS CONNECT tunnel broken")
|
||||||
|
root.after(0, lambda ok=tun_ok, msg=tun_msg: _set_tun(ok, msg))
|
||||||
|
except Exception as e:
|
||||||
|
root.after(0, lambda: _pf_set("https_tunnel", "warn", str(e)))
|
||||||
|
else:
|
||||||
|
root.after(0, lambda: _pf_set("https_tunnel", "fail", "chain not running"))
|
||||||
|
|
||||||
|
# ── 7. Target site reachability ───────────────────────────────────
|
||||||
|
if chain_ok:
|
||||||
|
site_res = test_single_site(proxy_url, target_host, url, timeout_seconds=timeout)
|
||||||
|
def _set_site(r: Any) -> None:
|
||||||
|
if r.status == "ok":
|
||||||
|
_pf_set("target", "ok", f"{target_host} — {r.detail}")
|
||||||
|
elif r.status == "banned":
|
||||||
|
_pf_set("target", "fail", f"likely BANNED · {r.detail}")
|
||||||
|
warns.append("target site likely blocks this exit IP")
|
||||||
|
else:
|
||||||
|
_pf_set("target", "warn", r.detail)
|
||||||
|
root.after(0, lambda r_=site_res: _set_site(r_))
|
||||||
|
else:
|
||||||
|
root.after(0, lambda: _pf_set("target", "fail", "chain not running"))
|
||||||
|
|
||||||
|
# ── Final summary ─────────────────────────────────────────────────
|
||||||
|
def _final() -> None:
|
||||||
pf_running[0] = False
|
pf_running[0] = False
|
||||||
|
if warns:
|
||||||
|
pf_summary_var.set(f"⚠ {len(warns)} warning(s): " + " · ".join(warns[:3]))
|
||||||
|
else:
|
||||||
|
pf_summary_var.set("✓ All pre-flight checks passed — good to go.")
|
||||||
|
|
||||||
root.after(0, done)
|
root.after(200, _final)
|
||||||
|
|
||||||
threading.Thread(target=work, daemon=True).start()
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
pf_btn_row = ctk.CTkFrame(preflight_card, fg_color="transparent")
|
pf_btn_row = ctk.CTkFrame(preflight_card, fg_color="transparent")
|
||||||
pf_btn_row.pack(fill="x", padx=12, pady=(0, 10))
|
pf_btn_row.pack(fill="x", padx=12, pady=(0, 10))
|
||||||
_btn(pf_btn_row, "Run pre-flight", _run_preflight, w=130, h=28,
|
_btn(pf_btn_row, "Run pre-flight", _run_preflight, w=140, h=28,
|
||||||
fg_color=ACCENT2, hover_color=ACCENT).pack(side="left")
|
fg_color=ACCENT2, hover_color=ACCENT).pack(side="left")
|
||||||
|
|
||||||
signup_status_var = ctk.StringVar(value="Ready — start chain, then Open & Autofill.")
|
signup_status_var = ctk.StringVar(value="Ready — start chain, then Open & Autofill.")
|
||||||
@@ -1824,19 +2010,46 @@ def main() -> None:
|
|||||||
accounts_list.pack(fill="x", padx=12, pady=(8, 10))
|
accounts_list.pack(fill="x", padx=12, pady=(8, 10))
|
||||||
|
|
||||||
def _refresh_accounts_ui() -> None:
|
def _refresh_accounts_ui() -> None:
|
||||||
for w in list(accounts_list.winfo_children()):
|
for child in list(accounts_list.winfo_children()):
|
||||||
w.destroy()
|
child.destroy()
|
||||||
rows = load_accounts()
|
rows = load_accounts()
|
||||||
if not rows:
|
if not rows:
|
||||||
ctk.CTkLabel(accounts_list, text="No saved accounts yet.", font=(FONT, 10), text_color=TEXT2).pack(
|
ctk.CTkLabel(accounts_list, text="No saved accounts yet.", font=(FONT, 10),
|
||||||
anchor="w", padx=8, pady=8
|
text_color=TEXT2).pack(anchor="w", padx=8, pady=8)
|
||||||
)
|
|
||||||
return
|
return
|
||||||
for rec in rows[:40]:
|
for rec in rows[:60]:
|
||||||
line = f"{rec.created_at[:16]} {rec.site} {rec.email} exit={rec.exit_ip or '—'}"
|
row_frame = ctk.CTkFrame(accounts_list, fg_color=PANEL, corner_radius=6)
|
||||||
ctk.CTkLabel(
|
row_frame.pack(fill="x", pady=2, padx=4)
|
||||||
accounts_list, text=line, font=("Consolas", 10), text_color=TEXT, anchor="w",
|
|
||||||
).pack(anchor="w", padx=8, pady=2)
|
info_lbl = ctk.CTkLabel(
|
||||||
|
row_frame,
|
||||||
|
text=f"{rec.created_at[:16]} {rec.site} {rec.email or rec.username or '—'} exit={rec.exit_ip or '—'}",
|
||||||
|
font=("Consolas", 10), text_color=TEXT, anchor="w",
|
||||||
|
)
|
||||||
|
info_lbl.pack(side="left", fill="x", expand=True, padx=(8, 4), pady=4)
|
||||||
|
|
||||||
|
def _copy_email(r: Any = rec) -> None:
|
||||||
|
v = r.email or r.username or ""
|
||||||
|
root.clipboard_clear()
|
||||||
|
root.clipboard_append(v)
|
||||||
|
_log(f"Copied email/user: {v}")
|
||||||
|
|
||||||
|
def _copy_pass(r: Any = rec) -> None:
|
||||||
|
root.clipboard_clear()
|
||||||
|
root.clipboard_append(r.password or "")
|
||||||
|
_log(f"Copied password for {r.email or r.username or r.site}.")
|
||||||
|
|
||||||
|
ctk.CTkButton(
|
||||||
|
row_frame, text="Copy Email", width=80, height=22,
|
||||||
|
font=(FONT, 10), fg_color=ACCENT2, hover_color=ACCENT,
|
||||||
|
command=_copy_email,
|
||||||
|
).pack(side="right", padx=(2, 4), pady=4)
|
||||||
|
ctk.CTkButton(
|
||||||
|
row_frame, text="Copy Pass", width=76, height=22,
|
||||||
|
font=(FONT, 10), fg_color="#1e3a1e", hover_color="#2e5a2e",
|
||||||
|
text_color=GREEN,
|
||||||
|
command=_copy_pass,
|
||||||
|
).pack(side="right", padx=(2, 2), pady=4)
|
||||||
|
|
||||||
def _current_exit_meta() -> tuple[str, str]:
|
def _current_exit_meta() -> tuple[str, str]:
|
||||||
ip = (v_exit_ip.get() or "—").strip()
|
ip = (v_exit_ip.get() or "—").strip()
|
||||||
@@ -1909,6 +2122,8 @@ def main() -> None:
|
|||||||
tag = f" [{' · '.join(extras)}]" if extras else ""
|
tag = f" [{' · '.join(extras)}]" if extras else ""
|
||||||
signup_status_var.set(f"Opened {label}{tag} — autofill active (you submit + captcha).")
|
signup_status_var.set(f"Opened {label}{tag} — autofill active (you submit + captcha).")
|
||||||
_log(f"Signup prep: opened {url} with autofill extension{tag}.")
|
_log(f"Signup prep: opened {url} with autofill extension{tag}.")
|
||||||
|
# Auto-save entry so the account is recorded even if user forgets to save manually
|
||||||
|
_save_signup_account()
|
||||||
else:
|
else:
|
||||||
signup_status_var.set("Browser launch failed.")
|
signup_status_var.set("Browser launch failed.")
|
||||||
svc.set_sticky(0)
|
svc.set_sticky(0)
|
||||||
@@ -2100,33 +2315,124 @@ def main() -> None:
|
|||||||
_btn(fp_card, "Refresh fingerprint audit", _refresh_fingerprint, w=180, h=28).pack(
|
_btn(fp_card, "Refresh fingerprint audit", _refresh_fingerprint, w=180, h=28).pack(
|
||||||
anchor="w", padx=12, pady=(0, 10))
|
anchor="w", padx=12, pady=(0, 10))
|
||||||
|
|
||||||
dns_card = _priv_section("DNS", "Checks whether public resolvers may bypass your chain.")
|
# ── DNS leak test ────────────────────────────────────────────────────────
|
||||||
dns_result_lbl = ctk.CTkLabel(dns_card, text="Run a check to see DNS configuration.",
|
dns_card = _priv_section(
|
||||||
font=(FONT, 11), text_color=TEXT2, wraplength=880, justify="left")
|
"DNS leak test",
|
||||||
dns_result_lbl.pack(anchor="w", padx=12, pady=8)
|
"Compares how DNS resolves through your chain vs direct. Detects resolver bypass.",
|
||||||
|
)
|
||||||
|
dns_box = ctk.CTkTextbox(
|
||||||
|
dns_card, height=120, font=("Consolas", 10),
|
||||||
|
fg_color=BG, text_color=TEXT, scrollbar_button_color=ACCENT,
|
||||||
|
)
|
||||||
|
dns_box.pack(fill="x", padx=12, pady=(4, 4))
|
||||||
|
dns_box.insert("end", "Click 'Full DNS leak test' or 'Quick check' to probe.")
|
||||||
|
dns_status_lbl = ctk.CTkLabel(dns_card, text="", font=(FONT, 10, "bold"), text_color=TEXT2)
|
||||||
|
dns_status_lbl.pack(anchor="w", padx=12, pady=(0, 2))
|
||||||
|
|
||||||
def _run_dns_check() -> None:
|
def _render_dns_report(rep: FullDnsLeakReport) -> None:
|
||||||
listen = f"http://{svc.settings.listen_addr()}" if svc.current_chain else None
|
dns_box.delete("1.0", "end")
|
||||||
r = check_dns_leak_hint(listen)
|
mark = "LEAK" if rep.leaked else "OK "
|
||||||
color = GREEN if r.ok else RED
|
dns_box.insert("end", f"[{mark}] {rep.summary}\n\n")
|
||||||
dns_result_lbl.configure(
|
if rep.system_resolvers:
|
||||||
text=r.message + (f"\nResolvers: {', '.join(r.system_resolvers)}" if r.system_resolvers else ""),
|
dns_box.insert("end", f"Configured resolvers: {', '.join(rep.system_resolvers)}\n")
|
||||||
|
for host, addrs in rep.direct_resolution.items():
|
||||||
|
via_proxy = rep.proxy_resolution.get(host, [])
|
||||||
|
proxy_str = ", ".join(via_proxy) if via_proxy else "(no proxy answer)"
|
||||||
|
dns_box.insert("end", f" {host:<22} direct={', '.join(addrs) or '—':<20} via-proxy={proxy_str}\n")
|
||||||
|
if rep.errors:
|
||||||
|
dns_box.insert("end", "\nErrors: " + "; ".join(rep.errors[:3]))
|
||||||
|
color = RED if rep.leaked else GREEN
|
||||||
|
dns_status_lbl.configure(
|
||||||
|
text="DNS LEAK DETECTED" if rep.leaked else "DNS looks clean",
|
||||||
text_color=color,
|
text_color=color,
|
||||||
)
|
)
|
||||||
|
|
||||||
dns_btn_row = ctk.CTkFrame(dns_card, fg_color="transparent")
|
def _run_full_dns_test() -> None:
|
||||||
dns_btn_row.pack(fill="x", padx=12, pady=(0, 10))
|
proxy = f"http://{svc.settings.listen_addr()}" if svc.current_chain else None
|
||||||
_btn(dns_btn_row, "DNS leak check", _run_dns_check, w=120, h=28).pack(side="left", padx=(0, 8))
|
dns_status_lbl.configure(text="Running…", text_color=YELLOW)
|
||||||
|
dns_box.delete("1.0", "end")
|
||||||
|
dns_box.insert("end", "Probing DNS via proxy vs direct…\n")
|
||||||
|
|
||||||
|
def work() -> None:
|
||||||
|
try:
|
||||||
|
rep = run_dns_leak_test(proxy_url=proxy,
|
||||||
|
timeout=min(12.0, svc.settings.validation_timeout_seconds))
|
||||||
|
except Exception as e:
|
||||||
|
root.after(0, lambda: dns_status_lbl.configure(
|
||||||
|
text=f"Error: {e}", text_color=RED))
|
||||||
|
return
|
||||||
|
root.after(0, lambda: _render_dns_report(rep))
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def _run_quick_dns_check() -> None:
|
||||||
|
listen = f"http://{svc.settings.listen_addr()}" if svc.current_chain else None
|
||||||
|
r = check_dns_leak_hint(listen)
|
||||||
|
color = GREEN if r.ok else RED
|
||||||
|
dns_box.delete("1.0", "end")
|
||||||
|
dns_box.insert("end", r.message)
|
||||||
|
if r.system_resolvers:
|
||||||
|
dns_box.insert("end", f"\nResolvers: {', '.join(r.system_resolvers)}")
|
||||||
|
dns_status_lbl.configure(text="clean" if r.ok else "issue detected", text_color=color)
|
||||||
|
|
||||||
def _manual_dns_flush() -> None:
|
def _manual_dns_flush() -> None:
|
||||||
ok, msg = flush_dns_cache()
|
ok, msg = flush_dns_cache()
|
||||||
dns_result_lbl.configure(
|
dns_status_lbl.configure(text=msg, text_color=GREEN if ok else RED)
|
||||||
text=msg, text_color=GREEN if ok else RED,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
dns_btn_row = ctk.CTkFrame(dns_card, fg_color="transparent")
|
||||||
|
dns_btn_row.pack(fill="x", padx=12, pady=(0, 10))
|
||||||
|
_btn(dns_btn_row, "Full DNS leak test", _run_full_dns_test, w=140, h=28,
|
||||||
|
fg_color=ACCENT2, hover_color=ACCENT).pack(side="left", padx=(0, 6))
|
||||||
|
_btn(dns_btn_row, "Quick check", _run_quick_dns_check, w=100, h=28).pack(side="left", padx=(0, 6))
|
||||||
_btn(dns_btn_row, "Flush DNS now", _manual_dns_flush, w=110, h=28,
|
_btn(dns_btn_row, "Flush DNS now", _manual_dns_flush, w=110, h=28,
|
||||||
fg_color=DIM).pack(side="left")
|
fg_color=DIM).pack(side="left")
|
||||||
|
|
||||||
|
# ── WebRTC leak test ─────────────────────────────────────────────────────
|
||||||
|
rtc_card = _priv_section(
|
||||||
|
"WebRTC leak test",
|
||||||
|
"Scans Chrome/Edge policy, Firefox profile prefs, and live STUN server reachability.",
|
||||||
|
)
|
||||||
|
rtc_box = ctk.CTkTextbox(
|
||||||
|
rtc_card, height=110, font=("Consolas", 10),
|
||||||
|
fg_color=BG, text_color=TEXT, scrollbar_button_color=ACCENT,
|
||||||
|
)
|
||||||
|
rtc_box.pack(fill="x", padx=12, pady=(4, 4))
|
||||||
|
rtc_box.insert("end", "Click 'WebRTC leak test' to probe.")
|
||||||
|
rtc_status_lbl = ctk.CTkLabel(rtc_card, text="", font=(FONT, 10, "bold"), text_color=TEXT2)
|
||||||
|
rtc_status_lbl.pack(anchor="w", padx=12, pady=(0, 2))
|
||||||
|
|
||||||
|
def _render_rtc_report(rep: WebRtcCheckResult) -> None:
|
||||||
|
rtc_box.delete("1.0", "end")
|
||||||
|
for name, ok, detail in rep.checks:
|
||||||
|
mark = "OK " if ok else "RISK"
|
||||||
|
rtc_box.insert("end", f"[{mark}] {name:<40} {detail}\n")
|
||||||
|
color = RED if rep.any_leak_risk else GREEN
|
||||||
|
rtc_status_lbl.configure(
|
||||||
|
text="WebRTC LEAK RISK — see items above" if rep.any_leak_risk else "WebRTC looks protected",
|
||||||
|
text_color=color,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _run_rtc_test() -> None:
|
||||||
|
rtc_status_lbl.configure(text="Running…", text_color=YELLOW)
|
||||||
|
rtc_box.delete("1.0", "end")
|
||||||
|
rtc_box.insert("end", "Scanning WebRTC surfaces…\n")
|
||||||
|
profile = Path(firefox_profile_var.get().strip() or default_profile_dir())
|
||||||
|
|
||||||
|
def work() -> None:
|
||||||
|
try:
|
||||||
|
rep = run_webrtc_check(profile_dir=profile)
|
||||||
|
except Exception as e:
|
||||||
|
root.after(0, lambda: rtc_status_lbl.configure(text=f"Error: {e}", text_color=RED))
|
||||||
|
return
|
||||||
|
root.after(0, lambda: _render_rtc_report(rep))
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
rtc_btn_row = ctk.CTkFrame(rtc_card, fg_color="transparent")
|
||||||
|
rtc_btn_row.pack(fill="x", padx=12, pady=(0, 10))
|
||||||
|
_btn(rtc_btn_row, "WebRTC leak test", _run_rtc_test, w=140, h=28,
|
||||||
|
fg_color=ACCENT2, hover_color=ACCENT).pack(side="left")
|
||||||
|
|
||||||
wipe_card = _priv_section(
|
wipe_card = _priv_section(
|
||||||
"Forensic artifact wipe",
|
"Forensic artifact wipe",
|
||||||
"One-button purge of common Windows breadcrumb trails. Irreversible.",
|
"One-button purge of common Windows breadcrumb trails. Irreversible.",
|
||||||
|
|||||||
@@ -1,29 +1,73 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import socket
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
# High-traffic destinations that often reveal proxy bans quickly.
|
# ── Site lists ────────────────────────────────────────────────────────────────
|
||||||
POPULAR_SITES: tuple[tuple[str, str], ...] = (
|
|
||||||
("Google", "https://www.google.com/generate_204"),
|
SITES_SOCIAL: tuple[tuple[str, str], ...] = (
|
||||||
("YouTube", "https://www.youtube.com/"),
|
("Google", "https://www.google.com/generate_204"),
|
||||||
("Facebook", "https://www.facebook.com/"),
|
("YouTube", "https://www.youtube.com/"),
|
||||||
("Instagram", "https://www.instagram.com/"),
|
("Facebook", "https://www.facebook.com/"),
|
||||||
("X", "https://x.com/"),
|
("Instagram", "https://www.instagram.com/"),
|
||||||
("Reddit", "https://www.reddit.com/"),
|
("X / Twitter", "https://x.com/"),
|
||||||
("Wikipedia", "https://www.wikipedia.org/"),
|
("Reddit", "https://www.reddit.com/"),
|
||||||
("Amazon", "https://www.amazon.com/"),
|
("TikTok", "https://www.tiktok.com/"),
|
||||||
("Netflix", "https://www.netflix.com/"),
|
("Wikipedia", "https://www.wikipedia.org/"),
|
||||||
("GitHub", "https://github.com/"),
|
("DuckDuckGo", "https://duckduckgo.com/"),
|
||||||
("Cloudflare", "https://www.cloudflare.com/"),
|
("BBC", "https://www.bbc.com/"),
|
||||||
("Microsoft", "https://www.microsoft.com/"),
|
("GitHub", "https://github.com/"),
|
||||||
("TikTok", "https://www.tiktok.com/"),
|
|
||||||
("BBC", "https://www.bbc.com/"),
|
|
||||||
("DuckDuckGo", "https://duckduckgo.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_STATUS_CODES = {401, 403, 407, 418, 429, 451}
|
||||||
_BANNED_TEXT_HINTS = (
|
_BANNED_TEXT_HINTS = (
|
||||||
"access denied",
|
"access denied",
|
||||||
@@ -33,6 +77,15 @@ _BANNED_TEXT_HINTS = (
|
|||||||
"unusual traffic",
|
"unusual traffic",
|
||||||
"captcha",
|
"captcha",
|
||||||
"challenge required",
|
"challenge required",
|
||||||
|
"bot detected",
|
||||||
|
"suspicious activity",
|
||||||
|
"geo-blocked",
|
||||||
|
"not available in your region",
|
||||||
|
"your ip",
|
||||||
|
"ip address",
|
||||||
|
"cloudflare ray",
|
||||||
|
"ddos protection",
|
||||||
|
"attention required",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -40,9 +93,10 @@ _BANNED_TEXT_HINTS = (
|
|||||||
class BanTestResult:
|
class BanTestResult:
|
||||||
site: str
|
site: str
|
||||||
url: str
|
url: str
|
||||||
status: str # ok | banned | error
|
status: str # ok | banned | error
|
||||||
code: int | None
|
code: int | None
|
||||||
detail: str
|
detail: str
|
||||||
|
category: str = ""
|
||||||
|
|
||||||
|
|
||||||
def _looks_banned_body(body: str) -> bool:
|
def _looks_banned_body(body: str) -> bool:
|
||||||
@@ -50,7 +104,13 @@ def _looks_banned_body(body: str) -> bool:
|
|||||||
return any(h in t for h in _BANNED_TEXT_HINTS)
|
return any(h in t for h in _BANNED_TEXT_HINTS)
|
||||||
|
|
||||||
|
|
||||||
def _test_one(proxy_url: str, site: str, url: str, timeout_seconds: float) -> BanTestResult:
|
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))
|
timeout = httpx.Timeout(timeout_seconds, connect=min(8.0, timeout_seconds))
|
||||||
try:
|
try:
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
@@ -58,27 +118,145 @@ def _test_one(proxy_url: str, site: str, url: str, timeout_seconds: float) -> Ba
|
|||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
verify=False,
|
verify=False,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
headers={"User-Agent": "Mozilla/5.0"},
|
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:
|
) as c:
|
||||||
r = c.get(url)
|
r = c.get(url)
|
||||||
body = r.text[:1800] if r.text else ""
|
body = r.text[:2400] if r.text else ""
|
||||||
if r.status_code in _BANNED_STATUS_CODES or _looks_banned_body(body):
|
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}")
|
return BanTestResult(
|
||||||
|
site, url, "banned", r.status_code,
|
||||||
|
f"HTTP {r.status_code}", category,
|
||||||
|
)
|
||||||
if 200 <= r.status_code < 400:
|
if 200 <= r.status_code < 400:
|
||||||
return BanTestResult(site, url, "ok", r.status_code, f"HTTP {r.status_code}")
|
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}")
|
return BanTestResult(site, url, "error", r.status_code, f"HTTP {r.status_code}", category)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return BanTestResult(site, url, "error", None, f"{type(e).__name__}: {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)."""
|
||||||
|
try:
|
||||||
|
socket.setdefaulttimeout(timeout)
|
||||||
|
info = socket.getaddrinfo(hostname, None)
|
||||||
|
for entry in info:
|
||||||
|
addr = entry[4][0]
|
||||||
|
if addr:
|
||||||
|
return addr
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DnsLeakResult:
|
||||||
|
resolver: str
|
||||||
|
ip_via_proxy: str | None
|
||||||
|
ip_direct: str | None
|
||||||
|
leaked: bool
|
||||||
|
detail: str
|
||||||
|
|
||||||
|
|
||||||
|
def check_dns_leak(
|
||||||
|
proxy_url: str,
|
||||||
|
timeout_seconds: float = 8.0,
|
||||||
|
) -> list[DnsLeakResult]:
|
||||||
|
"""
|
||||||
|
Detect DNS leaks: compare hostname resolution seen through proxy vs direct.
|
||||||
|
A mismatch means DNS is escaping the tunnel.
|
||||||
|
"""
|
||||||
|
test_hosts = [
|
||||||
|
("Cloudflare (1.1.1.1)", "one.one.one.one"),
|
||||||
|
("Google (8.8.8.8)", "dns.google"),
|
||||||
|
("OpenDNS", "resolver1.opendns.com"),
|
||||||
|
]
|
||||||
|
results: list[DnsLeakResult] = []
|
||||||
|
timeout = httpx.Timeout(timeout_seconds, connect=min(6.0, timeout_seconds))
|
||||||
|
|
||||||
|
for label, host in test_hosts:
|
||||||
|
# Get IP via direct system DNS
|
||||||
|
direct_ip = _dns_resolve_via_system(host)
|
||||||
|
|
||||||
|
# Get IP as seen from the proxy path (via http://dns-endpoint)
|
||||||
|
proxy_ip: str | None = None
|
||||||
|
try:
|
||||||
|
with httpx.Client(proxy=proxy_url, timeout=timeout, verify=False, follow_redirects=True) as c:
|
||||||
|
r = c.get(f"https://{host}/")
|
||||||
|
proxy_ip = str(r.headers.get("x-real-ip") or "")
|
||||||
|
if not proxy_ip:
|
||||||
|
# fall back: grab connected IP from response
|
||||||
|
proxy_ip = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Simple leak heuristic: if direct resolution works but proxy connection fails, possible leak path
|
||||||
|
leaked = bool(direct_ip and not proxy_ip)
|
||||||
|
if leaked:
|
||||||
|
detail = f"DNS resolved directly to {direct_ip} but proxy could not reach it — possible bypass"
|
||||||
|
elif not direct_ip:
|
||||||
|
detail = "Could not resolve directly"
|
||||||
|
leaked = False
|
||||||
|
else:
|
||||||
|
detail = f"Direct: {direct_ip}"
|
||||||
|
|
||||||
|
results.append(DnsLeakResult(
|
||||||
|
resolver=label,
|
||||||
|
ip_via_proxy=proxy_ip,
|
||||||
|
ip_direct=direct_ip,
|
||||||
|
leaked=leaked,
|
||||||
|
detail=detail,
|
||||||
|
))
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def run_ban_tests(
|
def run_ban_tests(
|
||||||
proxy_url: str,
|
proxy_url: str,
|
||||||
timeout_seconds: float = 12.0,
|
timeout_seconds: float = 12.0,
|
||||||
sites: Iterable[tuple[str, str]] = POPULAR_SITES,
|
sites: Iterable[tuple[str, str]] | None = None,
|
||||||
|
categories: Iterable[str] | None = None,
|
||||||
|
max_workers: int = 8,
|
||||||
) -> list[BanTestResult]:
|
) -> 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:
|
||||||
|
work = []
|
||||||
|
for cat in categories:
|
||||||
|
for s, u in SITE_CATEGORIES.get(cat, ()):
|
||||||
|
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] = []
|
out: list[BanTestResult] = []
|
||||||
for site, url in sites:
|
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||||
out.append(_test_one(proxy_url, site, url, timeout_seconds))
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,84 +15,78 @@ Two strategies are exposed:
|
|||||||
• **Hardened** — full RFP / blocked WebRTC / strict tracking / FPI. Stand-out
|
• **Hardened** — full RFP / blocked WebRTC / strict tracking / FPI. Stand-out
|
||||||
but maximum block. Same behavior as the original profile.
|
but maximum block. Same behavior as the original profile.
|
||||||
|
|
||||||
Cookie modes are independent of persona and let the user pick per-session
|
Cookie modes are independent of persona. Each mode expresses a complete
|
||||||
behavior from a dropdown.
|
policy: which cookies to accept, lifetime, whether to wipe on close, and
|
||||||
|
whether to partition 3rd-party storage.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
# Order matters: dropdown order in the UI.
|
|
||||||
|
# ── Personas ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
PERSONAS: list[str] = [
|
PERSONAS: list[str] = [
|
||||||
"blend_windows_chrome",
|
"blend_windows_chrome",
|
||||||
|
"blend_windows_chrome_laptop",
|
||||||
"blend_windows_firefox",
|
"blend_windows_firefox",
|
||||||
|
"blend_windows_edge",
|
||||||
"blend_mac_safari",
|
"blend_mac_safari",
|
||||||
|
"blend_mac_chrome",
|
||||||
|
"blend_linux_firefox",
|
||||||
"hardened",
|
"hardened",
|
||||||
"custom",
|
"custom",
|
||||||
]
|
]
|
||||||
|
|
||||||
PERSONA_LABELS: dict[str, str] = {
|
PERSONA_LABELS: dict[str, str] = {
|
||||||
"blend_windows_chrome": "Blend in — Windows 10 + Chrome (US-en, most common)",
|
"blend_windows_chrome": "Blend — Win10 + Chrome 124 (most common, US-en)",
|
||||||
"blend_windows_firefox": "Blend in — Windows 10 + Firefox (US-en)",
|
"blend_windows_chrome_laptop": "Blend — Win11 + Chrome 124 laptop 1366×768",
|
||||||
"blend_mac_safari": "Blend in — macOS + Safari (US-en)",
|
"blend_windows_firefox": "Blend — Win10 + Firefox 128 (US-en)",
|
||||||
"hardened": "Hardened — block + RFP (stands out; max anti-fingerprint)",
|
"blend_windows_edge": "Blend — Win11 + Edge 124 (corporate look)",
|
||||||
"custom": "Custom — use individual hardening toggles",
|
"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)",
|
||||||
COOKIE_MODES: list[str] = [
|
"hardened": "Hardened — RFP + FPI + block all (stands out)",
|
||||||
"accept_all",
|
"custom": "Custom — use individual hardening toggles",
|
||||||
"block_third_party",
|
|
||||||
"block_third_party_trackers",
|
|
||||||
"session_only",
|
|
||||||
"block_all",
|
|
||||||
]
|
|
||||||
|
|
||||||
COOKIE_LABELS: dict[str, str] = {
|
|
||||||
"accept_all": "Accept all cookies (most sites work)",
|
|
||||||
"block_third_party": "Block 3rd-party cookies (recommended)",
|
|
||||||
"block_third_party_trackers": "Block 3rd-party trackers only (Firefox default-strict)",
|
|
||||||
"session_only": "Session only — clear on close",
|
|
||||||
"block_all": "Block ALL cookies (breaks logins)",
|
|
||||||
}
|
|
||||||
|
|
||||||
# Firefox network.cookie.cookieBehavior values
|
|
||||||
_COOKIE_BEHAVIOR = {
|
|
||||||
"accept_all": 0,
|
|
||||||
"block_third_party": 1,
|
|
||||||
"block_third_party_trackers": 4,
|
|
||||||
"session_only": 0,
|
|
||||||
"block_all": 2,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Persona:
|
class Persona:
|
||||||
"""A user-agent / locale / timezone / screen tuple that mimics a real,
|
"""A user-agent / locale / timezone / screen tuple that mimics a real,
|
||||||
common configuration. All fields are deliberately popular values so the
|
common configuration."""
|
||||||
persona blends in.
|
|
||||||
"""
|
|
||||||
key: str
|
key: str
|
||||||
user_agent: str
|
user_agent: str
|
||||||
accept_language: str
|
accept_language: str
|
||||||
timezone: str # IANA tz; used only when persona drives env TZ
|
timezone: str
|
||||||
screen_w: int
|
screen_w: int
|
||||||
screen_h: int
|
screen_h: int
|
||||||
platform: str # navigator.platform
|
platform: str
|
||||||
locale: str # general.useragent.locale equivalent
|
locale: str
|
||||||
|
|
||||||
|
|
||||||
PERSONA_DATA: dict[str, Persona] = {
|
PERSONA_DATA: dict[str, Persona] = {
|
||||||
"blend_windows_chrome": Persona(
|
"blend_windows_chrome": Persona(
|
||||||
key="blend_windows_chrome",
|
key="blend_windows_chrome",
|
||||||
# Stable Chrome on Win10 x64 — by far the most common UA on the web.
|
|
||||||
user_agent=(
|
user_agent=(
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||||
),
|
),
|
||||||
accept_language="en-US,en;q=0.9",
|
accept_language="en-US,en;q=0.9",
|
||||||
timezone="America/New_York",
|
timezone="America/New_York",
|
||||||
screen_w=1920,
|
screen_w=1920, screen_h=1080,
|
||||||
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",
|
platform="Win32",
|
||||||
locale="en-US",
|
locale="en-US",
|
||||||
),
|
),
|
||||||
@@ -104,8 +98,19 @@ PERSONA_DATA: dict[str, Persona] = {
|
|||||||
),
|
),
|
||||||
accept_language="en-US,en;q=0.5",
|
accept_language="en-US,en;q=0.5",
|
||||||
timezone="America/New_York",
|
timezone="America/New_York",
|
||||||
screen_w=1920,
|
screen_w=1920, screen_h=1080,
|
||||||
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",
|
platform="Win32",
|
||||||
locale="en-US",
|
locale="en-US",
|
||||||
),
|
),
|
||||||
@@ -117,20 +122,201 @@ PERSONA_DATA: dict[str, Persona] = {
|
|||||||
),
|
),
|
||||||
accept_language="en-US,en;q=0.9",
|
accept_language="en-US,en;q=0.9",
|
||||||
timezone="America/Los_Angeles",
|
timezone="America/Los_Angeles",
|
||||||
screen_w=1680,
|
screen_w=1680, screen_h=1050,
|
||||||
screen_h=1050,
|
|
||||||
platform="MacIntel",
|
platform="MacIntel",
|
||||||
locale="en-US",
|
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:
|
def cookie_behavior_value(mode: str) -> int:
|
||||||
return _COOKIE_BEHAVIOR.get(mode, 1)
|
return get_cookie_policy(mode).behavior
|
||||||
|
|
||||||
|
|
||||||
def cookie_session_only(mode: str) -> bool:
|
def cookie_session_only(mode: str) -> bool:
|
||||||
return mode == "session_only"
|
return get_cookie_policy(mode).lifetime == 2
|
||||||
|
|
||||||
|
|
||||||
def get_persona(key: str) -> Persona | None:
|
def get_persona(key: str) -> Persona | None:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
|
import winreg
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -14,6 +15,14 @@ from .signup_prep import SignupDraft, install_signup_extension
|
|||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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 = (
|
||||||
|
getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000)
|
||||||
|
| getattr(subprocess, "DETACHED_PROCESS", 0x00000008)
|
||||||
|
| getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class BrowserConfig:
|
class BrowserConfig:
|
||||||
@@ -35,11 +44,74 @@ class BrowserConfig:
|
|||||||
cookie_mode: str = "block_third_party"
|
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:
|
def default_firefox_path() -> str:
|
||||||
candidates = [
|
"""Find Firefox.exe — registry first, then well-known paths."""
|
||||||
|
# 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\Mozilla Firefox\firefox.exe"),
|
||||||
Path(r"C:\Program Files (x86)\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:
|
for c in candidates:
|
||||||
if c.is_file():
|
if c.is_file():
|
||||||
return str(c)
|
return str(c)
|
||||||
@@ -50,13 +122,39 @@ def default_profile_dir() -> str:
|
|||||||
return str(app_data_dir() / "browser_profiles" / "firefox_hardened")
|
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)."""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
class BrowserSession:
|
class BrowserSession:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._proc: subprocess.Popen[str] | None = None
|
self._proc: subprocess.Popen[str] | None = None
|
||||||
self._profile_path: Path | None = None
|
self._profile_path: Path | None = None
|
||||||
|
self._launched_at: float = 0.0
|
||||||
|
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
return self._proc is not None and self._proc.poll() is None
|
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 but also
|
||||||
|
# fall back to checking the system process list so the GUI doesn't
|
||||||
|
# falsely report "stopped".
|
||||||
|
if self._proc.poll() is None:
|
||||||
|
return True
|
||||||
|
# Parent has exited — check if any firefox.exe is still alive AND we
|
||||||
|
# launched within the last 5 minutes (to avoid false positives from
|
||||||
|
# unrelated browser sessions).
|
||||||
|
age = time.monotonic() - self._launched_at
|
||||||
|
return age < 300 and _firefox_is_running_on_system()
|
||||||
|
|
||||||
def pid(self) -> int | None:
|
def pid(self) -> int | None:
|
||||||
if self._proc is None:
|
if self._proc is None:
|
||||||
@@ -72,11 +170,22 @@ class BrowserSession:
|
|||||||
signup_draft: SignupDraft | None = None,
|
signup_draft: SignupDraft | None = None,
|
||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
raw_exe = (cfg.firefox_path or default_firefox_path()).strip().strip('"').strip("'")
|
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)
|
exe = Path(raw_exe)
|
||||||
if not exe.is_file():
|
if not exe.is_file():
|
||||||
return False, f"Firefox executable not found: {raw_exe or '(empty path)'}"
|
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("'")
|
raw_profile = (cfg.profile_dir or default_profile_dir()).strip().strip('"').strip("'")
|
||||||
profile_dir = Path(raw_profile)
|
profile_dir = Path(raw_profile)
|
||||||
|
|
||||||
hard = FirefoxHardening(
|
hard = FirefoxHardening(
|
||||||
force_proxy=cfg.force_proxy,
|
force_proxy=cfg.force_proxy,
|
||||||
disable_webrtc=cfg.disable_webrtc,
|
disable_webrtc=cfg.disable_webrtc,
|
||||||
@@ -89,46 +198,88 @@ class BrowserSession:
|
|||||||
persona_key=cfg.persona,
|
persona_key=cfg.persona,
|
||||||
cookie_mode=cfg.cookie_mode,
|
cookie_mode=cfg.cookie_mode,
|
||||||
)
|
)
|
||||||
if cfg.lock_managed_profile:
|
|
||||||
ensure_firefox_profile(profile_dir, proxy_host, int(proxy_port), hard)
|
try:
|
||||||
if signup_draft is not None:
|
if cfg.lock_managed_profile:
|
||||||
install_signup_extension(profile_dir, signup_draft)
|
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():
|
if self.is_running():
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
if cfg.lock_managed_profile:
|
if cfg.lock_managed_profile:
|
||||||
args = [str(exe), "-no-remote", "-profile", str(profile_dir)]
|
args = [str(exe), "-no-remote", "-profile", str(profile_dir)]
|
||||||
else:
|
else:
|
||||||
args = [str(exe)]
|
args = [str(exe)]
|
||||||
|
|
||||||
url = (start_url or "").strip()
|
url = (start_url or "").strip()
|
||||||
if url:
|
if url:
|
||||||
args.append(url)
|
args.append(url)
|
||||||
|
|
||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.update(persona_env(hard))
|
env.update(persona_env(hard))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._proc = subprocess.Popen(
|
self._proc = subprocess.Popen(
|
||||||
args,
|
args,
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
# 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,
|
stdout=subprocess.DEVNULL,
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
text=True,
|
text=True,
|
||||||
env=env,
|
env=env,
|
||||||
)
|
)
|
||||||
# Smoke-check: catch immediate startup crashes and report clearly.
|
|
||||||
time.sleep(0.4)
|
|
||||||
rc = self._proc.poll()
|
|
||||||
if rc is not None:
|
|
||||||
self._proc = None
|
|
||||||
return False, f"Firefox exited immediately (code {rc}). Check path/profile."
|
|
||||||
self._profile_path = profile_dir if cfg.lock_managed_profile else None
|
|
||||||
log.info("Launched hardened Firefox pid=%s profile=%s", self._proc.pid, profile_dir)
|
|
||||||
if cfg.lock_managed_profile:
|
|
||||||
return True, f"Hardened Firefox started (pid {self._proc.pid})."
|
|
||||||
return True, f"Firefox started (unlocked mode, pid {self._proc.pid})."
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._proc = None
|
self._proc = None
|
||||||
return False, f"Launch failed: {e!s}"
|
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]:
|
def stop(self, dispose: bool = False) -> tuple[bool, str]:
|
||||||
|
# Terminate the Popen handle if still alive
|
||||||
if self._proc and self._proc.poll() is None:
|
if self._proc and self._proc.poll() is None:
|
||||||
try:
|
try:
|
||||||
self._proc.terminate()
|
self._proc.terminate()
|
||||||
@@ -140,9 +291,24 @@ class BrowserSession:
|
|||||||
pass
|
pass
|
||||||
finally:
|
finally:
|
||||||
self._proc = None
|
self._proc = None
|
||||||
|
|
||||||
|
# Also kill any remaining firefox.exe processes (handles the
|
||||||
|
# detached-child case where the parent already exited naturally)
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["taskkill", "/F", "/IM", "firefox.exe"],
|
||||||
|
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():
|
if dispose and self._profile_path and self._profile_path.exists():
|
||||||
try:
|
try:
|
||||||
shutil.rmtree(self._profile_path, ignore_errors=True)
|
shutil.rmtree(self._profile_path, ignore_errors=True)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
self._launched_at = 0.0
|
||||||
return True, "Browser stopped."
|
return True, "Browser stopped."
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from .browser_identity import (
|
from .browser_identity import (
|
||||||
|
CookiePolicy,
|
||||||
Persona,
|
Persona,
|
||||||
cookie_behavior_value,
|
cookie_behavior_value,
|
||||||
cookie_session_only,
|
cookie_session_only,
|
||||||
|
get_cookie_policy,
|
||||||
get_persona,
|
get_persona,
|
||||||
is_blend_persona,
|
is_blend_persona,
|
||||||
)
|
)
|
||||||
@@ -180,28 +182,62 @@ def build_user_js(
|
|||||||
f'user_pref("general.useragent.override", "{hard.user_agent_override}");'
|
f'user_pref("general.useragent.override", "{hard.user_agent_override}");'
|
||||||
)
|
)
|
||||||
|
|
||||||
# Cookie policy (persona-independent)
|
# ── Cookie policy ────────────────────────────────────────────────────────
|
||||||
behavior = cookie_behavior_value(hard.cookie_mode)
|
cp: CookiePolicy = get_cookie_policy(hard.cookie_mode)
|
||||||
lines.extend(
|
|
||||||
[
|
|
||||||
f'user_pref("network.cookie.cookieBehavior", {behavior});',
|
|
||||||
# 0 = expire normally, 2 = current session only
|
|
||||||
f'user_pref("network.cookie.lifetimePolicy", {2 if cookie_session_only(hard.cookie_mode) else 0});',
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
if hard.clear_on_shutdown:
|
lines.extend([
|
||||||
lines.extend(
|
f'user_pref("network.cookie.cookieBehavior", {cp.behavior});',
|
||||||
[
|
# pbmode = private-browsing mode uses same behavior
|
||||||
'user_pref("privacy.sanitize.sanitizeOnShutdown", true);',
|
f'user_pref("network.cookie.cookieBehavior.pbmode", {cp.behavior});',
|
||||||
'user_pref("privacy.clearOnShutdown.history", true);',
|
# lifetime: 0 = normal, 2 = session-only
|
||||||
'user_pref("privacy.clearOnShutdown.cookies", true);',
|
f'user_pref("network.cookie.lifetimePolicy", {cp.lifetime});',
|
||||||
'user_pref("privacy.clearOnShutdown.cache", true);',
|
])
|
||||||
'user_pref("privacy.clearOnShutdown.downloads", true);',
|
|
||||||
'user_pref("privacy.clearOnShutdown.formdata", true);',
|
# Total Cookie Protection (TCP / Firefox partitioning)
|
||||||
'user_pref("privacy.clearOnShutdown.sessions", true);',
|
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:
|
if hard.timezone_utc:
|
||||||
lines.append('user_pref("privacy.resistFingerprinting.reduceTimerPrecision", true);')
|
lines.append('user_pref("privacy.resistFingerprinting.reduceTimerPrecision", true);')
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
"""World-map visualization for proxy chains (Pillow + equirectangular projection)."""
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
import ipaddress
|
import ipaddress
|
||||||
@@ -6,10 +10,11 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import socket
|
import socket
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFilter, ImageFont
|
||||||
|
|
||||||
try:
|
try:
|
||||||
_RESAMPLE = Image.Resampling.LANCZOS
|
_RESAMPLE = Image.Resampling.LANCZOS
|
||||||
@@ -23,21 +28,42 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
ChainStatus = Literal["healthy", "connecting", "dead", "idle"]
|
ChainStatus = Literal["healthy", "connecting", "dead", "idle"]
|
||||||
|
|
||||||
# Theme colors (match app.py palette)
|
# Theme palette
|
||||||
_BG = "#070713"
|
_BG = "#070713"
|
||||||
_GRID = "#141432"
|
_GRID = "#1a1f3a"
|
||||||
_LAND = "#12182e"
|
_LAND = "#12182e"
|
||||||
_LAND_EDGE = "#1e2a4a"
|
_LAND_EDGE = "#1e2a4a"
|
||||||
_LINE_HEALTHY = "#00e5ff"
|
_LINE_HEALTHY = "#00e5ff"
|
||||||
_LINE_CONNECTING = "#ffd000"
|
_LINE_CONNECTING = "#ffd000"
|
||||||
_LINE_DEAD = "#ff2a6d"
|
_LINE_DEAD = "#ff2a6d"
|
||||||
_LINE_IDLE = "#3052ff"
|
_LINE_IDLE = "#3052ff"
|
||||||
_YOU = "#00ff9c"
|
_YOU = "#00ff9c"
|
||||||
_HOP = "#00e5ff"
|
_HOP = "#00e5ff"
|
||||||
_EXIT = "#ffd000"
|
_EXIT = "#ffd000"
|
||||||
_LABEL = "#eaf2ff"
|
_LABEL = "#eaf2ff"
|
||||||
_LABEL_DIM = "#7a8aab"
|
_LABEL_DIM = "#7a8aab"
|
||||||
_UNKNOWN = "#2a2f4a"
|
_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)
|
@dataclass(frozen=True)
|
||||||
@@ -134,26 +160,23 @@ def build_chain_map_points(
|
|||||||
ip = resolve_host_ip(host, timeout_seconds=min(4.0, timeout_seconds))
|
ip = resolve_host_ip(host, timeout_seconds=min(4.0, timeout_seconds))
|
||||||
if not ip:
|
if not ip:
|
||||||
continue
|
continue
|
||||||
short = redact_proxy_url(hop)
|
|
||||||
if len(short) > 22:
|
|
||||||
short = short[:20] + "…"
|
|
||||||
intel = fetch_ip_geo(ip, timeout_seconds)
|
intel = fetch_ip_geo(ip, timeout_seconds)
|
||||||
pt = _intel_to_point("hop", f"H{i + 1}", intel, hop_index=i)
|
pt = _intel_to_point("hop", f"H{i + 1}", intel, hop_index=i)
|
||||||
if pt:
|
if pt:
|
||||||
points.append(pt)
|
points.append(pt)
|
||||||
|
|
||||||
exit = (exit_ip or "").strip()
|
exit_str = (exit_ip or "").strip()
|
||||||
if exit and exit != "—":
|
if exit_str and exit_str != "—":
|
||||||
if (
|
if (
|
||||||
exit_intel
|
exit_intel
|
||||||
and exit_intel.ok
|
and exit_intel.ok
|
||||||
and exit_intel.ip == exit
|
and exit_intel.ip == exit_str
|
||||||
and exit_intel.lat is not None
|
and exit_intel.lat is not None
|
||||||
and exit_intel.lon is not None
|
and exit_intel.lon is not None
|
||||||
):
|
):
|
||||||
pt = _intel_to_point("exit", "EXIT", exit_intel)
|
pt = _intel_to_point("exit", "EXIT", exit_intel)
|
||||||
else:
|
else:
|
||||||
pt = _intel_to_point("exit", "EXIT", fetch_ip_geo(exit, timeout_seconds))
|
pt = _intel_to_point("exit", "EXIT", fetch_ip_geo(exit_str, timeout_seconds))
|
||||||
if pt:
|
if pt:
|
||||||
points.append(pt)
|
points.append(pt)
|
||||||
|
|
||||||
@@ -173,7 +196,7 @@ def _great_circle_points(
|
|||||||
lon1: float,
|
lon1: float,
|
||||||
lat2: float,
|
lat2: float,
|
||||||
lon2: float,
|
lon2: float,
|
||||||
steps: int = 24,
|
steps: int = 32,
|
||||||
) -> list[tuple[float, float]]:
|
) -> list[tuple[float, float]]:
|
||||||
"""Interpolate along a great circle for curved hop arcs."""
|
"""Interpolate along a great circle for curved hop arcs."""
|
||||||
phi1, lam1 = math.radians(lat1), math.radians(lon1)
|
phi1, lam1 = math.radians(lat1), math.radians(lon1)
|
||||||
@@ -194,21 +217,50 @@ def _great_circle_points(
|
|||||||
x = a * math.cos(phi1) * math.cos(lam1) + b * math.cos(phi2) * math.cos(lam2)
|
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)
|
y = a * math.cos(phi1) * math.sin(lam1) + b * math.cos(phi2) * math.sin(lam2)
|
||||||
z = a * math.sin(phi1) + b * math.sin(phi2)
|
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))))
|
out.append((
|
||||||
|
math.degrees(math.atan2(z, math.sqrt(x * x + y * y))),
|
||||||
|
math.degrees(math.atan2(y, x)),
|
||||||
|
))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _line_color(status: ChainStatus) -> str:
|
def _line_color(status: ChainStatus) -> tuple[int, int, int]:
|
||||||
return {
|
table = {
|
||||||
"healthy": _LINE_HEALTHY,
|
"healthy": (0, 229, 255),
|
||||||
"connecting": _LINE_CONNECTING,
|
"connecting": (255, 208, 0),
|
||||||
"dead": _LINE_DEAD,
|
"dead": (255, 42, 109),
|
||||||
"idle": _LINE_IDLE,
|
"idle": (48, 82, 255),
|
||||||
}.get(status, _LINE_IDLE)
|
}
|
||||||
|
return table.get(status, (48, 82, 255))
|
||||||
|
|
||||||
|
|
||||||
def _node_color(role: str) -> str:
|
def _node_color(role: str) -> tuple[int, int, int]:
|
||||||
return {"you": _YOU, "hop": _HOP, "exit": _EXIT}.get(role, _HOP)
|
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:
|
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||||
@@ -221,18 +273,13 @@ def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|||||||
|
|
||||||
|
|
||||||
def _draw_landmasses(draw: ImageDraw.ImageDraw, width: int, height: int, margin: int) -> None:
|
def _draw_landmasses(draw: ImageDraw.ImageDraw, width: int, height: int, margin: int) -> None:
|
||||||
"""Minimal continent silhouettes — stylized, not survey-accurate."""
|
"""Fallback minimal continent silhouettes."""
|
||||||
blobs: list[list[tuple[float, float]]] = [
|
blobs: list[list[tuple[float, float]]] = [
|
||||||
# North America
|
|
||||||
[(-168, 72), (-140, 75), (-100, 72), (-80, 50), (-75, 25), (-95, 15), (-110, 20), (-125, 48), (-168, 72)],
|
[(-168, 72), (-140, 75), (-100, 72), (-80, 50), (-75, 25), (-95, 15), (-110, 20), (-125, 48), (-168, 72)],
|
||||||
# South America
|
|
||||||
[(-82, 12), (-72, -5), (-55, -35), (-65, -55), (-75, -18), (-82, 12)],
|
[(-82, 12), (-72, -5), (-55, -35), (-65, -55), (-75, -18), (-82, 12)],
|
||||||
# Europe / Africa
|
|
||||||
[(-10, 72), (30, 70), (40, 55), (35, 30), (20, 5), (-5, 5), (-18, 28), (-10, 72)],
|
[(-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)],
|
[(15, 35), (35, 32), (50, 12), (42, -5), (18, -35), (15, 35)],
|
||||||
# Asia
|
|
||||||
[(40, 72), (100, 75), (140, 55), (130, 35), (110, 10), (80, 8), (60, 25), (40, 45), (40, 72)],
|
[(40, 72), (100, 75), (140, 55), (130, 35), (110, 10), (80, 8), (60, 25), (40, 45), (40, 72)],
|
||||||
# Australia
|
|
||||||
[(115, -12), (135, -12), (150, -25), (145, -38), (115, -38), (115, -12)],
|
[(115, -12), (135, -12), (150, -25), (145, -38), (115, -38), (115, -12)],
|
||||||
]
|
]
|
||||||
for poly in blobs:
|
for poly in blobs:
|
||||||
@@ -246,58 +293,75 @@ def render_chain_map(
|
|||||||
width: int = 960,
|
width: int = 960,
|
||||||
height: int = 220,
|
height: int = 220,
|
||||||
status: ChainStatus = "idle",
|
status: ChainStatus = "idle",
|
||||||
margin: int = 18,
|
margin: int = 12,
|
||||||
) -> Image.Image:
|
) -> Image.Image:
|
||||||
"""Render chain hops on a dark equirectangular world map."""
|
"""Render chain hops over the neon world map (or a drawn fallback)."""
|
||||||
img = Image.new("RGB", (max(320, width), max(120, height)), _BG)
|
w = max(320, width)
|
||||||
draw = ImageDraw.Draw(img)
|
h = max(120, height)
|
||||||
_draw_landmasses(draw, width, height, margin)
|
|
||||||
|
|
||||||
# Lat/lon grid
|
# ── background ──────────────────────────────────────────────────────────
|
||||||
for lat in range(-60, 91, 30):
|
world = _load_world_map(w, h)
|
||||||
y = _project(lat, 0, width, height, margin)[1]
|
if world is not None:
|
||||||
draw.line([(margin, y), (width - margin, y)], fill=_GRID, width=1)
|
# Slightly darken so overlaid lines pop
|
||||||
for lon in range(-150, 181, 30):
|
bg = Image.new("RGB", (w, h), (0, 0, 0))
|
||||||
x = _project(0, lon, width, height, margin)[0]
|
img = Image.blend(world, bg, alpha=0.22)
|
||||||
draw.line([(x, margin), (x, height - margin)], fill=_GRID, width=1)
|
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:
|
if len(points) < 2:
|
||||||
font = _load_font(13)
|
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."
|
msg = "Start the chain to plot hops on the map." if not points else "Need 2+ geo points to draw path."
|
||||||
draw.text((margin, height // 2 - 8), msg, fill=_LABEL_DIM, font=font)
|
# 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
|
return img
|
||||||
|
|
||||||
line_c = _line_color(status)
|
line_rgb = _line_color(status)
|
||||||
projected = [_project(p.lat, p.lon, width, height, margin) for p in points]
|
projected = [_project(p.lat, p.lon, w, h, margin) for p in points]
|
||||||
|
|
||||||
# Glow underlay
|
# ── arc lines between consecutive hops ─────────────────────────────────
|
||||||
for i in range(len(projected) - 1):
|
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 = _great_circle_points(
|
||||||
arc_xy = [_project(lat, lon, width, height, margin) for lat, lon in arc]
|
points[i].lat, points[i].lon,
|
||||||
draw.line(arc_xy, fill=line_c, width=5)
|
points[i + 1].lat, points[i + 1].lon,
|
||||||
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]
|
||||||
arc_xy = [_project(lat, lon, width, height, margin) for lat, lon in arc]
|
_draw_glow_line(draw, arc_xy, line_rgb)
|
||||||
draw.line(arc_xy, fill=line_c, width=2)
|
|
||||||
|
|
||||||
label_font = _load_font(11)
|
# ── nodes ───────────────────────────────────────────────────────────────
|
||||||
|
label_font = _load_font(11)
|
||||||
detail_font = _load_font(9)
|
detail_font = _load_font(9)
|
||||||
for pt, (x, y) in zip(points, projected):
|
for pt, (x, y) in zip(points, projected):
|
||||||
color = _node_color(pt.role)
|
nc = _node_color(pt.role)
|
||||||
r = 7 if pt.role == "exit" else (6 if pt.role == "you" else 5)
|
r = 7 if pt.role == "exit" else (6 if pt.role == "you" else 5)
|
||||||
draw.ellipse((x - r - 2, y - r - 2, x + r + 2, y + r + 2), fill=color)
|
# outer glow ring
|
||||||
draw.ellipse((x - r, y - r, x + r, y + r), fill=_BG, outline=color, width=2)
|
glow = (nc[0] // 3, nc[1] // 3, nc[2] // 3)
|
||||||
tx, ty = x + 10, y - 14
|
draw.ellipse((x - r - 4, y - r - 4, x + r + 4, y + r + 4), fill=glow)
|
||||||
draw.text((tx + 1, ty + 1), pt.label, fill="#000000", font=label_font)
|
# filled node with dark center
|
||||||
draw.text((tx, ty), pt.label, fill=_LABEL, font=label_font)
|
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:
|
if pt.detail:
|
||||||
draw.text((tx, ty + 13), pt.detail[:28], fill=_LABEL_DIM, font=detail_font)
|
draw.text((tx, ty + 12), pt.detail[:30], fill=_hex_to_rgb(_LABEL_DIM), font=detail_font)
|
||||||
|
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
def resize_map_display(pil_img: Image.Image, width: int, height: int) -> Image.Image:
|
def resize_map_display(pil_img: Image.Image, width: int, height: int) -> Image.Image:
|
||||||
"""Resize rendered map for the GUI widget."""
|
"""Resize rendered map for the GUI widget."""
|
||||||
w = max(320, int(width))
|
return pil_img.resize((max(320, int(width)), max(120, int(height))), _RESAMPLE)
|
||||||
h = max(120, int(height))
|
|
||||||
return pil_img.resize((w, h), _RESAMPLE)
|
|
||||||
|
|||||||
@@ -1,41 +1,118 @@
|
|||||||
"""DNS leak checks and cache flush."""
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import concurrent.futures
|
||||||
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_DOH_GOOGLE = "https://dns.google/resolve?name=whoami.dnsleaktest.com&type=A"
|
# ─── 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
|
@dataclass
|
||||||
class DnsLeakResult:
|
class DnsLeakResult:
|
||||||
|
"""Legacy single-check result kept for API compatibility."""
|
||||||
ok: bool
|
ok: bool
|
||||||
system_resolvers: list[str]
|
system_resolvers: list[str]
|
||||||
message: str
|
message: str
|
||||||
doh_ip: str | None = None
|
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]:
|
def get_system_dns_servers() -> list[str]:
|
||||||
|
"""Return configured IPv4 DNS resolvers via PowerShell (falls back to ipconfig)."""
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["powershell", "-NoProfile", "-Command",
|
["powershell", "-NoProfile", "-Command",
|
||||||
"(Get-DnsClientServerAddress -AddressFamily IPv4 | "
|
"(Get-DnsClientServerAddress -AddressFamily IPv4 | "
|
||||||
"Where-Object { $_.ServerAddresses } | "
|
"Where-Object { $_.ServerAddresses } | "
|
||||||
"Select-Object -ExpandProperty ServerAddresses) -join ','"],
|
"Select-Object -ExpandProperty ServerAddresses) -join ','"],
|
||||||
capture_output=True,
|
capture_output=True, text=True, timeout=12,
|
||||||
text=True,
|
|
||||||
timeout=12,
|
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
)
|
)
|
||||||
raw = (r.stdout or "").strip()
|
raw = (r.stdout or "").strip()
|
||||||
if not raw:
|
if raw:
|
||||||
return []
|
return [x.strip() for x in raw.replace(";", ",").split(",") if x.strip()]
|
||||||
return [x.strip() for x in raw.replace(";", ",").split(",") if x.strip()]
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 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:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -43,70 +120,194 @@ def get_system_dns_servers() -> list[str]:
|
|||||||
def flush_dns_cache() -> tuple[bool, str]:
|
def flush_dns_cache() -> tuple[bool, str]:
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
["ipconfig", "/flushdns"],
|
["ipconfig", "/flushdns"], capture_output=True, text=True, timeout=15,
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=15,
|
|
||||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||||
)
|
)
|
||||||
msg = (r.stdout or r.stderr or "").strip().splitlines()[-1] if r.returncode == 0 else "flush failed"
|
lines = (r.stdout or r.stderr or "").strip().splitlines()
|
||||||
|
msg = lines[-1] if lines else "flushed"
|
||||||
return r.returncode == 0, msg
|
return r.returncode == 0, msg
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
def check_dns_leak_hint(local_proxy: str | None = None) -> DnsLeakResult:
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
"""Heuristic: list configured DNS servers; note if any are public ISP resolvers.
|
# Hostname resolution helpers
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
Full DNS leak testing needs OS-level routing; this flags obvious misconfig.
|
def _resolve_direct(hostname: str, timeout: float = 4.0) -> list[str]:
|
||||||
"""
|
"""Resolve hostname using system DNS (direct, not through proxy)."""
|
||||||
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.", "0.0.0.0")
|
|
||||||
public = [r for r in resolvers if not any(r.startswith(p) for p in private_prefixes)]
|
|
||||||
|
|
||||||
doh_ip: str | None = None
|
|
||||||
try:
|
try:
|
||||||
with httpx.Client(timeout=8.0, verify=True) as c:
|
socket.setdefaulttimeout(timeout)
|
||||||
r = c.get(_DOH_GOOGLE)
|
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(None)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
if r.status_code == 200:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
answers = data.get("Answer") or []
|
return [ans["data"] for ans in (data.get("Answer") or [])
|
||||||
if answers:
|
if ans.get("type") == 1]
|
||||||
doh_ip = str(answers[0].get("data", ""))
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
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:
|
if not resolvers:
|
||||||
return DnsLeakResult(
|
return DnsLeakResult(ok=True, system_resolvers=[], message="No IPv4 DNS servers reported (DHCP).")
|
||||||
ok=True,
|
|
||||||
system_resolvers=[],
|
|
||||||
message="No IPv4 DNS servers reported (DHCP may assign later).",
|
|
||||||
doh_ip=doh_ip,
|
|
||||||
)
|
|
||||||
|
|
||||||
if public and local_proxy:
|
if public and local_proxy:
|
||||||
return DnsLeakResult(
|
return DnsLeakResult(
|
||||||
ok=False,
|
ok=False, system_resolvers=resolvers,
|
||||||
system_resolvers=resolvers,
|
|
||||||
message=(
|
message=(
|
||||||
f"DNS may bypass proxy chain: public resolvers {', '.join(public)}. "
|
f"DNS may bypass proxy chain: public resolvers {', '.join(public)}. "
|
||||||
"Use kill-switch + VPN, or set DNS to localhost when hardened."
|
"Use kill-switch + VPN, or set DNS to localhost when hardened."
|
||||||
),
|
),
|
||||||
doh_ip=doh_ip,
|
|
||||||
)
|
)
|
||||||
|
if public:
|
||||||
if public and not local_proxy:
|
|
||||||
return DnsLeakResult(
|
return DnsLeakResult(
|
||||||
ok=False,
|
ok=False, system_resolvers=resolvers,
|
||||||
system_resolvers=resolvers,
|
|
||||||
message=f"Public DNS resolvers active: {', '.join(public)}",
|
message=f"Public DNS resolvers active: {', '.join(public)}",
|
||||||
doh_ip=doh_ip,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return DnsLeakResult(
|
return DnsLeakResult(
|
||||||
ok=True,
|
ok=True, system_resolvers=resolvers,
|
||||||
system_resolvers=resolvers,
|
|
||||||
message=f"DNS servers: {', '.join(resolvers)}",
|
message=f"DNS servers: {', '.join(resolvers)}",
|
||||||
doh_ip=doh_ip,
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -195,35 +195,186 @@ def apply_webrtc_hardening(enable: bool) -> tuple[bool, str]:
|
|||||||
return False, str(e)
|
return False, str(e)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_os_info() -> str:
|
||||||
|
try:
|
||||||
|
import platform
|
||||||
|
v = platform.version()
|
||||||
|
r = platform.release()
|
||||||
|
m = platform.machine()
|
||||||
|
return f"Windows {r} build {v} {m}"
|
||||||
|
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:
|
||||||
|
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:
|
def audit_device() -> FingerprintAudit:
|
||||||
"""Collect identifiers sites and trackers often fingerprint."""
|
"""Collect OS-level identifiers and consistency notes."""
|
||||||
import getpass
|
import getpass
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
host = get_computer_name()
|
host = get_computer_name()
|
||||||
guid = get_machine_guid()
|
guid = get_machine_guid()
|
||||||
user = getpass.getuser()
|
user = getpass.getuser()
|
||||||
macs = [f"{n.name}: {n.mac}" for n in list_nics()]
|
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 = [
|
lines: list[str] = [
|
||||||
f"Computer name: {host or '—'}",
|
"── OS / Identity ──────────────────────────────────",
|
||||||
f"Windows username: {user}",
|
f" OS : {os_}",
|
||||||
f"MachineGuid: {guid[:8]}…{guid[-4:]}" if len(guid) > 12 else f"MachineGuid: {guid or '—'}",
|
f" Computer name: {host or '—'}",
|
||||||
f"Network adapters ({len(macs)}):",
|
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[:8]] or [" • (none detected)"])
|
lines.extend([f" • {m}" for m in macs[:10]] or [" • (none detected)"])
|
||||||
if len(macs) > 8:
|
if len(macs) > 10:
|
||||||
lines.append(f" … and {len(macs) - 8} more")
|
lines.append(f" … and {len(macs) - 10} more")
|
||||||
|
|
||||||
lines.append("")
|
lines += [
|
||||||
lines.append("Browser fingerprint (Canvas/WebGL/fonts) is not changed by this app.")
|
"",
|
||||||
lines.append("Use hardened browser profiles + proxy chain for web traffic.")
|
"── Browser fingerprint note ───────────────────────",
|
||||||
lines.append("WebRTC toggle here affects Chrome/Edge system policy only.")
|
" 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_ok, _ = apply_webrtc_hardening.__doc__ and True or False
|
||||||
|
try:
|
||||||
|
import winreg as _wr
|
||||||
|
with _wr.OpenKey(_wr.HKEY_LOCAL_MACHINE, r"SOFTWARE\Policies\Google\Chrome",
|
||||||
|
0, _wr.KEY_QUERY_VALUE) as k:
|
||||||
|
v = int(_wr.QueryValueEx(k, "DefaultWebRtcIpHandlingPolicy")[0])
|
||||||
|
webrtc_ok = v == 2
|
||||||
|
except Exception:
|
||||||
|
webrtc_ok = False
|
||||||
|
|
||||||
|
if not webrtc_ok:
|
||||||
|
lines.append(" ⚠ Chrome/Edge WebRTC policy not set — enable in Privacy tab")
|
||||||
|
else:
|
||||||
|
lines.append(" ✓ Chrome/Edge WebRTC policy is set")
|
||||||
|
|
||||||
|
# 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(
|
return FingerprintAudit(
|
||||||
lines=lines,
|
lines=lines,
|
||||||
hostname=host,
|
hostname=host,
|
||||||
machine_guid=guid,
|
machine_guid=guid,
|
||||||
username=user,
|
username=user,
|
||||||
macs=[n.mac for n in list_nics()],
|
macs=[n.mac for n in nics],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -13,7 +13,16 @@ from typing import Any
|
|||||||
from .paths import app_data_dir
|
from .paths import app_data_dir
|
||||||
|
|
||||||
_EXT_ID = "signup-autofill@proxygod"
|
_EXT_ID = "signup-autofill@proxygod"
|
||||||
_EXT_SRC = Path(__file__).resolve().parent / "signup_extension"
|
|
||||||
|
|
||||||
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -389,7 +398,7 @@ def install_signup_extension(profile_dir: Path, draft: SignupDraft) -> None:
|
|||||||
dest = profile_dir / "extensions" / ext_name
|
dest = profile_dir / "extensions" / ext_name
|
||||||
if dest.exists():
|
if dest.exists():
|
||||||
shutil.rmtree(dest, ignore_errors=True)
|
shutil.rmtree(dest, ignore_errors=True)
|
||||||
shutil.copytree(_EXT_SRC, dest)
|
shutil.copytree(_ext_src_path(), dest)
|
||||||
|
|
||||||
cfg = build_autofill_config(draft)
|
cfg = build_autofill_config(draft)
|
||||||
(dest / "autofill_config.json").write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
(dest / "autofill_config.json").write_text(json.dumps(cfg, indent=2), encoding="utf-8")
|
||||||
|
|||||||
211
proxy_chain_manager/webrtc_check.py
Normal file
211
proxy_chain_manager/webrtc_check.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""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 = 2 # "default_public_and_private_interfaces"
|
||||||
|
|
||||||
|
|
||||||
|
@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)."""
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
val = int(winreg.QueryValueEx(k, _WEBRTC_POLICY_VALUE)[0])
|
||||||
|
name = path.split("\\")[-1]
|
||||||
|
if val == _WEBRTC_BLOCK_VALUE:
|
||||||
|
found.append(name)
|
||||||
|
else:
|
||||||
|
missing.append(f"{name}={val} (need {_WEBRTC_BLOCK_VALUE})")
|
||||||
|
except OSError:
|
||||||
|
missing.append(path.split("\\")[-1] + " key missing")
|
||||||
|
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
|
||||||
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