Skip to content
This repository was archived by the owner on May 9, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions data/dnssec_signed_domains.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# DNSSEC-signed domains for testing resolver validation capabilities
cloudflare.com
isc.org
nlnetlabs.nl
dnssec-tools.org
ietf.org
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
76 changes: 75 additions & 1 deletion src/dns_benchmark/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
}
)

Expand All @@ -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"
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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]]:
Expand Down Expand Up @@ -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
126 changes: 124 additions & 2 deletions src/dns_benchmark/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from dns_benchmark.core import (
DNSQueryEngine,
DomainManager,
QueryProtocol,
QueryStatus,
ResolverManager,
)
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)}"))
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,
)
)

Expand All @@ -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 = [
Expand All @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading