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