From 67f4f331653c5b6f4e49a2c4c6acd059f3aef9a3 Mon Sep 17 00:00:00 2001 From: frankovo Date: Tue, 28 Apr 2026 21:19:25 +0200 Subject: [PATCH 1/5] feat(core): feat(core): add doh, dot, dnssec support to query engine - add queryprotocol enum (plain, doh, dot) - add dnssec_failed to querystatus - add protocol and dnssec_validated fields to dnsqueryresult - add enable_dnssec and enforce_dnssec to dnsqueryengine - set do bit via use_edns() in query_single when enable_dnssec=true - add query_single_doh() using httpx with http/2, tls enforced - add query_single_dot() using asyncio with rfc7858 length-prefix framing - add protocol dispatch to run_benchmark() via protocol param --- src/dns_benchmark/core.py | 647 ++++++++++++++++++++++++++++++++++---- 1 file changed, 579 insertions(+), 68 deletions(-) diff --git a/src/dns_benchmark/core.py b/src/dns_benchmark/core.py index c4e99ef..01d7a4d 100644 --- a/src/dns_benchmark/core.py +++ b/src/dns_benchmark/core.py @@ -3,6 +3,8 @@ import asyncio import ipaddress import json +import ssl +import struct import time import uuid from collections import defaultdict @@ -14,6 +16,13 @@ import click import dns.asyncresolver import dns.exception +import dns.flags +import dns.message +import dns.name + +# import dns.query +import dns.rdatatype +import httpx import idna from dns_benchmark.utils.messages import warning @@ -26,6 +35,13 @@ class QueryStatus(Enum): SERVFAIL = "servfail" CONNECTION_REFUSED = "connection_refused" UNKNOWN_ERROR = "unknown_error" + DNSSEC_FAILED = "dnssec_failed" + + +class QueryProtocol(Enum): + PLAIN = "plain" + DOH = "doh" + DOT = "dot" @dataclass @@ -45,12 +61,15 @@ class DNSQueryResult: error_message: Optional[str] = None attempt_number: int = 1 cache_hit: bool = False + dnssec_validated: bool = False + protocol: QueryProtocol = QueryProtocol.PLAIN iteration: int = 1 # which iteration this query belongs to query_id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) def to_dict(self) -> Dict[str, Any]: d = asdict(self) d["status"] = self.status.value + d["protocol"] = self.protocol.value return d @@ -65,6 +84,8 @@ def __init__( enable_cache: bool = False, retry_backoff_multiplier: float = 0.1, retry_backoff_base: float = 2.0, + enable_dnssec: bool = False, + enforce_dnssec: bool = False, # True when --dnssec-validate passed ) -> None: self.max_concurrent_queries = max_concurrent_queries self.timeout = timeout @@ -80,6 +101,8 @@ def __init__( self.retry_backoff_multiplier = retry_backoff_multiplier self.retry_backoff_base = retry_backoff_base self.failed_resolvers: Dict[str, int] = defaultdict(int) + self.enable_dnssec = enable_dnssec + self.enforce_dnssec = enforce_dnssec async def _ensure_async_primitives(self) -> None: """Create asyncio primitives when running inside an event loop.""" @@ -151,6 +174,9 @@ async def query_single( resolver.timeout = self.timeout resolver.lifetime = self.timeout + if self.enable_dnssec: + resolver.use_edns(0, dns.flags.DO, 1232) + response = await resolver.resolve( domain, record_type, raise_on_no_answer=False ) @@ -165,6 +191,17 @@ async def query_single( ) ttl = response.rrset.ttl if response.rrset else None + # DNSSEC: always read AD flag, enforce only if requested + ad_flag = False + try: + ad_flag = bool(response.response.flags & dns.flags.AD) + except AttributeError: + pass + + dnssec_status = QueryStatus.SUCCESS + if self.enforce_dnssec and not ad_flag: + dnssec_status = QueryStatus.DNSSEC_FAILED + result = DNSQueryResult( resolver_ip=resolver_ip, resolver_name=resolver_name, @@ -173,12 +210,14 @@ async def query_single( start_time=start_time, end_time=end_time, latency_ms=latency_ms, - status=QueryStatus.SUCCESS, + status=dnssec_status, answers=answers, ttl=ttl, attempt_number=attempt + 1, cache_hit=False, iteration=iteration, + dnssec_validated=ad_flag, + protocol=QueryProtocol.PLAIN, ) # Cache successful result @@ -344,6 +383,347 @@ async def query_single( await self._update_progress() return result + async def query_single_doh( + self, + resolver_ip: str, + resolver_name: str, + domain: str, + doh_url: str, + record_type: str = "A", + iteration: int = 1, + ) -> DNSQueryResult: + """Execute a single DNS-over-HTTPS query.""" + + await self._ensure_async_primitives() + assert self.semaphore is not None + + start_time = time.time() + + for attempt in range(self.max_retries + 1): + try: + async with self.semaphore: + start_time = time.time() + + # Build DNS wire-format query + qname = dns.name.from_text(domain) + rdtype = dns.rdatatype.from_text(record_type) + request = dns.message.make_query(qname, rdtype) + if self.enable_dnssec: + request.use_edns(ednsflags=dns.flags.DO) + wire = request.to_wire() + + async with httpx.AsyncClient( + http2=True, + timeout=self.timeout, + verify=True, # enforce TLS — never disable + ) as client: + response_raw = await client.post( + doh_url, + content=wire, + headers={ + "Content-Type": "application/dns-message", + "Accept": "application/dns-message", + }, + ) + response_raw.raise_for_status() + + end_time = time.time() + latency_ms = (end_time - start_time) * 1000 + + dns_response = dns.message.from_wire(response_raw.content) + answers = [ + str(rdata) for rrset in dns_response.answer for rdata in rrset + ] + ttl = dns_response.answer[0].ttl if dns_response.answer else None + + ad_flag = bool(dns_response.flags & dns.flags.AD) + dnssec_status = QueryStatus.SUCCESS + if self.enforce_dnssec and not ad_flag: + dnssec_status = QueryStatus.DNSSEC_FAILED + + result = DNSQueryResult( + resolver_ip=resolver_ip, + resolver_name=resolver_name, + domain=domain, + record_type=record_type, + start_time=start_time, + end_time=end_time, + latency_ms=latency_ms, + status=dnssec_status, + answers=answers, + ttl=ttl, + attempt_number=attempt + 1, + cache_hit=False, + iteration=iteration, + dnssec_validated=ad_flag, + protocol=QueryProtocol.DOH, + ) + await self._update_progress() + return result + + except httpx.TimeoutException: + if attempt == self.max_retries: + end_time = time.time() + async with self._lock: # type: ignore[union-attr] + self.failed_resolvers[resolver_ip] += 1 + result = DNSQueryResult( + resolver_ip=resolver_ip, + resolver_name=resolver_name, + domain=domain, + record_type=record_type, + start_time=start_time, + end_time=time.time(), + latency_ms=(time.time() - start_time) * 1000, + status=QueryStatus.TIMEOUT, + answers=[], + ttl=None, + error_message="DoH timeout", + attempt_number=attempt + 1, + cache_hit=False, + iteration=iteration, + protocol=QueryProtocol.DOH, + ) + await self._update_progress() + return result + await asyncio.sleep( + self.retry_backoff_base**attempt * self.retry_backoff_multiplier + ) + + except Exception as e: + if attempt == self.max_retries: + end_time = time.time() + async with self._lock: # type: ignore[union-attr] + self.failed_resolvers[resolver_ip] += 1 + result = DNSQueryResult( + resolver_ip=resolver_ip, + resolver_name=resolver_name, + domain=domain, + record_type=record_type, + start_time=start_time, + end_time=end_time, + latency_ms=(end_time - start_time) * 1000, + status=QueryStatus.UNKNOWN_ERROR, + answers=[], + ttl=None, + error_message=str(e), + attempt_number=attempt + 1, + cache_hit=False, + iteration=iteration, + protocol=QueryProtocol.DOH, + ) + await self._update_progress() + return result + await asyncio.sleep( + self.retry_backoff_base**attempt * self.retry_backoff_multiplier + ) + + # unreachable fallback + return DNSQueryResult( + resolver_ip=resolver_ip, + resolver_name=resolver_name, + domain=domain, + record_type=record_type, + start_time=start_time, + end_time=time.time(), + latency_ms=0.0, + status=QueryStatus.UNKNOWN_ERROR, + answers=[], + ttl=None, + error_message="Exhausted retries", + cache_hit=False, + iteration=iteration, + protocol=QueryProtocol.DOH, + ) + + async def query_single_dot( + self, + resolver_ip: str, + resolver_name: str, + domain: str, + record_type: str = "A", + port: int = 853, + iteration: int = 1, + ) -> DNSQueryResult: + """Execute a single DNS-over-TLS query.""" + + await self._ensure_async_primitives() + assert self.semaphore is not None + + start_time = time.time() + + for attempt in range(self.max_retries + 1): + try: + async with self.semaphore: + start_time = time.time() + + # Build wire-format query with length prefix (RFC 7858) + qname = dns.name.from_text(domain) + rdtype = dns.rdatatype.from_text(record_type) + request = dns.message.make_query(qname, rdtype) + if self.enable_dnssec: + request.use_edns(ednsflags=dns.flags.DO) + wire = request.to_wire() + # 2-byte length prefix required by DoT spec + prefixed = struct.pack("!H", len(wire)) + wire + + ssl_ctx = ssl.create_default_context() + # enforce cert validation — never bypass for security tool + ssl_ctx.verify_mode = ssl.CERT_REQUIRED + ssl_ctx.check_hostname = True + + reader, writer = await asyncio.wait_for( + asyncio.open_connection(resolver_ip, port, ssl=ssl_ctx), + timeout=self.timeout, + ) + try: + writer.write(prefixed) + await writer.drain() + + # Read 2-byte length prefix then full message + raw_len = await asyncio.wait_for( + reader.readexactly(2), timeout=self.timeout + ) + msg_len = struct.unpack("!H", raw_len)[0] + raw_msg = await asyncio.wait_for( + reader.readexactly(msg_len), timeout=self.timeout + ) + finally: + writer.close() + try: + await writer.wait_closed() + except Exception: + pass + + end_time = time.time() + latency_ms = (end_time - start_time) * 1000 + + dns_response = dns.message.from_wire(raw_msg) + answers = [ + str(rdata) for rrset in dns_response.answer for rdata in rrset + ] + ttl = dns_response.answer[0].ttl if dns_response.answer else None + + ad_flag = bool(dns_response.flags & dns.flags.AD) + dnssec_status = QueryStatus.SUCCESS + if self.enforce_dnssec and not ad_flag: + dnssec_status = QueryStatus.DNSSEC_FAILED + + result = DNSQueryResult( + resolver_ip=resolver_ip, + resolver_name=resolver_name, + domain=domain, + record_type=record_type, + start_time=start_time, + end_time=end_time, + latency_ms=latency_ms, + status=dnssec_status, + answers=answers, + ttl=ttl, + attempt_number=attempt + 1, + cache_hit=False, + iteration=iteration, + dnssec_validated=ad_flag, + protocol=QueryProtocol.DOT, + ) + await self._update_progress() + return result + + except asyncio.TimeoutError: + if attempt == self.max_retries: + async with self._lock: # type: ignore[union-attr] + self.failed_resolvers[resolver_ip] += 1 + result = DNSQueryResult( + resolver_ip=resolver_ip, + resolver_name=resolver_name, + domain=domain, + record_type=record_type, + start_time=start_time, + end_time=time.time(), + latency_ms=(time.time() - start_time) * 1000, + status=QueryStatus.TIMEOUT, + answers=[], + ttl=None, + error_message="DoT timeout", + attempt_number=attempt + 1, + cache_hit=False, + iteration=iteration, + protocol=QueryProtocol.DOT, + ) + await self._update_progress() + return result + await asyncio.sleep( + self.retry_backoff_base**attempt * self.retry_backoff_multiplier + ) + + except ssl.SSLError as e: + # SSL errors are not retryable + async with self._lock: # type: ignore[union-attr] + self.failed_resolvers[resolver_ip] += 1 + result = DNSQueryResult( + resolver_ip=resolver_ip, + resolver_name=resolver_name, + domain=domain, + record_type=record_type, + start_time=start_time, + end_time=time.time(), + latency_ms=(time.time() - start_time) * 1000, + status=QueryStatus.CONNECTION_REFUSED, + answers=[], + ttl=None, + error_message=f"TLS error: {e}", + attempt_number=attempt + 1, + cache_hit=False, + iteration=iteration, + protocol=QueryProtocol.DOT, + ) + await self._update_progress() + return result + + except Exception as e: + if attempt == self.max_retries: + async with self._lock: # type: ignore[union-attr] + self.failed_resolvers[resolver_ip] += 1 + result = DNSQueryResult( + resolver_ip=resolver_ip, + resolver_name=resolver_name, + domain=domain, + record_type=record_type, + start_time=start_time, + end_time=time.time(), + latency_ms=(time.time() - start_time) * 1000, + status=QueryStatus.UNKNOWN_ERROR, + answers=[], + ttl=None, + error_message=str(e), + attempt_number=attempt + 1, + cache_hit=False, + iteration=iteration, + protocol=QueryProtocol.DOT, + ) + await self._update_progress() + return result + await asyncio.sleep( + self.retry_backoff_base**attempt * self.retry_backoff_multiplier + ) + + # unreachable fallback + return DNSQueryResult( + resolver_ip=resolver_ip, + resolver_name=resolver_name, + domain=domain, + record_type=record_type, + start_time=start_time, + end_time=time.time(), + latency_ms=0.0, + status=QueryStatus.UNKNOWN_ERROR, + answers=[], + ttl=None, + error_message="Exhausted retries", + cache_hit=False, + iteration=iteration, + protocol=QueryProtocol.DOT, + ) + async def run_benchmark( self, resolvers: List[Dict[str, str]], @@ -353,6 +733,8 @@ async def run_benchmark( warmup: bool = False, warmup_fast: bool = False, use_cache: bool = False, + protocol: QueryProtocol = QueryProtocol.PLAIN, + doh_urls: Optional[Dict[str, str]] = None, # resolver_ip -> doh_url ) -> List[DNSQueryResult]: """Run benchmark across all resolvers and domains. @@ -404,14 +786,33 @@ async def run_benchmark( for resolver in resolvers: for domain in domains: for record_type in record_types: - task = self.query_single( - resolver_ip=resolver["ip"], - resolver_name=resolver["name"], - domain=domain, - record_type=record_type, - use_cache=use_cache, - iteration=iteration + 1, # 1-indexed for readability - ) + if protocol == QueryProtocol.DOH: + url = (doh_urls or {}).get(resolver["ip"], "") + task = self.query_single_doh( + resolver_ip=resolver["ip"], + resolver_name=resolver["name"], + domain=domain, + doh_url=url, + record_type=record_type, + iteration=iteration + 1, + ) + elif protocol == QueryProtocol.DOT: + task = self.query_single_dot( + resolver_ip=resolver["ip"], + resolver_name=resolver["name"], + domain=domain, + record_type=record_type, + iteration=iteration + 1, + ) + else: + task = self.query_single( + resolver_ip=resolver["ip"], + resolver_name=resolver["name"], + domain=domain, + record_type=record_type, + use_cache=use_cache, + iteration=iteration + 1, + ) tasks.append(task) results = await asyncio.gather(*tasks) @@ -497,6 +898,7 @@ class ResolverManager: "features": ["DNSSEC", "Filtering", "Anycast", "DoH", "DoT"], "description": "Fast privacy-focused DNS with malware protection", "country": "Global", + "doh_url": "https://cloudflare-dns.com/dns-query", }, { "name": "Cloudflare Family", @@ -508,6 +910,7 @@ class ResolverManager: "features": ["Malware Blocking", "Adult Content Blocking", "DNSSEC"], "description": "Family-friendly DNS with malware and adult content blocking", "country": "Global", + "doh_url": "https://family.cloudflare-dns.com/dns-query", }, { "name": "Google", @@ -519,6 +922,7 @@ class ResolverManager: "features": ["Anycast", "Global Infrastructure", "DoH"], "description": "Google's public DNS with global anycast network", "country": "Global", + "doh_url": "https://dns.google/dns-query", }, { "name": "Quad9", @@ -530,6 +934,7 @@ class ResolverManager: "features": ["Malware Blocking", "Phishing Protection", "DNSSEC"], "description": "Security-focused DNS with threat intelligence", "country": "Global", + "doh_url": "https://dns.quad9.net/dns-query", }, { "name": "OpenDNS", @@ -541,6 +946,7 @@ class ResolverManager: "features": ["Content Filtering", "Phishing Protection", "Customizable"], "description": "Cisco's secure DNS with content filtering", "country": "Global", + "doh_url": "https://doh.opendns.com/dns-query", }, { "name": "OpenDNS Family", @@ -552,28 +958,7 @@ class ResolverManager: "features": ["Adult Content Blocking", "Malware Protection"], "description": "FamilyShield with pre-configured adult content blocking", "country": "Global", - }, - { - "name": "Comodo Secure", - "provider": "Comodo", - "ip": "8.26.56.26", - "ipv6": "", - "type": "public", - "category": "security", - "features": ["Malware Protection", "Phishing Protection"], - "description": "Comodo's secure DNS with threat protection", - "country": "USA", - }, - { - "name": "Verisign", - "provider": "Verisign", - "ip": "64.6.64.6", - "ipv6": "2620:74:1b::1:1", - "type": "public", - "category": "reliability", - "features": ["Stability", "DNSSEC", "Anycast"], - "description": "Verisign public DNS focused on stability and reliability", - "country": "USA", + "doh_url": "https://doh.familyshield.opendns.com/dns-query", }, { "name": "AdGuard", @@ -585,6 +970,7 @@ class ResolverManager: "features": ["Ad Blocking", "Tracker Blocking", "Malware Protection"], "description": "Privacy-focused DNS with ad and tracker blocking", "country": "Cyprus", + "doh_url": "https://dns.adguard.com/dns-query", }, { "name": "AdGuard Family", @@ -596,6 +982,7 @@ class ResolverManager: "features": ["Ad Blocking", "Adult Content Blocking", "Safe Search"], "description": "Family protection with ad blocking and safe search", "country": "Cyprus", + "doh_url": "https://dns-family.adguard.com/dns-query", }, { "name": "CleanBrowsing", @@ -607,6 +994,7 @@ class ResolverManager: "features": ["Adult Content Blocking", "Safe Search", "Malware Protection"], "description": "Content filtering DNS for families", "country": "USA", + "doh_url": "https://doh.cleanbrowsing.org/doh/family-filter/", }, { "name": "Yandex", @@ -618,28 +1006,7 @@ class ResolverManager: "features": ["Regional Optimization", "Safe Search"], "description": "Yandex DNS optimized for Russian and CIS regions", "country": "Russia", - }, - { - "name": "DNS.WATCH", - "provider": "DNS.WATCH", - "ip": "84.200.69.80", - "ipv6": "2001:1608:10:25::1c04:b12f", - "type": "public", - "category": "privacy", - "features": ["No Filtering", "No Logging", "Net Neutrality"], - "description": "German DNS provider with no filtering and strong privacy", - "country": "Germany", - }, - { - "name": "Level3", - "provider": "CenturyLink", - "ip": "4.2.2.1", - "ipv6": "", - "type": "public", - "category": "legacy", - "features": ["Reliability", "Long History"], - "description": "One of the original public DNS services", - "country": "USA", + "doh_url": "https://dns.yandex.net/dns-query", }, { "name": "Neustar", @@ -651,6 +1018,7 @@ class ResolverManager: "features": ["Malware Protection", "Phishing Protection", "Performance"], "description": "Neustar's security-focused recursive DNS", "country": "USA", + "doh_url": "https://dns.neustar/dns-query", }, { "name": "SafeDNS", @@ -662,17 +1030,7 @@ class ResolverManager: "features": ["Content Filtering", "Malware Protection"], "description": "SafeDNS with content filtering capabilities", "country": "UK", - }, - { - "name": "Norton ConnectSafe", - "provider": "Norton", - "ip": "199.85.126.10", - "ipv6": "", - "type": "public", - "category": "security", - "features": ["Malware Protection", "Phishing Protection"], - "description": "Norton's security-focused DNS service", - "country": "USA", + "doh_url": "https://doh.safedns.com/dns-query", }, { "name": "ControlD", @@ -684,6 +1042,7 @@ class ResolverManager: "features": ["Custom Filtering", "Analytics", "DoH"], "description": "Customizable DNS with extensive filtering options", "country": "Canada", + "doh_url": "https://freedns.controld.com/p0", }, { "name": "Alternate DNS", @@ -695,6 +1054,7 @@ class ResolverManager: "features": ["Ad Blocking", "Tracker Blocking"], "description": "Alternative DNS focused on privacy and ad blocking", "country": "USA", + "doh_url": "https://dns.alternate-dns.com/dns-query", }, { "name": "CZ.NIC", @@ -706,7 +1066,64 @@ class ResolverManager: "features": ["DNSSEC", "Local Optimization"], "description": "Czech NIC's public DNS service", "country": "Czech Republic", - }, + "doh_url": "https://odvr.nic.cz/doh", + }, + # --- NO DoH support. If you know a valid endpoint, please open a Pull Request --- + # { + # "name": "Comodo Secure", + # "provider": "Comodo", + # "ip": "8.26.56.26", + # "ipv6": "", + # "type": "public", + # "category": "security", + # "features": ["Malware Protection", "Phishing Protection"], + # "description": "Comodo's secure DNS with threat protection", + # "country": "USA", + # }, + # { + # "name": "Verisign", + # "provider": "Verisign", + # "ip": "64.6.64.6", + # "ipv6": "2620:74:1b::1:1", + # "type": "public", + # "category": "reliability", + # "features": ["Stability", "DNSSEC", "Anycast"], + # "description": "Verisign public DNS focused on stability and reliability", + # "country": "USA", + # }, + # { + # "name": "DNS.WATCH", + # "provider": "DNS.WATCH", + # "ip": "84.200.69.80", + # "ipv6": "2001:1608:10:25::1c04:b12f", + # "type": "public", + # "category": "privacy", + # "features": ["No Filtering", "No Logging", "Net Neutrality"], + # "description": "German DNS provider with no filtering and strong privacy", + # "country": "Germany", + # }, + # { + # "name": "Level3", + # "provider": "CenturyLink", + # "ip": "4.2.2.1", + # "ipv6": "", + # "type": "public", + # "category": "legacy", + # "features": ["Reliability", "Long History"], + # "description": "One of the original public DNS services", + # "country": "USA", + # }, + # { + # "name": "Norton ConnectSafe", + # "provider": "Norton", + # "ip": "199.85.126.10", + # "ipv6": "", + # "type": "public", + # "category": "security", + # "features": ["Malware Protection", "Phishing Protection"], + # "description": "Norton's security-focused DNS service (discontinued)", + # "country": "USA", + # }, ] @staticmethod @@ -897,360 +1314,447 @@ class DomainManager: """Manage domain lists with comprehensive database.""" # Comprehensive domain database - DOMAINS_DATABASE = [ + DOMAINS_DATABASE: List[Dict[str, Any]] = [ { "domain": "google.com", "category": "search", "description": "World's most popular search engine", "country": "USA", + "dnssec_signed": False, }, { "domain": "youtube.com", "category": "video", "description": "Video sharing platform", "country": "USA", + "dnssec_signed": False, }, { "domain": "facebook.com", "category": "social", "description": "Social networking service", "country": "USA", + "dnssec_signed": False, }, { "domain": "amazon.com", "category": "ecommerce", "description": "E-commerce and cloud computing", "country": "USA", + "dnssec_signed": False, }, { "domain": "twitter.com", "category": "social", "description": "Social media and microblogging", "country": "USA", + "dnssec_signed": False, }, { "domain": "instagram.com", "category": "social", "description": "Photo and video sharing platform", "country": "USA", + "dnssec_signed": False, }, { "domain": "linkedin.com", "category": "professional", "description": "Professional networking", "country": "USA", + "dnssec_signed": False, }, { "domain": "wikipedia.org", "category": "reference", "description": "Free online encyclopedia", "country": "USA", + "dnssec_signed": False, }, { "domain": "microsoft.com", "category": "tech", "description": "Software and technology company", "country": "USA", + "dnssec_signed": False, }, { "domain": "apple.com", "category": "tech", "description": "Consumer electronics and software", "country": "USA", + "dnssec_signed": False, }, { "domain": "netflix.com", "category": "streaming", "description": "Video streaming service", "country": "USA", + "dnssec_signed": False, }, { "domain": "github.com", "category": "tech", "description": "Code hosting and collaboration", "country": "USA", + "dnssec_signed": False, }, { "domain": "stackoverflow.com", "category": "tech", "description": "Programming Q&A community", "country": "USA", + "dnssec_signed": False, }, { "domain": "reddit.com", "category": "social", "description": "Social news aggregation", "country": "USA", + "dnssec_signed": False, }, { "domain": "whatsapp.com", "category": "messaging", "description": "Instant messaging platform", "country": "USA", + "dnssec_signed": False, }, { "domain": "cloudflare.com", "category": "infrastructure", "description": "CDN and security services", "country": "USA", + "dnssec_signed": True, + }, + { + "domain": "isc.org", + "category": "infrastructure", + "description": "Internet Systems Consortium (BIND, DHCP)", + "country": "USA", + "dnssec_signed": True, + }, + { + "domain": "nlnetlabs.nl", + "category": "dns", + "description": "NLnet Labs (NSD, Unbound, ldns)", + "country": "Netherlands", + "dnssec_signed": True, + }, + { + "domain": "dnssec-tools.org", + "category": "security", + "description": "DNSSEC tools and test suite", + "country": "USA", + "dnssec_signed": True, + }, + { + "domain": "ietf.org", + "category": "standards", + "description": "Internet Engineering Task Force", + "country": "USA", + "dnssec_signed": True, }, { "domain": "baidu.com", "category": "search", "description": "Chinese search engine", "country": "China", + "dnssec_signed": False, }, { "domain": "taobao.com", "category": "ecommerce", "description": "Chinese online shopping", "country": "China", + "dnssec_signed": False, }, { "domain": "qq.com", "category": "portal", "description": "Chinese web portal", "country": "China", + "dnssec_signed": False, }, { "domain": "tmall.com", "category": "ecommerce", "description": "Chinese B2C online retail", "country": "China", + "dnssec_signed": False, }, { "domain": "yahoo.com", "category": "portal", "description": "Web services portal", "country": "USA", + "dnssec_signed": False, }, { "domain": "bing.com", "category": "search", "description": "Microsoft's search engine", "country": "USA", + "dnssec_signed": False, }, { "domain": "live.com", "category": "email", "description": "Microsoft email and services", "country": "USA", + "dnssec_signed": False, }, { "domain": "office.com", "category": "productivity", "description": "Microsoft Office suite", "country": "USA", + "dnssec_signed": False, }, { "domain": "zoom.us", "category": "communication", "description": "Video conferencing platform", "country": "USA", + "dnssec_signed": False, }, { "domain": "slack.com", "category": "communication", "description": "Business communication platform", "country": "USA", + "dnssec_signed": False, }, { "domain": "dropbox.com", "category": "storage", "description": "Cloud storage service", "country": "USA", + "dnssec_signed": False, }, { "domain": "adobe.com", "category": "creative", "description": "Creative software suite", "country": "USA", + "dnssec_signed": False, }, { "domain": "paypal.com", "category": "finance", "description": "Online payments system", "country": "USA", + "dnssec_signed": False, }, { "domain": "wordpress.com", "category": "publishing", "description": "Blogging and website platform", "country": "USA", + "dnssec_signed": False, }, { "domain": "medium.com", "category": "publishing", "description": "Online publishing platform", "country": "USA", + "dnssec_signed": False, }, { "domain": "quora.com", "category": "qna", "description": "Question and answer platform", "country": "USA", + "dnssec_signed": False, }, { "domain": "imdb.com", "category": "entertainment", "description": "Movie and TV database", "country": "USA", + "dnssec_signed": False, }, { "domain": "bbc.com", "category": "news", "description": "British broadcasting news", "country": "UK", + "dnssec_signed": False, }, { "domain": "cnn.com", "category": "news", "description": "Cable news network", "country": "USA", + "dnssec_signed": False, }, { "domain": "nytimes.com", "category": "news", "description": "New York Times newspaper", "country": "USA", + "dnssec_signed": False, }, { "domain": "weather.com", "category": "weather", "description": "Weather forecasting service", "country": "USA", + "dnssec_signed": False, }, { "domain": "espn.com", "category": "sports", "description": "Sports news and coverage", "country": "USA", + "dnssec_signed": False, }, { "domain": "craigslist.org", "category": "classifieds", "description": "Classified advertisements", "country": "USA", + "dnssec_signed": False, }, { "domain": "ebay.com", "category": "ecommerce", "description": "Online auction and shopping", "country": "USA", + "dnssec_signed": False, }, { "domain": "aliexpress.com", "category": "ecommerce", "description": "Chinese online retail", "country": "China", + "dnssec_signed": False, }, { "domain": "walmart.com", "category": "ecommerce", "description": "Multinational retail corporation", "country": "USA", + "dnssec_signed": False, }, { "domain": "target.com", "category": "ecommerce", "description": "Retail corporation", "country": "USA", + "dnssec_signed": False, }, { "domain": "bestbuy.com", "category": "ecommerce", "description": "Consumer electronics retailer", "country": "USA", + "dnssec_signed": False, }, { "domain": "hulu.com", "category": "streaming", "description": "Video streaming service", "country": "USA", + "dnssec_signed": False, }, { "domain": "spotify.com", "category": "music", "description": "Music streaming platform", "country": "Sweden", + "dnssec_signed": False, }, { "domain": "soundcloud.com", "category": "music", "description": "Audio distribution platform", "country": "Germany", + "dnssec_signed": False, }, { "domain": "deezer.com", "category": "music", "description": "Music streaming service", "country": "France", + "dnssec_signed": False, }, { "domain": "twitch.tv", "category": "gaming", "description": "Live streaming for gamers", "country": "USA", + "dnssec_signed": False, }, { "domain": "steampowered.com", "category": "gaming", "description": "Digital game distribution", "country": "USA", + "dnssec_signed": False, }, { "domain": "epicgames.com", "category": "gaming", "description": "Video game and software developer", "country": "USA", + "dnssec_signed": False, }, { "domain": "ubuntu.com", "category": "tech", "description": "Linux distribution", "country": "UK", + "dnssec_signed": False, }, { "domain": "docker.com", "category": "tech", "description": "Container platform", "country": "USA", + "dnssec_signed": False, }, { "domain": "kubernetes.io", "category": "tech", "description": "Container orchestration", "country": "USA", + "dnssec_signed": False, }, { "domain": "gitlab.com", "category": "tech", "description": "DevOps platform", "country": "USA", + "dnssec_signed": False, }, { "domain": "atlassian.com", "category": "tech", "description": "Software development tools", "country": "Australia", + "dnssec_signed": False, }, { "domain": "notion.so", "category": "productivity", "description": "Note-taking and collaboration", "country": "USA", + "dnssec_signed": False, }, { "domain": "figma.com", "category": "design", "description": "Collaborative design tool", "country": "USA", + "dnssec_signed": False, }, { "domain": "canva.com", "category": "design", "description": "Graphic design platform", "country": "Australia", + "dnssec_signed": False, }, ] @@ -1345,6 +1849,13 @@ def parse_domains_input(input_value: Optional[str]) -> List[str]: def get_sample_domains() -> List[str]: """Get a list of sample domains for testing.""" return [ + # Signed domains with DNSSEC + "cloudflare.com", + "isc.org", + "nlnetlabs.nl", + "dnssec-tools.org", + "ietf.org", + # Non-signed domains "google.com", "github.com", "stackoverflow.com", @@ -1367,12 +1878,12 @@ def load_domains_from_file(file_path: str) -> List[str]: return domains @staticmethod - def get_all_domains() -> List[Dict[str, str]]: + def get_all_domains() -> List[Dict[str, Any]]: """Get all available domains with detailed information.""" return DomainManager.DOMAINS_DATABASE @staticmethod - def get_domains_by_category(category: str) -> List[Dict[str, str]]: + def get_domains_by_category(category: str) -> List[Dict[str, Any]]: """Get domains filtered by category.""" return [ d for d in DomainManager.DOMAINS_DATABASE if d.get("category") == category From 6f4d48af5fd18d10bd49e24f4fd62c96bdae6420 Mon Sep 17 00:00:00 2001 From: frankovo Date: Tue, 28 Apr 2026 21:20:43 +0200 Subject: [PATCH 2/5] feat(analysis): feat(analysis): add protocol and dnssec stats to benchmark analyzer - add protocol and dnssec_validated columns to dataframe - add dnssec_validated_queries and dnssec_validation_rate to resolverstats - add get_protocol_statistics() method - add get_dnssec_statistics() per resolver+protocol with fully_validated flag - update get_overall_statistics() with protocols_used and dnssec summary --- src/dns_benchmark/analysis.py | 76 ++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/src/dns_benchmark/analysis.py b/src/dns_benchmark/analysis.py index 5c0d0a1..3c66cb8 100644 --- a/src/dns_benchmark/analysis.py +++ b/src/dns_benchmark/analysis.py @@ -27,6 +27,8 @@ class ResolverStats: p99_latency: float jitter: float = 0.0 consistency_score: float = 0.0 + dnssec_validated_queries: int = 0 + dnssec_validation_rate: float = 0.0 class BenchmarkAnalyzer: @@ -56,6 +58,8 @@ def _create_dataframe(self) -> pd.DataFrame: "cache_hit": result.cache_hit, "interation": result.iteration, "query_id": result.query_id, + "protocol": result.protocol.value, + "dnssec_validated": result.dnssec_validated, } ) @@ -75,7 +79,12 @@ def get_resolver_statistics(self) -> List[ResolverStats]: success_rate = ( (successful_queries / total_queries) * 100 if total_queries > 0 else 0 ) - + dnssec_validated_queries = int(resolver_data["dnssec_validated"].sum()) + dnssec_validation_rate = ( + (dnssec_validated_queries / total_queries) * 100 + if total_queries > 0 + else 0.0 + ) # Latency statistics (only for successful queries) successful_latencies = resolver_data[resolver_data["success"] == True][ "latency_ms" @@ -123,6 +132,8 @@ def get_resolver_statistics(self) -> List[ResolverStats]: p99_latency=p99_latency, jitter=jitter, consistency_score=consistency_score, + dnssec_validated_queries=dnssec_validated_queries, + dnssec_validation_rate=dnssec_validation_rate, ) resolver_stats.append(stats) @@ -169,6 +180,13 @@ def get_overall_statistics(self) -> Dict[str, Any]: "resolver_count": len(resolver_stats), "domain_count": len(self.df["domain"].unique()), "record_types": list(self.df["record_type"].unique()), + "protocols_used": list(self.df["protocol"].unique()), + "dnssec_validated_queries": int(self.df["dnssec_validated"].sum()), + "dnssec_validation_rate": ( + float(self.df["dnssec_validated"].sum() / total_queries * 100) + if total_queries > 0 + else 0.0 + ), } def get_domain_statistics(self) -> List[Dict[str, Any]]: @@ -236,3 +254,59 @@ def get_error_statistics(self) -> Dict[str, int]: """Count errors by message across all failed queries.""" errors = self.df[self.df["success"] == False]["error_message"] return cast(Dict[str, int], errors.value_counts().to_dict()) + + def get_protocol_statistics(self) -> List[Dict[str, Any]]: + """Compute statistics broken down by protocol (plain/doh/dot).""" + proto_stats: List[Dict[str, Any]] = [] + for proto in self.df["protocol"].unique(): + proto_df = self.df[self.df["protocol"] == proto] + total = len(proto_df) + success = int(proto_df["success"].sum()) + rate = (success / total) * 100 if total > 0 else 0.0 + latencies = proto_df[proto_df["success"] == True]["latency_ms"] + dnssec_validated = int(proto_df["dnssec_validated"].sum()) + proto_stats.append( + { + "protocol": proto, + "total_queries": total, + "successful_queries": success, + "success_rate": rate, + "avg_latency": float(latencies.mean()) if len(latencies) else 0.0, + "median_latency": ( + float(latencies.median()) if len(latencies) else 0.0 + ), + "p95_latency": ( + float(latencies.quantile(0.95)) if len(latencies) else 0.0 + ), + "dnssec_validated_queries": dnssec_validated, + "dnssec_validation_rate": ( + (dnssec_validated / total * 100) if total > 0 else 0.0 + ), + } + ) + return proto_stats + + def get_dnssec_statistics(self) -> List[Dict[str, Any]]: + """DNSSEC validation breakdown per resolver + protocol combination.""" + dnssec_stats: List[Dict[str, Any]] = [] + for resolver_name in self.df["resolver_name"].unique(): + resolver_df = self.df[self.df["resolver_name"] == resolver_name] + for proto in resolver_df["protocol"].unique(): + proto_df = resolver_df[resolver_df["protocol"] == proto] + total = len(proto_df) + validated = int(proto_df["dnssec_validated"].sum()) + dnssec_stats.append( + { + "resolver_name": resolver_name, + "resolver_ip": proto_df["resolver_ip"].iloc[0], + "protocol": proto, + "total_queries": total, + "dnssec_validated_queries": validated, + "dnssec_validation_rate": ( + (validated / total * 100) if total > 0 else 0.0 + ), + # True only if ALL queries for this resolver+protocol validated + "fully_validated": validated == total, + } + ) + return dnssec_stats From f19d2b7e8ad886ba94e04bf47bcbecbcaf3cf010 Mon Sep 17 00:00:00 2001 From: frankovo Date: Tue, 28 Apr 2026 21:21:36 +0200 Subject: [PATCH 3/5] feat(exporters): feat(exporters): surface protocol and dnssec data in all export formats - add protocol and dnssec_validated to csv and json raw results - add dnssec columns to excel resolver summary sheet - add protocol stats sheet to excel export - add dnssec sheet with green/red row colouring to excel export - add dnssec validation table to pdf report executive summary --- src/dns_benchmark/exporters.py | 144 ++++++++++++++++++++++++++++++++- 1 file changed, 143 insertions(+), 1 deletion(-) diff --git a/src/dns_benchmark/exporters.py b/src/dns_benchmark/exporters.py index 8ce97ea..d77737e 100644 --- a/src/dns_benchmark/exporters.py +++ b/src/dns_benchmark/exporters.py @@ -35,6 +35,8 @@ def export_json( payload = { "overall": analyzer.get_overall_statistics(), "resolver_stats": [vars(s) for s in analyzer.get_resolver_statistics()], + "protocol_stats": analyzer.get_protocol_statistics(), + "dnssec_stats": analyzer.get_dnssec_statistics(), "raw_results": [ { "resolver_name": r.resolver_name, @@ -52,6 +54,8 @@ def export_json( "cache_hit": r.cache_hit, "iteration": r.iteration, "query_id": r.query_id, + "protocol": r.protocol.value, + "dnssec_validated": r.dnssec_validated, } for r in results ], @@ -88,6 +92,8 @@ def export_raw_results(results: List[DNSQueryResult], output_path: str) -> None: "cache_hit": result.cache_hit, "iteration": result.iteration, "query_id": result.query_id, + "protocol": result.protocol.value, + "dnssec_validated": result.dnssec_validated, } ) @@ -119,6 +125,8 @@ def export_summary_statistics( "p99_latency_ms": stats.p99_latency, "jitter_ms": stats.jitter, "consistency_score": stats.consistency_score, + "dnssec_validated_queries": stats.dnssec_validated_queries, + "dnssec_validation_rate": stats.dnssec_validation_rate, } ) @@ -146,6 +154,20 @@ def export_error_statistics(error_stats: Dict[str, int], output_path: str) -> No ) df.to_csv(output_path, index=False) + @staticmethod + def export_protocol_statistics( + protocol_stats: List[Dict[str, Any]], output_path: str + ) -> None: + df = pd.DataFrame(protocol_stats) + df.to_csv(output_path, index=False) + + @staticmethod + def export_dnssec_statistics( + dnssec_stats: List[Dict[str, Any]], output_path: str + ) -> None: + df = pd.DataFrame(dnssec_stats) + df.to_csv(output_path, index=False) + class ExcelExporter: """Export DNS benchmark results to Excel format.""" @@ -185,6 +207,18 @@ def export_results( ) ExcelExporter._add_simple_table_sheet(wb, "Error Breakdown", df) + # Protocol breakdown sheet + protocol_stats = analyzer.get_protocol_statistics() + if protocol_stats: + ExcelExporter._add_simple_table_sheet( + wb, "Protocol Stats", pd.DataFrame(protocol_stats) + ) + + # DNSSEC breakdown sheet + dnssec_stats = analyzer.get_dnssec_statistics() + if dnssec_stats: + ExcelExporter._add_dnssec_sheet(wb, dnssec_stats) + # Add charts if requested if include_charts: temp_dir = tempfile.mkdtemp() @@ -254,6 +288,8 @@ def _add_raw_data_sheet(wb: Workbook, results: List[DNSQueryResult]) -> None: "Attempts": result.attempt_number, "Cached": result.cache_hit, "Iteration": result.iteration, + "Protocol": result.protocol.value, + "DNSSEC Validated": result.dnssec_validated, } ) @@ -313,6 +349,8 @@ def _add_resolver_summary_sheet(wb: Workbook, analyzer: BenchmarkAnalyzer) -> No "P99 Latency (ms)": round(stats.p99_latency, 2), "Jitter": round(stats.jitter, 2), "Consistency": round(stats.consistency_score, 2), + "DNSSEC Validated": stats.dnssec_validated_queries, + "DNSSEC Rate (%)": round(stats.dnssec_validation_rate, 2), } ) @@ -391,6 +429,56 @@ def _add_charts_sheet( # Return paths for cleanup after workbook is saved return [latency_chart_path, success_chart_path] + @staticmethod + def _add_dnssec_sheet(wb: Workbook, dnssec_stats: List[Dict[str, Any]]) -> None: + """DNSSEC validation sheet with conditional colouring.""" + ws = wb.create_sheet("DNSSEC") + headers = [ + "Resolver", + "IP", + "Protocol", + "Total Queries", + "Validated", + "Validation Rate (%)", + "Fully Validated", + ] + GREEN = PatternFill(start_color="C6EFCE", end_color="C6EFCE", fill_type="solid") + RED = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid") + HEADER = PatternFill( + start_color="E0E0E0", end_color="E0E0E0", fill_type="solid" + ) + + for col_idx, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_idx, value=header) + cell.font = Font(bold=True) + cell.fill = HEADER + + for row_idx, stat in enumerate(dnssec_stats, 2): + values = [ + stat["resolver_name"], + stat["resolver_ip"], + stat["protocol"], + stat["total_queries"], + stat["dnssec_validated_queries"], + round(stat["dnssec_validation_rate"], 2), + stat["fully_validated"], + ] + for col_idx, value in enumerate(values, 1): + cell = ws.cell(row=row_idx, column=col_idx, value=value) + + # Colour entire row by validation status + row_fill = GREEN if stat["fully_validated"] else RED + for col_idx in range(1, len(headers) + 1): + ws.cell(row=row_idx, column=col_idx).fill = row_fill + + for column in ws.columns: + max_length = max( + (len(str(cell.value)) for cell in column if cell.value), default=10 + ) + ws.column_dimensions[column[0].column_letter].width = min( + max_length + 2, 40 + ) + @staticmethod def _generate_latency_chart_for_excel( analyzer: BenchmarkAnalyzer, output_dir: str @@ -523,7 +611,10 @@ def export_results( # Generate HTML content with base64 images html_content = PDFExporter._generate_html_content( - analyzer, latency_b64, success_b64 + analyzer, + latency_b64, + success_b64, + dnssec_stats=analyzer.get_dnssec_statistics(), ) # Write PDF before cleaning up temp files HTML(string=html_content).write_pdf(output_path) @@ -626,6 +717,7 @@ def _generate_html_content( analyzer: BenchmarkAnalyzer, latency_chart_b64: str, success_chart_b64: Optional[str] = None, + dnssec_stats: Optional[List[Dict[str, Any]]] = None, ) -> str: """Generate HTML content for PDF report.""" resolver_stats = analyzer.get_resolver_statistics() @@ -652,6 +744,55 @@ def _generate_html_content( """ + dnssec_block = "" + if dnssec_stats: + rows = "".join( + f"" + f"{s['resolver_name']}" + f"{s['protocol']}" + f"{s['dnssec_validated_queries']}/{s['total_queries']}" + f"{s['dnssec_validation_rate']:.1f}%" + f"" + f"{'✓' if s['fully_validated'] else '✗'}" + f"" + for s in dnssec_stats + ) + dnssec_block = f""" +
+

