3075 lines
134 KiB
Python
3075 lines
134 KiB
Python
"""
|
||
Proxy God — GUI
|
||
Tabs: Live | Chain Builder | Browser | Ban Tester | Signup Prep | Privacy | Settings
|
||
(Live tab includes exit intel + world chain map)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import ipaddress
|
||
import queue
|
||
import sys
|
||
import threading
|
||
import time
|
||
from dataclasses import asdict
|
||
from datetime import datetime
|
||
from typing import Any, Literal
|
||
from tkinter import filedialog
|
||
from urllib.parse import urlparse
|
||
|
||
import customtkinter as ctk
|
||
|
||
from .config import (
|
||
LISTEN_HOST,
|
||
OBFUSCATION_LABELS,
|
||
OBFUSCATION_MODES,
|
||
Settings,
|
||
load_settings,
|
||
merge_proxy_credentials,
|
||
normalize_proxy_url,
|
||
parse_exit_proxy_input,
|
||
redact_proxy_url,
|
||
save_settings,
|
||
split_proxy_for_edit,
|
||
)
|
||
from .firewall import (
|
||
disengage as fw_disengage,
|
||
is_admin,
|
||
is_engaged as fw_is_engaged,
|
||
request_admin_relaunch,
|
||
)
|
||
from .artifact_wipe import wipe_artifacts
|
||
from .ban_tester import BanTestResult, SITE_CATEGORIES, run_ban_tests, test_single_site
|
||
from .exit_intel import ExitIntel, fetch_exit_intel
|
||
from .chain_map import build_chain_map_points, render_chain_map, resize_map_display
|
||
from .signup_prep import (
|
||
SIGNUP_PRESET_KEYS,
|
||
SIGNUP_PRESETS,
|
||
AccountRecord,
|
||
SignupDraft,
|
||
append_account,
|
||
generate_password,
|
||
load_accounts,
|
||
load_draft,
|
||
random_first_name,
|
||
random_full_name,
|
||
random_last_name,
|
||
random_username,
|
||
resolve_signup_url,
|
||
save_draft,
|
||
)
|
||
from .browser_identity import (
|
||
COOKIE_LABELS,
|
||
COOKIE_MODES,
|
||
PERSONA_LABELS,
|
||
PERSONAS,
|
||
)
|
||
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 .webrtc_check import WebRtcCheckResult, run_webrtc_check
|
||
from .browser_launcher import (
|
||
BrowserConfig,
|
||
BrowserSession,
|
||
default_firefox_path,
|
||
default_profile_dir,
|
||
)
|
||
from .fingerprint import audit_device
|
||
from .gui_validate import run_validate_each_sync
|
||
from .paths import app_data_dir
|
||
from .proxy_picker import show_proxy_picker
|
||
from .service import ChainService
|
||
from .sysproxy import clear_system_proxy, is_system_proxy_set
|
||
from .tray import TrayIcon
|
||
from .vpn_detect import detect_vpn
|
||
from .windows_task import install_logon_task, task_exists, uninstall_logon_task
|
||
|
||
LOG_PATH = app_data_dir() / "proxy_chain_manager.log"
|
||
from logging.handlers import RotatingFileHandler as _RotatingFileHandler # noqa: E402
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||
handlers=[
|
||
_RotatingFileHandler(
|
||
LOG_PATH, maxBytes=5 * 1024 * 1024, backupCount=3, encoding="utf-8"
|
||
),
|
||
logging.StreamHandler(sys.stdout),
|
||
],
|
||
)
|
||
|
||
|
||
class UILogHandler(logging.Handler):
|
||
"""Forward ``proxy_chain_manager`` log records to the Live tab (via ``ui_q``). Thread-safe."""
|
||
|
||
def __init__(self, q: queue.Queue[dict[str, Any]]) -> None:
|
||
super().__init__(level=logging.DEBUG)
|
||
self._q = q
|
||
|
||
def emit(self, record: logging.LogRecord) -> None:
|
||
try:
|
||
text = self.format(record)
|
||
self._q.put_nowait({"type": "log", "text": text})
|
||
except Exception:
|
||
self.handleError(record)
|
||
|
||
|
||
# ── palette ──────────────────────────────────────────────────────────────────
|
||
# Neon "blueprint terminal" theme — same overall vibe, deeper black, sharper
|
||
# cyan/magenta accent, glow borders rendered via 1px border_color on cards.
|
||
BG = "#070713"
|
||
CARD = "#0f0f24"
|
||
PANEL = "#171738"
|
||
ACCENT = "#3052ff" # primary action / outlines
|
||
ACCENT2 = "#00e5ff" # secondary highlight / glow
|
||
GREEN = "#00ff9c"
|
||
RED = "#ff2a6d"
|
||
YELLOW = "#ffd000"
|
||
ORANGE = "#ff8a00"
|
||
PURPLE = "#c77dff"
|
||
BLUE = "#5b8bff"
|
||
CYAN = "#00f5ff"
|
||
MAGENTA = "#ff00d6"
|
||
DIM = "#2a2f4a"
|
||
TEXT = "#eaf2ff"
|
||
TEXT2 = "#7a8aab"
|
||
GLOW = "#0a87ff"
|
||
FONT = "Segoe UI"
|
||
|
||
|
||
def _ts() -> str:
|
||
return datetime.now().strftime("%H:%M:%S")
|
||
|
||
|
||
def main() -> None:
|
||
try:
|
||
_main_inner()
|
||
except Exception as _exc: # noqa: BLE001
|
||
_log = logging.getLogger(__name__)
|
||
_log.exception("Unhandled exception in main loop")
|
||
try:
|
||
import tkinter.messagebox as _mb
|
||
_mb.showerror(
|
||
"Proxy God — Fatal Error",
|
||
f"An unexpected error occurred and the application must close.\n\n"
|
||
f"{type(_exc).__name__}: {_exc}\n\n"
|
||
f"Details have been written to:\n{LOG_PATH}",
|
||
)
|
||
except Exception:
|
||
pass
|
||
raise
|
||
|
||
|
||
def _main_inner() -> None:
|
||
ctk.set_appearance_mode("dark")
|
||
ctk.set_default_color_theme("dark-blue")
|
||
|
||
root = ctk.CTk()
|
||
from . import __version__ as _APP_VERSION
|
||
root.title(f"Proxy God v{_APP_VERSION}")
|
||
root.geometry("1080x860")
|
||
root.minsize(900, 720)
|
||
root.configure(fg_color=BG)
|
||
|
||
# ── 3D-ish neon header banner (drawn on a Canvas for a true gradient) ────
|
||
def _draw_header_banner(parent: Any) -> Any:
|
||
import tkinter as tk
|
||
canvas = tk.Canvas(
|
||
parent, height=44, highlightthickness=0, bd=0, bg=BG,
|
||
)
|
||
canvas.pack(fill="x", side="top")
|
||
|
||
def _hex(rgb: tuple[int, int, int]) -> str:
|
||
return "#" + "".join(f"{v:02x}" for v in rgb)
|
||
|
||
def _lerp(a: tuple[int, int, int], b: tuple[int, int, int], t: float) -> tuple[int, int, int]:
|
||
return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(3)) # type: ignore[return-value]
|
||
|
||
def _repaint(_event: Any = None) -> None:
|
||
canvas.delete("grad")
|
||
w = canvas.winfo_width() or 1080
|
||
h = 44
|
||
left = (8, 32, 64) # near-black blue
|
||
mid = (48, 82, 255) # ACCENT
|
||
right = (0, 229, 255) # ACCENT2 cyan
|
||
for x in range(0, w, 2):
|
||
t = x / max(1, w - 1)
|
||
c = _lerp(left, mid, t * 2) if t < 0.5 else _lerp(mid, right, (t - 0.5) * 2)
|
||
canvas.create_line(x, 0, x, h, fill=_hex(c), tags="grad")
|
||
# 1px neon underline
|
||
canvas.create_line(0, h - 1, w, h - 1, fill=CYAN, width=1, tags="grad")
|
||
# Title text + small subtitle
|
||
canvas.create_text(
|
||
18, h // 2 - 2, anchor="w",
|
||
text="P R O X Y G O D",
|
||
fill="#ffffff", font=(FONT, 16, "bold"),
|
||
tags="grad",
|
||
)
|
||
canvas.create_text(
|
||
190, h // 2 - 1, anchor="w",
|
||
text="// multi-hop · anti-fingerprint · paranoid mode",
|
||
fill="#bfeaff", font=(FONT, 9, "italic"),
|
||
tags="grad",
|
||
)
|
||
|
||
canvas.bind("<Configure>", _repaint)
|
||
return canvas
|
||
|
||
_draw_header_banner(root)
|
||
|
||
ui_q: queue.Queue[dict[str, Any]] = queue.Queue()
|
||
svc = ChainService(notify=lambda m: ui_q.put(m))
|
||
s = load_settings()
|
||
browser = BrowserSession()
|
||
browser_should_run = [False]
|
||
browser_last_launch_ts = [0.0]
|
||
browser_relaunch_count = [0]
|
||
service_running = [False]
|
||
|
||
# ── tray ─────────────────────────────────────────────────────────────────
|
||
tray = TrayIcon(
|
||
on_show=lambda: root.after(0, lambda: (root.deiconify(), root.lift())),
|
||
on_quit=lambda: root.after(0, _close),
|
||
on_rotate=svc.rotate_now,
|
||
)
|
||
tray.start()
|
||
|
||
pulse_job: dict[str, str | None] = {"id": None}
|
||
HopState = Literal["untested", "testing", "ok", "fail"]
|
||
|
||
def _cancel_pulse() -> None:
|
||
if pulse_job["id"]:
|
||
try:
|
||
root.after_cancel(pulse_job["id"]) # type: ignore[arg-type]
|
||
except Exception:
|
||
pass
|
||
pulse_job["id"] = None
|
||
|
||
def _short(u: str) -> str:
|
||
u = redact_proxy_url(u)
|
||
u = u.replace("http://", "").replace("socks5://", "s5://").replace("socks4://", "s4://").replace("https://", "")
|
||
return u[:28] + "…" if len(u) > 30 else u
|
||
|
||
def _extract_ip_host(proxy_url: str) -> str | None:
|
||
p = urlparse(normalize_proxy_url(proxy_url))
|
||
host = (p.hostname or "").strip()
|
||
if not host:
|
||
return None
|
||
try:
|
||
ipaddress.ip_address(host)
|
||
return host
|
||
except ValueError:
|
||
return None
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# HELPERS
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
def _btn(parent: Any, label: str, cmd: Any, w: int = 80, h: int = 28, **kw: Any) -> ctk.CTkButton:
|
||
# Pull known keys out first so **kw never duplicates CTkButton parameters (font, width, …).
|
||
kw.pop("text", None)
|
||
kw.pop("command", None)
|
||
font = kw.pop("font", (FONT, 11))
|
||
fg_color = kw.pop("fg_color", ACCENT)
|
||
hover_color = kw.pop("hover_color", ACCENT2)
|
||
text_color = kw.pop("text_color", TEXT)
|
||
corner_radius = kw.pop("corner_radius", 6)
|
||
width = kw.pop("width", w)
|
||
height = kw.pop("height", h)
|
||
return ctk.CTkButton(
|
||
parent,
|
||
text=label,
|
||
command=cmd,
|
||
width=width,
|
||
height=height,
|
||
font=font,
|
||
fg_color=fg_color,
|
||
hover_color=hover_color,
|
||
text_color=text_color,
|
||
corner_radius=corner_radius,
|
||
**kw,
|
||
)
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# TOP BAR
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
topbar = ctk.CTkFrame(root, fg_color=CARD, corner_radius=0, height=48)
|
||
topbar.pack(fill="x", side="top")
|
||
topbar.pack_propagate(False)
|
||
|
||
# Status
|
||
status_dot = ctk.CTkLabel(topbar, text="●", font=(FONT, 20), text_color=RED)
|
||
status_dot.pack(side="left", padx=(10, 2))
|
||
status_txt = ctk.CTkLabel(topbar, text="STOPPED", font=(FONT, 12, "bold"), text_color=TEXT)
|
||
status_txt.pack(side="left", padx=(0, 8))
|
||
|
||
def _start() -> None:
|
||
_save_settings()
|
||
# Hard-stop: if the kill-switch is enabled but we are not admin, the
|
||
# firewall lockdown is silently skipped — which contradicts the
|
||
# README's "fail closed" guarantee. Refuse to start in that mode.
|
||
try:
|
||
from .firewall import is_admin as _is_admin
|
||
except Exception:
|
||
_is_admin = lambda: True # noqa: E731
|
||
if svc.settings.kill_switch_enabled and not _is_admin():
|
||
from tkinter import messagebox
|
||
if messagebox.askyesno(
|
||
"Kill-switch requires Admin",
|
||
"Kill-switch is enabled in Settings but this process is not "
|
||
"running as Administrator.\n\n"
|
||
"Continuing now will start the chain WITHOUT the firewall "
|
||
"lockdown — your traffic will not 'fail closed' if the chain "
|
||
"drops.\n\n"
|
||
"Start anyway?",
|
||
):
|
||
svc.start()
|
||
return
|
||
svc.start()
|
||
|
||
start_btn = _btn(topbar, "▶ Start", _start, w=86)
|
||
start_btn.pack(side="left", padx=2)
|
||
stop_btn = _btn(topbar, "■ Stop", svc.stop, w=76)
|
||
stop_btn.pack(side="left", padx=2)
|
||
rotate_btn = _btn(topbar, "↻ Rotate", svc.rotate_now, w=76)
|
||
rotate_btn.pack(side="left", padx=2)
|
||
_btn(topbar, "Quit", lambda: root.after(0, _close), w=52,
|
||
fg_color="#7f1d1d", hover_color="#991b1b").pack(side="left", padx=(10, 2))
|
||
|
||
# Boot buttons
|
||
ctk.CTkLabel(topbar, text="│", text_color=DIM).pack(side="left", padx=4)
|
||
boot_lbl = ctk.CTkLabel(topbar, text="Boot:?", font=(FONT, 10), text_color=TEXT2)
|
||
boot_lbl.pack(side="left", padx=2)
|
||
|
||
def _refresh_boot() -> None:
|
||
on = task_exists()
|
||
boot_lbl.configure(text="Boot:ON" if on else "Boot:OFF",
|
||
text_color=GREEN if on else DIM)
|
||
|
||
def _inst_boot() -> None:
|
||
ok, msg = install_logon_task()
|
||
_log("Boot task installed." if ok else f"Boot install: {msg}")
|
||
_refresh_boot()
|
||
|
||
def _rm_boot() -> None:
|
||
ok, msg = uninstall_logon_task()
|
||
_log("Boot task removed." if ok else f"Boot remove: {msg}")
|
||
_refresh_boot()
|
||
|
||
_btn(topbar, "Boot+", _inst_boot, w=54).pack(side="left", padx=2)
|
||
_btn(topbar, "Boot−", _rm_boot, w=54).pack(side="left", padx=2)
|
||
|
||
# Right side indicators
|
||
rotation_lbl = ctk.CTkLabel(topbar, text="rot: 0", font=(FONT, 10), text_color=TEXT2)
|
||
rotation_lbl.pack(side="right", padx=(4, 12))
|
||
|
||
countdown_lbl = ctk.CTkLabel(topbar, text="next: —", font=(FONT, 10), text_color=TEXT2)
|
||
countdown_lbl.pack(side="right", padx=4)
|
||
|
||
proxy_lbl = ctk.CTkLabel(topbar, text=f"{LISTEN_HOST}:{s.local_port}",
|
||
font=(FONT, 11, "bold"), text_color=BLUE)
|
||
proxy_lbl.pack(side="right", padx=(4, 4))
|
||
ctk.CTkLabel(topbar, text="proxy:", font=(FONT, 10), text_color=TEXT2).pack(side="right")
|
||
|
||
fw_lbl = ctk.CTkLabel(topbar, text="FW:—", font=(FONT, 10), text_color=DIM)
|
||
fw_lbl.pack(side="right", padx=6)
|
||
|
||
sys_lbl = ctk.CTkLabel(topbar, text="SYS:—", font=(FONT, 10), text_color=DIM)
|
||
sys_lbl.pack(side="right", padx=4)
|
||
|
||
admin_badge = ctk.CTkLabel(
|
||
topbar,
|
||
text="⚡ ADMIN" if is_admin() else "👤 USER",
|
||
font=(FONT, 10, "bold"),
|
||
text_color=GREEN if is_admin() else YELLOW,
|
||
)
|
||
admin_badge.pack(side="right", padx=4)
|
||
|
||
if not is_admin():
|
||
def _elevate() -> None:
|
||
# Persist UI state to disk before handing off to the elevated
|
||
# process — otherwise unsaved edits in Chain Builder / Settings
|
||
# are lost when this window closes.
|
||
try:
|
||
_save_settings()
|
||
except Exception as exc: # noqa: BLE001
|
||
log.warning("Could not save settings before admin relaunch: %s", exc)
|
||
if request_admin_relaunch():
|
||
_close()
|
||
_btn(topbar, "Run as Admin", _elevate, w=104,
|
||
fg_color="#7f1d1d", hover_color="#991b1b").pack(side="right", padx=4)
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# CHAIN VIZ BAR
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
viz_bar = ctk.CTkFrame(root, fg_color=PANEL, corner_radius=0, height=52)
|
||
viz_bar.pack(fill="x")
|
||
viz_bar.pack_propagate(False)
|
||
|
||
your_ip_lbl = ctk.CTkLabel(viz_bar, text="Your IP: —", font=(FONT, 10), text_color=TEXT2)
|
||
your_ip_lbl.pack(side="left", padx=(12, 8))
|
||
|
||
chain_canvas = ctk.CTkFrame(viz_bar, fg_color="transparent")
|
||
chain_canvas.pack(side="left", fill="both", expand=True)
|
||
|
||
exit_ip_lbl = ctk.CTkLabel(viz_bar, text="Exit: —",
|
||
font=(FONT, 13, "bold"), text_color=YELLOW)
|
||
exit_ip_lbl.pack(side="right", padx=(8, 14))
|
||
|
||
hop_dot_labels: list[ctk.CTkLabel] = []
|
||
outer_hop_label = "VPN" # updated when VPN detected; "Direct" if no tunnel
|
||
|
||
def _pulse(step: int = 0) -> None:
|
||
if not hop_dot_labels:
|
||
pulse_job["id"] = None
|
||
return
|
||
c = YELLOW if step % 2 == 0 else DIM
|
||
for d in hop_dot_labels:
|
||
d.configure(text_color=c)
|
||
pulse_job["id"] = root.after(380, lambda s=step: _pulse(s + 1)) # type: ignore[arg-type]
|
||
|
||
def _render_chain(
|
||
hops: list[str], status: str, exit_ip: str | None, fixed_last: bool = False
|
||
) -> None:
|
||
_cancel_pulse()
|
||
for w in list(chain_canvas.winfo_children()):
|
||
w.destroy()
|
||
hop_dot_labels.clear()
|
||
|
||
hop_c = {"healthy": GREEN, "dead": RED, "connecting": YELLOW}.get(status, DIM)
|
||
|
||
def _node(text: str, color: str, bold: bool = False) -> None:
|
||
ctk.CTkLabel(
|
||
chain_canvas, text=text,
|
||
font=(FONT, 11, "bold") if bold else (FONT, 10),
|
||
text_color=color,
|
||
).pack(side="left", padx=1)
|
||
|
||
def _arr() -> None:
|
||
ctk.CTkLabel(chain_canvas, text="→", font=(FONT, 11), text_color=DIM).pack(side="left", padx=2)
|
||
|
||
_node("YOU", GREEN, bold=True)
|
||
_arr()
|
||
_node(outer_hop_label[:12], PURPLE, bold=True)
|
||
_arr()
|
||
|
||
last_i = len(hops) - 1
|
||
for i, h in enumerate(hops):
|
||
is_fixed = fixed_last and i == last_i and last_i >= 0
|
||
lbl_c = ORANGE if is_fixed else hop_c
|
||
dot = ctk.CTkLabel(chain_canvas, text="●", font=(FONT, 12), text_color=lbl_c)
|
||
dot.pack(side="left", padx=1)
|
||
hop_dot_labels.append(dot)
|
||
label = _short(h) + (" ★" if is_fixed else "")
|
||
ctk.CTkLabel(chain_canvas, text=label, font=(FONT, 10), text_color=lbl_c).pack(side="left", padx=1)
|
||
if i < len(hops) - 1:
|
||
_arr()
|
||
|
||
_arr()
|
||
_node("WEB", BLUE, bold=True)
|
||
|
||
exit_ip_lbl.configure(
|
||
text=f"Exit: {exit_ip}" if exit_ip else "Exit: —",
|
||
text_color=GREEN if status == "healthy" else (RED if exit_ip else YELLOW),
|
||
)
|
||
|
||
if status == "connecting" and hop_dot_labels:
|
||
_pulse()
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# PROGRESS STRIP
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
prog_strip = ctk.CTkFrame(root, fg_color=BG, height=22)
|
||
prog_strip.pack(fill="x", padx=8, pady=(4, 0))
|
||
prog_strip.pack_propagate(False)
|
||
|
||
phase_lbl = ctk.CTkLabel(prog_strip, text="idle", font=(FONT, 10), text_color=TEXT2, width=70)
|
||
phase_lbl.pack(side="left", padx=4)
|
||
|
||
prog_bar = ctk.CTkProgressBar(prog_strip, height=8, corner_radius=4,
|
||
fg_color=CARD, progress_color=BLUE)
|
||
prog_bar.pack(side="left", fill="x", expand=True, padx=4)
|
||
prog_bar.set(0)
|
||
|
||
pool_lbl = ctk.CTkLabel(prog_strip, text="pool: 0", font=(FONT, 10), text_color=TEXT2, width=70)
|
||
pool_lbl.pack(side="right", padx=4)
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# TABVIEW
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
tabs = ctk.CTkTabview(
|
||
root,
|
||
fg_color=BG,
|
||
segmented_button_fg_color=CARD,
|
||
segmented_button_selected_color=ACCENT2,
|
||
segmented_button_unselected_color=CARD,
|
||
segmented_button_selected_hover_color=ACCENT,
|
||
border_width=0,
|
||
)
|
||
tabs.pack(fill="both", expand=True, padx=8, pady=(4, 8))
|
||
|
||
tab_live = tabs.add(" Live ")
|
||
tab_chain = tabs.add(" Chain Builder ")
|
||
tab_browser = tabs.add(" Browser ")
|
||
tab_ban = tabs.add(" Ban Tester ")
|
||
tab_signup = tabs.add(" Signup Prep ")
|
||
tab_privacy = tabs.add(" Privacy ")
|
||
tab_settings = tabs.add(" Settings ")
|
||
for tab in (tab_live, tab_chain, tab_browser, tab_ban, tab_signup, tab_privacy, tab_settings):
|
||
tab.configure(fg_color=BG)
|
||
|
||
# ─── TAB: LIVE ───────────────────────────────────────────────────────────
|
||
# Stats cards row
|
||
stats_row = ctk.CTkFrame(tab_live, fg_color="transparent")
|
||
stats_row.pack(fill="x", pady=(4, 6))
|
||
|
||
def _stat_card(parent: Any, title: str, var: ctk.StringVar, color: str = TEXT) -> None:
|
||
f = ctk.CTkFrame(parent, fg_color=CARD, corner_radius=8)
|
||
f.pack(side="left", fill="x", expand=True, padx=4)
|
||
ctk.CTkLabel(f, text=title, font=(FONT, 9), text_color=TEXT2).pack(
|
||
anchor="w", padx=8, pady=(6, 0))
|
||
ctk.CTkLabel(f, textvariable=var, font=(FONT, 14, "bold"), text_color=color).pack(
|
||
anchor="w", padx=8, pady=(0, 6))
|
||
|
||
v_real_ip = ctk.StringVar(value="—")
|
||
v_exit_ip = ctk.StringVar(value="—")
|
||
v_pool = ctk.StringVar(value="0")
|
||
v_rotation = ctk.StringVar(value="0")
|
||
|
||
_stat_card(stats_row, "Direct / VPN IP", v_real_ip)
|
||
_stat_card(stats_row, "Exit IP (last hop)", v_exit_ip)
|
||
_stat_card(stats_row, "Valid pool size", v_pool)
|
||
_stat_card(stats_row, "Rotations this session", v_rotation)
|
||
|
||
# ── Exit IP intel card (geo / ASN / datacenter) ──────────────────────────
|
||
intel_card = ctk.CTkFrame(tab_live, fg_color=CARD, corner_radius=8)
|
||
intel_card.pack(fill="x", pady=(0, 6))
|
||
|
||
intel_head = ctk.CTkFrame(intel_card, fg_color="transparent")
|
||
intel_head.pack(fill="x", padx=10, pady=(8, 2))
|
||
ctk.CTkLabel(
|
||
intel_head, text="Exit IP intel", font=(FONT, 11, "bold"), text_color=CYAN,
|
||
).pack(side="left")
|
||
|
||
v_intel_loc = ctk.StringVar(value="Location: —")
|
||
v_intel_asn = ctk.StringVar(value="ASN / ISP: —")
|
||
v_intel_tags = ctk.StringVar(value="Flags: —")
|
||
|
||
intel_body = ctk.CTkFrame(intel_card, fg_color="transparent")
|
||
intel_body.pack(fill="x", padx=10, pady=(0, 8))
|
||
ctk.CTkLabel(intel_body, textvariable=v_intel_loc, font=(FONT, 11), text_color=TEXT,
|
||
anchor="w").pack(anchor="w")
|
||
ctk.CTkLabel(intel_body, textvariable=v_intel_asn, font=(FONT, 11), text_color=TEXT2,
|
||
anchor="w").pack(anchor="w")
|
||
intel_tags_lbl = ctk.CTkLabel(intel_body, textvariable=v_intel_tags, font=(FONT, 11, "bold"),
|
||
text_color=GREEN, anchor="w")
|
||
intel_tags_lbl.pack(anchor="w")
|
||
|
||
intel_running = [False]
|
||
last_intel: dict[str, ExitIntel | None] = {"intel": None}
|
||
|
||
def _apply_intel(intel: ExitIntel | None) -> None:
|
||
last_intel["intel"] = intel
|
||
if intel is None or not intel.ok:
|
||
v_intel_loc.set("Location: —")
|
||
v_intel_asn.set("ASN / ISP: —")
|
||
v_intel_tags.set("Flags: " + (intel.detail if intel and intel.detail else "—"))
|
||
intel_tags_lbl.configure(text_color=TEXT2)
|
||
return
|
||
loc_parts = [p for p in (intel.city, intel.region, intel.country) if p]
|
||
v_intel_loc.set("Location: " + (", ".join(loc_parts) if loc_parts else "—"))
|
||
asn_str = f"AS{intel.asn} " if intel.asn else ""
|
||
org_str = intel.org or intel.isp or "—"
|
||
v_intel_asn.set(f"ASN / ISP: {asn_str}{org_str}")
|
||
tags: list[str] = []
|
||
color = GREEN
|
||
if intel.is_datacenter:
|
||
tags.append("DATACENTER")
|
||
color = ORANGE
|
||
if intel.is_proxy_flagged:
|
||
tags.append("PROXY-FLAGGED")
|
||
color = RED
|
||
if intel.is_mobile:
|
||
tags.append("MOBILE")
|
||
if not tags:
|
||
tags.append("residential-like")
|
||
color = GREEN
|
||
v_intel_tags.set("Flags: " + " · ".join(tags))
|
||
intel_tags_lbl.configure(text_color=color)
|
||
root.after(200, lambda: _refresh_chain_map(silent=True))
|
||
|
||
def _refresh_exit_intel(silent: bool = False) -> None:
|
||
if intel_running[0]:
|
||
return
|
||
if not service_running[0] or not svc.current_chain:
|
||
if not silent:
|
||
_log("Exit intel: chain not running.")
|
||
_apply_intel(None)
|
||
return
|
||
intel_running[0] = True
|
||
v_intel_tags.set("Flags: looking up…")
|
||
intel_tags_lbl.configure(text_color=YELLOW)
|
||
proxy_url = f"http://{svc.settings.listen_addr()}"
|
||
|
||
def work() -> None:
|
||
intel = fetch_exit_intel(
|
||
proxy_url,
|
||
timeout_seconds=min(15.0, svc.settings.validation_timeout_seconds + 3.0),
|
||
)
|
||
|
||
def done() -> None:
|
||
_apply_intel(intel)
|
||
intel_running[0] = False
|
||
if not silent:
|
||
if intel.ok:
|
||
_log(f"Exit intel: {intel.summary()} (via {intel.source})")
|
||
else:
|
||
_log(f"Exit intel: lookup failed — {intel.detail}")
|
||
|
||
root.after(0, done)
|
||
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
intel_btn_row = ctk.CTkFrame(intel_card, fg_color="transparent")
|
||
intel_btn_row.pack(fill="x", padx=10, pady=(0, 8))
|
||
_btn(intel_btn_row, "Refresh intel", lambda: _refresh_exit_intel(False), w=110, h=24,
|
||
fg_color=ACCENT2, hover_color=ACCENT).pack(side="left")
|
||
|
||
# ── Chain world map ───────────────────────────────────────────────────────
|
||
map_card = ctk.CTkFrame(tab_live, fg_color=CARD, corner_radius=8)
|
||
map_card.pack(fill="x", pady=(0, 6))
|
||
|
||
map_head = ctk.CTkFrame(map_card, fg_color="transparent")
|
||
map_head.pack(fill="x", padx=10, pady=(8, 2))
|
||
ctk.CTkLabel(
|
||
map_head, text="Chain map", font=(FONT, 11, "bold"), text_color=CYAN,
|
||
).pack(side="left")
|
||
v_map_status = ctk.StringVar(value="Plotting: waiting for chain…")
|
||
ctk.CTkLabel(
|
||
map_head, textvariable=v_map_status, font=(FONT, 10), text_color=TEXT2,
|
||
).pack(side="right")
|
||
|
||
map_frame = ctk.CTkFrame(map_card, fg_color=BG, corner_radius=6)
|
||
map_frame.pack(fill="x", padx=10, pady=(0, 8))
|
||
map_img_label = ctk.CTkLabel(map_frame, text="")
|
||
map_img_label.pack(padx=2, pady=2)
|
||
|
||
map_ctk_image: dict[str, ctk.CTkImage | None] = {"img": None}
|
||
last_chain_state: dict[str, Any] = {
|
||
"hops": [],
|
||
"status": "idle",
|
||
"exit_ip": "",
|
||
"direct_ip": "",
|
||
}
|
||
map_running = [False]
|
||
|
||
def _show_chain_map(pil_img: Any) -> None:
|
||
try:
|
||
map_frame.update_idletasks()
|
||
w = max(640, map_frame.winfo_width() - 8)
|
||
h = max(180, min(240, int(w * 0.23)))
|
||
resized = resize_map_display(pil_img, w, h)
|
||
map_ctk_image["img"] = ctk.CTkImage(
|
||
light_image=resized, dark_image=resized, size=(w, h),
|
||
)
|
||
map_img_label.configure(image=map_ctk_image["img"], text="")
|
||
except Exception as e:
|
||
logging.getLogger(__name__).debug("chain map display: %s", e)
|
||
|
||
def _refresh_chain_map(silent: bool = True) -> None:
|
||
if map_running[0]:
|
||
return
|
||
hops = list(last_chain_state["hops"])
|
||
status = str(last_chain_state["status"] or "idle")
|
||
if not service_running[0] or not hops:
|
||
v_map_status.set("Plotting: chain not running")
|
||
_show_chain_map(render_chain_map([], status="idle"))
|
||
return
|
||
map_running[0] = True
|
||
v_map_status.set("Plotting: resolving hop locations…")
|
||
direct_ip = str(last_chain_state.get("direct_ip") or "")
|
||
exit_ip = str(last_chain_state.get("exit_ip") or "")
|
||
exit_intel = last_intel.get("intel")
|
||
timeout = min(12.0, svc.settings.validation_timeout_seconds + 2.0)
|
||
|
||
def work() -> None:
|
||
points = build_chain_map_points(
|
||
hops,
|
||
direct_ip=direct_ip,
|
||
exit_ip=exit_ip,
|
||
exit_intel=exit_intel if isinstance(exit_intel, ExitIntel) else None,
|
||
timeout_seconds=timeout,
|
||
)
|
||
img = render_chain_map(points, width=960, height=220, status=status) # type: ignore[arg-type]
|
||
|
||
def done() -> None:
|
||
_show_chain_map(img)
|
||
if points:
|
||
v_map_status.set(f"Plotting: {len(points)} nodes · {status}")
|
||
if not silent:
|
||
_log(f"Chain map updated — {len(points)} geo nodes ({status}).")
|
||
else:
|
||
v_map_status.set("Plotting: no geo data yet (hop DNS/geo pending)")
|
||
map_running[0] = False
|
||
|
||
root.after(0, done)
|
||
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
_btn(map_head, "Refresh map", lambda: _refresh_chain_map(False), w=100, h=22,
|
||
fg_color=ACCENT2, hover_color=ACCENT).pack(side="right", padx=(8, 0))
|
||
|
||
_show_chain_map(render_chain_map([], status="idle"))
|
||
|
||
# Log box
|
||
log_frame = ctk.CTkFrame(tab_live, fg_color=CARD, corner_radius=8)
|
||
log_frame.pack(fill="both", expand=True)
|
||
log_box = ctk.CTkTextbox(
|
||
log_frame, font=("Consolas", 10),
|
||
fg_color=BG, text_color=TEXT,
|
||
scrollbar_button_color=ACCENT,
|
||
)
|
||
log_box.pack(fill="both", expand=True, padx=4, pady=(0, 4))
|
||
|
||
def _log(line: str) -> None:
|
||
log_box.insert("end", f"[{_ts()}] {line}\n")
|
||
log_box.see("end")
|
||
|
||
log_tool = ctk.CTkFrame(log_frame, fg_color="transparent")
|
||
log_tool.pack(fill="x", padx=8, pady=(6, 0), before=log_box)
|
||
verbose_logs_var = ctk.BooleanVar(value=True)
|
||
_pcm_logger = logging.getLogger("proxy_chain_manager")
|
||
_ui_log_handler: UILogHandler | None = None
|
||
for _h in _pcm_logger.handlers:
|
||
if isinstance(_h, UILogHandler):
|
||
_ui_log_handler = _h
|
||
break
|
||
if _ui_log_handler is None:
|
||
_ui_log_handler = UILogHandler(ui_q)
|
||
_ui_log_handler.setFormatter(
|
||
logging.Formatter("%(levelname)-5s [%(name)s] %(message)s")
|
||
)
|
||
_pcm_logger.addHandler(_ui_log_handler)
|
||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||
|
||
def _sync_ui_log_level() -> None:
|
||
on = bool(verbose_logs_var.get())
|
||
lvl = logging.DEBUG if on else logging.INFO
|
||
_pcm_logger.setLevel(lvl)
|
||
if _ui_log_handler is not None:
|
||
_ui_log_handler.setLevel(lvl)
|
||
_log(f"UI log level: {'DEBUG (verbose)' if on else 'INFO'}")
|
||
|
||
ctk.CTkCheckBox(
|
||
log_tool,
|
||
text="Verbose logs (DEBUG in UI — file stays INFO)",
|
||
variable=verbose_logs_var,
|
||
command=_sync_ui_log_level,
|
||
font=(FONT, 11),
|
||
fg_color=ACCENT2,
|
||
hover_color=ACCENT,
|
||
text_color=TEXT,
|
||
).pack(side="left", padx=(0, 12))
|
||
_btn(log_tool, "Clear log", lambda: log_box.delete("1.0", "end"), w=88, h=26,
|
||
fg_color=DIM, hover_color=ACCENT).pack(side="left", padx=(0, 6))
|
||
_sync_ui_log_level()
|
||
|
||
# ─── TAB: CHAIN BUILDER ──────────────────────────────────────────────────
|
||
cb_split = ctk.CTkFrame(tab_chain, fg_color="transparent")
|
||
cb_split.pack(fill="x", pady=(4, 8))
|
||
|
||
# Left — mode + hop slider
|
||
cb_left = ctk.CTkFrame(cb_split, fg_color=CARD, corner_radius=8)
|
||
cb_left.pack(side="left", fill="both", expand=True, padx=(0, 6))
|
||
|
||
ctk.CTkLabel(cb_left, text="Obfuscation Mode", font=(FONT, 12, "bold"),
|
||
text_color=TEXT).pack(anchor="w", padx=12, pady=(12, 4))
|
||
|
||
mode_var = ctk.StringVar(value=s.obfuscation_mode)
|
||
for mk in OBFUSCATION_MODES:
|
||
ctk.CTkRadioButton(
|
||
cb_left,
|
||
text=OBFUSCATION_LABELS[mk],
|
||
variable=mode_var,
|
||
value=mk,
|
||
font=(FONT, 11),
|
||
fg_color=ACCENT2,
|
||
hover_color=ACCENT,
|
||
text_color=TEXT,
|
||
).pack(anchor="w", padx=16, pady=3)
|
||
|
||
elite_var = ctk.BooleanVar(value=s.prefer_elite)
|
||
ctk.CTkCheckBox(
|
||
cb_left, text="Elite proxies only (slower but more anonymous)",
|
||
variable=elite_var, font=(FONT, 11),
|
||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
||
).pack(anchor="w", padx=16, pady=(0, 8))
|
||
|
||
exit_fix_frame = ctk.CTkFrame(cb_left, fg_color=PANEL, corner_radius=8)
|
||
exit_fix_frame.pack(fill="x", padx=12, pady=(0, 12))
|
||
exit_hdr = ctk.CTkFrame(exit_fix_frame, fg_color="transparent")
|
||
exit_hdr.pack(fill="x", padx=10, pady=(8, 2))
|
||
ctk.CTkLabel(
|
||
exit_hdr,
|
||
text="Exit node (optional final hop)",
|
||
font=(FONT, 11, "bold"),
|
||
text_color=ORANGE,
|
||
).pack(side="left")
|
||
exit_test_dot = ctk.CTkLabel(exit_hdr, text="●", font=(FONT, 14), text_color=RED)
|
||
exit_test_dot.pack(side="left", padx=(8, 4))
|
||
exit_proxy_entry = ctk.CTkEntry(
|
||
exit_fix_frame,
|
||
placeholder_text="host:port:user:pass (defaults to socks5 — paste any format)",
|
||
font=("Consolas", 10),
|
||
fg_color=BG,
|
||
border_color=ACCENT,
|
||
height=28,
|
||
)
|
||
exit_proxy_entry.pack(fill="x", padx=10, pady=(0, 4))
|
||
exit_auth_row = ctk.CTkFrame(exit_fix_frame, fg_color="transparent")
|
||
exit_auth_row.pack(fill="x", padx=10, pady=(0, 4))
|
||
ctk.CTkLabel(exit_auth_row, text="User", font=(FONT, 10), text_color=TEXT2, width=36).pack(
|
||
side="left", padx=(0, 4)
|
||
)
|
||
exit_user_entry = ctk.CTkEntry(
|
||
exit_auth_row, width=120, height=26, font=("Consolas", 10),
|
||
fg_color=BG, border_color=ACCENT, placeholder_text="optional",
|
||
)
|
||
exit_user_entry.pack(side="left", padx=(0, 10))
|
||
ctk.CTkLabel(exit_auth_row, text="Pass", font=(FONT, 10), text_color=TEXT2, width=36).pack(
|
||
side="left", padx=(0, 4)
|
||
)
|
||
exit_pass_entry = ctk.CTkEntry(
|
||
exit_auth_row, width=120, height=26, font=("Consolas", 10),
|
||
fg_color=BG, border_color=ACCENT, placeholder_text="optional", show="*",
|
||
)
|
||
exit_pass_entry.pack(side="left", padx=(0, 0))
|
||
|
||
_eb, _eu, _epw = split_proxy_for_edit(s.manual_exit_proxy)
|
||
if _eb:
|
||
exit_proxy_entry.insert(0, _eb)
|
||
if _eu:
|
||
exit_user_entry.insert(0, _eu)
|
||
if _epw:
|
||
exit_pass_entry.insert(0, _epw)
|
||
|
||
exit_btn_row = ctk.CTkFrame(exit_fix_frame, fg_color="transparent")
|
||
exit_btn_row.pack(fill="x", padx=10, pady=(0, 4))
|
||
|
||
def _auto_split_exit_paste(_event: Any | None = None) -> None:
|
||
"""If the URL field looks like ``host:port:user:pass``, distribute
|
||
the user/pass parts into their own fields so the user sees what got
|
||
parsed. Triggered on key release and explicit paste.
|
||
"""
|
||
raw = exit_proxy_entry.get()
|
||
if "://" in raw:
|
||
return
|
||
bits = [b for b in raw.strip().split(":") if b != ""]
|
||
if len(bits) < 4 and "@" not in raw and not any(c in raw for c in (" ", "\t")):
|
||
return
|
||
base, user, pw = parse_exit_proxy_input(raw, default_scheme="socks5")
|
||
if not base:
|
||
return
|
||
if base != raw:
|
||
exit_proxy_entry.delete(0, "end")
|
||
exit_proxy_entry.insert(0, base)
|
||
if user and not exit_user_entry.get().strip():
|
||
exit_user_entry.delete(0, "end")
|
||
exit_user_entry.insert(0, user)
|
||
if pw and not exit_pass_entry.get().strip():
|
||
exit_pass_entry.delete(0, "end")
|
||
exit_pass_entry.insert(0, pw)
|
||
|
||
exit_proxy_entry.bind("<KeyRelease>", _auto_split_exit_paste)
|
||
exit_proxy_entry.bind("<<Paste>>", lambda e: exit_proxy_entry.after(1, _auto_split_exit_paste))
|
||
exit_proxy_entry.bind("<FocusOut>", _auto_split_exit_paste)
|
||
|
||
def _exit_url() -> str:
|
||
"""Build the final exit-proxy URL.
|
||
|
||
Accepts every paste format ``parse_exit_proxy_input`` understands
|
||
(host:port:user:pass, user:pass@host:port, scheme://…). Defaults to
|
||
SOCKS5 when no scheme is supplied.
|
||
"""
|
||
base, parsed_user, parsed_pw = parse_exit_proxy_input(
|
||
exit_proxy_entry.get(), default_scheme="socks5"
|
||
)
|
||
if not base:
|
||
return ""
|
||
user = (exit_user_entry.get() or "").strip() or parsed_user
|
||
pw = (exit_pass_entry.get() or "").strip() or parsed_pw
|
||
return merge_proxy_credentials(base, user, pw)
|
||
|
||
def _test_exit() -> None:
|
||
url = _exit_url()
|
||
if not url:
|
||
_log("Exit node: enter a proxy URL first.")
|
||
return
|
||
exit_test_dot.configure(text_color=YELLOW)
|
||
test_exit_btn.configure(state="disabled")
|
||
|
||
def work() -> None:
|
||
def on_result(_u: str, ok: bool) -> None:
|
||
root.after(0, lambda: exit_test_dot.configure(text_color=GREEN if ok else RED))
|
||
root.after(0, lambda: _log(
|
||
f"Exit node: {'OK' if ok else 'FAILED'} — {_short(url)}"
|
||
))
|
||
|
||
run_validate_each_sync(
|
||
[url],
|
||
svc.settings.ip_check_url,
|
||
min(12.0, svc.settings.validation_timeout_seconds),
|
||
on_result,
|
||
concurrency=1,
|
||
)
|
||
root.after(0, lambda: test_exit_btn.configure(state="normal"))
|
||
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
test_exit_btn = _btn(exit_btn_row, "Test exit", _test_exit, w=88, h=26,
|
||
fg_color=ACCENT2, hover_color=ACCENT)
|
||
test_exit_btn.pack(side="left")
|
||
|
||
def _save_exit_to_chain() -> None:
|
||
"""Append the typed exit proxy to the manual chain as the final hop."""
|
||
url = _exit_url()
|
||
if not url:
|
||
_log("Exit node: paste a host:port[:user:pass] first.")
|
||
return
|
||
# If the URL is already in the chain, move it to the bottom rather than dup.
|
||
if url in manual_chain:
|
||
manual_chain.remove(url)
|
||
manual_chain.append(url)
|
||
hop_status[url] = "ok" if (
|
||
exit_test_dot.cget("text_color") == GREEN
|
||
) else "untested"
|
||
use_manual_var.set(True)
|
||
_rebuild_chain_ui()
|
||
exit_proxy_entry.delete(0, "end")
|
||
exit_user_entry.delete(0, "end")
|
||
exit_pass_entry.delete(0, "end")
|
||
exit_test_dot.configure(text_color=RED)
|
||
_log(f"Exit node added as final hop ({len(manual_chain)} total).")
|
||
|
||
_btn(exit_btn_row, "Save as final hop", _save_exit_to_chain, w=140, h=26,
|
||
fg_color=GREEN, hover_color="#00b377").pack(side="left", padx=(6, 0))
|
||
|
||
ctk.CTkLabel(
|
||
exit_fix_frame,
|
||
text=(
|
||
"Test exit, then 'Save as final hop' appends it to YOUR CHAIN above as the last proxy.\n"
|
||
"Paste any of: host:port:user:pass • user:pass@host:port • scheme://host:port\n"
|
||
"No scheme = SOCKS5. Paste auto-splits into User/Pass below."
|
||
),
|
||
font=(FONT, 9),
|
||
text_color=TEXT2,
|
||
justify="left",
|
||
).pack(anchor="w", padx=10, pady=(0, 8))
|
||
|
||
# Right — your chain
|
||
cb_right = ctk.CTkFrame(cb_split, fg_color=CARD, corner_radius=8, width=400)
|
||
cb_right.pack(side="left", fill="both", expand=True)
|
||
|
||
hdr_row = ctk.CTkFrame(cb_right, fg_color="transparent")
|
||
hdr_row.pack(fill="x", padx=12, pady=(12, 2))
|
||
ctk.CTkLabel(
|
||
hdr_row, text="Your chain",
|
||
font=(FONT, 12, "bold"), text_color=TEXT,
|
||
).pack(side="left")
|
||
chain_count_lbl = ctk.CTkLabel(hdr_row, text="", font=(FONT, 10), text_color=TEXT2)
|
||
chain_count_lbl.pack(side="left", padx=(8, 0))
|
||
|
||
use_manual_var = ctk.BooleanVar(value=True if s.pinned_chain else s.use_pinned_chain)
|
||
use_chain_cb = ctk.CTkCheckBox(
|
||
hdr_row, text="Use this chain on Start",
|
||
variable=use_manual_var, font=(FONT, 10),
|
||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT2,
|
||
)
|
||
use_chain_cb.pack(side="right")
|
||
|
||
ctk.CTkLabel(
|
||
cb_right,
|
||
text="Traffic flows top → bottom. ● red = not tested ● green = live",
|
||
font=(FONT, 9), text_color=TEXT2,
|
||
).pack(anchor="w", padx=12, pady=(0, 4))
|
||
|
||
manual_chain: list[str] = list(s.pinned_chain)
|
||
hop_status: dict[str, HopState] = {u: "untested" for u in manual_chain}
|
||
status_dots: dict[str, ctk.CTkLabel] = {}
|
||
|
||
_HOP_COLOR = {"untested": RED, "testing": YELLOW, "ok": GREEN, "fail": RED}
|
||
|
||
chain_list_frame = ctk.CTkScrollableFrame(
|
||
cb_right, fg_color=BG, height=200, corner_radius=6,
|
||
scrollbar_button_color=ACCENT,
|
||
)
|
||
chain_list_frame.pack(fill="x", padx=12, pady=(0, 4))
|
||
|
||
def _update_chain_count() -> None:
|
||
n = len(manual_chain)
|
||
chain_count_lbl.configure(text=f"({n} hop{'s' if n != 1 else ''})")
|
||
|
||
def _rebuild_chain_ui() -> None:
|
||
status_dots.clear()
|
||
for w in list(chain_list_frame.winfo_children()):
|
||
w.destroy()
|
||
for i, hop in enumerate(manual_chain):
|
||
if hop not in hop_status:
|
||
hop_status[hop] = "untested"
|
||
_chain_row(i, hop)
|
||
_update_chain_count()
|
||
|
||
def _chain_row(idx: int, hop: str) -> None:
|
||
fr = ctk.CTkFrame(chain_list_frame, fg_color=PANEL, corner_radius=6, height=32)
|
||
fr.pack(fill="x", pady=2)
|
||
fr.pack_propagate(False)
|
||
|
||
st = hop_status.get(hop, "untested")
|
||
dot = ctk.CTkLabel(fr, text="●", font=(FONT, 12),
|
||
text_color=_HOP_COLOR[st], width=18)
|
||
dot.pack(side="left", padx=(4, 0))
|
||
status_dots[hop] = dot
|
||
|
||
ctk.CTkLabel(fr, text=f"{idx+1}", font=(FONT, 10, "bold"),
|
||
text_color=CYAN, width=22).pack(side="left")
|
||
|
||
proto = hop.split("://")[0].upper() if "://" in hop else "HTTP"
|
||
badge_c = {"HTTP": "#1e3a8a", "SOCKS5": "#064e3b", "SOCKS4": "#3b1a64"}.get(proto, DIM)
|
||
ctk.CTkLabel(fr, text=proto, font=(FONT, 9, "bold"),
|
||
fg_color=badge_c, corner_radius=4, padx=4,
|
||
text_color=TEXT).pack(side="left", padx=2)
|
||
|
||
ctk.CTkLabel(fr, text=_short(hop), font=("Consolas", 10),
|
||
text_color=TEXT, anchor="w").pack(side="left", fill="x", expand=True, padx=4)
|
||
|
||
def _up(i: int = idx) -> None:
|
||
if i > 0:
|
||
manual_chain[i], manual_chain[i-1] = manual_chain[i-1], manual_chain[i]
|
||
_rebuild_chain_ui()
|
||
|
||
def _dn(i: int = idx) -> None:
|
||
if i < len(manual_chain) - 1:
|
||
manual_chain[i], manual_chain[i+1] = manual_chain[i+1], manual_chain[i]
|
||
_rebuild_chain_ui()
|
||
|
||
def _rm(i: int = idx) -> None:
|
||
removed = manual_chain[i]
|
||
del manual_chain[i]
|
||
hop_status.pop(removed, None)
|
||
_rebuild_chain_ui()
|
||
|
||
_btn(fr, "↑", _up, w=26, h=24, fg_color=DIM, hover_color=ACCENT).pack(side="right", padx=1)
|
||
_btn(fr, "↓", _dn, w=26, h=24, fg_color=DIM, hover_color=ACCENT).pack(side="right", padx=1)
|
||
_btn(fr, "✕", _rm, w=26, h=24, fg_color="#7f1d1d", hover_color=RED).pack(side="right", padx=(1, 6))
|
||
|
||
_rebuild_chain_ui()
|
||
|
||
test_row = ctk.CTkFrame(cb_right, fg_color="transparent")
|
||
test_row.pack(fill="x", padx=12, pady=(0, 6))
|
||
|
||
def _set_all_status(state: HopState) -> None:
|
||
for u in manual_chain:
|
||
hop_status[u] = state
|
||
if u in status_dots:
|
||
status_dots[u].configure(text_color=_HOP_COLOR[state])
|
||
|
||
def _test_chain() -> None:
|
||
if not manual_chain:
|
||
_log("Add proxies to your chain first.")
|
||
return
|
||
test_chain_btn.configure(state="disabled")
|
||
_set_all_status("testing")
|
||
check_url = svc.settings.ip_check_url
|
||
timeout = min(12.0, svc.settings.validation_timeout_seconds)
|
||
ok_count = [0]
|
||
|
||
def on_result(url: str, ok: bool) -> None:
|
||
hop_status[url] = "ok" if ok else "fail"
|
||
if ok:
|
||
ok_count[0] += 1
|
||
|
||
def ui() -> None:
|
||
if url in status_dots:
|
||
status_dots[url].configure(text_color=GREEN if ok else RED)
|
||
|
||
root.after(0, ui)
|
||
|
||
def work() -> None:
|
||
run_validate_each_sync(manual_chain, check_url, timeout, on_result)
|
||
n_ok, n_tot = ok_count[0], len(manual_chain)
|
||
|
||
def done() -> None:
|
||
test_chain_btn.configure(state="normal")
|
||
_log(f"Chain test: {n_ok}/{n_tot} proxies responded OK.")
|
||
|
||
root.after(0, done)
|
||
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
test_chain_btn = _btn(test_row, "▶ Test entire chain", _test_chain, w=150, h=28,
|
||
fg_color=ACCENT2, hover_color=ACCENT)
|
||
test_chain_btn.pack(side="left", padx=(0, 8))
|
||
|
||
# Add proxy input
|
||
add_row = ctk.CTkFrame(cb_right, fg_color="transparent")
|
||
add_row.pack(fill="x", padx=12, pady=(0, 4))
|
||
add_entry = ctk.CTkEntry(
|
||
add_row,
|
||
placeholder_text="http://host:port or user:pass@host:port",
|
||
font=(FONT, 10), fg_color=BG, border_color=ACCENT, height=28,
|
||
)
|
||
add_entry.pack(side="left", fill="x", expand=True, padx=(0, 4))
|
||
add_user_entry = ctk.CTkEntry(
|
||
add_row, width=88, height=28, font=("Consolas", 10),
|
||
fg_color=BG, border_color=ACCENT, placeholder_text="user",
|
||
)
|
||
add_user_entry.pack(side="left", padx=(0, 2))
|
||
add_pass_entry = ctk.CTkEntry(
|
||
add_row, width=88, height=28, font=("Consolas", 10),
|
||
fg_color=BG, border_color=ACCENT, placeholder_text="pass", show="*",
|
||
)
|
||
add_pass_entry.pack(side="left", padx=(0, 4))
|
||
|
||
def _add_proxy() -> None:
|
||
v = add_entry.get().strip()
|
||
if not v:
|
||
return
|
||
if "://" not in v:
|
||
v = "http://" + v
|
||
au = add_user_entry.get().strip()
|
||
ap = add_pass_entry.get().strip()
|
||
if au or ap:
|
||
v = merge_proxy_credentials(v, au, ap)
|
||
else:
|
||
v = normalize_proxy_url(v)
|
||
manual_chain.append(v)
|
||
hop_status[v] = "untested"
|
||
_rebuild_chain_ui()
|
||
add_entry.delete(0, "end")
|
||
add_user_entry.delete(0, "end")
|
||
add_pass_entry.delete(0, "end")
|
||
|
||
_btn(add_row, "+ Add", _add_proxy, w=68, h=28).pack(side="left")
|
||
|
||
chain_btn_row = ctk.CTkFrame(cb_right, fg_color="transparent")
|
||
chain_btn_row.pack(pady=(0, 10))
|
||
|
||
def _on_picker_done(urls: list[str], mode: str) -> None:
|
||
if mode == "replace":
|
||
manual_chain.clear()
|
||
hop_status.clear()
|
||
for u in urls:
|
||
if u not in manual_chain:
|
||
manual_chain.append(u)
|
||
hop_status[u] = "untested"
|
||
_rebuild_chain_ui()
|
||
_log(f"{'Replaced' if mode == 'replace' else 'Added'} {len(urls)} proxies to your chain.")
|
||
|
||
def _open_picker() -> None:
|
||
_save_settings()
|
||
show_proxy_picker(root, svc.settings, bool(elite_var.get()), _on_picker_done)
|
||
|
||
browse_btn = _btn(chain_btn_row, "Browse proxy lists…", _open_picker, w=150, h=26,
|
||
fg_color=ACCENT2, hover_color=ACCENT)
|
||
browse_btn.pack(side="left")
|
||
|
||
# Sources
|
||
src_frame = ctk.CTkFrame(tab_chain, fg_color=CARD, corner_radius=8)
|
||
src_frame.pack(fill="x", pady=(0, 6))
|
||
ctk.CTkLabel(src_frame, text="Proxy Sources (one JSON URL per line)",
|
||
font=(FONT, 11, "bold"), text_color=TEXT).pack(
|
||
anchor="w", padx=12, pady=(8, 4))
|
||
sources_box = ctk.CTkTextbox(
|
||
src_frame, height=72, font=("Consolas", 10),
|
||
fg_color=BG, text_color=TEXT, scrollbar_button_color=ACCENT,
|
||
)
|
||
sources_box.pack(fill="x", padx=12, pady=(0, 8))
|
||
sources_box.insert("end", "\n".join(s.sources))
|
||
|
||
# ─── TAB: BROWSER ─────────────────────────────────────────────────────────
|
||
br_scroll = ctk.CTkScrollableFrame(tab_browser, fg_color=BG, scrollbar_button_color=ACCENT)
|
||
br_scroll.pack(fill="both", expand=True, padx=4, pady=4)
|
||
|
||
def _br_section(title: str, subtitle: str = "") -> ctk.CTkFrame:
|
||
ctk.CTkLabel(br_scroll, text=title, font=(FONT, 13, "bold"), text_color=CYAN).pack(
|
||
anchor="w", padx=8, pady=(12, 0)
|
||
)
|
||
if subtitle:
|
||
ctk.CTkLabel(
|
||
br_scroll, text=subtitle, font=(FONT, 10), text_color=TEXT2, wraplength=900, justify="left"
|
||
).pack(anchor="w", padx=8, pady=(0, 4))
|
||
f = ctk.CTkFrame(
|
||
br_scroll, fg_color=CARD, corner_radius=10,
|
||
border_width=1, border_color=GLOW,
|
||
)
|
||
f.pack(fill="x", padx=8, pady=(4, 8))
|
||
return f
|
||
|
||
browser_status_card = _br_section(
|
||
"Hardened Firefox",
|
||
"Launches Firefox in an app-controlled profile that enforces proxy/hardening prefs on every launch.",
|
||
)
|
||
browser_status_lbl = ctk.CTkLabel(
|
||
browser_status_card, text="Browser status: stopped", font=(FONT, 12, "bold"), text_color=TEXT2
|
||
)
|
||
browser_status_lbl.pack(anchor="w", padx=12, pady=(10, 2))
|
||
browser_hint_lbl = ctk.CTkLabel(
|
||
browser_status_card, text="Exit IP on hover in tray while chain is healthy.", font=(FONT, 10), text_color=TEXT2
|
||
)
|
||
browser_hint_lbl.pack(anchor="w", padx=12, pady=(0, 10))
|
||
|
||
browser_path_card = _br_section("Executable and profile", "Set your Firefox path once; profile is kept isolated.")
|
||
path_row = ctk.CTkFrame(browser_path_card, fg_color="transparent")
|
||
path_row.pack(fill="x", padx=12, pady=(10, 4))
|
||
ctk.CTkLabel(path_row, text="Firefox exe", width=90, anchor="w", font=(FONT, 10), text_color=TEXT2).pack(side="left")
|
||
firefox_path_var = ctk.StringVar(value=s.firefox_path or default_firefox_path())
|
||
firefox_path_entry = ctk.CTkEntry(path_row, textvariable=firefox_path_var, height=28, fg_color=BG, border_color=ACCENT)
|
||
firefox_path_entry.pack(side="left", fill="x", expand=True, padx=(0, 6))
|
||
|
||
def _pick_firefox_path() -> None:
|
||
p = filedialog.askopenfilename(
|
||
title="Select firefox.exe",
|
||
filetypes=[("Firefox", "firefox.exe"), ("Executable", "*.exe"), ("All files", "*.*")],
|
||
)
|
||
if p:
|
||
firefox_path_var.set(p)
|
||
|
||
_btn(path_row, "Browse", _pick_firefox_path, w=72, h=28, fg_color=DIM, hover_color=ACCENT).pack(side="left")
|
||
|
||
profile_row = ctk.CTkFrame(browser_path_card, fg_color="transparent")
|
||
profile_row.pack(fill="x", padx=12, pady=(0, 10))
|
||
ctk.CTkLabel(profile_row, text="Profile dir", width=90, anchor="w", font=(FONT, 10), text_color=TEXT2).pack(side="left")
|
||
firefox_profile_var = ctk.StringVar(value=s.firefox_profile_dir or default_profile_dir())
|
||
firefox_profile_entry = ctk.CTkEntry(
|
||
profile_row, textvariable=firefox_profile_var, height=28, fg_color=BG, border_color=ACCENT
|
||
)
|
||
firefox_profile_entry.pack(side="left", fill="x", expand=True)
|
||
|
||
browser_toggles_card = _br_section(
|
||
"Browser controls",
|
||
"These settings are applied each time you launch from this tab.",
|
||
)
|
||
persona_card = _section_inline(
|
||
br_scroll,
|
||
"Identity persona (spoof when possible, block when not)",
|
||
"Blend-in personas mimic the most common Windows + Chrome setup so the "
|
||
"browser looks normal. Hardened mode is the classic 'block everything' "
|
||
"profile but stands out as anti-fingerprint.",
|
||
) if "_section_inline" in dir() else None
|
||
if persona_card is None:
|
||
persona_card = ctk.CTkFrame(
|
||
br_scroll, fg_color=CARD, corner_radius=10,
|
||
border_width=1, border_color=GLOW,
|
||
)
|
||
persona_card.pack(fill="x", padx=8, pady=(2, 8))
|
||
ctk.CTkLabel(
|
||
persona_card,
|
||
text="Identity persona (spoof when possible, block when not)",
|
||
font=(FONT, 12, "bold"),
|
||
text_color=ORANGE,
|
||
).pack(anchor="w", padx=12, pady=(10, 0))
|
||
ctk.CTkLabel(
|
||
persona_card,
|
||
text=(
|
||
"Blend-in personas mimic the most common Windows + Chrome setup "
|
||
"so the browser looks normal. Hardened = full anti-fingerprint."
|
||
),
|
||
font=(FONT, 9), text_color=TEXT2, justify="left", wraplength=820,
|
||
).pack(anchor="w", padx=12, pady=(0, 6))
|
||
|
||
persona_row = ctk.CTkFrame(persona_card, fg_color="transparent")
|
||
persona_row.pack(fill="x", padx=12, pady=(0, 6))
|
||
ctk.CTkLabel(persona_row, text="Persona:", font=(FONT, 11), text_color=TEXT,
|
||
width=80, anchor="w").pack(side="left")
|
||
persona_var = ctk.StringVar(
|
||
value=PERSONA_LABELS.get(s.browser_persona, PERSONA_LABELS["blend_windows_chrome"])
|
||
)
|
||
persona_menu = ctk.CTkOptionMenu(
|
||
persona_row,
|
||
values=[PERSONA_LABELS[k] for k in PERSONAS],
|
||
variable=persona_var,
|
||
fg_color=BG, button_color=ACCENT2, button_hover_color=ACCENT,
|
||
text_color=TEXT, font=(FONT, 11), width=500,
|
||
)
|
||
persona_menu.pack(side="left", padx=(4, 0))
|
||
|
||
cookie_row = ctk.CTkFrame(persona_card, fg_color="transparent")
|
||
cookie_row.pack(fill="x", padx=12, pady=(0, 4))
|
||
ctk.CTkLabel(cookie_row, text="Cookies:", font=(FONT, 11), text_color=TEXT,
|
||
width=80, anchor="w").pack(side="left")
|
||
_default_cookie_label = COOKIE_LABELS.get(
|
||
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_row,
|
||
values=[COOKIE_LABELS[k] for k in COOKIE_MODES],
|
||
variable=cookie_var,
|
||
fg_color=BG, button_color=ACCENT2, button_hover_color=ACCENT,
|
||
text_color=TEXT, font=(FONT, 11), width=680,
|
||
command=_on_cookie_change,
|
||
)
|
||
cookie_menu.pack(side="left", padx=(4, 0))
|
||
cookie_note_lbl.pack(anchor="w", padx=(92, 12), pady=(0, 10))
|
||
|
||
# Build a label→key lookup ONCE so the reverse mapping doesn't break
|
||
# when a label changes copy. Falls through to the configured default if
|
||
# the label string somehow no longer exists in the dropdown.
|
||
_PERSONA_LABEL_TO_KEY = {PERSONA_LABELS[k]: k for k in PERSONAS}
|
||
_COOKIE_LABEL_TO_KEY = {COOKIE_LABELS[k]: k for k in COOKIE_MODES}
|
||
|
||
def _persona_key() -> str:
|
||
return _PERSONA_LABEL_TO_KEY.get(persona_var.get(), "blend_windows_chrome")
|
||
|
||
def _cookie_key() -> str:
|
||
return _COOKIE_LABEL_TO_KEY.get(cookie_var.get(), "block_third_party")
|
||
|
||
br_force_proxy_var = ctk.BooleanVar(value=s.browser_force_proxy)
|
||
br_disable_webrtc_var = ctk.BooleanVar(value=s.browser_disable_webrtc)
|
||
br_rfp_var = ctk.BooleanVar(value=s.browser_resist_fingerprinting)
|
||
br_telemetry_var = ctk.BooleanVar(value=s.browser_disable_telemetry)
|
||
br_fpi_var = ctk.BooleanVar(value=s.browser_first_party_isolation)
|
||
br_strict_tp_var = ctk.BooleanVar(value=s.browser_strict_tracking_protection)
|
||
br_tz_utc_var = ctk.BooleanVar(value=s.browser_timezone_utc)
|
||
br_clear_var = ctk.BooleanVar(value=s.browser_clear_on_close)
|
||
br_disposable_var = ctk.BooleanVar(value=s.browser_disposable_profile)
|
||
br_kill_drop_var = ctk.BooleanVar(value=s.browser_kill_on_chain_drop)
|
||
br_auto_relaunch_var = ctk.BooleanVar(value=s.browser_auto_relaunch)
|
||
br_lock_profile_var = ctk.BooleanVar(value=s.browser_lock_managed_profile)
|
||
|
||
def _br_toggle(parent: Any, text: str, var: ctk.BooleanVar, tip: str = "") -> ctk.CTkCheckBox:
|
||
cb = ctk.CTkCheckBox(
|
||
parent, text=text, variable=var, font=(FONT, 11),
|
||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
||
)
|
||
cb.pack(anchor="w", padx=12, pady=5)
|
||
return cb
|
||
|
||
_br_toggle(
|
||
browser_toggles_card, "Force browser proxy to local chain", br_force_proxy_var,
|
||
"Forces Firefox traffic through Proxy God listener. Prevents direct bypass.",
|
||
)
|
||
_br_toggle(
|
||
browser_toggles_card, "Disable WebRTC peer connections", br_disable_webrtc_var,
|
||
"Stops common WebRTC local-IP leaks in browser calls.",
|
||
)
|
||
_br_toggle(
|
||
browser_toggles_card, "Enable Resist Fingerprinting", br_rfp_var,
|
||
"Turns on Firefox anti-fingerprinting mode (slightly changes site behavior).",
|
||
)
|
||
_br_toggle(
|
||
browser_toggles_card, "Disable telemetry and studies", br_telemetry_var,
|
||
"Disables browser diagnostics/study pings.",
|
||
)
|
||
_br_toggle(
|
||
browser_toggles_card, "Enable First-Party Isolation", br_fpi_var,
|
||
"Separates site storage per domain to reduce cross-site tracking.",
|
||
)
|
||
_br_toggle(
|
||
browser_toggles_card, "Strict tracking protection", br_strict_tp_var,
|
||
"Uses stronger tracker/cookie blocking in Firefox.",
|
||
)
|
||
_br_toggle(
|
||
browser_toggles_card, "Timezone hardening (UTC-friendly timer precision)", br_tz_utc_var,
|
||
"Reduces timing precision used in fingerprinting.",
|
||
)
|
||
_br_toggle(
|
||
browser_toggles_card, "Clear cookies/cache/history on close", br_clear_var,
|
||
"Wipes session traces from the managed profile when browser closes.",
|
||
)
|
||
_br_toggle(
|
||
browser_toggles_card, "Disposable profile (delete on close)", br_disposable_var,
|
||
"Deletes the entire managed profile folder on stop/close.",
|
||
)
|
||
_br_toggle(
|
||
browser_toggles_card, "Kill browser if chain drops", br_kill_drop_var,
|
||
"Stops browser immediately if chain becomes unhealthy.",
|
||
)
|
||
_br_toggle(
|
||
browser_toggles_card, "Auto-relaunch browser while chain is healthy", br_auto_relaunch_var,
|
||
"If browser exits unexpectedly, relaunch it automatically while chain stays up.",
|
||
)
|
||
_br_toggle(
|
||
browser_toggles_card, "Lock to managed profile (recommended)", br_lock_profile_var,
|
||
"Launches Firefox only with Proxy God profile and re-applies settings each launch.",
|
||
)
|
||
|
||
browser_action_row = ctk.CTkFrame(br_scroll, fg_color="transparent")
|
||
browser_action_row.pack(fill="x", padx=8, pady=(2, 14))
|
||
|
||
def _browser_cfg() -> BrowserConfig:
|
||
return BrowserConfig(
|
||
firefox_path=firefox_path_var.get().strip(),
|
||
profile_dir=firefox_profile_var.get().strip(),
|
||
clear_on_close=bool(br_clear_var.get()),
|
||
disposable_profile=bool(br_disposable_var.get()),
|
||
kill_on_chain_drop=bool(br_kill_drop_var.get()),
|
||
auto_relaunch=bool(br_auto_relaunch_var.get()),
|
||
lock_managed_profile=bool(br_lock_profile_var.get()),
|
||
force_proxy=bool(br_force_proxy_var.get()),
|
||
disable_webrtc=bool(br_disable_webrtc_var.get()),
|
||
resist_fingerprinting=bool(br_rfp_var.get()),
|
||
disable_telemetry=bool(br_telemetry_var.get()),
|
||
first_party_isolation=bool(br_fpi_var.get()),
|
||
strict_tracking_protection=bool(br_strict_tp_var.get()),
|
||
timezone_utc=bool(br_tz_utc_var.get()),
|
||
persona=_persona_key(),
|
||
cookie_mode=_cookie_key(),
|
||
)
|
||
|
||
def _refresh_browser_status() -> None:
|
||
if browser.is_running():
|
||
browser_status_lbl.configure(
|
||
text=f"Browser status: running (pid {browser.pid()})",
|
||
text_color=GREEN,
|
||
)
|
||
else:
|
||
if browser_should_run[0]:
|
||
browser_status_lbl.configure(text="Browser status: waiting/restarting", text_color=YELLOW)
|
||
else:
|
||
browser_status_lbl.configure(text="Browser status: stopped", text_color=TEXT2)
|
||
|
||
def _launch_browser_with_cfg(cfg: BrowserConfig) -> bool:
|
||
if not svc.current_chain:
|
||
_log("Chain not ready yet — starting chain and launching browser now.")
|
||
_start()
|
||
ok, msg = browser.launch(cfg, LISTEN_HOST, svc.settings.local_port)
|
||
_log(msg)
|
||
_refresh_browser_status()
|
||
return bool(ok)
|
||
|
||
def _launch_browser() -> None:
|
||
_save_settings()
|
||
cfg = _browser_cfg()
|
||
browser_should_run[0] = True
|
||
browser_relaunch_count[0] = 0 # user-initiated launch resets the cap
|
||
if _launch_browser_with_cfg(cfg):
|
||
browser_last_launch_ts[0] = time.monotonic()
|
||
|
||
def _stop_browser() -> None:
|
||
cfg = _browser_cfg()
|
||
browser_should_run[0] = False
|
||
browser_relaunch_count[0] = 0
|
||
ok, msg = browser.stop(dispose=cfg.disposable_profile)
|
||
_log(msg)
|
||
_refresh_browser_status()
|
||
if svc.sticky_remaining() > 0:
|
||
svc.set_sticky(0)
|
||
_log("Sticky exit released (browser stopped).")
|
||
|
||
launch_browser_btn = _btn(
|
||
browser_action_row, "Launch Hardened Firefox", _launch_browser, w=190, h=34, font=(FONT, 12)
|
||
)
|
||
launch_browser_btn.pack(side="left", padx=(0, 8))
|
||
stop_browser_btn = _btn(
|
||
browser_action_row, "Stop Browser", _stop_browser, w=110, h=34,
|
||
fg_color="#7f1d1d", hover_color="#991b1b",
|
||
)
|
||
stop_browser_btn.pack(side="left")
|
||
|
||
# ─── TAB: BAN TESTER ───────────────────────────────────────────────────────
|
||
ban_scroll = ctk.CTkScrollableFrame(tab_ban, fg_color=BG, scrollbar_button_color=ACCENT)
|
||
ban_scroll.pack(fill="both", expand=True, padx=4, pady=4)
|
||
|
||
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))
|
||
ctk.CTkLabel(
|
||
ban_head, text="Ban / Reachability Tester",
|
||
font=(FONT, 13, "bold"), text_color=CYAN,
|
||
).pack(anchor="w", padx=12, pady=(10, 2))
|
||
ctk.CTkLabel(
|
||
ban_head,
|
||
text=(
|
||
"Tests social, shopping, crypto exchanges, and DNS providers through your ACTIVE chain. "
|
||
"Detects exit-IP bans and geo-blocks."
|
||
),
|
||
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")
|
||
ctk.CTkLabel(ban_head, textvariable=ban_status_var, font=(FONT, 11, "bold"), text_color=TEXT).pack(
|
||
anchor="w", padx=12, pady=(0, 6)
|
||
)
|
||
|
||
ban_results_frame = ctk.CTkFrame(
|
||
ban_scroll, 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_rows_wrap = ctk.CTkFrame(ban_results_frame, fg_color="transparent")
|
||
ban_rows_wrap.pack(fill="both", expand=True, padx=8, pady=8)
|
||
|
||
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.pack(fill="x", pady=2)
|
||
row.pack_propagate(False)
|
||
ctk.CTkLabel(row, text=site, font=(FONT, 11, "bold"), text_color=TEXT,
|
||
width=160, anchor="w").pack(side="left", padx=(8, 4))
|
||
ctk.CTkLabel(row, text=status, font=(FONT, 10, "bold"), text_color=color,
|
||
width=72).pack(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))
|
||
|
||
def _render_ban_results(results: list[BanTestResult]) -> None:
|
||
for child in list(ban_rows_wrap.winfo_children()):
|
||
child.destroy()
|
||
if not results:
|
||
ctk.CTkLabel(ban_rows_wrap, text="No results yet.", font=(FONT, 11),
|
||
text_color=TEXT2).pack(anchor="w", padx=8, pady=8)
|
||
return
|
||
seen_cats: set[str] = set()
|
||
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":
|
||
st, color = "OK", GREEN
|
||
elif r.status == "banned":
|
||
st, color = "BANNED", ORANGE
|
||
else:
|
||
st, color = "ERROR", RED
|
||
_ban_row(r.site, st, r.detail, color, cat_header=header)
|
||
|
||
_render_ban_results([])
|
||
|
||
ban_action = ctk.CTkFrame(ban_head, fg_color="transparent")
|
||
ban_action.pack(fill="x", padx=12, pady=(0, 10))
|
||
|
||
def _start_ban_test() -> None:
|
||
if not service_running[0] or not svc.current_chain:
|
||
ban_status_var.set("Status: chain not running")
|
||
_log("Ban tester: start the chain first, then run tests.")
|
||
return
|
||
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_start_btn.configure(state="disabled")
|
||
_render_ban_results([])
|
||
|
||
def work() -> None:
|
||
timeout = min(18.0, max(8.0, float(svc.settings.validation_timeout_seconds) + 3.0))
|
||
results = run_ban_tests(proxy_url, timeout_seconds=timeout, categories=cats)
|
||
ok_n = sum(1 for r in results if r.status == "ok")
|
||
banned_n = sum(1 for r in results if r.status == "banned")
|
||
err_n = len(results) - ok_n - banned_n
|
||
|
||
def done() -> None:
|
||
_render_ban_results(results)
|
||
ban_status_var.set(f"Status: done — ok={ok_n} banned={banned_n} errors={err_n}")
|
||
_log(f"Ban tester done via {proxy_url} — ok={ok_n}, banned={banned_n}, errors={err_n}.")
|
||
ban_start_btn.configure(state="normal")
|
||
|
||
root.after(0, done)
|
||
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
ban_start_btn = _btn(
|
||
ban_action, "Start test", _start_ban_test,
|
||
w=120, h=30, fg_color=ACCENT2, hover_color=ACCENT,
|
||
)
|
||
ban_start_btn.pack(side="left")
|
||
|
||
# ─── TAB: SIGNUP PREP ──────────────────────────────────────────────────────
|
||
signup_scroll = ctk.CTkScrollableFrame(tab_signup, fg_color=BG, scrollbar_button_color=ACCENT)
|
||
signup_scroll.pack(fill="both", expand=True, padx=4, pady=4)
|
||
|
||
signup_draft = load_draft()
|
||
|
||
def _signup_card(title: str, subtitle: str = "") -> ctk.CTkFrame:
|
||
ctk.CTkLabel(signup_scroll, text=title, font=(FONT, 13, "bold"), text_color=CYAN).pack(
|
||
anchor="w", padx=8, pady=(12, 0)
|
||
)
|
||
if subtitle:
|
||
ctk.CTkLabel(
|
||
signup_scroll, text=subtitle, font=(FONT, 10), text_color=TEXT2,
|
||
wraplength=900, justify="left",
|
||
).pack(anchor="w", padx=8, pady=(0, 4))
|
||
f = ctk.CTkFrame(signup_scroll, fg_color=CARD, corner_radius=10, border_width=1, border_color=GLOW)
|
||
f.pack(fill="x", padx=8, pady=(4, 8))
|
||
return f
|
||
|
||
signup_head = _signup_card(
|
||
"Signup autofill (manual submit)",
|
||
"Opens signup pages through your chain and autofills your fields. You solve captchas and click submit.",
|
||
)
|
||
|
||
site_row = ctk.CTkFrame(signup_head, fg_color="transparent")
|
||
site_row.pack(fill="x", padx=12, pady=(10, 4))
|
||
ctk.CTkLabel(site_row, text="Site", width=70, anchor="w", font=(FONT, 10), text_color=TEXT2).pack(side="left")
|
||
signup_site_keys = list(SIGNUP_PRESET_KEYS) + ["custom"]
|
||
signup_site_labels = [
|
||
(SIGNUP_PRESETS[k].label if k in SIGNUP_PRESETS else "Custom URL")
|
||
for k in signup_site_keys
|
||
]
|
||
signup_site_var = ctk.StringVar(
|
||
value=next(
|
||
(signup_site_labels[i] for i, k in enumerate(signup_site_keys) if k == signup_draft.site_key),
|
||
signup_site_labels[0],
|
||
)
|
||
)
|
||
custom_url_row = ctk.CTkFrame(signup_head, fg_color="transparent")
|
||
custom_url_row.pack(fill="x", padx=12, pady=(0, 4))
|
||
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_custom_url.pack(side="left", fill="x", expand=True)
|
||
signup_custom_url.insert(0, signup_draft.custom_url or "")
|
||
|
||
def _signup_site_key() -> str:
|
||
label = signup_site_var.get()
|
||
for i, lbl in enumerate(signup_site_labels):
|
||
if lbl == label:
|
||
return signup_site_keys[i]
|
||
return signup_site_keys[0]
|
||
|
||
def _on_signup_site_change(_choice: str = "") -> None:
|
||
key = _signup_site_key()
|
||
preset = SIGNUP_PRESETS.get(key)
|
||
if preset and preset.url:
|
||
signup_custom_url.delete(0, "end")
|
||
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:
|
||
row = ctk.CTkFrame(parent, fg_color="transparent")
|
||
row.pack(fill="x", padx=12, pady=3)
|
||
ctk.CTkLabel(row, text=label, width=70, anchor="w", font=(FONT, 10), text_color=TEXT2).pack(side="left")
|
||
e = ctk.CTkEntry(row, height=28, fg_color=BG, border_color=ACCENT, show=show)
|
||
e.pack(side="left", fill="x", expand=True)
|
||
return e
|
||
|
||
signup_email = _signup_field(signup_head, "Email")
|
||
signup_email.insert(0, signup_draft.email or "")
|
||
signup_pass = _signup_field(signup_head, "Password", show="*")
|
||
signup_pass.insert(0, signup_draft.password or "")
|
||
signup_user = _signup_field(signup_head, "Username")
|
||
signup_user.insert(0, signup_draft.username or "")
|
||
signup_fname = _signup_field(signup_head, "First name")
|
||
signup_fname.insert(0, signup_draft.first_name or "")
|
||
signup_lname = _signup_field(signup_head, "Last name")
|
||
signup_lname.insert(0, signup_draft.last_name or "")
|
||
signup_notes = _signup_field(signup_head, "Notes")
|
||
signup_notes.insert(0, signup_draft.notes or "")
|
||
|
||
def _clipboard_copy(text: str, label: str) -> None:
|
||
t = (text or "").strip()
|
||
if not t:
|
||
_log(f"Signup prep: nothing to copy for {label}.")
|
||
return
|
||
root.clipboard_clear()
|
||
root.clipboard_append(t)
|
||
root.update()
|
||
_log(f"Copied {label} to clipboard.")
|
||
|
||
def _collect_signup_draft() -> SignupDraft:
|
||
return SignupDraft(
|
||
site_key=_signup_site_key(),
|
||
custom_url=signup_custom_url.get().strip(),
|
||
email=signup_email.get().strip(),
|
||
password=signup_pass.get().strip(),
|
||
username=signup_user.get().strip(),
|
||
first_name=signup_fname.get().strip(),
|
||
last_name=signup_lname.get().strip(),
|
||
notes=signup_notes.get().strip(),
|
||
)
|
||
|
||
def _persist_signup_draft() -> SignupDraft:
|
||
d = _collect_signup_draft()
|
||
save_draft(d)
|
||
return d
|
||
|
||
signup_btn_row = ctk.CTkFrame(signup_head, fg_color="transparent")
|
||
signup_btn_row.pack(fill="x", padx=12, pady=(8, 6))
|
||
|
||
def _gen_signup_password() -> None:
|
||
pw = generate_password()
|
||
signup_pass.delete(0, "end")
|
||
signup_pass.insert(0, pw)
|
||
_persist_signup_draft()
|
||
_log("Generated signup password.")
|
||
|
||
def _gen_signup_names() -> None:
|
||
f, l = random_full_name()
|
||
signup_fname.delete(0, "end"); signup_fname.insert(0, f)
|
||
signup_lname.delete(0, "end"); signup_lname.insert(0, l)
|
||
_persist_signup_draft()
|
||
_log(f"Generated name: {f} {l}")
|
||
|
||
def _gen_signup_username() -> None:
|
||
f = signup_fname.get().strip() or random_first_name()
|
||
l = signup_lname.get().strip() or random_last_name()
|
||
u = random_username(f, l)
|
||
signup_user.delete(0, "end"); signup_user.insert(0, u)
|
||
_persist_signup_draft()
|
||
_log(f"Generated username: {u}")
|
||
|
||
def _gen_signup_everything() -> None:
|
||
f, l = random_full_name()
|
||
signup_fname.delete(0, "end"); signup_fname.insert(0, f)
|
||
signup_lname.delete(0, "end"); signup_lname.insert(0, l)
|
||
signup_user.delete(0, "end"); signup_user.insert(0, random_username(f, l))
|
||
signup_pass.delete(0, "end"); signup_pass.insert(0, generate_password())
|
||
_persist_signup_draft()
|
||
_log(f"Generated identity: {f} {l}")
|
||
|
||
_btn(signup_btn_row, "Gen password", _gen_signup_password, w=100, h=28,
|
||
fg_color=DIM, hover_color=ACCENT).pack(side="left", padx=(0, 6))
|
||
_btn(signup_btn_row, "Gen name", _gen_signup_names, w=80, h=28,
|
||
fg_color=DIM, hover_color=ACCENT).pack(side="left", padx=(0, 6))
|
||
_btn(signup_btn_row, "Gen username", _gen_signup_username, w=100, h=28,
|
||
fg_color=DIM, hover_color=ACCENT).pack(side="left", padx=(0, 6))
|
||
_btn(signup_btn_row, "Gen ALL", _gen_signup_everything, w=80, h=28,
|
||
fg_color=ACCENT2, hover_color=ACCENT).pack(side="left", padx=(0, 12))
|
||
_btn(signup_btn_row, "Copy email", lambda: _clipboard_copy(signup_email.get(), "email"),
|
||
w=88, h=28, fg_color=DIM, hover_color=ACCENT).pack(side="left", padx=(0, 6))
|
||
_btn(signup_btn_row, "Copy pass", lambda: _clipboard_copy(signup_pass.get(), "password"),
|
||
w=88, h=28, fg_color=DIM, hover_color=ACCENT).pack(side="left", padx=(0, 6))
|
||
|
||
# Sticky exit + paranoid mode toggles
|
||
signup_opts_row = ctk.CTkFrame(signup_head, fg_color="transparent")
|
||
signup_opts_row.pack(fill="x", padx=12, pady=(0, 6))
|
||
sticky_exit_var = ctk.BooleanVar(value=True)
|
||
paranoid_signup_var = ctk.BooleanVar(value=False)
|
||
ctk.CTkCheckBox(
|
||
signup_opts_row, text="Sticky exit (hold final hop while signup is open)",
|
||
variable=sticky_exit_var, font=(FONT, 10),
|
||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
||
).pack(side="left", padx=(0, 12))
|
||
ctk.CTkCheckBox(
|
||
signup_opts_row, text="Paranoid mode (disposable profile + DNS flush)",
|
||
variable=paranoid_signup_var, font=(FONT, 10),
|
||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
||
).pack(side="left")
|
||
|
||
# ── Pre-flight checklist ─────────────────────────────────────────────────
|
||
preflight_card = _signup_card(
|
||
"Pre-flight check",
|
||
"Exhaustive readiness check: chain, exit IP, geo/ASN, DNS leak, WebRTC, "
|
||
"IPv6, HTTPS tunnel, target site reachability, and direct-IP comparison.",
|
||
)
|
||
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:
|
||
row = ctk.CTkFrame(preflight_card, fg_color=PANEL, corner_radius=6)
|
||
row.pack(fill="x", padx=12, pady=2)
|
||
dot = ctk.CTkLabel(row, text="●", font=(FONT, 14), text_color=DIM, width=18)
|
||
dot.pack(side="left", padx=(8, 4))
|
||
ctk.CTkLabel(row, text=label, font=(FONT, 11, "bold"), text_color=TEXT,
|
||
width=180, anchor="w").pack(side="left")
|
||
var = ctk.StringVar(value="not run")
|
||
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)
|
||
pf_rows[key] = {"var": var, "dot": dot}
|
||
|
||
for _k, _lbl in _PF_CHECKS:
|
||
_pf_row(_k, _lbl)
|
||
|
||
def _pf_set(key: str, status: str, detail: str) -> None:
|
||
r = pf_rows.get(key)
|
||
if not r:
|
||
return
|
||
color = {"ok": GREEN, "warn": ORANGE, "fail": RED, "running": YELLOW}.get(status, DIM)
|
||
r["dot"].configure(text_color=color) # type: ignore[union-attr]
|
||
r["var"].set(detail) # type: ignore[union-attr]
|
||
|
||
pf_summary_var = ctk.StringVar(value="Not run.")
|
||
ctk.CTkLabel(preflight_card, textvariable=pf_summary_var, font=(FONT, 10, "bold"),
|
||
text_color=TEXT).pack(anchor="w", padx=12, pady=(6, 8))
|
||
|
||
pf_running = [False]
|
||
|
||
def _run_preflight() -> None:
|
||
if pf_running[0]:
|
||
return
|
||
draft = _persist_signup_draft()
|
||
url = resolve_signup_url(draft)
|
||
if not url:
|
||
pf_summary_var.set("Pick a site or enter a custom URL first.")
|
||
return
|
||
pf_running[0] = True
|
||
for k, _ in _PF_CHECKS:
|
||
_pf_set(k, "running", "checking…")
|
||
pf_summary_var.set("Running pre-flight…")
|
||
|
||
# Immediate sync checks ──────────────────────────────────────────────
|
||
chain_ok = service_running[0] and bool(svc.current_chain)
|
||
if chain_ok:
|
||
_pf_set("chain", "ok", f"{len(svc.current_chain)} hop(s) active")
|
||
else:
|
||
_pf_set("chain", "fail", "chain not running — start first")
|
||
|
||
# Firefox path check
|
||
ff_path = firefox_path_var.get().strip() or default_firefox_path()
|
||
from pathlib import Path as _Path
|
||
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()}"
|
||
target_host = urlparse(url).hostname or "(unknown)"
|
||
|
||
def work() -> None:
|
||
from .leak_detect import is_chain_leak, leak_reason
|
||
from .validator import get_direct_ip as _get_direct_ip
|
||
import asyncio as _asyncio
|
||
|
||
timeout = min(15.0, max(8.0, float(svc.settings.validation_timeout_seconds) + 2.0))
|
||
warns: list[str] = []
|
||
vpn_active = detect_vpn().active
|
||
|
||
# Single dedicated event loop for the whole preflight pass. Using
|
||
# `asyncio.run()` per call tore down the loop each time, which
|
||
# caused nested-loop errors when caller context (e.g. async http
|
||
# libraries) leaked tasks across invocations.
|
||
_loop = _asyncio.new_event_loop()
|
||
_asyncio.set_event_loop(_loop)
|
||
|
||
def _run_async(coro):
|
||
return _loop.run_until_complete(coro)
|
||
|
||
# ── 1. Direct IP (bypass chain) ──────────────────────────────────
|
||
try:
|
||
direct_ip = _run_async(_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:
|
||
_pf_set("direct_ip", "warn", "could not reach check URL directly")
|
||
|
||
root.after(0, lambda ip=direct_ip: _set_direct(ip))
|
||
|
||
# ── 2. Exit IP via chain ─────────────────────────────────────────
|
||
if chain_ok:
|
||
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:
|
||
_pf_set("exit_ip", "warn", "no exit IP yet")
|
||
# IP leak compare (same logic as production leak_detect)
|
||
if direct_ip and ip_ and ip_ not in ("—", ""):
|
||
if is_chain_leak(ip_, direct_ip, vpn_active):
|
||
reason = leak_reason(ip_, direct_ip, vpn_active)
|
||
_pf_set("leak_compare", "fail", reason[:80])
|
||
warns.append(f"IP leak: {reason}")
|
||
else:
|
||
_pf_set("leak_compare", "ok",
|
||
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")
|
||
|
||
root.after(0, lambda i=exit_ip_str, d=intel: _set_exit(i, d))
|
||
else:
|
||
for k in ("exit_ip", "leak_compare", "intel", "datacenter"):
|
||
root.after(0, lambda k_=k: _pf_set(k_, "fail", "chain not running"))
|
||
|
||
# ── 3. DNS routing check ─────────────────────────────────────────
|
||
try:
|
||
dns_rep = run_dns_leak_test(
|
||
proxy_url=proxy_url if chain_ok else None,
|
||
timeout=min(10.0, timeout),
|
||
)
|
||
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
|
||
tun_ok, tun_msg = _run_async(
|
||
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 ─────────────────────────────────────────────────
|
||
# Schedule _final via root.after(0, ...) so it is enqueued AFTER
|
||
# all previously-scheduled root.after(0, ...) callbacks (which
|
||
# populate `warns`). A fixed 200 ms delay could fire before those
|
||
# callbacks execute on a slow main thread.
|
||
def _final() -> None:
|
||
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, _final)
|
||
|
||
# Tear down the dedicated event loop now that all coroutines are
|
||
# done; leaving it open would leak the underlying selector socket.
|
||
try:
|
||
_loop.close()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
pf_btn_row = ctk.CTkFrame(preflight_card, fg_color="transparent")
|
||
pf_btn_row.pack(fill="x", padx=12, pady=(0, 10))
|
||
_btn(pf_btn_row, "Run pre-flight", _run_preflight, w=140, h=28,
|
||
fg_color=ACCENT2, hover_color=ACCENT).pack(side="left")
|
||
|
||
signup_status_var = ctk.StringVar(value="Ready — start chain, then Open & Autofill.")
|
||
ctk.CTkLabel(signup_head, textvariable=signup_status_var, font=(FONT, 10), text_color=TEXT2).pack(
|
||
anchor="w", padx=12, pady=(0, 8)
|
||
)
|
||
|
||
signup_action = ctk.CTkFrame(signup_scroll, fg_color="transparent")
|
||
signup_action.pack(fill="x", padx=8, pady=(0, 6))
|
||
signup_launch_retries = [0]
|
||
|
||
accounts_card = _signup_card("Saved accounts", "Logged locally with exit IP + hop used at save time.")
|
||
|
||
accounts_list = ctk.CTkScrollableFrame(
|
||
accounts_card, fg_color=BG, height=180, corner_radius=6, scrollbar_button_color=ACCENT,
|
||
)
|
||
accounts_list.pack(fill="x", padx=12, pady=(8, 10))
|
||
|
||
def _refresh_accounts_ui() -> None:
|
||
for child in list(accounts_list.winfo_children()):
|
||
child.destroy()
|
||
rows = load_accounts()
|
||
if not rows:
|
||
ctk.CTkLabel(accounts_list, text="No saved accounts yet.", font=(FONT, 10),
|
||
text_color=TEXT2).pack(anchor="w", padx=8, pady=8)
|
||
return
|
||
for rec in rows[:60]:
|
||
row_frame = ctk.CTkFrame(accounts_list, fg_color=PANEL, corner_radius=6)
|
||
row_frame.pack(fill="x", pady=2, padx=4)
|
||
|
||
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]:
|
||
ip = (v_exit_ip.get() or "—").strip()
|
||
if ip == "—":
|
||
ip = ""
|
||
hop = ""
|
||
chain = svc.current_chain
|
||
if chain:
|
||
hop = redact_proxy_url(chain[-1])
|
||
return ip, hop
|
||
|
||
def _launch_signup_autofill() -> None:
|
||
draft = _persist_signup_draft()
|
||
url = resolve_signup_url(draft)
|
||
if not url:
|
||
signup_status_var.set("Missing signup URL.")
|
||
_log("Signup prep: pick a site or enter a custom URL.")
|
||
return
|
||
if not draft.email and not draft.username:
|
||
signup_status_var.set("Enter email or username.")
|
||
_log("Signup prep: email or username required for autofill.")
|
||
return
|
||
if not draft.password:
|
||
signup_status_var.set("Enter or generate a password.")
|
||
_log("Signup prep: password required for autofill.")
|
||
return
|
||
|
||
cfg = _browser_cfg()
|
||
cfg.lock_managed_profile = True
|
||
paranoid = bool(paranoid_signup_var.get())
|
||
if paranoid:
|
||
cfg.disposable_profile = True
|
||
cfg.clear_on_close = True
|
||
cfg.kill_on_chain_drop = True
|
||
ok_dns, msg_dns = flush_dns_cache()
|
||
_log(f"Paranoid mode: DNS flush — {msg_dns}" if ok_dns else f"Paranoid mode: DNS flush failed — {msg_dns}")
|
||
if not service_running[0] or not svc.current_chain:
|
||
if signup_launch_retries[0] == 0:
|
||
_log("Signup prep: starting chain first…")
|
||
_start()
|
||
signup_launch_retries[0] += 1
|
||
if signup_launch_retries[0] > 12:
|
||
signup_status_var.set("Chain did not start in time.")
|
||
signup_launch_retries[0] = 0
|
||
_log("Signup prep: chain not ready — start chain manually.")
|
||
return
|
||
signup_status_var.set("Waiting for chain…")
|
||
root.after(2500, _launch_signup_autofill)
|
||
return
|
||
signup_launch_retries[0] = 0
|
||
|
||
if bool(sticky_exit_var.get()):
|
||
svc.set_sticky(1800)
|
||
_log("Sticky exit engaged — auto-rotation suspended for 30 min.")
|
||
|
||
ok, msg = browser.launch(
|
||
cfg, LISTEN_HOST, svc.settings.local_port,
|
||
start_url=url, signup_draft=draft,
|
||
)
|
||
_log(msg)
|
||
_refresh_browser_status()
|
||
if ok:
|
||
preset = SIGNUP_PRESETS.get(draft.site_key)
|
||
label = preset.label if preset else "Custom"
|
||
extras = []
|
||
if bool(sticky_exit_var.get()):
|
||
extras.append("sticky")
|
||
if paranoid:
|
||
extras.append("paranoid")
|
||
tag = f" [{' · '.join(extras)}]" if extras else ""
|
||
signup_status_var.set(f"Opened {label}{tag} — autofill active (you submit + captcha).")
|
||
_log(f"Signup prep: opened {url} with autofill extension{tag}.")
|
||
# Record draft metadata only — password saved when user clicks Save Account
|
||
_save_signup_account_draft_only()
|
||
else:
|
||
signup_status_var.set("Browser launch failed.")
|
||
svc.set_sticky(0)
|
||
|
||
def _save_signup_account_draft_only() -> None:
|
||
"""After browser open: save email/exit metadata without password until user confirms."""
|
||
draft = _persist_signup_draft()
|
||
url = resolve_signup_url(draft)
|
||
exit_ip, exit_hop = _current_exit_meta()
|
||
preset = SIGNUP_PRESETS.get(draft.site_key)
|
||
site_label = preset.label if preset else "Custom"
|
||
rec = AccountRecord(
|
||
id=datetime.now().strftime("%Y%m%d%H%M%S%f"),
|
||
site=site_label,
|
||
url=url,
|
||
email=draft.email,
|
||
password="",
|
||
username=draft.username,
|
||
first_name=draft.first_name,
|
||
last_name=draft.last_name,
|
||
exit_ip=exit_ip,
|
||
exit_hop=exit_hop,
|
||
notes=draft.notes,
|
||
status="pending",
|
||
created_at=datetime.now().isoformat(timespec="seconds"),
|
||
)
|
||
append_account(rec)
|
||
_refresh_accounts_ui()
|
||
_log(f"Signup prep: draft account {draft.email or draft.username} ({site_label}) — save password after signup.")
|
||
|
||
def _save_signup_account() -> None:
|
||
draft = _persist_signup_draft()
|
||
url = resolve_signup_url(draft)
|
||
exit_ip, exit_hop = _current_exit_meta()
|
||
preset = SIGNUP_PRESETS.get(draft.site_key)
|
||
site_label = preset.label if preset else "Custom"
|
||
rec = AccountRecord(
|
||
id=datetime.now().strftime("%Y%m%d%H%M%S%f"),
|
||
site=site_label,
|
||
url=url,
|
||
email=draft.email,
|
||
password=draft.password,
|
||
username=draft.username,
|
||
first_name=draft.first_name,
|
||
last_name=draft.last_name,
|
||
exit_ip=exit_ip,
|
||
exit_hop=exit_hop,
|
||
notes=draft.notes,
|
||
status="created",
|
||
created_at=datetime.now().isoformat(timespec="seconds"),
|
||
)
|
||
append_account(rec)
|
||
_refresh_accounts_ui()
|
||
signup_status_var.set(f"Saved account for {site_label}.")
|
||
_log(f"Signup prep: saved account {draft.email or draft.username} ({site_label}).")
|
||
|
||
def _preflight_then_open() -> None:
|
||
_run_preflight()
|
||
|
||
def follow_up(attempts: int = 0) -> None:
|
||
if pf_running[0] and attempts < 30:
|
||
root.after(500, lambda: follow_up(attempts + 1))
|
||
return
|
||
_launch_signup_autofill()
|
||
|
||
root.after(500, follow_up)
|
||
|
||
signup_open_btn = _btn(
|
||
signup_action, "Open & Autofill", _launch_signup_autofill,
|
||
w=140, h=34, fg_color=ACCENT2, hover_color=ACCENT, font=(FONT, 12),
|
||
)
|
||
signup_open_btn.pack(side="left", padx=(0, 8))
|
||
_btn(
|
||
signup_action, "Pre-flight + Open", _preflight_then_open,
|
||
w=150, h=34, fg_color=ACCENT, hover_color=ACCENT2,
|
||
).pack(side="left", padx=(0, 8))
|
||
_btn(
|
||
signup_action, "Save account", _save_signup_account,
|
||
w=110, h=34, fg_color=GREEN, hover_color="#00b377",
|
||
).pack(side="left", padx=(0, 8))
|
||
_btn(
|
||
signup_action, "Refresh list", _refresh_accounts_ui,
|
||
w=100, h=34, fg_color=DIM, hover_color=ACCENT,
|
||
).pack(side="left")
|
||
|
||
_refresh_accounts_ui()
|
||
|
||
# ─── TAB: PRIVACY ──────────────────────────────────────────────────────────
|
||
priv_scroll = ctk.CTkScrollableFrame(tab_privacy, fg_color=BG,
|
||
scrollbar_button_color=ACCENT)
|
||
priv_scroll.pack(fill="both", expand=True, padx=4, pady=4)
|
||
|
||
def _priv_section(title: str, subtitle: str = "") -> ctk.CTkFrame:
|
||
ctk.CTkLabel(priv_scroll, text=title, font=(FONT, 13, "bold"),
|
||
text_color=CYAN).pack(anchor="w", padx=8, pady=(12, 0))
|
||
if subtitle:
|
||
ctk.CTkLabel(priv_scroll, text=subtitle, font=(FONT, 10),
|
||
text_color=TEXT2, wraplength=900, justify="left").pack(
|
||
anchor="w", padx=8, pady=(0, 4))
|
||
f = ctk.CTkFrame(
|
||
priv_scroll, fg_color=CARD, corner_radius=10,
|
||
border_width=1, border_color=GLOW,
|
||
)
|
||
f.pack(fill="x", padx=8, pady=(4, 8))
|
||
return f
|
||
|
||
status_card = _priv_section(
|
||
"Network identity",
|
||
"VPN detection drives leak checks: strict without VPN, subnet-aware with VPN.",
|
||
)
|
||
status_row = ctk.CTkFrame(status_card, fg_color="transparent")
|
||
status_row.pack(fill="x", padx=12, pady=10)
|
||
vpn_status_lbl = ctk.CTkLabel(status_row, text="VPN: scanning…",
|
||
font=(FONT, 12, "bold"), text_color=TEXT)
|
||
vpn_status_lbl.pack(anchor="w")
|
||
leak_mode_lbl = ctk.CTkLabel(status_row, text="Leak check: —",
|
||
font=(FONT, 11), text_color=TEXT2)
|
||
leak_mode_lbl.pack(anchor="w", pady=(4, 0))
|
||
|
||
def _refresh_vpn_ui() -> None:
|
||
vs = detect_vpn()
|
||
nonlocal outer_hop_label
|
||
if vs.active:
|
||
outer_hop_label = vs.short_label()[:12]
|
||
vpn_status_lbl.configure(
|
||
text=f"VPN: {vs.label}" + (f" ({vs.adapter})" if vs.adapter else ""),
|
||
text_color=GREEN,
|
||
)
|
||
leak_mode_lbl.configure(text="Leak check: VPN-aware (/16 subnet)")
|
||
else:
|
||
outer_hop_label = "Direct"
|
||
vpn_status_lbl.configure(text="VPN: not detected (direct connection)",
|
||
text_color=YELLOW)
|
||
leak_mode_lbl.configure(text="Leak check: strict (exit must differ from your IP)")
|
||
|
||
_btn(status_card, "Refresh VPN status", _refresh_vpn_ui, w=140, h=28).pack(
|
||
anchor="w", padx=12, pady=(0, 10))
|
||
|
||
toggles_card = _priv_section(
|
||
"Protections while chain is running",
|
||
"Applied on Start, restored on Stop. Admin required for MAC, hostname, IPv6, and WebRTC.",
|
||
)
|
||
mac_var = ctk.BooleanVar(value=s.mac_spoof_enabled)
|
||
mac_rotate_var = ctk.BooleanVar(value=s.mac_rotate_on_chain_rotate)
|
||
host_var = ctk.BooleanVar(value=s.spoof_hostname_enabled)
|
||
dns_flush_var = ctk.BooleanVar(value=s.flush_dns_on_rotate)
|
||
ipv6_var = ctk.BooleanVar(value=s.disable_ipv6_while_active)
|
||
webrtc_var = ctk.BooleanVar(value=s.harden_webrtc_enabled)
|
||
lan_var = ctk.BooleanVar(value=s.lan_lockdown_enabled)
|
||
telemetry_var = ctk.BooleanVar(value=s.telemetry_kill_enabled)
|
||
|
||
def _priv_toggle(parent: Any, text: str, var: ctk.BooleanVar, tip: str = "") -> ctk.CTkCheckBox:
|
||
cb = ctk.CTkCheckBox(
|
||
parent, text=text, variable=var, font=(FONT, 11),
|
||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
||
)
|
||
cb.pack(anchor="w", padx=12, pady=6)
|
||
return cb
|
||
|
||
_priv_toggle(
|
||
toggles_card, "Randomize MAC addresses on physical adapters", mac_var,
|
||
"Changes NIC MAC values while chain runs (admin required).",
|
||
)
|
||
_priv_toggle(
|
||
toggles_card, "Re-randomize MAC on EVERY chain rotation (paranoid)", mac_rotate_var,
|
||
"Mutates MAC every time the chain rotates — prevents long-session correlation.",
|
||
)
|
||
_priv_toggle(
|
||
toggles_card, "Spoof computer / NetBIOS hostname", host_var,
|
||
"Temporarily renames machine identity while active (admin required).",
|
||
)
|
||
_priv_toggle(
|
||
toggles_card, "Flush DNS cache when starting or rotating chains", dns_flush_var,
|
||
"Clears cached resolver results to reduce stale direct routes.",
|
||
)
|
||
_priv_toggle(
|
||
toggles_card, "Disable IPv6 on active adapters", ipv6_var,
|
||
"Turns off IPv6 bindings while active to reduce IPv6 leak paths.",
|
||
)
|
||
_priv_toggle(
|
||
toggles_card,
|
||
"Harden WebRTC in Chrome / Edge (block non-proxied UDP)",
|
||
webrtc_var,
|
||
"Applies Windows policy to block direct WebRTC UDP bypass in Chromium browsers.",
|
||
)
|
||
_priv_toggle(
|
||
toggles_card,
|
||
"LAN lockdown: kill LLMNR / NetBIOS / mDNS hostname broadcasts",
|
||
lan_var,
|
||
"Stops your machine from advertising its hostname on the local network "
|
||
"(admin required, reversible on stop).",
|
||
)
|
||
_priv_toggle(
|
||
toggles_card,
|
||
"Telemetry kill: DiagTrack, Activity History, Cortana web, ad ID",
|
||
telemetry_var,
|
||
"Stops Windows telemetry pipelines while chain is up. Reversible on stop "
|
||
"(snapshot of original state taken).",
|
||
)
|
||
|
||
fp_card = _priv_section(
|
||
"Device fingerprint",
|
||
"OS-level identifiers. Browsers still fingerprint Canvas, fonts, and WebGL separately.",
|
||
)
|
||
fp_box = ctk.CTkTextbox(fp_card, height=140, font=("Consolas", 10),
|
||
fg_color=BG, text_color=TEXT,
|
||
scrollbar_button_color=ACCENT)
|
||
fp_box.pack(fill="x", padx=12, pady=(4, 8))
|
||
|
||
def _refresh_fingerprint() -> None:
|
||
fp_box.delete("1.0", "end")
|
||
audit = audit_device()
|
||
fp_box.insert("end", "\n".join(audit.lines))
|
||
|
||
_btn(fp_card, "Refresh fingerprint audit", _refresh_fingerprint, w=180, h=28).pack(
|
||
anchor="w", padx=12, pady=(0, 10))
|
||
|
||
# ── DNS leak test ────────────────────────────────────────────────────────
|
||
dns_card = _priv_section(
|
||
"DNS leak test",
|
||
"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 _render_dns_report(rep: FullDnsLeakReport) -> None:
|
||
dns_box.delete("1.0", "end")
|
||
mark = "LEAK" if rep.leaked else "OK "
|
||
dns_box.insert("end", f"[{mark}] {rep.summary}\n\n")
|
||
if rep.system_resolvers:
|
||
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,
|
||
)
|
||
|
||
def _run_full_dns_test() -> None:
|
||
proxy = f"http://{svc.settings.listen_addr()}" if svc.current_chain else None
|
||
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:
|
||
ok, msg = flush_dns_cache()
|
||
dns_status_lbl.configure(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,
|
||
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(
|
||
"Forensic artifact wipe",
|
||
"One-button purge of common Windows breadcrumb trails. Irreversible.",
|
||
)
|
||
wipe_result_lbl = ctk.CTkLabel(
|
||
wipe_card, text="Click 'Wipe now' to purge %TEMP%, Recent, Jump Lists, "
|
||
"Prefetch (Admin), MRU lists, and the clipboard.",
|
||
font=(FONT, 10), text_color=TEXT2, wraplength=880, justify="left",
|
||
)
|
||
wipe_result_lbl.pack(anchor="w", padx=12, pady=8)
|
||
|
||
def _do_wipe() -> None:
|
||
wipe_result_lbl.configure(text="Wiping…", text_color=YELLOW)
|
||
|
||
def work() -> None:
|
||
try:
|
||
rep = wipe_artifacts(include_prefetch=True)
|
||
except Exception as e:
|
||
root.after(0, lambda: wipe_result_lbl.configure(
|
||
text=f"Wipe failed: {e}", text_color=RED))
|
||
return
|
||
root.after(0, lambda: wipe_result_lbl.configure(
|
||
text=rep.summary(), text_color=GREEN if not rep.errors else YELLOW))
|
||
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
_btn(wipe_card, "Wipe now", _do_wipe, w=110, h=28,
|
||
fg_color=RED, hover_color="#8a1d1d").pack(anchor="w", padx=12, pady=(0, 10))
|
||
|
||
audit_card = _priv_section(
|
||
"Leak audit (mission-critical)",
|
||
"Probes every leak surface: IP, DNS, IPv6, WPAD, Group Policy, "
|
||
"ProxySettingsPerUser, LLMNR / NetBIOS / mDNS, VPN, WebRTC policy.",
|
||
)
|
||
audit_box = ctk.CTkTextbox(
|
||
audit_card, height=220, font=("Consolas", 10),
|
||
fg_color=BG, text_color=TEXT, scrollbar_button_color=ACCENT,
|
||
)
|
||
audit_box.pack(fill="x", padx=12, pady=(4, 4))
|
||
audit_box.insert("end", "Click 'Run audit' to probe live leak status.")
|
||
audit_status_lbl = ctk.CTkLabel(
|
||
audit_card, text="", font=(FONT, 11, "bold"), text_color=TEXT2,
|
||
)
|
||
audit_status_lbl.pack(anchor="w", padx=12, pady=(0, 4))
|
||
|
||
def _render_audit(rep: AuditReport) -> None:
|
||
audit_box.delete("1.0", "end")
|
||
for f in rep.findings:
|
||
mark = "OK " if f.ok else "LEAK"
|
||
line = f"[{mark}] {f.name:<34} {f.value}"
|
||
if f.note:
|
||
line += f" — {f.note}"
|
||
audit_box.insert("end", line + "\n")
|
||
bad = sum(1 for f in rep.findings if not f.ok)
|
||
if rep.overall_ok:
|
||
audit_status_lbl.configure(
|
||
text="CLEAN — no leaks detected on probed surfaces.", text_color=GREEN
|
||
)
|
||
else:
|
||
audit_status_lbl.configure(
|
||
text=f"{bad} leak(s) detected — see report above.", text_color=RED
|
||
)
|
||
|
||
def _run_audit_async() -> None:
|
||
audit_status_lbl.configure(text="Running audit…", text_color=YELLOW)
|
||
audit_box.delete("1.0", "end")
|
||
audit_box.insert("end", "Probing every leak surface in parallel…\n")
|
||
|
||
def work() -> None:
|
||
try:
|
||
rep = run_audit_sync(
|
||
f"http://{svc.settings.listen_addr()}",
|
||
svc.settings.ip_check_url,
|
||
timeout_seconds=min(12.0, svc.settings.validation_timeout_seconds),
|
||
)
|
||
except Exception as e:
|
||
root.after(0, lambda: audit_status_lbl.configure(
|
||
text=f"Audit failed: {e}", text_color=RED))
|
||
return
|
||
root.after(0, lambda: _render_audit(rep))
|
||
|
||
threading.Thread(target=work, daemon=True).start()
|
||
|
||
audit_btn_row = ctk.CTkFrame(audit_card, fg_color="transparent")
|
||
audit_btn_row.pack(fill="x", padx=12, pady=(0, 10))
|
||
_btn(audit_btn_row, "Run audit", _run_audit_async, w=120, h=28,
|
||
fg_color=ACCENT2, hover_color=ACCENT).pack(side="left", padx=(0, 8))
|
||
|
||
_btn(priv_scroll, "Save privacy settings", lambda: _save_settings(verbose=True),
|
||
w=200, h=34, font=(FONT, 12)).pack(anchor="w", padx=8, pady=12)
|
||
|
||
# ─── TAB: SETTINGS ───────────────────────────────────────────────────────
|
||
sf_outer = ctk.CTkScrollableFrame(tab_settings, fg_color=BG,
|
||
scrollbar_button_color=ACCENT)
|
||
sf_outer.pack(fill="both", expand=True)
|
||
|
||
def _section(title: str) -> ctk.CTkFrame:
|
||
ctk.CTkLabel(sf_outer, text=title, font=(FONT, 12, "bold"),
|
||
text_color=CYAN).pack(anchor="w", padx=6, pady=(14, 4))
|
||
f = ctk.CTkFrame(sf_outer, fg_color=CARD, corner_radius=8)
|
||
f.pack(fill="x", padx=6, pady=(0, 4))
|
||
return f
|
||
|
||
entries: dict[str, ctk.CTkEntry] = {}
|
||
|
||
def _fld(parent: Any, label: str, key: str, val: str, tip: str = "") -> None:
|
||
row = ctk.CTkFrame(parent, fg_color="transparent")
|
||
row.pack(fill="x", padx=12, pady=5)
|
||
ctk.CTkLabel(row, text=label, font=(FONT, 11), text_color=TEXT,
|
||
width=230, anchor="w").pack(side="left")
|
||
e = ctk.CTkEntry(row, height=26, font=(FONT, 11),
|
||
fg_color=BG, border_color=ACCENT, width=130)
|
||
e.insert(0, val)
|
||
e.pack(side="left")
|
||
if tip:
|
||
ctk.CTkLabel(row, text=f" {tip}", font=(FONT, 10),
|
||
text_color=TEXT2).pack(side="left")
|
||
entries[key] = e
|
||
|
||
net = _section("Network")
|
||
_fld(net, "Local listener port", "port", str(s.local_port), "(point apps here)")
|
||
_fld(net, "Proxy bypass list", "bypass", s.proxy_bypass, "(semicolons)")
|
||
_fld(net, "IP check URL", "check_url", s.ip_check_url)
|
||
|
||
timing = _section("Timing")
|
||
_fld(timing, "Health check interval (sec)", "health", str(s.health_check_seconds),
|
||
"(how often exit IP is re-verified)")
|
||
_fld(timing, "Full pool refresh (sec)", "refresh", str(s.full_refresh_seconds),
|
||
"(re-fetch + re-validate)")
|
||
|
||
val_sec = _section("Validation")
|
||
_fld(val_sec, "Concurrent validators", "conc", str(s.validation_concurrency))
|
||
_fld(val_sec, "Stop after N valid proxies found", "minpool", str(s.min_pool_size),
|
||
"(early-exit — stop testing once enough found)")
|
||
_fld(val_sec, "Max candidates to test per cycle", "maxc", str(s.max_candidates),
|
||
"(shuffled random sample from fetch)")
|
||
_fld(val_sec, "Per-proxy timeout (sec)", "timeout", str(s.validation_timeout_seconds))
|
||
|
||
sec_sec = _section("Security")
|
||
ks_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
|
||
ks_row.pack(fill="x", padx=12, pady=8)
|
||
ks_var = ctk.BooleanVar(value=s.kill_switch_enabled)
|
||
ctk.CTkCheckBox(
|
||
ks_row,
|
||
text="Firewall kill-switch (block ALL traffic if chain is down — requires Admin)",
|
||
variable=ks_var, font=(FONT, 11),
|
||
fg_color=ACCENT2, hover_color=ACCENT, text_color=TEXT,
|
||
).pack(side="left")
|
||
|
||
# Emergency disengage — for crashes where the kill-switch lingers and
|
||
# the operator needs internet back fast without restarting the app.
|
||
def _emergency_disengage_now() -> None:
|
||
from tkinter import messagebox
|
||
from .firewall import emergency_disengage as _ed, is_engaged as _ie
|
||
if not _ie():
|
||
messagebox.showinfo("Kill-switch", "No Proxy God firewall rules detected — nothing to disengage.")
|
||
return
|
||
if not messagebox.askyesno(
|
||
"Emergency disengage",
|
||
"Remove ALL Proxy God firewall rules and restore normal outbound traffic?\n\n"
|
||
"Use this only when the chain has dropped and the kill-switch is "
|
||
"blocking everything.",
|
||
):
|
||
return
|
||
_ed()
|
||
messagebox.showinfo("Kill-switch", "Firewall rules removed.")
|
||
_log("Emergency disengage: firewall rules removed by operator.")
|
||
|
||
ks_btn_row = ctk.CTkFrame(sec_sec, fg_color="transparent")
|
||
ks_btn_row.pack(fill="x", padx=12, pady=(0, 8))
|
||
_btn(
|
||
ks_btn_row, "⚠ Emergency disengage firewall now",
|
||
_emergency_disengage_now,
|
||
w=320, h=28,
|
||
fg_color="#7f1d1d", hover_color="#991b1b",
|
||
).pack(side="left")
|
||
|
||
def _apply_point_and_shoot() -> None:
|
||
"""Simple safe defaults: secure + low-friction launch profile."""
|
||
use_manual_var.set(True)
|
||
mode_var.set("auto")
|
||
elite_var.set(False)
|
||
ks_var.set(True)
|
||
dns_flush_var.set(True)
|
||
mac_var.set(False)
|
||
mac_rotate_var.set(False)
|
||
host_var.set(False)
|
||
ipv6_var.set(False)
|
||
webrtc_var.set(True)
|
||
lan_var.set(False)
|
||
telemetry_var.set(False)
|
||
persona_var.set(PERSONA_LABELS["blend_windows_chrome"])
|
||
cookie_var.set(COOKIE_LABELS["block_third_party"])
|
||
br_force_proxy_var.set(True)
|
||
br_disable_webrtc_var.set(True)
|
||
br_rfp_var.set(True)
|
||
br_telemetry_var.set(True)
|
||
br_fpi_var.set(True)
|
||
br_strict_tp_var.set(True)
|
||
br_tz_utc_var.set(True)
|
||
br_clear_var.set(True)
|
||
br_disposable_var.set(False)
|
||
br_kill_drop_var.set(True)
|
||
br_auto_relaunch_var.set(False)
|
||
br_lock_profile_var.set(True)
|
||
if not firefox_path_var.get().strip():
|
||
firefox_path_var.set(default_firefox_path())
|
||
if not firefox_profile_var.get().strip():
|
||
firefox_profile_var.set(default_profile_dir())
|
||
_save_settings(verbose=False)
|
||
_log("⚡ Point-and-shoot preset applied.")
|
||
|
||
ready_btn = _btn(
|
||
topbar,
|
||
"⚡ Ready",
|
||
_apply_point_and_shoot,
|
||
w=78,
|
||
fg_color="#0f766e",
|
||
hover_color="#0d9488",
|
||
)
|
||
ready_btn.pack(side="left", padx=(8, 2))
|
||
|
||
ctk.CTkFrame(sf_outer, fg_color="transparent", height=4).pack()
|
||
_btn(sf_outer, "Save All Settings",
|
||
lambda: _save_settings(verbose=True), w=220, h=36,
|
||
font=(FONT, 13)).pack(anchor="w", padx=6, pady=8)
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# SAVE SETTINGS
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
def _save_settings(verbose: bool = False) -> None:
|
||
try:
|
||
src_raw = sources_box.get("1.0", "end").strip()
|
||
src_list = [ln.strip() for ln in src_raw.splitlines()
|
||
if ln.strip().startswith("http")]
|
||
|
||
n_hops = max(1, len(manual_chain)) if manual_chain else 1
|
||
|
||
ns = Settings(
|
||
local_host=LISTEN_HOST,
|
||
local_port=int(entries["port"].get().strip()),
|
||
chain_length=n_hops,
|
||
obfuscation_mode=mode_var.get(),
|
||
use_pinned_chain=bool(use_manual_var.get()),
|
||
pinned_chain=list(manual_chain),
|
||
manual_exit_proxy=_exit_url(),
|
||
health_check_seconds=min(3600, max(10, int(entries["health"].get().strip()))),
|
||
full_refresh_seconds=min(86400, max(60, int(entries["refresh"].get().strip()))),
|
||
validation_concurrency=max(1, int(entries["conc"].get().strip())),
|
||
min_pool_size=max(3, int(entries["minpool"].get().strip())),
|
||
max_candidates=max(10, int(entries["maxc"].get().strip())),
|
||
validation_timeout_seconds=min(120.0, max(2.0, float(entries["timeout"].get().strip()))),
|
||
prefer_elite=bool(elite_var.get()),
|
||
kill_switch_enabled=bool(ks_var.get()),
|
||
proxy_bypass=entries["bypass"].get().strip() or Settings().proxy_bypass,
|
||
sources=src_list or Settings().sources,
|
||
ip_check_url=entries["check_url"].get().strip() or Settings().ip_check_url,
|
||
mac_spoof_enabled=bool(mac_var.get()),
|
||
mac_rotate_on_chain_rotate=bool(mac_rotate_var.get()),
|
||
spoof_hostname_enabled=bool(host_var.get()),
|
||
flush_dns_on_rotate=bool(dns_flush_var.get()),
|
||
disable_ipv6_while_active=bool(ipv6_var.get()),
|
||
harden_webrtc_enabled=bool(webrtc_var.get()),
|
||
lan_lockdown_enabled=bool(lan_var.get()),
|
||
telemetry_kill_enabled=bool(telemetry_var.get()),
|
||
firefox_path=firefox_path_var.get().strip(),
|
||
firefox_profile_dir=firefox_profile_var.get().strip(),
|
||
browser_clear_on_close=bool(br_clear_var.get()),
|
||
browser_disposable_profile=bool(br_disposable_var.get()),
|
||
browser_kill_on_chain_drop=bool(br_kill_drop_var.get()),
|
||
browser_auto_relaunch=bool(br_auto_relaunch_var.get()),
|
||
browser_lock_managed_profile=bool(br_lock_profile_var.get()),
|
||
browser_force_proxy=bool(br_force_proxy_var.get()),
|
||
browser_disable_webrtc=bool(br_disable_webrtc_var.get()),
|
||
browser_resist_fingerprinting=bool(br_rfp_var.get()),
|
||
browser_disable_telemetry=bool(br_telemetry_var.get()),
|
||
browser_first_party_isolation=bool(br_fpi_var.get()),
|
||
browser_strict_tracking_protection=bool(br_strict_tp_var.get()),
|
||
browser_timezone_utc=bool(br_tz_utc_var.get()),
|
||
browser_persona=_persona_key(),
|
||
browser_cookie_mode=_cookie_key(),
|
||
)
|
||
if not (1 <= ns.local_port <= 65535):
|
||
raise ValueError("Port must be 1-65535")
|
||
svc.update_settings(ns)
|
||
save_settings(ns)
|
||
proxy_lbl.configure(text=f"{LISTEN_HOST}:{ns.local_port}")
|
||
if verbose:
|
||
_log("✓ Settings saved.")
|
||
except Exception as e:
|
||
_log(f"⚠ Settings error: {e!s}")
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# STATUS REFRESHERS
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
def _refresh_sysproxy() -> None:
|
||
on = is_system_proxy_set()
|
||
sys_lbl.configure(text="SYS:ON" if on else "SYS:OFF",
|
||
text_color=GREEN if on else DIM)
|
||
|
||
def _refresh_fw(engaged: bool | None = None) -> None:
|
||
if engaged is None:
|
||
engaged = fw_is_engaged()
|
||
fw_lbl.configure(text="FW:ON" if engaged else "FW:OFF",
|
||
text_color=GREEN if engaged else DIM)
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# MESSAGE PUMP
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
def _handle(m: dict[str, Any]) -> None:
|
||
t = m.get("type")
|
||
|
||
if t == "log":
|
||
_log(str(m.get("text", "")))
|
||
|
||
elif t == "state":
|
||
running = bool(m.get("running"))
|
||
service_running[0] = running
|
||
status_dot.configure(text_color=GREEN if running else RED)
|
||
status_txt.configure(text="RUNNING" if running else "STOPPED")
|
||
_refresh_sysproxy()
|
||
if running:
|
||
tray.set_state("yellow", exit_ip=None)
|
||
if not running:
|
||
prog_bar.set(0)
|
||
phase_lbl.configure(text="idle")
|
||
countdown_lbl.configure(text="next: —", text_color=TEXT2)
|
||
tray.set_state("gray", exit_ip=None)
|
||
v_exit_ip.set("—")
|
||
v_rotation.set("0")
|
||
|
||
elif t == "phase":
|
||
ph = str(m.get("phase", ""))
|
||
phase_labels = {
|
||
"validate": "validate",
|
||
"fetch": "fetch",
|
||
"running": "running",
|
||
"exit_check": "fixed exit",
|
||
"verify_chain": "verify chain",
|
||
"gost_start": "starting chain",
|
||
}
|
||
phase_lbl.configure(text=phase_labels.get(ph, ph or "idle"))
|
||
if ph == "validate":
|
||
prog_bar.configure(progress_color=YELLOW)
|
||
elif ph == "fetch":
|
||
prog_bar.configure(progress_color=ORANGE)
|
||
prog_bar.set(0.15)
|
||
elif ph == "exit_check":
|
||
prog_bar.configure(progress_color=YELLOW)
|
||
prog_bar.set(0.96)
|
||
elif ph == "gost_start":
|
||
prog_bar.configure(progress_color=GREEN)
|
||
prog_bar.set(0.985)
|
||
elif ph == "verify_chain":
|
||
prog_bar.configure(progress_color=GREEN)
|
||
prog_bar.set(0.98)
|
||
elif ph == "running":
|
||
prog_bar.configure(progress_color=GREEN)
|
||
prog_bar.set(1.0)
|
||
elif ph in ("idle", ""):
|
||
prog_bar.set(0)
|
||
|
||
elif t == "validate_progress":
|
||
d, tot = int(m.get("done", 0)), max(1, int(m.get("total", 1)))
|
||
prog_bar.set(d / tot)
|
||
|
||
elif t == "pool":
|
||
cnt = int(m.get("count", 0))
|
||
pool_lbl.configure(text=f"pool: {cnt}")
|
||
v_pool.set(str(cnt))
|
||
|
||
elif t == "real_ip":
|
||
ip = str(m.get("ip", "—"))
|
||
your_ip_lbl.configure(text=f"Direct IP: {ip}")
|
||
v_real_ip.set(ip)
|
||
last_chain_state["direct_ip"] = ip
|
||
root.after(300, lambda: _refresh_chain_map(silent=True))
|
||
|
||
elif t == "vpn":
|
||
nonlocal outer_hop_label
|
||
if m.get("active"):
|
||
outer_hop_label = str(m.get("label", "VPN"))[:12]
|
||
vpn_status_lbl.configure(
|
||
text=f"VPN: {m.get('label', 'Active')}"
|
||
+ (f" ({m.get('adapter')})" if m.get("adapter") else ""),
|
||
text_color=GREEN,
|
||
)
|
||
leak_mode_lbl.configure(
|
||
text=f"Leak check: {m.get('leak_mode', 'VPN-aware')}",
|
||
text_color=TEXT2,
|
||
)
|
||
else:
|
||
outer_hop_label = "Direct"
|
||
vpn_status_lbl.configure(
|
||
text="VPN: not detected (direct connection)", text_color=YELLOW,
|
||
)
|
||
leak_mode_lbl.configure(
|
||
text=f"Leak check: {m.get('leak_mode', 'strict')}",
|
||
text_color=TEXT2,
|
||
)
|
||
|
||
elif t == "firewall":
|
||
_refresh_fw(bool(m.get("engaged")))
|
||
|
||
elif t == "rotation":
|
||
n = str(m.get("n", 0))
|
||
rotation_lbl.configure(text=f"rot: {n}")
|
||
v_rotation.set(n)
|
||
|
||
elif t == "countdown":
|
||
secs = int(m.get("secs", 0))
|
||
if secs > 0:
|
||
m_s, s_s = divmod(secs, 60)
|
||
countdown_lbl.configure(
|
||
text=f"next: {m_s}m{s_s:02d}s",
|
||
text_color=YELLOW if secs < 30 else TEXT2,
|
||
)
|
||
else:
|
||
countdown_lbl.configure(text="next: checking…", text_color=YELLOW)
|
||
|
||
elif t == "hops":
|
||
hops = [str(x) for x in (m.get("hops") or [])]
|
||
status = str(m.get("status", "connecting"))
|
||
exit_ip = m.get("exit_ip")
|
||
exit_s = str(exit_ip) if exit_ip else None
|
||
# If the live check hasn't resolved yet, show the explicit final-hop IP
|
||
# (when available) so tunnel/fixed-exit setups still expose the egress node.
|
||
if not exit_s and hops:
|
||
exit_s = _extract_ip_host(hops[-1])
|
||
fix = normalize_proxy_url(svc.settings.manual_exit_proxy)
|
||
fixed_last = bool(fix) and not svc.settings.use_pinned_chain
|
||
_render_chain(hops, status, exit_s, fixed_last=fixed_last)
|
||
_refresh_sysproxy()
|
||
tray_color = {"healthy": "green", "dead": "red", "connecting": "yellow"}.get(
|
||
status, "yellow"
|
||
)
|
||
tray.set_state(tray_color, exit_ip=exit_s)
|
||
prev_exit = v_exit_ip.get()
|
||
v_exit_ip.set(exit_s if exit_s else "—")
|
||
last_chain_state["hops"] = hops
|
||
last_chain_state["status"] = status
|
||
last_chain_state["exit_ip"] = exit_s or ""
|
||
root.after(250, lambda: _refresh_chain_map(silent=True))
|
||
if status == "healthy" and exit_s and exit_s != prev_exit:
|
||
root.after(400, lambda: _refresh_exit_intel(silent=True))
|
||
elif status != "healthy":
|
||
_apply_intel(None)
|
||
if status == "dead" and browser.is_running() and bool(br_kill_drop_var.get()):
|
||
browser.stop(dispose=bool(br_disposable_var.get()))
|
||
_refresh_browser_status()
|
||
_log("Browser stopped because chain dropped (Kill-on-drop enabled).")
|
||
|
||
def _pump() -> None:
|
||
try:
|
||
while True:
|
||
_handle(ui_q.get_nowait())
|
||
except queue.Empty:
|
||
pass
|
||
if browser_should_run[0] and not browser.is_running():
|
||
cfg = _browser_cfg()
|
||
now = time.monotonic()
|
||
max_retries = max(0, int(svc.settings.max_browser_relaunches))
|
||
if cfg.auto_relaunch and (now - browser_last_launch_ts[0]) >= 3.0:
|
||
if browser_relaunch_count[0] >= max_retries:
|
||
browser_should_run[0] = False
|
||
_log(
|
||
f"Browser auto-relaunch disabled after "
|
||
f"{browser_relaunch_count[0]} attempts — fix the profile "
|
||
"or restart manually."
|
||
)
|
||
elif _launch_browser_with_cfg(cfg):
|
||
browser_last_launch_ts[0] = now
|
||
browser_relaunch_count[0] += 1
|
||
root.after(80, _pump)
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# CLOSE / MINIMIZE
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
def _close() -> None:
|
||
_cancel_pulse()
|
||
browser_should_run[0] = False
|
||
browser.stop(dispose=bool(br_disposable_var.get()))
|
||
tray.stop()
|
||
svc.stop()
|
||
clear_system_proxy()
|
||
if is_admin() and fw_is_engaged():
|
||
fw_disengage()
|
||
root.destroy()
|
||
|
||
def _on_window_x() -> None:
|
||
"""X-button handler: warn the operator that the chain is still active
|
||
before silently going to tray, then offer to fully quit instead."""
|
||
chain_alive = bool(svc.current_chain) or fw_is_engaged()
|
||
if chain_alive:
|
||
from tkinter import messagebox
|
||
choice = messagebox.askyesnocancel(
|
||
"Proxy God still running",
|
||
"The proxy chain and/or kill-switch are still active.\n\n"
|
||
" • Yes — minimize to tray (chain keeps running)\n"
|
||
" • No — fully quit (stop chain + remove firewall rules)\n"
|
||
" • Cancel — keep window open",
|
||
)
|
||
if choice is None:
|
||
return
|
||
if choice is False:
|
||
_close()
|
||
return
|
||
root.withdraw()
|
||
|
||
root.protocol("WM_DELETE_WINDOW", _on_window_x)
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
# INIT
|
||
# ─────────────────────────────────────────────────────────────────────────
|
||
_refresh_boot()
|
||
_refresh_sysproxy()
|
||
_refresh_fw()
|
||
_refresh_vpn_ui()
|
||
_refresh_browser_status()
|
||
_refresh_fingerprint()
|
||
_log("─" * 60)
|
||
_log("Proxy God v2 — ready.")
|
||
_log(f"Admin: {'YES — kill-switch + privacy hardening available' if is_admin() else 'NO — run as Admin for full privacy tools'}")
|
||
_log(f"Log file: {LOG_PATH}")
|
||
_log("Press START to fetch, validate and chain proxies.")
|
||
_log("Chain Builder → build your chain, Test entire chain, then Start.")
|
||
_log("Browser tab → launch hardened Firefox profile that follows your chain.")
|
||
_log("Signup Prep → open signup pages with autofill (Google / Proton / HydraProxy / custom).")
|
||
_log("Privacy tab → MAC, hostname, IPv6, WebRTC, fingerprint audit, DNS checks.")
|
||
_log("Works with or without VPN — leak detection adapts automatically.")
|
||
_log("─" * 60)
|
||
_pump()
|
||
root.mainloop()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|