DNSSEC Validation

+ + + + + + + + + {rows} +
ResolverProtocolValidatedRateFully Validated
+
""" + + # Extended executive summary with DNSSEC fields (safe fallback) + dnssec_summary = "" + if dnssec_stats: + total_validated = sum( + s.get("dnssec_validated_queries", 0) for s in dnssec_stats + ) + total_queries_dnssec = sum(s.get("total_queries", 0) for s in dnssec_stats) + validation_rate = ( + (total_validated / total_queries_dnssec * 100) + if total_queries_dnssec + else 0 + ) + dnssec_summary = f""" +

DNSSEC validated queries: {total_validated} ({validation_rate:.1f}%)

+ """ + # Add protocols used (collect unique protocols from dnssec_stats) + protocols = sorted(set(s.get("protocol", "Unknown") for s in dnssec_stats)) + dnssec_summary += ( + f"

Protocols tested: {', '.join(protocols)}

" + ) + template_str = f""" @@ -771,6 +912,7 @@ def _generate_html_content( )} + {dnssec_block} """ From 145e3bd0dacb4846cf1fe3c35f928dd8c46a33a8 Mon Sep 17 00:00:00 2001 From: frankovo Date: Tue, 28 Apr 2026 21:22:30 +0200 Subject: [PATCH 4/5] build(pyproject): build(deps): add httpx[http2] for doh and future http benchmark --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index be7c5fa..dc50018 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,8 @@ dependencies = [ "pyyaml>=6.0,<7.0", "tqdm>=4.66,<5.0", "matplotlib>=3.8,<4.0", - "pillow>=11.0.0,<12.0.0" + "pillow>=11.0.0,<12.0.0", + "httpx[http2]>=0.28.1,<0.29", ] classifiers = [ From b5d1127a15739b1636e633a65b7af72ff8646dc6 Mon Sep 17 00:00:00 2001 From: frankovo Date: Tue, 28 Apr 2026 21:23:36 +0200 Subject: [PATCH 5/5] feat(cli): feat(cli): add --doh, --dot, --doh-url, --dnssec-validate to benchmark command - add _resolve_protocol_and_doh_urls() helper, fails early before any queries run - fail on --doh and --dot used together - fail on --doh-url count mismatching resolver count - fail on resolver missing doh_url in db with no explicit --doh-url - warn when no dnssec-signed domains in test set - enable_dnssec always true, enforce_dnssec only when --dnssec-validate passed - add data/dnssec_signed_domains.txt - add tests/test_protocols.py covering doh, dot, dnssec and dispatch --- data/dnssec_signed_domains.txt | 6 + src/dns_benchmark/cli.py | 126 ++++++++++- tests/test_protocols.py | 374 +++++++++++++++++++++++++++++++++ 3 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 data/dnssec_signed_domains.txt create mode 100644 tests/test_protocols.py diff --git a/data/dnssec_signed_domains.txt b/data/dnssec_signed_domains.txt new file mode 100644 index 0000000..a3e80f8 --- /dev/null +++ b/data/dnssec_signed_domains.txt @@ -0,0 +1,6 @@ +# DNSSEC-signed domains for testing resolver validation capabilities +cloudflare.com +isc.org +nlnetlabs.nl +dnssec-tools.org +ietf.org \ No newline at end of file diff --git a/src/dns_benchmark/cli.py b/src/dns_benchmark/cli.py index 0e27b06..f70a9a2 100644 --- a/src/dns_benchmark/cli.py +++ b/src/dns_benchmark/cli.py @@ -16,6 +16,7 @@ from dns_benchmark.core import ( DNSQueryEngine, DomainManager, + QueryProtocol, QueryStatus, ResolverManager, ) @@ -38,6 +39,67 @@ init() +def _resolve_protocol_and_doh_urls( + doh: bool, + dot: bool, + doh_url: Optional[str], + resolvers: List[Dict[str, str]], +) -> Tuple[QueryProtocol, Dict[str, str]]: + """ + Validate protocol flags and build resolver_ip -> doh_url mapping. + Fails fast with a clear message before any queries run. + """ + + if doh and dot: + raise click.UsageError("--doh and --dot are mutually exclusive.") + + if not doh and not dot: + return QueryProtocol.PLAIN, {} + + if dot: + return QueryProtocol.DOT, {} + + # ── DoH path ────────────────────────────────────────────────────────── + url_map: Dict[str, str] = {} + + if doh_url: + # User supplied explicit list — must match resolver count 1:1 + urls = [u.strip() for u in doh_url.split(",") if u.strip()] + if len(urls) != len(resolvers): + raise click.UsageError( + f"--doh-url has {len(urls)} URL(s) but --resolvers has " + f"{len(resolvers)} resolver(s). Counts must match." + ) + for resolver, url in zip(resolvers, urls): + url_map[resolver["ip"]] = url + else: + # Fall back to db doh_url field — fail if any resolver is missing it + missing = [] + for resolver in resolvers: + db_entry = next( + ( + r + for r in ResolverManager.RESOLVERS_DATABASE + if r.get("ip") == resolver["ip"] + or str(r.get("name", "")).lower() == resolver["name"].lower() + ), + None, + ) + url = cast(str, (db_entry or {}).get("doh_url", "")) + if not url: + missing.append(resolver["name"] or resolver["ip"]) + else: + url_map[resolver["ip"]] = url + + if missing: + raise click.UsageError( + f"--doh requires a DoH URL for: {', '.join(missing)}. " + "Use --doh-url to supply them explicitly." + ) + + return QueryProtocol.DOH, url_map + + @click.group() @click.version_option(__version__, prog_name="DNS Benchmark Tool") def cli() -> None: @@ -280,6 +342,20 @@ def reset_feedback() -> None: # =================== Benchmark command @cli.command() +@click.option("--doh", is_flag=True, default=False, help="Use DNS-over-HTTPS.") +@click.option("--dot", is_flag=True, default=False, help="Use DNS-over-TLS.") +@click.option( + "--doh-url", + default=None, + help="Comma-separated DoH URLs, one per resolver (required if resolver not in db).", +) +@click.option( + "--dnssec-validate", + is_flag=True, + default=False, + help="Fail queries where DNSSEC AD flag is not set.", +) +# ------------ @click.option("--resolvers", "-r", help="JSON file with resolver list") @click.option("--domains", "-d", help="Text file with domain list") @click.option( @@ -319,6 +395,11 @@ def reset_feedback() -> None: "--include-charts", is_flag=True, help="Include charts in Excel and PDF exports" ) def benchmark( + # New + doh: bool, + dot: bool, + doh_url: Optional[str], + dnssec_validate: bool, resolvers: Optional[str], domains: Optional[str], record_types: str, @@ -408,11 +489,33 @@ def benchmark( except Exception as e: click.echo(error(f"Error loading domains: {e}")) return + # New + if dnssec_validate: + signed = { + d["domain"] + for d in DomainManager.DOMAINS_DATABASE + if d.get("dnssec_signed") + } + if not any(d in signed for d in domain_list): + click.echo( + warning( + "No DNSSEC-signed domains in test set — all queries will fail AD validation. " + "Add signed domains or use --domains with known signed domains." + ) + ) # Calculate total queries total_queries = ( len(resolver_list) * len(domain_list) * len(record_type_list) * iterations ) + + protocol, doh_urls = _resolve_protocol_and_doh_urls( + doh=doh, + dot=dot, + doh_url=doh_url, + resolvers=resolver_list, + ) + if not quiet: click.echo(info("Configuration:")) click.echo(info(f"- Resolvers: {len(resolver_list)}")) @@ -422,6 +525,15 @@ def benchmark( click.echo(info(f"- Total queries: {total_queries}")) if use_cache: click.echo(info("- Cache enabled: queries may be reused across iterations")) + # New + if protocol != QueryProtocol.PLAIN: + click.echo(info(f"- Protocol: {protocol.value.upper()}")) + if dnssec_validate: + click.echo( + info("- DNSSEC validation: enforced (queries fail if AD flag absent)") + ) + else: + click.echo(info("- DNSSEC: passive (AD flag collected, not enforced)")) # Show warmup message if (warmup or warmup_fast) and not quiet: @@ -437,13 +549,15 @@ def benchmark( feedback_manager.increment_run() start_time = time.time() - + # New try: engine = DNSQueryEngine( max_concurrent_queries=max_concurrent, timeout=timeout, max_retries=retries, enable_cache=use_cache, + enable_dnssec=True, # always collect AD flag - always True + enforce_dnssec=dnssec_validate, ) progress_bar = None @@ -465,7 +579,7 @@ def _progress_cb(completed: int, total: int) -> None: pass engine.set_progress_callback(_progress_cb) - + # New results = asyncio.run( engine.run_benchmark( resolvers=resolver_list, @@ -475,6 +589,8 @@ def _progress_cb(completed: int, total: int) -> None: warmup=warmup, warmup_fast=warmup_fast, use_cache=use_cache, + protocol=protocol, + doh_urls=doh_urls, ) ) @@ -489,6 +605,7 @@ def _progress_cb(completed: int, total: int) -> None: analyzer = BenchmarkAnalyzer(results) overall_stats = analyzer.get_overall_statistics() + # New if not quiet: click.echo(info("=== BENCHMARK SUMMARY ===")) summary_lines = [ @@ -498,6 +615,8 @@ def _progress_cb(completed: int, total: int) -> None: f"Median latency: {overall_stats['overall_median_latency']:.2f} ms", f"Fastest resolver: {overall_stats['fastest_resolver']}", f"Slowest resolver: {overall_stats['slowest_resolver']}", + f"Protocol: {protocol.value.upper()}", + f"DNSSEC validated: {sum(1 for r in results if r.dnssec_validated)} / {len(results)} queries", ] # Add iteration info if multiple iterations if iterations > 1: @@ -624,6 +743,7 @@ def _progress_cb(completed: int, total: int) -> None: # ====================== Top Resolvers Command @cli.command() +# -------- @click.option("--limit", "-n", default=10, help="Number of top resolvers to display") @click.option( "--metric", @@ -931,6 +1051,7 @@ def _progress_cb(completed: int, total: int) -> None: # ======================= Compare @cli.command() +# ---------- @click.argument("resolvers", nargs=-1, required=True) @click.option("--domains", "-d", help="Text file with domain list") @click.option( @@ -1191,6 +1312,7 @@ def _progress_cb(completed: int, total: int) -> None: # ==================== Monitoring Command @cli.command() +# --------- @click.option("--resolvers", "-r", help="JSON file with resolver list") @click.option("--domains", "-d", help="Text file with domain list") @click.option( diff --git a/tests/test_protocols.py b/tests/test_protocols.py new file mode 100644 index 0000000..016554a --- /dev/null +++ b/tests/test_protocols.py @@ -0,0 +1,374 @@ +""" +Tests for DoH, DoT, and DNSSEC additions. +""" + +import asyncio +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import dns.flags +import dns.message +import dns.name +import dns.rdatatype +import pytest + +from dns_benchmark.core import ( + DNSQueryEngine, + DNSQueryResult, + QueryProtocol, + QueryStatus, +) + + +@pytest.fixture +def engine() -> DNSQueryEngine: + return DNSQueryEngine( + timeout=5.0, + max_retries=0, + enable_dnssec=True, + enforce_dnssec=False, + ) + + +@pytest.fixture +def engine_enforced() -> DNSQueryEngine: + return DNSQueryEngine( + timeout=5.0, + max_retries=0, + enable_dnssec=True, + enforce_dnssec=True, + ) + + +def _make_dns_wire_response(domain: str = "google.com", ad: bool = False) -> bytes: + """Build a minimal valid DNS wire response for mocking.""" + qname = dns.name.from_text(domain) + rdtype = dns.rdatatype.from_text("A") + request = dns.message.make_query(qname, rdtype) + response = dns.message.make_response(request) + response.flags |= dns.flags.QR | dns.flags.RA + if ad: + response.flags |= dns.flags.AD + # Add a minimal A record answer + rrset = response.find_rrset( + response.answer, + qname, + dns.rdataclass.IN, + dns.rdatatype.A, + create=True, + ) + rrset.add( + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, "1.2.3.4"), ttl=300 + ) + return response.to_wire() + + +def test_query_protocol_values() -> None: + assert QueryProtocol.PLAIN.value == "plain" + assert QueryProtocol.DOH.value == "doh" + assert QueryProtocol.DOT.value == "dot" + + +def test_dnssec_failed_status_exists() -> None: + assert QueryStatus.DNSSEC_FAILED.value == "dnssec_failed" + + +def test_result_default_fields(engine: DNSQueryEngine) -> None: + """protocol and dnssec_validated have correct defaults.""" + import time + + from dns_benchmark.core import DNSQueryResult + + result = DNSQueryResult( + resolver_ip="1.1.1.1", + resolver_name="Cloudflare", + domain="example.com", + record_type="A", + start_time=time.time(), + end_time=time.time(), + latency_ms=10.0, + status=QueryStatus.SUCCESS, + answers=["1.2.3.4"], + ttl=300, + ) + assert result.protocol == QueryProtocol.PLAIN + assert result.dnssec_validated is False + + +def test_result_to_dict_includes_protocol() -> None: + result = DNSQueryResult( + resolver_ip="1.1.1.1", + resolver_name="Cloudflare", + domain="example.com", + record_type="A", + start_time=time.time(), + end_time=time.time(), + latency_ms=10.0, + status=QueryStatus.SUCCESS, + answers=[], + ttl=None, + protocol=QueryProtocol.DOH, + dnssec_validated=True, + ) + d = result.to_dict() + assert d["protocol"] == "doh" + assert d["dnssec_validated"] is True + + +@pytest.mark.asyncio +async def test_query_single_doh_success(engine: DNSQueryEngine) -> None: + wire = _make_dns_wire_response("google.com", ad=False) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = wire + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("dns_benchmark.core.httpx.AsyncClient", return_value=mock_client): + result = await engine.query_single_doh( + resolver_ip="1.1.1.1", + resolver_name="Cloudflare", + domain="google.com", + doh_url="https://cloudflare-dns.com/dns-query", + ) + + assert result.status == QueryStatus.SUCCESS + assert result.protocol == QueryProtocol.DOH + assert result.latency_ms > 0 + assert result.answers == ["1.2.3.4"] + + +@pytest.mark.asyncio +async def test_query_single_doh_ad_flag(engine: DNSQueryEngine) -> None: + wire = _make_dns_wire_response("cloudflare.com", ad=True) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = wire + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("dns_benchmark.core.httpx.AsyncClient", return_value=mock_client): + result = await engine.query_single_doh( + resolver_ip="1.1.1.1", + resolver_name="Cloudflare", + domain="cloudflare.com", + doh_url="https://cloudflare-dns.com/dns-query", + ) + + assert result.dnssec_validated is True + assert result.status == QueryStatus.SUCCESS + + +@pytest.mark.asyncio +async def test_query_single_doh_enforced_no_ad(engine_enforced: DNSQueryEngine) -> None: + """enforce_dnssec=True + AD absent → DNSSEC_FAILED.""" + wire = _make_dns_wire_response("google.com", ad=False) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = wire + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("dns_benchmark.core.httpx.AsyncClient", return_value=mock_client): + result = await engine_enforced.query_single_doh( + resolver_ip="1.1.1.1", + resolver_name="Cloudflare", + domain="google.com", + doh_url="https://cloudflare-dns.com/dns-query", + ) + + assert result.status == QueryStatus.DNSSEC_FAILED + assert result.dnssec_validated is False + + +@pytest.mark.asyncio +async def test_query_single_doh_timeout(engine: DNSQueryEngine) -> None: + import httpx + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(side_effect=httpx.TimeoutException("timeout")) + + with patch("dns_benchmark.core.httpx.AsyncClient", return_value=mock_client): + result = await engine.query_single_doh( + resolver_ip="1.1.1.1", + resolver_name="Cloudflare", + domain="google.com", + doh_url="https://cloudflare-dns.com/dns-query", + ) + + assert result.status == QueryStatus.TIMEOUT + assert result.protocol == QueryProtocol.DOH + + +@pytest.mark.asyncio +async def test_query_single_dot_success(engine: DNSQueryEngine) -> None: + import struct + + wire = _make_dns_wire_response("google.com", ad=False) + # reader returns 2-byte length then message + length_bytes = struct.pack("!H", len(wire)) + + mock_reader = AsyncMock() + mock_reader.readexactly = AsyncMock(side_effect=[length_bytes, wire]) + + mock_writer = MagicMock() + mock_writer.write = MagicMock() + mock_writer.drain = AsyncMock() + mock_writer.close = MagicMock() + mock_writer.wait_closed = AsyncMock() + mock_writer.get_extra_info = MagicMock(return_value=None) + + with ( + patch( + "dns_benchmark.core.asyncio.open_connection", + new=AsyncMock(return_value=(mock_reader, mock_writer)), + ), + patch( + "dns_benchmark.core.asyncio.wait_for", + side_effect=[ + (mock_reader, mock_writer), + length_bytes, # readexactly(2) + wire, # readexactly(msg_len) + ], + ), + ): + result = await engine.query_single_dot( + resolver_ip="1.1.1.1", + resolver_name="Cloudflare", + domain="google.com", + ) + + assert result.status == QueryStatus.SUCCESS + assert result.protocol == QueryProtocol.DOT + assert result.answers == ["1.2.3.4"] + + +@pytest.mark.asyncio +async def test_query_single_dot_tls_error(engine: DNSQueryEngine) -> None: + import ssl + + ssl_error = ssl.SSLError("cert verify failed") + + with patch( + "dns_benchmark.core.asyncio.open_connection", + side_effect=ssl_error, + ): + result = await engine.query_single_dot( + resolver_ip="1.1.1.1", + resolver_name="Cloudflare", + domain="google.com", + ) + + assert result.status == QueryStatus.CONNECTION_REFUSED + assert result.protocol == QueryProtocol.DOT + assert "TLS error" in (result.error_message or "") + + +@pytest.mark.asyncio +async def test_query_single_dot_timeout(engine: DNSQueryEngine) -> None: + mock_connect = AsyncMock( + side_effect=asyncio.TimeoutError("simulated connection timeout") + ) + + with patch( + "dns_benchmark.core.asyncio.open_connection", + new=mock_connect, + ): + result = await engine.query_single_dot( + resolver_ip="1.1.1.1", + resolver_name="Cloudflare", + domain="google.com", + ) + + assert result.status == QueryStatus.TIMEOUT + assert result.protocol == QueryProtocol.DOT + + +@pytest.mark.asyncio +async def test_run_benchmark_dispatches_doh(engine: DNSQueryEngine) -> None: + mock_result = MagicMock() + engine.query_single_doh = AsyncMock(return_value=mock_result) # type: ignore + + await engine.run_benchmark( + resolvers=[{"ip": "1.1.1.1", "name": "Cloudflare"}], + domains=["google.com"], + protocol=QueryProtocol.DOH, + doh_urls={"1.1.1.1": "https://cloudflare-dns.com/dns-query"}, + ) + + engine.query_single_doh.assert_called_once() + call_kwargs = engine.query_single_doh.call_args.kwargs + assert call_kwargs["doh_url"] == "https://cloudflare-dns.com/dns-query" + + +@pytest.mark.asyncio +async def test_run_benchmark_dispatches_dot(engine: DNSQueryEngine) -> None: + mock_result = MagicMock() + engine.query_single_dot = AsyncMock(return_value=mock_result) # type: ignore + + await engine.run_benchmark( + resolvers=[{"ip": "1.1.1.1", "name": "Cloudflare"}], + domains=["google.com"], + protocol=QueryProtocol.DOT, + ) + + engine.query_single_dot.assert_called_once() + + +@pytest.mark.asyncio +async def test_run_benchmark_dispatches_plain(engine: DNSQueryEngine) -> None: + mock_result = MagicMock() + engine.query_single = AsyncMock(return_value=mock_result) # type: ignore + + await engine.run_benchmark( + resolvers=[{"ip": "1.1.1.1", "name": "Cloudflare"}], + domains=["google.com"], + protocol=QueryProtocol.PLAIN, + ) + + engine.query_single.assert_called_once() + + +@pytest.mark.asyncio +async def test_dnssec_passive_no_status_change(engine: DNSQueryEngine) -> None: + """AD=False with enforce_dnssec=False must NOT flip status to DNSSEC_FAILED.""" + wire = _make_dns_wire_response("google.com", ad=False) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = wire + mock_response.raise_for_status = MagicMock() + + mock_client = AsyncMock() + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__ = AsyncMock(return_value=False) + mock_client.post = AsyncMock(return_value=mock_response) + + with patch("dns_benchmark.core.httpx.AsyncClient", return_value=mock_client): + result = await engine.query_single_doh( + resolver_ip="1.1.1.1", + resolver_name="Cloudflare", + domain="google.com", + doh_url="https://cloudflare-dns.com/dns-query", + ) + + # passive: status stays SUCCESS, dnssec_validated reflects reality + assert result.status == QueryStatus.SUCCESS + assert result.dnssec_validated is False