diff --git a/CLAUDE.md b/CLAUDE.md
index 6a598f3..90bddb5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -47,6 +47,12 @@ grep -rn 'Math.random' src/utils/
The first should return only genuine coefficients in formulas. The second should return
only ID generation and cache-busters — `createId()` and `_cb=`/`_nr=` query params.
+`dnsBenchmark.ts` also generates randomness, via `crypto.getRandomValues` rather than
+`Math.random` so the grep above stays clean. Two sanctioned uses, both commented:
+`randomMessageId()` (a fresh DNS message ID per query — the cache-buster, because a
+`_nr=` parameter makes Quad9 return 403) and `randomLabel()` (labels for names that must
+not be in any cache). Anything else there is a bug.
+
---
## Know what a browser genuinely cannot do
diff --git a/README.md b/README.md
index 73e72e9..633b2cd 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,34 @@ security policy and are reported as such rather than silently skipped.
`A`, `AAAA`, `MX`, `TXT`, `NS`, `CNAME`, `CAA`, `SRV` and `SOA` records via Cloudflare or Google,
with response codes, TTLs and the raw JSON payload.
+### 4b. ⏱️ DNS Resolver Benchmark
+
+Times seven public DNS-over-HTTPS resolvers three ways — a name already in the resolver's cache, a
+random label under a popular domain that forces it out to an authoritative server, and a random
+`.com` name that forces a `.com` TLD consultation — plus whether each returns `NXDOMAIN` for names
+that do not exist, and whether each validates DNSSEC.
+
+The three-way split is [Steve Gibson's](https://www.grc.com/dns/benchmark.htm), and the reason it
+matters is his: a resolver can be instant from cache and badly connected to everything else, so
+measuring only one of those tells you neither.
+
+What a browser cannot do, and the tool says so permanently on screen rather than approximating:
+
+- **Your own resolver is not in the table and cannot be.** No raw sockets, no UDP/53, no way to
+ learn the address your system is using. For that, use Steve's native tool.
+- **Every figure includes the HTTPS round trip.** No DoH endpoint sends `Timing-Allow-Origin`, so
+ the DNS/TCP/TLS breakdown is unreadable — re-checked each run rather than asserted.
+- **Ten well-known providers** (OpenDNS, AdGuard, Mullvad, NextDNS, Cisco Umbrella, Yandex,
+ LibreDNS, CIRA, Wikimedia, Digitale Gesellschaft) send no CORS header, so a browser cannot read
+ their answers at all. They are listed with no figures rather than omitted.
+- **A failed request is not the resolver's fault.** The column is called *Answered*, never
+ *Reliability*: over HTTPS a lost query, a TLS failure, a blocking extension and a CORS rejection
+ are indistinguishable.
+
+A "fastest" resolver is named only when its observed range does not overlap the runner-up's;
+otherwise the conclusion is that this run does not separate them. Queries run one at a time and
+cycle between resolvers, so a burst of other traffic does not land on whichever went first.
+
### 5. 🌐 WebRTC ICE Analyzer
Discovers public and local ICE candidates via STUN and infers NAT topology. Modern browsers return
mDNS `.local` candidates instead of real LAN addresses, so local-interface discovery frequently
@@ -200,6 +228,8 @@ without touching it, so the following go directly from your browser to third par
| `speed.cloudflare.com` | Your IP, plus tens of MB of transfer, during a speed test |
| `cdn.jsdelivr.net`, `cdnjs.cloudflare.com`, `unpkg.com` | Your IP, as Edge Path Explorer probe targets (a few KB each) |
| `cloudflare-dns.com`, `dns.google` | Every domain you resolve, over encrypted DoH |
+| `cloudflare-dns.com`, `dns.google`, `dns.quad9.net`, `dns10.quad9.net`, `freedns.controld.com`, `doh.sb`, `public.dns.iij.jp` | Your IP and every name the DNS benchmark queries — ~20 each, most randomly generated, which identifies the run to each provider |
+| `dnssec-failed.org`, `internetsociety.org` | Not contacted; their names are the DNSSEC test pair sent to the resolvers above |
| `ipwho.is`, `ipapi.co`, `freeipapi.com` | Your public IP on opening the GeoIP tool, and every IP or domain you look up |
| `1.1.1.1`, `one.one.one.one`, `dns.quad9.net`, `doh.opendns.com`, `en.wikipedia.org` | Your IP, as latency probe targets, and as the two halves of the resolver test |
| `ipv4.icanhazip.com`, `ipv6.icanhazip.com`, `api4.ipify.org`, `api6.ipify.org` | Your IP, during the dual-stack check — each answers on one address family only |
@@ -231,6 +261,12 @@ CI runs all three on every push and pull request; deployment is gated on them pa
## 👏 Acknowledgments
+The DNS Resolver Benchmark exists because of **Steve Gibson's**
+[GRC DNS Benchmark](https://www.grc.com/dns/benchmark.htm), which has been measuring nameservers
+properly — over UDP, against their actual IP addresses — since 2010. The cached / uncached /
+"dotcom" separation, the NXDOMAIN-redirection check and the plain-English conclusions are all his
+design; NetReady reproduces what a browser honestly can and says plainly where it cannot follow.
+
[Lucide](https://lucide.dev/) · [Tailwind CSS](https://tailwindcss.com/) ·
[Vite](https://vitejs.dev/) · [React](https://react.dev/) · [Leaflet](https://leafletjs.com/) ·
[Recharts](https://recharts.org/) · [Cloudflare](https://developers.cloudflare.com/1.1.1.1/encryption/dns-over-https/)
diff --git a/src/App.tsx b/src/App.tsx
index ca13620..bdb9614 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -6,6 +6,7 @@ import { Navbar } from './components/Navbar';
import { Dashboard } from './components/Dashboard';
import { TriagePanel } from './components/TriagePanel';
import { DualStackCheck } from './components/DualStackCheck';
+import { DnsBenchmark } from './components/DnsBenchmark';
import { CaptivePortalCheck } from './components/CaptivePortalCheck';
import { EdgePathExplorer } from './components/EdgePathExplorer';
import { TracertVisualizer } from './components/TracertVisualizer';
@@ -86,6 +87,7 @@ export default function App() {
{activeTab === 'triage' && }
{activeTab === 'dualstack' && }
+ {activeTab === 'dnsbench' && }
{activeTab === 'captive' && }
diff --git a/src/components/Dashboard.tsx b/src/components/Dashboard.tsx
index 20020ba..e9bb0f2 100644
--- a/src/components/Dashboard.tsx
+++ b/src/components/Dashboard.tsx
@@ -21,6 +21,7 @@ import {
Stethoscope,
Network,
ShieldQuestion,
+ Timer,
} from 'lucide-react';
import { ToolTab, NetworkConnectionInfo, SpeedTestResult, PingResult, HistoryItem } from '../types';
import {
@@ -428,6 +429,32 @@ export const Dashboard: React.FC = ({
+ {/* DNS resolver benchmark */}
+
+ Cached, uncached and .com lookups timed across public DoH resolvers — the split Steve
+ Gibson designed for GRC’s DNS Benchmark, as far as a browser can take it.
+
+
+ Compare resolvers
+
+
+
+
{/* Captive portal & DNS hijack */}
setActiveTab('captive')}
diff --git a/src/components/DnsBenchmark.tsx b/src/components/DnsBenchmark.tsx
new file mode 100644
index 0000000..a1db6fc
--- /dev/null
+++ b/src/components/DnsBenchmark.tsx
@@ -0,0 +1,570 @@
+import React, { useMemo, useRef, useState } from 'react';
+import {
+ Timer,
+ Play,
+ Loader2,
+ Square,
+ Info,
+ ArrowUpDown,
+ ShieldCheck,
+ ShieldOff,
+ ShieldQuestion,
+} from 'lucide-react';
+import {
+ Bar,
+ BarChart,
+ CartesianGrid,
+ Legend,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from 'recharts';
+import type {
+ DnsBenchmarkResult,
+ DnsMetricSummary,
+ DnssecValidation,
+ HistoryItem,
+ NxdomainHonesty,
+ ResolverBenchmark,
+} from '../types';
+import {
+ CORS_BLOCKED_RESOLVERS,
+ DOH_RESOLVERS,
+ MIN_SAMPLES_PER_METRIC,
+ POPULAR_NAMES,
+ runDnsBenchmark,
+} from '../utils/dnsBenchmark';
+import { displayMetric, FailureNotice, MetricValue } from './MetricValue';
+import { saveHistoryItem, StorageFullError } from '../utils/storage';
+
+/**
+ * DNS resolver benchmark, after Steve Gibson's GRC DNS Benchmark.
+ *
+ * The design job here is mostly about what the screen must not imply. A
+ * resolver that produced no samples gets an em-dash in every cell and no bar on
+ * the chart at all — a zero-length bar reads as "instant", which is the
+ * opposite of the truth. The panel explaining that your own ISP's resolver
+ * cannot be measured from a browser is permanent rather than collapsible,
+ * because a user who misses it will read this table as a ranking of their DNS.
+ */
+
+interface DnsBenchmarkProps {
+ onHistoryUpdate: () => void;
+}
+
+type SortKey = 'cached' | 'uncached' | 'dotcom' | 'label';
+
+const NXDOMAIN_LABEL: Record = {
+ honest: 'Honest',
+ 'answers-with-an-address': 'Returns an address',
+ inconclusive: 'Not determined',
+};
+
+const NXDOMAIN_TONE: Record = {
+ honest: 'text-emerald-400',
+ 'answers-with-an-address': 'text-amber-400',
+ inconclusive: 'text-slate-500',
+};
+
+const DNSSEC_LABEL: Record = {
+ validates: 'Validates',
+ 'does-not-validate': 'Passes through',
+ inconclusive: 'Not determined',
+};
+
+const DNSSEC_ICON: Record> = {
+ validates: ShieldCheck,
+ 'does-not-validate': ShieldOff,
+ inconclusive: ShieldQuestion,
+};
+
+const DNSSEC_TONE: Record = {
+ validates: 'text-emerald-400',
+ 'does-not-validate': 'text-slate-400',
+ inconclusive: 'text-slate-500',
+};
+
+const OUTCOME_BADGE: Record = {
+ measured: null,
+ 'partially-measured': { text: 'partial', tone: 'bg-amber-500/15 text-amber-300' },
+ 'no-readable-answer': { text: 'no answer', tone: 'bg-rose-500/15 text-rose-300' },
+ 'blocked-by-browser': { text: 'cors-blocked', tone: 'bg-slate-700/60 text-slate-400' },
+ 'not-attempted': { text: 'not attempted', tone: 'bg-slate-700/60 text-slate-400' },
+};
+
+const SERIES = [
+ { key: 'cached' as const, name: 'Cached', fill: '#f87171' },
+ { key: 'uncached' as const, name: 'Uncached', fill: '#4ade80' },
+ { key: 'dotcom' as const, name: '.com', fill: '#c084fc' },
+];
+
+/** Signed rendering, so a negative reads as a negative rather than disappearing. */
+const signedMs = (value: number | null): string => {
+ if (value === null) return '—';
+ if (value > 0) return `+${value} ms`;
+ return `${value} ms`;
+};
+
+const failureFor = (row: ResolverBenchmark, metric: DnsMetricSummary) =>
+ metric.medianMs === null
+ ? {
+ metric: row.resolverId,
+ reason: 'insufficient-samples' as const,
+ detail: row.note,
+ }
+ : undefined;
+
+const MetricCell: React.FC<{ row: ResolverBenchmark; metric: DnsMetricSummary }> = ({
+ row,
+ metric,
+}) => (
+
+
+
+);
+
+export const DnsBenchmark: React.FC = ({ onHistoryUpdate }) => {
+ const [result, setResult] = useState(null);
+ const [isRunning, setIsRunning] = useState(false);
+ const [stage, setStage] = useState('');
+ const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
+ const [dnssec, setDnssec] = useState(true);
+ const [sortKey, setSortKey] = useState('cached');
+ const [storageWarning, setStorageWarning] = useState(null);
+ const abortRef = useRef(null);
+
+ const run = async () => {
+ const controller = new AbortController();
+ abortRef.current = controller;
+ setIsRunning(true);
+ setResult(null);
+ setStorageWarning(null);
+ setProgress(null);
+
+ try {
+ const r = await runDnsBenchmark({
+ dnssec,
+ signal: controller.signal,
+ onProgress: (nextStage, done, total) => {
+ setStage(nextStage);
+ setProgress({ done, total });
+ },
+ });
+ setResult(r);
+
+ const measured = r.resolvers.filter((row) => row.outcome === 'measured').length;
+ const fastest = r.resolvers.find((row) => row.resolverId === r.fastestCachedResolverId);
+ const item: HistoryItem = {
+ id: r.id,
+ type: 'dnsbench',
+ timestamp: r.timestamp,
+ title: `DNS benchmark: ${measured} of ${DOH_RESOLVERS.length} resolvers measured`,
+ summary:
+ fastest === undefined
+ ? 'No resolver produced enough answers to rank.'
+ : `Lowest cached median: ${fastest.label} at ` +
+ `${displayMetric(fastest.cached.medianMs, 'ms')}` +
+ (r.fastestIsWithinNoise === true ? ' (within noise of the runner-up)' : ''),
+ data: r,
+ };
+ try {
+ saveHistoryItem(item);
+ onHistoryUpdate();
+ } catch (error) {
+ // This result is larger than most, so it is the one most likely to hit
+ // the quota. Say so rather than losing the run silently.
+ setStorageWarning(
+ error instanceof StorageFullError
+ ? 'The results are shown below but could not be saved to history — browser storage is full.'
+ : 'The results are shown below but could not be saved to history.',
+ );
+ }
+ } finally {
+ setIsRunning(false);
+ setStage('');
+ setProgress(null);
+ abortRef.current = null;
+ }
+ };
+
+ const sorted = useMemo(() => {
+ if (result === null) return [];
+ const rows = [...result.resolvers];
+ if (sortKey === 'label') return rows.sort((a, b) => a.label.localeCompare(b.label));
+
+ // Absent medians sort last in every direction. A resolver that produced
+ // nothing must never surface at the top of a list headed "fastest".
+ return rows.sort((a, b) => {
+ const av = a[sortKey].medianMs;
+ const bv = b[sortKey].medianMs;
+ if (av === null && bv === null) return a.label.localeCompare(b.label);
+ if (av === null) return 1;
+ if (bv === null) return -1;
+ if (av !== bv) return av - bv;
+ // GRC sorts hierarchically: cached first, then uncached, then dotcom.
+ const tie = (a.uncached.medianMs ?? Infinity) - (b.uncached.medianMs ?? Infinity);
+ if (tie !== 0) return tie;
+ return (a.dotcom.medianMs ?? Infinity) - (b.dotcom.medianMs ?? Infinity);
+ });
+ }, [result, sortKey]);
+
+ const chartRows = useMemo(
+ () =>
+ sorted
+ .filter((row) => row.cached.medianMs !== null)
+ .map((row) => ({
+ label: row.label,
+ cached: row.cached.medianMs,
+ uncached: row.uncached.medianMs,
+ dotcom: row.dotcom.medianMs,
+ })),
+ [sorted],
+ );
+
+ const unplotted = sorted.length - chartRows.length;
+
+ const SortHeader: React.FC<{ id: SortKey; children: React.ReactNode; align?: string }> = ({
+ id,
+ children,
+ align = 'text-right',
+ }) => (
+
+
+
+ );
+
+ return (
+
+
+
+
+
+
+
+
DNS resolver benchmark
+
+ Times public DNS-over-HTTPS resolvers three ways, following the split Steve Gibson
+ designed for GRC’s DNS Benchmark: a name the resolver already holds, a name it
+ must fetch from an authoritative server, and a name that forces it out to the{' '}
+ .com servers. A resolver can be instant from cache
+ and badly connected to everything else, and measuring only one of those tells you
+ neither.
+
+
+ {/* Permanent, not collapsible. A reader who misses this will take the
+ table below for a ranking of their own DNS, which it is not. */}
+
+
+
+ What this cannot measure
+
+
+
+
+ Your own resolver is not in this list, and cannot be.
+ {' '}
+ A web page has no raw sockets, no way to learn the address your system is using, and no
+ way to force a fresh lookup through it with a readable timing. This compares public
+ DNS-over-HTTPS providers to each other. To benchmark the resolver you are actually
+ using, use Steve Gibson’s{' '}
+
+ GRC DNS Benchmark
+
+ , a native tool that queries nameservers directly over UDP. The three-way split above is
+ his design.
+
+
+
+ Every figure includes the HTTPS round trip.
+ {' '}
+ TLS, HTTP framing and the operator’s front-end are inside each number, so these
+ are not comparable to the UDP timings a native tool reports.
+ {result?.phaseTimingsAvailable === false && (
+ <>
+ {' '}
+ This run confirmed it: none of these endpoints sent{' '}
+ Timing-Allow-Origin, so the browser could not
+ split DNS from TCP from TLS. That breakdown is absent rather than estimated.
+ >
+ )}
+
+
+
+ The list is short because of CORS, not merit.
+ {' '}
+ {CORS_BLOCKED_RESOLVERS.map((r) => r.label).join(', ')} send no{' '}
+ Access-Control-Allow-Origin header, so a browser
+ cannot read their answers at all. They are listed in the table with no figures, and
+ NetReady never sends them a query.
+
+
+
+ A failed request is not the resolver’s fault.
+ {' '}
+ Over HTTPS a lost query, a TLS failure, a blocking extension and a CORS rejection all
+ look identical to a browser. The “Answered” column counts replies; it is
+ deliberately not called reliability.
+
+
+
+
+ {/* The axis must start at zero. Recharts' default of
+ ['auto','auto'] can begin a numeric axis above zero, which
+ visually multiplies a small difference into a large one.
+ Scaling the top end is fine — GRC does it too. */}
+
+
+ (typeof value === 'number' ? `${value} ms` : '—')}
+ />
+
+ {SERIES.map((s) => (
+
+ ))}
+
+
+ {unplotted > 0 && (
+
+ {unplotted} resolver{unplotted === 1 ? '' : 's'} produced no measurements and{' '}
+ {unplotted === 1 ? 'is' : 'are'} not plotted — a zero-length bar would read as
+ “instant”. See the table below.
+
+ Each resolver received {result.samplesPerMetric} queries per column, sent one at a
+ time and cycled between resolvers so a burst of other traffic does not land on one of
+ them. Before measuring, each resolver got one warm-up query per name (
+ {POPULAR_NAMES.join(', ')}) whose timing was discarded — the first request to a host
+ pays for DNS, TCP and TLS. A column reads “—” below{' '}
+ {MIN_SAMPLES_PER_METRIC} answers.
+
+ );
+};
diff --git a/src/components/ExportPage.tsx b/src/components/ExportPage.tsx
index ffdc055..8c59325 100644
--- a/src/components/ExportPage.tsx
+++ b/src/components/ExportPage.tsx
@@ -26,6 +26,7 @@ import {
Stethoscope,
Network,
ShieldQuestion,
+ Timer,
} from 'lucide-react';
import { HistoryItem } from '../types';
import {
@@ -76,6 +77,8 @@ export const ExportPage: React.FC = ({ onHistoryUpdate }) => {
return ;
case 'dns':
return ;
+ case 'dnsbench':
+ return ;
case 'webrtc':
return ;
case 'httpprobe':
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx
index fabcbb6..e99743f 100644
--- a/src/components/Navbar.tsx
+++ b/src/components/Navbar.tsx
@@ -25,6 +25,7 @@ import {
Stethoscope,
Network,
ShieldQuestion,
+ Timer,
} from 'lucide-react';
import { ToolTab, NetworkConnectionInfo } from '../types';
import { PrivacySafetyModal } from './PrivacySafetyModal';
@@ -64,6 +65,7 @@ export const Navbar: React.FC = ({
{ id: 'speedtest', label: 'Speed Test', icon: Gauge },
{ id: 'ping', label: 'Ping & Jitter', icon: Radio },
{ id: 'dns', label: 'DoH DNS', icon: Globe },
+ { id: 'dnsbench', label: 'DNS Benchmark', icon: Timer, badge: 'NEW' },
{ id: 'webrtc', label: 'WebRTC STUN', icon: Cpu },
{ id: 'cidr', label: 'CIDR Subnet', icon: Calculator },
{ id: 'mac', label: 'MAC / OUI', icon: Search },
diff --git a/src/components/PrivacySafetyModal.tsx b/src/components/PrivacySafetyModal.tsx
index 63c4704..adec73b 100644
--- a/src/components/PrivacySafetyModal.tsx
+++ b/src/components/PrivacySafetyModal.tsx
@@ -20,6 +20,24 @@ export const THIRD_PARTY_DISCLOSURES: { host: string; receives: string }[] = [
host: 'cloudflare-dns.com / dns.google',
receives: 'Every domain name you resolve, over encrypted DNS-over-HTTPS.',
},
+ {
+ host:
+ 'cloudflare-dns.com, dns.google, dns.quad9.net, dns10.quad9.net, ' +
+ 'freedns.controld.com, doh.sb, public.dns.iij.jp',
+ receives:
+ 'Your IP and every name the DNS benchmark queries, when you run it — roughly twenty each. ' +
+ 'Most of those names are randomly generated (they have to be, or the resolver would answer ' +
+ 'from its cache and there would be nothing to measure), so each provider sees a set of ' +
+ 'unique strings that identifies that run to them. The rest are well-known names such as ' +
+ 'google.com. Each provider has its own logging and filtering practices.',
+ },
+ {
+ host: 'dnssec-failed.org, internetsociety.org',
+ receives:
+ 'Nothing directly — your browser never contacts them. Their names are sent to the DNS ' +
+ 'providers above as the DNSSEC test pair: one has a deliberately broken signature chain, ' +
+ 'the other a valid one. Only the resolvers see these queries.',
+ },
{
host: 'ipwho.is / ipapi.co / freeipapi.com',
receives:
diff --git a/src/types.ts b/src/types.ts
index 4f576a8..e983677 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -10,6 +10,7 @@ export type ToolTab =
| 'speedtest'
| 'ping'
| 'dns'
+ | 'dnsbench'
| 'webrtc'
| 'cidr'
| 'mac'
@@ -566,6 +567,121 @@ export interface DnsIntegrityResult {
failures: MeasurementFailure[];
}
+// ---------------------------------------------------------------------------
+// DNS resolver benchmark
+//
+// Inspired by Steve Gibson's GRC DNS Benchmark, which measures DNS over UDP
+// against a nameserver's IP address, separating cached, uncached and "dotcom"
+// lookups. A browser cannot do that: no raw sockets, no way to learn the system
+// resolver's address, and no way to read the phase breakdown of a cross-origin
+// request. What it *can* do is time RFC 8484 DNS-over-HTTPS queries to public
+// resolvers that permit cross-origin reads.
+//
+// Every millisecond in these types is therefore a full HTTPS round trip — TLS,
+// HTTP framing and the operator's front-end included. It is not "DNS lookup
+// time", and nothing in this file or its UI calls it that. The system/ISP
+// resolver, the one most users actually want measured, cannot be benchmarked
+// from a web page at all; that is stated in the UI rather than approximated.
+// ---------------------------------------------------------------------------
+
+/** The three lookup kinds GRC's benchmark separates, and why each is distinct:
+ * a resolver can be fast from cache and slow to reach the wider internet. */
+export type DnsProbeKind = 'cached' | 'uncached' | 'dotcom';
+
+export interface DnsMetricSummary {
+ /** Queries that produced a readable DNS response. Deliberately not called
+ * "reliability": over HTTPS a failed request may be the resolver, the
+ * network, an extension or TLS, and a browser cannot tell them apart. */
+ answered: number;
+ attempted: number;
+ medianMs: number | null;
+ /** Null below ten samples — see `summariseSamples` in network.ts. */
+ p95Ms: number | null;
+ minMs: number | null;
+ maxMs: number | null;
+ stdDevMs: number | null;
+}
+
+/** What a resolver did when asked for a name that provably does not exist.
+ * `answers-with-an-address` is deliberately neutral: the observation is that
+ * an address came back, not why. */
+export type NxdomainHonesty = 'honest' | 'answers-with-an-address' | 'inconclusive';
+
+export type DnssecValidation = 'validates' | 'does-not-validate' | 'inconclusive';
+
+export type ResolverOutcome =
+ | 'measured'
+ | 'partially-measured'
+ | 'no-readable-answer'
+ | 'blocked-by-browser'
+ | 'not-attempted';
+
+export interface ResolverBenchmark {
+ resolverId: string;
+ label: string;
+ operator: string;
+ endpoint: string;
+ /** The operator's published filtering policy, quoted for context. Not
+ * measured, and no verdict is derived from it. */
+ policyNote: string;
+ cached: DnsMetricSummary;
+ uncached: DnsMetricSummary;
+ dotcom: DnsMetricSummary;
+ /**
+ * Uncached median minus cached median, for this resolver only.
+ *
+ * Both halves went to the same endpoint over the same connection, so the
+ * constant HTTPS transport cost largely cancels and what remains is closer to
+ * the resolver's own cost of leaving its cache. It is a difference of two
+ * medians, not the median of paired differences, so it cancels the *typical*
+ * transport cost rather than any single query's.
+ *
+ * Null when either median is null. May be negative — a "cached" name whose
+ * TTL had expired at that anycast node produces exactly that — and the
+ * negative is reported. Clamping it to zero would be substituting a value for
+ * a measurement.
+ */
+ uncachedCostMs: number | null;
+ nxdomainHonesty: NxdomainHonesty;
+ nxdomainDetail: string;
+ /** Null when DNSSEC checking was switched off for the run, which is not the
+ * same as having checked and been unable to tell. */
+ dnssec: DnssecValidation | null;
+ dnssecDetail: string;
+ outcome: ResolverOutcome;
+ note: string;
+}
+
+export interface DnsBenchmarkResult {
+ id: string;
+ timestamp: number;
+ resolvers: ResolverBenchmark[];
+ samplesPerMetric: number;
+ /** The popular names used for the cached measurement, so a reader can see
+ * what was actually asked for. */
+ namesQueried: string[];
+ dnssecRequested: boolean;
+ /**
+ * Whether the browser could read the DNS/TCP/TLS phase breakdown of these
+ * requests. Observed from the Resource Timing entries rather than asserted:
+ * no DoH endpoint currently sends `Timing-Allow-Origin`, but that is a fact
+ * about today's deployments, not a law, so it is re-checked each run. Null
+ * when no timing entry was readable at all.
+ */
+ phaseTimingsAvailable: boolean | null;
+ fastestCachedResolverId: string | null;
+ /** True when the fastest resolver's observed range overlaps the runner-up's,
+ * i.e. this run does not separate them. Null when there is no runner-up. */
+ fastestIsWithinNoise: boolean | null;
+ verdict: 'measured' | 'partial' | 'nothing-measured' | null;
+ explanation: string;
+ /** Plain-English findings, in the spirit of GRC's "Conclusions" tab. Empty
+ * when nothing was measured — silence is not a clean bill of health. */
+ conclusions: string[];
+ totalTimeMs: number;
+ failures: MeasurementFailure[];
+}
+
export interface HistoryItem {
id: string;
type:
@@ -583,7 +699,8 @@ export interface HistoryItem {
| 'edgepath'
| 'triage'
| 'dualstack'
- | 'captive';
+ | 'captive'
+ | 'dnsbench';
timestamp: number;
title: string;
summary: string;
diff --git a/src/utils/captivePortal.ts b/src/utils/captivePortal.ts
index 3ac89cd..f1530db 100644
--- a/src/utils/captivePortal.ts
+++ b/src/utils/captivePortal.ts
@@ -5,7 +5,7 @@ import type {
IntegrityProbe,
MeasurementFailure,
} from '../types';
-import { createId, queryDnsOverHttps } from './network';
+import { createId, queryDnsOverHttps, timeoutSignal } from './network';
/**
* Captive-portal and DNS-hijack detection.
@@ -104,20 +104,6 @@ function hasJsonKey(body: string, key: string): boolean {
}
}
-function timeoutSignal(ms: number, external?: AbortSignal): { signal: AbortSignal; done: () => void } {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), ms);
- const onAbort = () => controller.abort();
- external?.addEventListener('abort', onAbort);
- return {
- signal: controller.signal,
- done: () => {
- clearTimeout(timer);
- external?.removeEventListener('abort', onAbort);
- },
- };
-}
-
/** True when the current page may open plaintext http: connections. */
export function canProbePlaintext(protocol: string): boolean {
return protocol === 'http:';
diff --git a/src/utils/dnsBenchmark.test.ts b/src/utils/dnsBenchmark.test.ts
new file mode 100644
index 0000000..8c5fce1
--- /dev/null
+++ b/src/utils/dnsBenchmark.test.ts
@@ -0,0 +1,367 @@
+import { describe, it, expect } from 'vitest';
+import {
+ CORS_BLOCKED_RESOLVERS,
+ DOH_RESOLVERS,
+ classifyDnssec,
+ classifyNxdomain,
+ classifyResolver,
+ summariseBenchmark,
+ uncachedCost,
+ type DnsProbeOutcome,
+} from './dnsBenchmark';
+import { DNS_TYPE, type DnsMessage } from './dnsWire';
+import type { DnsMetricSummary, ResolverBenchmark } from '../types';
+
+const message = (over: {
+ rcode?: number;
+ ad?: boolean;
+ addresses?: number;
+} = {}): DnsMessage => {
+ const { rcode = 0, ad = false, addresses = 0 } = over;
+ return {
+ header: {
+ id: 1,
+ qr: true,
+ opcode: 0,
+ aa: false,
+ tc: false,
+ rd: true,
+ ra: true,
+ ad,
+ cd: false,
+ rcode,
+ qdcount: 1,
+ ancount: addresses,
+ nscount: 0,
+ arcount: 0,
+ },
+ question: { name: 'x.example', qtype: 1, qclass: 1 },
+ answers: Array.from({ length: addresses }, () => ({
+ name: 'x.example',
+ type: DNS_TYPE.A,
+ class: 1,
+ ttl: 60,
+ data: '203.0.113.1',
+ })),
+ };
+};
+
+const answered = (msg: DnsMessage): DnsProbeOutcome => ({
+ roundTripMs: 20,
+ message: msg,
+ status: 'answered',
+ note: null,
+});
+
+const silent = (): DnsProbeOutcome => ({
+ roundTripMs: null,
+ message: null,
+ status: 'no-answer',
+ note: 'nothing came back',
+});
+
+const metric = (over: Partial = {}): DnsMetricSummary => ({
+ answered: 5,
+ attempted: 5,
+ medianMs: 20,
+ p95Ms: null,
+ minMs: 18,
+ maxMs: 24,
+ stdDevMs: 2,
+ ...over,
+});
+
+const emptyMetric = (): DnsMetricSummary => ({
+ answered: 0,
+ attempted: 5,
+ medianMs: null,
+ p95Ms: null,
+ minMs: null,
+ maxMs: null,
+ stdDevMs: null,
+});
+
+const row = (over: Partial = {}): ResolverBenchmark => ({
+ resolverId: 'r',
+ label: 'Resolver',
+ operator: 'Operator',
+ endpoint: 'https://example.test/dns-query',
+ policyNote: '',
+ cached: metric(),
+ uncached: metric({ medianMs: 60, minMs: 55, maxMs: 70 }),
+ dotcom: metric({ medianMs: 70, minMs: 65, maxMs: 80 }),
+ uncachedCostMs: 40,
+ nxdomainHonesty: 'honest',
+ nxdomainDetail: '',
+ dnssec: 'validates',
+ dnssecDetail: '',
+ outcome: 'measured',
+ note: '',
+ ...over,
+});
+
+describe('the resolver catalogue', () => {
+ it('has unique ids and reaches every endpoint over https', () => {
+ const ids = DOH_RESOLVERS.map((r) => r.id);
+ expect(new Set(ids).size).toBe(ids.length);
+ for (const r of DOH_RESOLVERS) {
+ expect(r.endpoint.startsWith('https://')).toBe(true);
+ expect(r.operator.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('names the resolvers a browser cannot reach, rather than omitting them', () => {
+ // A short list that does not say why it is short reads as a complete one.
+ expect(CORS_BLOCKED_RESOLVERS.length).toBeGreaterThan(0);
+ const blockedEndpoints = new Set(CORS_BLOCKED_RESOLVERS.map((r) => r.endpoint));
+ for (const r of DOH_RESOLVERS) expect(blockedEndpoints.has(r.endpoint)).toBe(false);
+ });
+});
+
+describe('classifyNxdomain', () => {
+ it('says nothing when nothing came back', () => {
+ // A resolver that did not answer has not demonstrated honesty.
+ expect(classifyNxdomain([]).honesty).toBe('inconclusive');
+ expect(classifyNxdomain([silent(), silent()]).honesty).toBe('inconclusive');
+ });
+
+ it('calls NXDOMAIN for a name that does not exist honest', () => {
+ const result = classifyNxdomain([
+ answered(message({ rcode: 3 })),
+ answered(message({ rcode: 3 })),
+ ]);
+ expect(result.honesty).toBe('honest');
+ });
+
+ it('flags a resolver only when both made-up names got an address', () => {
+ const both = classifyNxdomain([
+ answered(message({ rcode: 0, addresses: 1 })),
+ answered(message({ rcode: 0, addresses: 2 })),
+ ]);
+ expect(both.honesty).toBe('answers-with-an-address');
+ // The wording must describe the observation, not assert a motive.
+ expect(both.detail).toContain('not the reason for it');
+ });
+
+ it('stays inconclusive when only one made-up name got an address', () => {
+ // One is within the noise of a parked wildcard, and the accusation is
+ // specific enough to be worth being sure about.
+ const mixed = classifyNxdomain([
+ answered(message({ rcode: 0, addresses: 1 })),
+ answered(message({ rcode: 3 })),
+ ]);
+ expect(mixed.honesty).toBe('inconclusive');
+ });
+
+ it('does not treat SERVFAIL as honesty', () => {
+ const result = classifyNxdomain([
+ answered(message({ rcode: 2 })),
+ answered(message({ rcode: 2 })),
+ ]);
+ expect(result.honesty).toBe('inconclusive');
+ expect(result.detail).toContain('SERVFAIL');
+ });
+
+ it('needs two probes before claiming an address was returned', () => {
+ const single = classifyNxdomain([answered(message({ rcode: 0, addresses: 1 }))]);
+ expect(single.honesty).toBe('inconclusive');
+ });
+});
+
+describe('classifyDnssec', () => {
+ const brokenRefused = answered(message({ rcode: 2 }));
+ const brokenServed = answered(message({ rcode: 0, addresses: 1 }));
+ const signedAuthentic = answered(message({ rcode: 0, ad: true, addresses: 1 }));
+ const signedPlain = answered(message({ rcode: 0, ad: false, addresses: 1 }));
+
+ it('requires both signals to agree before claiming validation', () => {
+ expect(classifyDnssec(brokenRefused, signedAuthentic).validation).toBe('validates');
+ expect(classifyDnssec(brokenServed, signedPlain).validation).toBe('does-not-validate');
+ });
+
+ it('reports disagreement as inconclusive rather than resolving it', () => {
+ // A resolver simply broken for one name also SERVFAILs, and a resolver can
+ // set AD without ever refusing a broken chain. Neither half stands alone.
+ expect(classifyDnssec(brokenRefused, signedPlain).validation).toBe('inconclusive');
+ expect(classifyDnssec(brokenServed, signedAuthentic).validation).toBe('inconclusive');
+ });
+
+ it('is inconclusive when either probe produced nothing', () => {
+ expect(classifyDnssec(silent(), signedAuthentic).validation).toBe('inconclusive');
+ expect(classifyDnssec(brokenRefused, silent()).validation).toBe('inconclusive');
+ });
+
+ it('describes the AD bit as a claim, not as proof', () => {
+ const detail = classifyDnssec(brokenRefused, signedAuthentic).detail;
+ expect(detail).toContain('own claim');
+ });
+});
+
+describe('classifyResolver', () => {
+ const base = {
+ label: 'Resolver',
+ cached: metric(),
+ uncached: metric(),
+ dotcom: metric(),
+ corsBlocked: false,
+ everAttempted: true,
+ sawHttpResponse: false,
+ };
+
+ it('reports a CORS block as a browser limitation, not a resolver result', () => {
+ const r = classifyResolver({ ...base, corsBlocked: true });
+ expect(r.outcome).toBe('blocked-by-browser');
+ expect(r.note).toContain('not a measurement of the resolver');
+ });
+
+ it('distinguishes "never asked" from "asked and got nothing"', () => {
+ expect(classifyResolver({ ...base, everAttempted: false }).outcome).toBe('not-attempted');
+ const nothing = classifyResolver({
+ ...base,
+ cached: emptyMetric(),
+ uncached: emptyMetric(),
+ dotcom: emptyMetric(),
+ });
+ expect(nothing.outcome).toBe('no-readable-answer');
+ });
+
+ it('refuses to blame the resolver for an ambiguous failure', () => {
+ const nothing = classifyResolver({
+ ...base,
+ cached: emptyMetric(),
+ uncached: emptyMetric(),
+ dotcom: emptyMetric(),
+ });
+ expect(nothing.note).toContain('both fail identically');
+ });
+
+ it('does not claim a browser limitation when the resolver plainly replied', () => {
+ // A live run caught this: a resolver returning HTTP 505 was reported as
+ // "does not send the header that lets a web page read its answers". Reading
+ // a status code at all proves the browser was allowed to see the response,
+ // so that note asserted something the reply itself disproved.
+ const errored = classifyResolver({
+ ...base,
+ cached: emptyMetric(),
+ uncached: emptyMetric(),
+ dotcom: emptyMetric(),
+ sawHttpResponse: true,
+ lastNote: 'The resolver declined the query with HTTP 505.',
+ });
+ expect(errored.outcome).toBe('no-readable-answer');
+ expect(errored.note).toContain('replied, but never with a usable DNS answer');
+ expect(errored.note).toContain('505');
+ expect(errored.note).not.toContain('does not send the header');
+ expect(errored.note).not.toContain('blocked it');
+ });
+
+ it('marks a resolver partial when only some metrics have enough samples', () => {
+ const partial = classifyResolver({ ...base, dotcom: metric({ answered: 1, medianMs: null }) });
+ expect(partial.outcome).toBe('partially-measured');
+ });
+
+ it('marks a resolver measured when every metric produced a median', () => {
+ expect(classifyResolver(base).outcome).toBe('measured');
+ });
+});
+
+describe('uncachedCost', () => {
+ it('is null when either median is missing', () => {
+ expect(uncachedCost(metric({ medianMs: null }), metric())).toBeNull();
+ expect(uncachedCost(metric(), metric({ medianMs: null }))).toBeNull();
+ });
+
+ it('preserves a negative difference instead of clamping it to zero', () => {
+ // A "cached" name whose TTL had expired at that anycast node produces
+ // exactly this. Clamping would substitute a value for a measurement.
+ expect(uncachedCost(metric({ medianMs: 30 }), metric({ medianMs: 26 }))).toBe(-4);
+ });
+
+ it('preserves a genuine zero', () => {
+ expect(uncachedCost(metric({ medianMs: 30 }), metric({ medianMs: 30 }))).toBe(0);
+ });
+});
+
+describe('summariseBenchmark', () => {
+ it('produces no verdict and no conclusions from nothing', () => {
+ // The engine's empty-snapshot test, for this tool. Silence must not read as
+ // a clean bill of health.
+ const s = summariseBenchmark([]);
+ expect(s.verdict).toBeNull();
+ expect(s.conclusions).toEqual([]);
+ expect(s.fastestCachedResolverId).toBeNull();
+ });
+
+ it('declines to rank when every resolver failed', () => {
+ const dead = row({
+ cached: emptyMetric(),
+ uncached: emptyMetric(),
+ dotcom: emptyMetric(),
+ outcome: 'no-readable-answer',
+ });
+ const s = summariseBenchmark([dead]);
+ expect(s.verdict).toBe('nothing-measured');
+ expect(s.fastestCachedResolverId).toBeNull();
+ expect(s.conclusions).toEqual([]);
+ });
+
+ it('does not call a single result a ranking', () => {
+ const s = summariseBenchmark([row({ resolverId: 'only' })]);
+ expect(s.fastestCachedResolverId).toBe('only');
+ expect(s.fastestIsWithinNoise).toBeNull();
+ expect(s.conclusions[0]).toContain('not a ranking');
+ });
+
+ it('declares a winner only when the observed ranges do not overlap', () => {
+ const fast = row({
+ resolverId: 'fast',
+ label: 'Fast',
+ cached: metric({ medianMs: 10, minMs: 8, maxMs: 12 }),
+ });
+ const slow = row({
+ resolverId: 'slow',
+ label: 'Slow',
+ cached: metric({ medianMs: 90, minMs: 85, maxMs: 95 }),
+ });
+ const s = summariseBenchmark([slow, fast]);
+ expect(s.fastestCachedResolverId).toBe('fast');
+ expect(s.fastestIsWithinNoise).toBe(false);
+ expect(s.conclusions[0]).toContain('clear of');
+ });
+
+ it('says two resolvers are indistinguishable when their ranges overlap', () => {
+ // The replacement for a significance claim: a statement about the intervals
+ // that were actually observed, not a p-value with no basis.
+ const a = row({ resolverId: 'a', label: 'A', cached: metric({ medianMs: 20, minMs: 15, maxMs: 40 }) });
+ const b = row({ resolverId: 'b', label: 'B', cached: metric({ medianMs: 22, minMs: 16, maxMs: 44 }) });
+ const s = summariseBenchmark([a, b]);
+ expect(s.fastestIsWithinNoise).toBe(true);
+ expect(s.conclusions[0]).toContain('does not separate them');
+ });
+
+ it('always states that the user’s own resolver is not in the table', () => {
+ const s = summariseBenchmark([row(), row({ resolverId: 'b' })]);
+ expect(s.conclusions[s.conclusions.length - 1]).toContain(
+ 'describes the resolver your device is actually using',
+ );
+ });
+
+ it('reports a resolver that answers made-up names with an address', () => {
+ const liar = row({ resolverId: 'liar', label: 'Liar', nxdomainHonesty: 'answers-with-an-address' });
+ const s = summariseBenchmark([row(), liar]);
+ expect(s.conclusions.join(' ')).toContain('Liar');
+ expect(s.conclusions.join(' ')).toContain('do not exist');
+ });
+
+ it('does not let a browser-blocked resolver drag the verdict to partial', () => {
+ const blocked = row({
+ resolverId: 'blocked',
+ outcome: 'blocked-by-browser',
+ cached: emptyMetric(),
+ uncached: emptyMetric(),
+ dotcom: emptyMetric(),
+ });
+ const s = summariseBenchmark([row(), blocked]);
+ expect(s.verdict).toBe('measured');
+ });
+});
diff --git a/src/utils/dnsBenchmark.ts b/src/utils/dnsBenchmark.ts
new file mode 100644
index 0000000..0153abb
--- /dev/null
+++ b/src/utils/dnsBenchmark.ts
@@ -0,0 +1,1168 @@
+import type {
+ DnsBenchmarkResult,
+ DnsMetricSummary,
+ DnsProbeKind,
+ DnssecValidation,
+ MeasurementFailure,
+ NxdomainHonesty,
+ ResolverBenchmark,
+ ResolverOutcome,
+} from '../types';
+import { createId, summariseSamples, timeoutSignal } from './network';
+import { readPhases } from './edgePath';
+import {
+ DNS_TYPE,
+ decodeDnsMessage,
+ encodeDnsQuery,
+ rcodeName,
+ toBase64Url,
+ type DnsMessage,
+} from './dnsWire';
+
+/**
+ * DNS resolver benchmark, after Steve Gibson's GRC DNS Benchmark.
+ *
+ * His tool sends UDP queries straight to a nameserver's IP and separates three
+ * kinds of lookup — one already in the resolver's cache, one that forces the
+ * resolver out to an authoritative server, and one that forces it to consult
+ * the .com TLD servers. That separation is the insight worth borrowing: a
+ * resolver can be instant from cache and badly connected to everything else,
+ * and a benchmark that mixes the two tells you neither.
+ *
+ * What a browser can reproduce, and what it cannot:
+ *
+ * - It cannot open a UDP socket, so nothing here touches port 53. Every
+ * measurement is an RFC 8484 DNS-over-HTTPS request, and every millisecond
+ * includes TLS, HTTP framing and the operator's front-end.
+ * - It cannot discover the address of the system resolver, cannot query it,
+ * and cannot force a fresh lookup through it whose timing is readable. The
+ * resolver a user is actually using is therefore absent from these results.
+ * That is stated in the UI, not approximated.
+ * - It cannot query a provider that omits `Access-Control-Allow-Origin`. Ten
+ * well-known resolvers are unreachable for that reason alone, and they are
+ * listed rather than quietly dropped.
+ * - It cannot count dropped DNS queries. TCP and TLS hide packet loss; what
+ * is observable is a failed HTTPS request, which is not the same thing and
+ * is never labelled "reliability".
+ *
+ * The three lookup kinds still work, though, and they work honestly: a random
+ * label under a popular domain is guaranteed not to be in any cache, and a
+ * random second-level `.com` name forces a TLD consultation. Both were verified
+ * against all seven endpoints below before this was written.
+ */
+
+export interface DohResolver {
+ id: string;
+ label: string;
+ operator: string;
+ endpoint: string;
+ /** The operator's own published policy, quoted for context so a user can tell
+ * a filtering resolver from an unfiltered one. NetReady does not verify it
+ * and derives no verdict from it. */
+ policyNote: string;
+}
+
+/**
+ * Resolvers this tool can actually query.
+ *
+ * Membership is decided by one thing only: whether the endpoint sends
+ * `Access-Control-Allow-Origin`, without which a browser cannot read the
+ * response at all. Each of these was confirmed to answer RFC 8484 wireformat
+ * queries cross-origin on 2026-08-16.
+ */
+export const DOH_RESOLVERS: DohResolver[] = [
+ {
+ id: 'cloudflare',
+ label: 'Cloudflare',
+ operator: 'Cloudflare, Inc.',
+ endpoint: 'https://cloudflare-dns.com/dns-query',
+ policyNote: 'Operator states: unfiltered.',
+ },
+ {
+ id: 'google',
+ label: 'Google Public DNS',
+ operator: 'Google LLC',
+ endpoint: 'https://dns.google/dns-query',
+ policyNote: 'Operator states: unfiltered.',
+ },
+ {
+ id: 'quad9',
+ label: 'Quad9',
+ operator: 'Quad9 Foundation',
+ endpoint: 'https://dns.quad9.net/dns-query',
+ policyNote: 'Operator states: blocks known malicious domains.',
+ },
+ {
+ id: 'quad9-unfiltered',
+ label: 'Quad9 (unfiltered)',
+ operator: 'Quad9 Foundation',
+ endpoint: 'https://dns10.quad9.net/dns-query',
+ policyNote: 'Operator states: no blocking, no DNSSEC validation.',
+ },
+ {
+ id: 'controld',
+ label: 'Control D',
+ operator: 'Control D Inc.',
+ endpoint: 'https://freedns.controld.com/p0',
+ policyNote: 'Operator states: unfiltered (the p0 profile).',
+ },
+ {
+ id: 'dnssb',
+ label: 'DNS.SB',
+ operator: 'xTom / DNS.SB',
+ endpoint: 'https://doh.sb/dns-query',
+ policyNote: 'Operator states: unfiltered, no logging.',
+ },
+ {
+ id: 'iij',
+ label: 'IIJ Public DNS',
+ operator: 'Internet Initiative Japan',
+ endpoint: 'https://public.dns.iij.jp/dns-query',
+ policyNote: 'Operator states: unfiltered.',
+ },
+];
+
+/**
+ * Public DoH resolvers a browser cannot query.
+ *
+ * These sent no `Access-Control-Allow-Origin` header when the list was compiled
+ * (2026-08-16), so `fetch` discards their responses before any script can read
+ * them. They are listed rather than omitted for the same reason
+ * `describeTargetExpansion` exists: a short list that does not say why it is
+ * short reads as a complete one. Their absence is a browser limitation, not a
+ * judgement about the operators, and NetReady never sends them a query.
+ */
+export const CORS_BLOCKED_RESOLVERS: { label: string; endpoint: string }[] = [
+ { label: 'OpenDNS', endpoint: 'https://doh.opendns.com/dns-query' },
+ { label: 'AdGuard DNS', endpoint: 'https://dns.adguard-dns.com/dns-query' },
+ { label: 'Mullvad DNS', endpoint: 'https://dns.mullvad.net/dns-query' },
+ { label: 'NextDNS', endpoint: 'https://dns.nextdns.io' },
+ { label: 'Cisco Umbrella', endpoint: 'https://doh.umbrella.com/dns-query' },
+ { label: 'Yandex DNS', endpoint: 'https://common.dot.dns.yandex.net/dns-query' },
+ { label: 'LibreDNS', endpoint: 'https://doh.libredns.gr/dns-query' },
+ { label: 'CIRA Canadian Shield', endpoint: 'https://private.canadianshield.cira.ca/dns-query' },
+ { label: 'Wikimedia DNS', endpoint: 'https://wikimedia-dns.org/dns-query' },
+ { label: 'Digitale Gesellschaft', endpoint: 'https://dns.digitale-gesellschaft.ch/dns-query' },
+];
+
+/**
+ * Names near-certain to be in any public resolver's cache.
+ *
+ * Five rather than one: a single name whose TTL happened to expire partway
+ * through a run would turn that resolver's "cached" figure into an uncached one
+ * and make it look slow for a reason that has nothing to do with the resolver.
+ */
+export const POPULAR_NAMES = [
+ 'google.com',
+ 'youtube.com',
+ 'facebook.com',
+ 'wikipedia.org',
+ 'amazon.com',
+];
+
+/** Comcast's public test domain: its DNSSEC chain is deliberately broken, so a
+ * validating resolver must refuse to serve it. */
+const DNSSEC_BROKEN_NAME = 'dnssec-failed.org';
+/** A correctly signed control. A validating resolver sets the AD bit on it. */
+const DNSSEC_SIGNED_NAME = 'internetsociety.org';
+
+export const SAMPLES_PER_METRIC = 5;
+
+/**
+ * Per-query deadline.
+ *
+ * Deliberately shorter than the 6 s `PROBE_TIMEOUT_MS` the one-shot checks use:
+ * this is a loop of ~150 queries, and a DoH answer slower than 2.5 s is itself
+ * the finding rather than something worth waiting out.
+ */
+const QUERY_TIMEOUT_MS = 2500;
+
+/** After this many consecutive silences a resolver's remaining queries are not
+ * sent. They are recorded as `not-attempted`, which is true, rather than as
+ * failures the resolver never had the chance to cause. */
+const CONSECUTIVE_FAILURES_BEFORE_GIVING_UP = 2;
+
+/** Minimum answered queries before any statistic is reported for a metric. */
+export const MIN_SAMPLES_PER_METRIC = 3;
+
+/**
+ * Fresh 16-bit DNS message ID per query.
+ *
+ * RFC 8484 §4.1 recommends id = 0 so identical queries are byte-identical and
+ * HTTP caches can share them. This tool wants precisely the opposite: a cached
+ * HTTP response would report the browser's disk latency as the resolver's DNS
+ * latency. Randomising the ID changes the `dns=` parameter itself, which is the
+ * only cache-buster that works here — appending an extra query parameter such
+ * as `&_nr=` makes Quad9 reject the request with HTTP 403.
+ *
+ * It doubles as a correctness check: a reply whose ID does not match the query
+ * did not come from this query.
+ */
+function randomMessageId(): number {
+ const buf = new Uint16Array(1);
+ crypto.getRandomValues(buf);
+ return buf[0];
+}
+
+const LABEL_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
+
+/** Random DNS label, for names that must not be in anyone's cache. */
+function randomLabel(length: number): string {
+ const bytes = new Uint8Array(length);
+ crypto.getRandomValues(bytes);
+ let out = '';
+ // Start with a letter: a leading digit is legal in a hostname label but not
+ // universally handled, and this is not the place to find that out.
+ out += LABEL_ALPHABET[bytes[0] % 26];
+ for (let i = 1; i < length; i++) out += LABEL_ALPHABET[bytes[i] % LABEL_ALPHABET.length];
+ return out;
+}
+
+export type ProbeStatus =
+ | 'answered'
+ /** The resolver replied, but not with something usable — an HTTP error, an
+ * undecodable body, a reply that did not match the query. Distinct from
+ * `request-failed` because reading any of that proves the request completed
+ * and the browser was allowed to see the response. */
+ | 'no-answer'
+ /** Nothing came back at all. `fetch` rejected, and a browser cannot say why. */
+ | 'request-failed'
+ | 'blocked-by-browser'
+ | 'timeout'
+ | 'not-attempted';
+
+export interface DnsProbeOutcome {
+ /** Wall clock around the fetch. Null whenever nothing readable came back —
+ * including for an HTTP error, because timing a rate-limit rejection would
+ * put it in the median as though it were a lookup. */
+ roundTripMs: number | null;
+ message: DnsMessage | null;
+ status: ProbeStatus;
+ note: string | null;
+}
+
+const notAttempted = (note: string): DnsProbeOutcome => ({
+ roundTripMs: null,
+ message: null,
+ status: 'not-attempted',
+ note,
+});
+
+/**
+ * Sends one DoH query and times it.
+ *
+ * Kept a CORS *simple* request on purpose: method GET, and `Accept` is the only
+ * header, with a value that is on the safelist. Any custom header would trigger
+ * an `OPTIONS` preflight, which several of these endpoints reject — and the
+ * rejection would look exactly like "this resolver is unreachable".
+ */
+export async function queryResolver(
+ endpoint: string,
+ name: string,
+ qtype: number,
+ options: { dnssecOk?: boolean; corsBlocked?: boolean } = {},
+ signal?: AbortSignal,
+): Promise {
+ if (options.corsBlocked) {
+ return {
+ roundTripMs: null,
+ message: null,
+ status: 'blocked-by-browser',
+ note: 'This page is not permitted to read responses from this endpoint.',
+ };
+ }
+
+ const id = randomMessageId();
+ const wire = encodeDnsQuery(name, { id, qtype, dnssecOk: options.dnssecOk ?? false });
+ if (wire === null) {
+ // Our own encoder refused the name. That is a bug in the caller, not a
+ // result about the resolver, so it must not be recorded as one.
+ return notAttempted(`The name “${name}” could not be encoded as a DNS query.`);
+ }
+
+ const url = `${endpoint}${endpoint.includes('?') ? '&' : '?'}dns=${toBase64Url(wire)}`;
+ const gate = timeoutSignal(QUERY_TIMEOUT_MS, signal);
+ const started = performance.now();
+
+ try {
+ const res = await fetch(url, {
+ method: 'GET',
+ headers: { Accept: 'application/dns-message' },
+ cache: 'no-store',
+ mode: 'cors',
+ signal: gate.signal,
+ });
+ const elapsed = performance.now() - started;
+
+ if (!res.ok) {
+ return {
+ roundTripMs: null,
+ message: null,
+ status: 'no-answer',
+ note: `The resolver declined the query with HTTP ${res.status}.`,
+ };
+ }
+
+ const message = decodeDnsMessage(new Uint8Array(await res.arrayBuffer()));
+ if (message === null) {
+ return {
+ roundTripMs: null,
+ message: null,
+ status: 'no-answer',
+ note: 'The response was not a readable DNS message.',
+ };
+ }
+
+ if (message.header.id !== id) {
+ // The random ID pays for itself here: a mismatched reply is a stale cache
+ // hit or a crossed wire, and timing it would be timing the wrong thing.
+ return {
+ roundTripMs: null,
+ message: null,
+ status: 'no-answer',
+ note: 'The reply did not match the query that was sent.',
+ };
+ }
+
+ return { roundTripMs: elapsed, message, status: 'answered', note: null };
+ } catch (error) {
+ if (signal?.aborted) return notAttempted('The run was stopped before this query was sent.');
+ if (error instanceof DOMException && error.name === 'AbortError') {
+ return {
+ roundTripMs: null,
+ message: null,
+ status: 'timeout',
+ note: `No answer within ${QUERY_TIMEOUT_MS} ms.`,
+ };
+ }
+ return {
+ roundTripMs: null,
+ message: null,
+ status: 'request-failed',
+ note: error instanceof Error ? error.message : 'The request failed.',
+ };
+ } finally {
+ gate.done();
+ }
+}
+
+/**
+ * Decides whether a `fetch` rejection was the browser refusing to show us a
+ * response, or nothing arriving at all.
+ *
+ * `TypeError: Failed to fetch` is emitted identically for a CORS rejection, a
+ * DNS failure, a refused connection, a TLS failure, an extension block and
+ * being offline. Picking one would be a guess. Repeating the request in
+ * `no-cors` mode settles it: if that resolves, the connection completed and the
+ * browser withheld the body, which is CORS. If it also fails, nothing got
+ * through and we say so without claiming to know which.
+ *
+ * Only ever called for `request-failed`. An HTTP error is not ambiguous and
+ * must never come through here: reading a status code at all proves the browser
+ * was allowed to see the response. Calling a resolver CORS-blocked on the
+ * strength of a 503 would be asserting something its own reply disproves — an
+ * earlier version of this file did exactly that to a resolver returning 505.
+ *
+ * This is the same two-step already used by `probeFamilyEndpoint`. Note that
+ * the retry sends a real query the resolver will log, which is why it runs at
+ * most once per resolver per run — and why it appears in the privacy
+ * disclosure. An opaque success proves the connection completed and nothing
+ * more; it cannot distinguish 200 from 403, so it never yields a timing sample.
+ */
+async function probeIsCorsBlocked(
+ endpoint: string,
+ signal?: AbortSignal,
+): Promise {
+ const wire = encodeDnsQuery('example.com', { id: randomMessageId(), qtype: DNS_TYPE.A });
+ if (wire === null) return false;
+ const url = `${endpoint}${endpoint.includes('?') ? '&' : '?'}dns=${toBase64Url(wire)}`;
+ const gate = timeoutSignal(QUERY_TIMEOUT_MS, signal);
+ try {
+ await fetch(url, { method: 'GET', cache: 'no-store', mode: 'no-cors', signal: gate.signal });
+ return true;
+ } catch {
+ return false;
+ } finally {
+ gate.done();
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Pure classifiers. Everything the user reads is produced here, so every branch
+// is testable without a network.
+// ---------------------------------------------------------------------------
+
+const addressAnswers = (outcome: DnsProbeOutcome): number =>
+ (outcome.message?.answers ?? []).filter(
+ (a) => a.type === DNS_TYPE.A || a.type === DNS_TYPE.AAAA,
+ ).length;
+
+/**
+ * Whether a resolver tells the truth about names that do not exist.
+ *
+ * GRC calls the failure mode "redirection" and colours those nameservers
+ * orange: instead of NXDOMAIN, some operators return an address so a browser
+ * lands on a search or advertising page.
+ *
+ * Both probes must come back with an address before that is claimed. One is
+ * within the noise of a parked wildcard or a registry glitch, and the accusation
+ * is specific enough to be worth being sure about. Anything else — SERVFAIL,
+ * REFUSED, silence — is `inconclusive`, not `honest`: a resolver that failed to
+ * answer has not demonstrated honesty.
+ */
+export function classifyNxdomain(outcomes: readonly DnsProbeOutcome[]): {
+ honesty: NxdomainHonesty;
+ detail: string;
+} {
+ const answered = outcomes.filter((o) => o.status === 'answered' && o.message !== null);
+ if (answered.length === 0) {
+ return {
+ honesty: 'inconclusive',
+ detail: 'No usable reply came back for the made-up names, so this could not be checked.',
+ };
+ }
+
+ const withAddress = answered.filter((o) => addressAnswers(o) > 0);
+ if (withAddress.length === answered.length && answered.length >= 2) {
+ return {
+ honesty: 'answers-with-an-address',
+ detail:
+ 'Asked for names that do not exist, this resolver returned an address instead of ' +
+ '“no such name”. A browser sent to that address usually lands on a search or ' +
+ 'advertising page. What is observable here is the address, not the reason for it.',
+ };
+ }
+
+ if (withAddress.length > 0) {
+ return {
+ honesty: 'inconclusive',
+ detail:
+ 'One made-up name got an address back and another did not, which is not a consistent ' +
+ 'enough result to call either way.',
+ };
+ }
+
+ const allNxdomain = answered.every((o) => o.message!.header.rcode === 3);
+ if (allNxdomain) {
+ return {
+ honesty: 'honest',
+ detail: 'Names that do not exist came back as NXDOMAIN, which is the correct answer.',
+ };
+ }
+
+ const codes = [...new Set(answered.map((o) => rcodeName(o.message!.header.rcode)))].join(', ');
+ return {
+ honesty: 'inconclusive',
+ detail: `The made-up names produced ${codes} rather than NXDOMAIN, which settles nothing either way.`,
+ };
+}
+
+/**
+ * Whether a resolver validates DNSSEC.
+ *
+ * Both signals must agree. Either on its own is ambiguous: a resolver that is
+ * simply broken for one name also returns SERVFAIL, and a resolver can set the
+ * AD bit without ever refusing a broken chain. Disagreement is reported as
+ * inconclusive rather than resolved in the resolver's favour.
+ *
+ * Note what the AD bit is: the resolver's *claim* that it validated. A browser
+ * cannot check the signature chain itself, which is why the broken-chain probe
+ * is the half that carries the weight.
+ */
+export function classifyDnssec(
+ brokenChain: DnsProbeOutcome,
+ signedControl: DnsProbeOutcome,
+): { validation: DnssecValidation; detail: string } {
+ const brokenMsg = brokenChain.status === 'answered' ? brokenChain.message : null;
+ const signedMsg = signedControl.status === 'answered' ? signedControl.message : null;
+
+ if (brokenMsg === null || signedMsg === null) {
+ return {
+ validation: 'inconclusive',
+ detail:
+ 'One of the two DNSSEC test names did not produce a usable reply, so validation could ' +
+ 'not be determined. Both halves are needed: one name with a deliberately broken ' +
+ 'signature chain, and one correctly signed name as a control.',
+ };
+ }
+
+ const refusedBrokenChain = brokenMsg.header.rcode === 2; // SERVFAIL
+ const claimsAuthenticated = signedMsg.header.ad;
+
+ if (refusedBrokenChain && claimsAuthenticated) {
+ return {
+ validation: 'validates',
+ detail:
+ 'This resolver refused to serve a name with a deliberately broken signature chain, and ' +
+ 'set the AD bit on a correctly signed name. The AD bit is the resolver’s own claim — a ' +
+ 'browser cannot verify the chain itself — but the refusal is behaviour, and the two agree.',
+ };
+ }
+
+ if (!refusedBrokenChain && !claimsAuthenticated) {
+ return {
+ validation: 'does-not-validate',
+ detail:
+ 'This resolver served a name whose signature chain is deliberately broken, and did not ' +
+ 'mark a correctly signed name as authenticated. It is passing DNSSEC records through ' +
+ 'rather than checking them.',
+ };
+ }
+
+ return {
+ validation: 'inconclusive',
+ detail: refusedBrokenChain
+ ? 'This resolver refused the broken-chain name but did not set the AD bit on the signed ' +
+ 'control, so the two signals disagree.'
+ : 'This resolver set the AD bit on the signed control but still served the broken-chain ' +
+ 'name, so the two signals disagree.',
+ };
+}
+
+/** Whether a resolver produced enough to be worth reading. */
+export function classifyResolver(row: {
+ label: string;
+ cached: DnsMetricSummary;
+ uncached: DnsMetricSummary;
+ dotcom: DnsMetricSummary;
+ corsBlocked: boolean;
+ everAttempted: boolean;
+ /** Whether any HTTP response was read from this resolver, usable or not. It
+ * changes what can honestly be said about a failure. */
+ sawHttpResponse: boolean;
+ /** The last thing that went wrong, quoted into the note when it is known. */
+ lastNote?: string | null;
+}): { outcome: ResolverOutcome; note: string } {
+ if (row.corsBlocked) {
+ return {
+ outcome: 'blocked-by-browser',
+ note:
+ `${row.label} does not send the header that lets a web page read its answers, so a ` +
+ 'browser cannot query it at all. This is a limitation of running in a browser, not a ' +
+ 'measurement of the resolver.',
+ };
+ }
+
+ if (!row.everAttempted) {
+ return { outcome: 'not-attempted', note: `No queries were sent to ${row.label}.` };
+ }
+
+ const metrics = [row.cached, row.uncached, row.dotcom];
+ const answered = metrics.reduce((sum, m) => sum + m.answered, 0);
+ const measured = metrics.filter((m) => m.medianMs !== null).length;
+
+ if (answered === 0) {
+ // Two different failures, and the difference is knowable. If an HTTP
+ // response was read, the request plainly arrived and came back — saying
+ // "something may have blocked it" would contradict our own evidence.
+ return {
+ outcome: 'no-readable-answer',
+ note: row.sawHttpResponse
+ ? `${row.label} replied, but never with a usable DNS answer` +
+ `${row.lastNote ? `: ${row.lastNote}` : '.'}`
+ : `No readable answer came back from ${row.label}. A browser cannot tell a resolver that ` +
+ 'did not reply apart from a request the network or an extension blocked before it left ' +
+ '— both fail identically.',
+ };
+ }
+
+ if (measured === metrics.length) {
+ return { outcome: 'measured', note: `${row.label} answered every kind of query.` };
+ }
+
+ return {
+ outcome: 'partially-measured',
+ note:
+ `${row.label} answered some queries but not enough of each kind for every figure. ` +
+ 'Missing figures are shown as “—”.',
+ };
+}
+
+export interface BenchmarkSummary {
+ verdict: DnsBenchmarkResult['verdict'];
+ explanation: string;
+ fastestCachedResolverId: string | null;
+ fastestIsWithinNoise: boolean | null;
+ conclusions: string[];
+}
+
+/**
+ * Distils the table into plain English, in the spirit of GRC's "Conclusions"
+ * tab — the part of his tool most users end up relying on.
+ *
+ * The one claim this deliberately does not make is a statistical one. GRC
+ * applies a 95% confidence threshold; there is no significance test whose
+ * assumptions hold over five correlated samples sharing one uplink, so instead
+ * of inventing a p-value this reports whether the leader's observed range
+ * overlaps the runner-up's. "These two overlap, so this run does not separate
+ * them" is a statement about what was seen.
+ */
+export function summariseBenchmark(rows: readonly ResolverBenchmark[]): BenchmarkSummary {
+ const measured = rows.filter((r) => r.cached.medianMs !== null);
+
+ if (measured.length === 0) {
+ return {
+ verdict: rows.length === 0 ? null : 'nothing-measured',
+ explanation:
+ rows.length === 0
+ ? 'Nothing was measured.'
+ : 'No resolver produced enough answers to time. Nothing here can be ranked, and the ' +
+ 'reasons are listed against each one.',
+ fastestCachedResolverId: null,
+ fastestIsWithinNoise: null,
+ conclusions: [],
+ };
+ }
+
+ const ranked = [...measured].sort((a, b) => a.cached.medianMs! - b.cached.medianMs!);
+ const leader = ranked[0];
+ const runnerUp = ranked.length > 1 ? ranked[1] : null;
+
+ // Overlap of the two observed ranges. Null rather than false when either
+ // range is unknown: "we could not tell" is not "they are distinct".
+ let withinNoise: boolean | null = null;
+ if (runnerUp !== null) {
+ const a = leader.cached;
+ const b = runnerUp.cached;
+ withinNoise =
+ a.maxMs !== null && b.minMs !== null ? a.maxMs >= b.minMs : null;
+ }
+
+ const conclusions: string[] = [];
+
+ if (runnerUp === null) {
+ conclusions.push(
+ `Only ${leader.label} produced enough answers to time, so there is nothing to compare it ` +
+ 'against. A single result is not a ranking.',
+ );
+ } else if (withinNoise === true) {
+ conclusions.push(
+ `${leader.label} had the lowest median for cached lookups (${leader.cached.medianMs} ms), ` +
+ `but its range overlaps ${runnerUp.label}’s (${runnerUp.cached.medianMs} ms). This run ` +
+ 'does not separate them — treat them as equally quick from here.',
+ );
+ } else if (withinNoise === false) {
+ conclusions.push(
+ `${leader.label} was fastest for cached lookups at a median of ${leader.cached.medianMs} ms, ` +
+ `clear of ${runnerUp.label} at ${runnerUp.cached.medianMs} ms. Cached lookups are the ` +
+ 'common case, which is why GRC’s benchmark sorts on them first.',
+ );
+ } else {
+ conclusions.push(
+ `${leader.label} had the lowest median for cached lookups (${leader.cached.medianMs} ms), ` +
+ 'but there was not enough spread information to say whether that lead is real.',
+ );
+ }
+
+ const costed = measured.filter((r) => r.uncachedCostMs !== null);
+ if (costed.length > 0) {
+ const worst = costed.reduce((a, b) => (b.uncachedCostMs! > a.uncachedCostMs! ? b : a));
+ if (worst.uncachedCostMs! > 0) {
+ conclusions.push(
+ `${worst.label} paid the most to leave its cache: ${worst.uncachedCostMs} ms more for a ` +
+ 'name it had to look up than for one it already held. That gap is about the resolver’s ' +
+ 'own connectivity to the rest of the DNS, not about your connection to it.',
+ );
+ }
+ }
+
+ const dishonest = rows.filter((r) => r.nxdomainHonesty === 'answers-with-an-address');
+ if (dishonest.length > 0) {
+ conclusions.push(
+ `${dishonest.map((r) => r.label).join(', ')} returned an address for names that do not ` +
+ 'exist, instead of saying the name does not exist. That usually means a typo in the ' +
+ 'address bar lands on a search page rather than a browser error.',
+ );
+ }
+
+ const validating = rows.filter((r) => r.dnssec === 'validates');
+ const notValidating = rows.filter((r) => r.dnssec === 'does-not-validate');
+ if (validating.length > 0 || notValidating.length > 0) {
+ const parts: string[] = [];
+ if (validating.length > 0) parts.push(`${validating.map((r) => r.label).join(', ')} validate`);
+ if (notValidating.length > 0) {
+ parts.push(`${notValidating.map((r) => r.label).join(', ')} do not`);
+ }
+ conclusions.push(
+ `DNSSEC: ${parts.join('; ')}. A validating resolver refuses to hand you an answer whose ` +
+ 'signatures do not check out.',
+ );
+ }
+
+ const unusable = rows.filter((r) => r.outcome === 'no-readable-answer');
+ if (unusable.length > 0) {
+ conclusions.push(
+ `${unusable.map((r) => r.label).join(', ')} produced no readable answer. That may be the ` +
+ 'resolver, or something between here and it — a browser cannot tell the difference, so ' +
+ 'this is not evidence that they are slow.',
+ );
+ }
+
+ conclusions.push(
+ 'None of this describes the resolver your device is actually using. A web page cannot reach ' +
+ 'it, so it is not in the table above.',
+ );
+
+ const everyMetricMeasured = rows
+ .filter((r) => r.outcome !== 'blocked-by-browser')
+ .every((r) => r.outcome === 'measured');
+
+ return {
+ verdict: everyMetricMeasured ? 'measured' : 'partial',
+ explanation: everyMetricMeasured
+ ? `All ${measured.length} reachable resolvers answered every kind of query.`
+ : `${measured.length} of ${rows.filter((r) => r.outcome !== 'blocked-by-browser').length} ` +
+ 'reachable resolvers produced timings. The rest are listed with the reason they did not.',
+ fastestCachedResolverId: leader.resolverId,
+ fastestIsWithinNoise: withinNoise,
+ conclusions,
+ };
+}
+
+/** Difference of the two medians. Null when either is missing; negatives are
+ * kept, because a negative is a real observation about TTLs and anycast. */
+export function uncachedCost(
+ cached: DnsMetricSummary,
+ uncached: DnsMetricSummary,
+): number | null {
+ if (cached.medianMs === null || uncached.medianMs === null) return null;
+ return uncached.medianMs - cached.medianMs;
+}
+
+const toMetric = (samples: number[], attempted: number): DnsMetricSummary => {
+ const s = summariseSamples(samples, MIN_SAMPLES_PER_METRIC);
+ return {
+ answered: samples.length,
+ attempted,
+ medianMs: s.medianMs,
+ p95Ms: s.p95Ms,
+ minMs: s.minMs,
+ maxMs: s.maxMs,
+ stdDevMs: s.stdDevMs,
+ };
+};
+
+// ---------------------------------------------------------------------------
+// The run
+// ---------------------------------------------------------------------------
+
+interface ResolverState {
+ resolver: DohResolver;
+ samples: Record;
+ attempts: Record;
+ consecutiveFailures: number;
+ givenUp: boolean;
+ corsBlocked: boolean;
+ everAttempted: boolean;
+ sawHttpResponse: boolean;
+ nxdomainProbes: DnsProbeOutcome[];
+ dnssecBroken: DnsProbeOutcome | null;
+ dnssecSigned: DnsProbeOutcome | null;
+ lastNote: string | null;
+}
+
+export interface BenchmarkOptions {
+ dnssec?: boolean;
+ samplesPerMetric?: number;
+ onProgress?: (stage: string, completed: number, total: number) => void;
+ signal?: AbortSignal;
+}
+
+/** Builds the ordered list of (resolver index, kind, name) each slot will run. */
+function buildSchedule(
+ samplesPerMetric: number,
+): { kind: DnsProbeKind; nameFor: (slot: number) => string }[] {
+ const plan: { kind: DnsProbeKind; nameFor: (slot: number) => string }[] = [];
+ for (let i = 0; i < samplesPerMetric; i++) {
+ plan.push({ kind: 'cached', nameFor: (slot) => POPULAR_NAMES[slot % POPULAR_NAMES.length] });
+ }
+ for (let i = 0; i < samplesPerMetric; i++) {
+ plan.push({
+ kind: 'uncached',
+ nameFor: (slot) => `${randomLabel(12)}.${POPULAR_NAMES[slot % POPULAR_NAMES.length]}`,
+ });
+ }
+ for (let i = 0; i < samplesPerMetric; i++) {
+ plan.push({ kind: 'dotcom', nameFor: () => `${randomLabel(16)}.com` });
+ }
+ return plan;
+}
+
+/**
+ * Runs the benchmark.
+ *
+ * Scheduling is round-robin and strictly sequential: one request in flight at a
+ * time, cycling resolvers between samples, with the starting resolver rotated
+ * each round.
+ *
+ * - One at a time, because concurrent requests share the same uplink and
+ * inflate each other in proportion to how many are running. A concurrent
+ * DNS benchmark measures the browser congesting itself. This is the
+ * condition GRC's introduction warns users about.
+ * - Cycling, because the dominant confound over a twenty-second window is a
+ * burst of other traffic; running one resolver's queries to completion
+ * before starting the next would land that burst entirely on one of them.
+ * - Rotating, because plain round-robin still puts the same resolver first in
+ * every round, handing it the coldest connection state each time.
+ */
+export async function runDnsBenchmark(
+ options: BenchmarkOptions = {},
+): Promise {
+ const {
+ dnssec = true,
+ samplesPerMetric = SAMPLES_PER_METRIC,
+ onProgress,
+ signal,
+ } = options;
+ const startedAt = performance.now();
+ const id = createId('dnsbench');
+
+ const blockedRows = (): ResolverBenchmark[] =>
+ CORS_BLOCKED_RESOLVERS.map((blocked) => {
+ const empty = toMetric([], 0);
+ const classified = classifyResolver({
+ label: blocked.label,
+ cached: empty,
+ uncached: empty,
+ dotcom: empty,
+ corsBlocked: true,
+ everAttempted: false,
+ sawHttpResponse: false,
+ });
+ return {
+ resolverId: `blocked:${blocked.endpoint}`,
+ label: blocked.label,
+ operator: '',
+ endpoint: blocked.endpoint,
+ policyNote: '',
+ cached: empty,
+ uncached: empty,
+ dotcom: empty,
+ uncachedCostMs: null,
+ nxdomainHonesty: 'inconclusive' as NxdomainHonesty,
+ nxdomainDetail: 'Not checked — this endpoint cannot be queried from a browser.',
+ dnssec: null,
+ dnssecDetail: 'Not checked — this endpoint cannot be queried from a browser.',
+ outcome: classified.outcome,
+ note: classified.note,
+ };
+ });
+
+ if (!navigator.onLine) {
+ // Still list the reachable resolvers, marked as never tried. Dropping them
+ // would leave the table showing only the ten a browser can never query,
+ // which reads as though those were the whole field.
+ const untried: ResolverBenchmark[] = DOH_RESOLVERS.map((resolver) => {
+ const empty = toMetric([], 0);
+ const classified = classifyResolver({
+ label: resolver.label,
+ cached: empty,
+ uncached: empty,
+ dotcom: empty,
+ corsBlocked: false,
+ everAttempted: false,
+ sawHttpResponse: false,
+ });
+ return {
+ resolverId: resolver.id,
+ label: resolver.label,
+ operator: resolver.operator,
+ endpoint: resolver.endpoint,
+ policyNote: resolver.policyNote,
+ cached: empty,
+ uncached: empty,
+ dotcom: empty,
+ uncachedCostMs: null,
+ nxdomainHonesty: 'inconclusive' as NxdomainHonesty,
+ nxdomainDetail: 'Not checked — the browser reports no network connection.',
+ dnssec: null,
+ dnssecDetail: 'Not checked — the browser reports no network connection.',
+ outcome: classified.outcome,
+ note: classified.note,
+ };
+ });
+
+ return {
+ id,
+ timestamp: Date.now(),
+ resolvers: [...untried, ...blockedRows()],
+ samplesPerMetric,
+ namesQueried: [],
+ dnssecRequested: dnssec,
+ phaseTimingsAvailable: null,
+ fastestCachedResolverId: null,
+ fastestIsWithinNoise: null,
+ verdict: null,
+ explanation:
+ 'The browser reports no network connection, so no resolver was queried and nothing was ' +
+ 'measured.',
+ conclusions: [],
+ totalTimeMs: 0,
+ failures: [
+ {
+ metric: 'all',
+ reason: 'network-offline',
+ detail:
+ 'The browser reports no network connection. No DNS queries were sent, so every ' +
+ 'figure is absent rather than estimated.',
+ },
+ ],
+ };
+ }
+
+ const states: ResolverState[] = DOH_RESOLVERS.map((resolver) => ({
+ resolver,
+ samples: { cached: [], uncached: [], dotcom: [] },
+ attempts: { cached: 0, uncached: 0, dotcom: 0 },
+ consecutiveFailures: 0,
+ givenUp: false,
+ corsBlocked: false,
+ everAttempted: false,
+ sawHttpResponse: false,
+ nxdomainProbes: [],
+ dnssecBroken: null,
+ dnssecSigned: null,
+ lastNote: null,
+ }));
+
+ const plan = buildSchedule(samplesPerMetric);
+ const perResolverExtras = 2 + (dnssec ? 2 : 0); // NXDOMAIN pair, DNSSEC pair
+ const total = states.length * (plan.length + perResolverExtras);
+ let completed = 0;
+ const tick = (stage: string) => {
+ completed += 1;
+ onProgress?.(stage, completed, total);
+ };
+
+ const record = async (
+ state: ResolverState,
+ kind: DnsProbeKind,
+ name: string,
+ ): Promise => {
+ state.attempts[kind] += 1;
+ if (state.givenUp) return;
+
+ state.everAttempted = true;
+ const outcome = await queryResolver(
+ state.resolver.endpoint,
+ name,
+ DNS_TYPE.A,
+ { corsBlocked: state.corsBlocked },
+ signal,
+ );
+
+ if (outcome.status === 'answered' && outcome.roundTripMs !== null) {
+ state.samples[kind].push(outcome.roundTripMs);
+ state.consecutiveFailures = 0;
+ return;
+ }
+
+ state.lastNote = outcome.note;
+ if (outcome.status === 'no-answer') state.sawHttpResponse = true;
+ // Only a `TypeError` is ambiguous, and it is settled once per resolver
+ // rather than by doubling every request in the run. An HTTP error is not
+ // ambiguous: we read its status, so the browser was allowed to see it.
+ if (
+ !state.corsBlocked &&
+ outcome.status === 'request-failed' &&
+ state.consecutiveFailures === 0
+ ) {
+ state.corsBlocked = await probeIsCorsBlocked(state.resolver.endpoint, signal);
+ }
+ state.consecutiveFailures += 1;
+ if (
+ state.consecutiveFailures >= CONSECUTIVE_FAILURES_BEFORE_GIVING_UP ||
+ state.corsBlocked
+ ) {
+ state.givenUp = true;
+ }
+ };
+
+ // Priming pass. The first request to a host pays DNS, TCP and TLS, and the
+ // resolver may not hold the name yet. Its timing is discarded — and the UI
+ // says so, because dropping samples without saying so is the same failure as
+ // inventing them.
+ for (let slot = 0; slot < POPULAR_NAMES.length; slot++) {
+ for (let i = 0; i < states.length; i++) {
+ if (signal?.aborted) break;
+ const state = states[(i + slot) % states.length];
+ if (state.givenUp) continue;
+ state.everAttempted = true;
+ const outcome = await queryResolver(
+ state.resolver.endpoint,
+ POPULAR_NAMES[slot],
+ DNS_TYPE.A,
+ { corsBlocked: state.corsBlocked },
+ signal,
+ );
+ if (outcome.status !== 'answered') {
+ state.lastNote = outcome.note;
+ if (outcome.status === 'no-answer') state.sawHttpResponse = true;
+ if (!state.corsBlocked && outcome.status === 'request-failed') {
+ state.corsBlocked = await probeIsCorsBlocked(state.resolver.endpoint, signal);
+ if (state.corsBlocked) state.givenUp = true;
+ }
+ }
+ }
+ onProgress?.('Warming up connections', 0, total);
+ }
+
+ for (let slot = 0; slot < plan.length && !signal?.aborted; slot++) {
+ const step = plan[slot];
+ for (let i = 0; i < states.length && !signal?.aborted; i++) {
+ const state = states[(i + slot) % states.length];
+ await record(state, step.kind, step.nameFor(slot));
+ tick(`Measuring ${step.kind} lookups`);
+ }
+ }
+
+ // NXDOMAIN honesty and DNSSEC. Two probes each, same round-robin discipline.
+ for (let round = 0; round < 2 && !signal?.aborted; round++) {
+ for (let i = 0; i < states.length && !signal?.aborted; i++) {
+ const state = states[(i + round) % states.length];
+ if (state.givenUp) {
+ state.nxdomainProbes.push(notAttempted('Skipped: this resolver stopped answering.'));
+ } else {
+ state.nxdomainProbes.push(
+ await queryResolver(
+ state.resolver.endpoint,
+ `${randomLabel(16)}.${randomLabel(8)}.com`,
+ DNS_TYPE.A,
+ { corsBlocked: state.corsBlocked },
+ signal,
+ ),
+ );
+ }
+ tick('Checking behaviour for names that do not exist');
+ }
+ }
+
+ if (dnssec) {
+ for (const [index, name] of [DNSSEC_BROKEN_NAME, DNSSEC_SIGNED_NAME].entries()) {
+ for (let i = 0; i < states.length && !signal?.aborted; i++) {
+ const state = states[(i + index) % states.length];
+ const outcome = state.givenUp
+ ? notAttempted('Skipped: this resolver stopped answering.')
+ : await queryResolver(
+ state.resolver.endpoint,
+ name,
+ DNS_TYPE.A,
+ { dnssecOk: true, corsBlocked: state.corsBlocked },
+ signal,
+ );
+ if (index === 0) state.dnssecBroken = outcome;
+ else state.dnssecSigned = outcome;
+ tick('Checking DNSSEC validation');
+ }
+ }
+ }
+
+ const failures: MeasurementFailure[] = [];
+ if (signal?.aborted) {
+ failures.push({
+ metric: 'all',
+ reason: 'aborted',
+ detail:
+ 'The run was stopped before it finished. Only the queries that completed are counted; ' +
+ 'the rest are absent rather than assumed.',
+ });
+ }
+
+ const resolvers: ResolverBenchmark[] = states.map((state) => {
+ const cached = toMetric(state.samples.cached, state.attempts.cached);
+ const uncached = toMetric(state.samples.uncached, state.attempts.uncached);
+ const dotcom = toMetric(state.samples.dotcom, state.attempts.dotcom);
+ const classified = classifyResolver({
+ label: state.resolver.label,
+ cached,
+ uncached,
+ dotcom,
+ corsBlocked: state.corsBlocked,
+ everAttempted: state.everAttempted,
+ sawHttpResponse: state.sawHttpResponse,
+ lastNote: state.lastNote,
+ });
+ const nx = classifyNxdomain(state.nxdomainProbes);
+ const sec =
+ dnssec && state.dnssecBroken !== null && state.dnssecSigned !== null
+ ? classifyDnssec(state.dnssecBroken, state.dnssecSigned)
+ : null;
+
+ if (classified.outcome === 'blocked-by-browser') {
+ failures.push({
+ metric: `${state.resolver.id}.all`,
+ reason: 'cors-blocked',
+ detail: classified.note,
+ });
+ } else if (classified.outcome === 'no-readable-answer') {
+ failures.push({
+ metric: `${state.resolver.id}.all`,
+ reason: 'api-unreachable',
+ detail: classified.note,
+ });
+ } else if (classified.outcome === 'partially-measured') {
+ failures.push({
+ metric: `${state.resolver.id}.samples`,
+ reason: 'insufficient-samples',
+ detail:
+ `${state.resolver.label} did not answer at least ${MIN_SAMPLES_PER_METRIC} of every ` +
+ 'kind of query, so some of its figures are absent.',
+ });
+ }
+
+ return {
+ resolverId: state.resolver.id,
+ label: state.resolver.label,
+ operator: state.resolver.operator,
+ endpoint: state.resolver.endpoint,
+ policyNote: state.resolver.policyNote,
+ cached,
+ uncached,
+ dotcom,
+ uncachedCostMs: uncachedCost(cached, uncached),
+ nxdomainHonesty: nx.honesty,
+ nxdomainDetail: nx.detail,
+ dnssec: sec === null ? null : sec.validation,
+ dnssecDetail: sec === null ? 'DNSSEC checking was switched off for this run.' : sec.detail,
+ outcome: classified.outcome,
+ note: classified.note,
+ };
+ });
+
+ const summary = summariseBenchmark(resolvers);
+
+ return {
+ id,
+ timestamp: Date.now(),
+ resolvers: [...resolvers, ...blockedRows()],
+ samplesPerMetric,
+ namesQueried: POPULAR_NAMES,
+ dnssecRequested: dnssec,
+ phaseTimingsAvailable: readPhaseAvailability(),
+ fastestCachedResolverId: summary.fastestCachedResolverId,
+ fastestIsWithinNoise: summary.fastestIsWithinNoise,
+ verdict: summary.verdict,
+ explanation: summary.explanation,
+ conclusions: summary.conclusions,
+ totalTimeMs: Math.round(performance.now() - startedAt),
+ failures,
+ };
+}
+
+/**
+ * Whether the browser could read the connection phases of these requests.
+ *
+ * Checked rather than asserted. No DoH endpoint sends `Timing-Allow-Origin`
+ * today, which is why the per-request timings here are wall clock only — but
+ * that is a fact about current deployments, not a rule, so it is re-observed on
+ * every run using the same reader the Edge Path Explorer uses.
+ */
+function readPhaseAvailability(): boolean | null {
+ if (typeof performance.getEntriesByType !== 'function') return null;
+ const hosts = DOH_RESOLVERS.map((r) => new URL(r.endpoint).origin);
+ const entries = performance
+ .getEntriesByType('resource')
+ .filter((e): e is PerformanceResourceTiming => hosts.some((h) => e.name.startsWith(h)));
+ if (entries.length === 0) return null;
+ return entries.some((e) => readPhases(e).availability === 'available');
+}
diff --git a/src/utils/dnsWire.test.ts b/src/utils/dnsWire.test.ts
new file mode 100644
index 0000000..5543301
--- /dev/null
+++ b/src/utils/dnsWire.test.ts
@@ -0,0 +1,269 @@
+import { describe, it, expect } from 'vitest';
+import {
+ DNS_TYPE,
+ decodeDnsMessage,
+ encodeDnsQuery,
+ rcodeName,
+ readName,
+ toBase64Url,
+} from './dnsWire';
+
+const hex = (s: string): Uint8Array =>
+ new Uint8Array((s.match(/../g) ?? []).map((byte) => parseInt(byte, 16)));
+
+const bytesToHex = (b: Uint8Array): string =>
+ Array.from(b, (byte) => byte.toString(16).padStart(2, '0')).join('');
+
+/*
+ * Real responses, captured from https://cloudflare-dns.com/dns-query on
+ * 2026-08-16 with message ID 0x1234. Using genuine bytes rather than
+ * hand-assembled ones is deliberate: a decoder tested only against fixtures
+ * built by its own author's mental model will agree with that model and not
+ * with the wire.
+ */
+const FIXTURES = {
+ /** example.com A → NOERROR, two A records. */
+ noerrorA:
+ '123481800001000200000000076578616d706c6503636f6d0000010001c00c00010001000000c40004ac4293f3' +
+ 'c00c00010001000000c400046814179a',
+ /** nrbench7x2qk4vm.com A → NXDOMAIN with an SOA in the authority section. */
+ nxdomain:
+ '1234818300010000000100000f6e7262656e6368377832716b34766d03636f6d0000010001c01c000600010000' +
+ '0384003d01610c67746c642d73657276657273036e657400056e73746c640c766572697369676e2d677273c01c' +
+ '6a812b74000007080000038400093a8000000384',
+ /** example.com AAAA → two AAAA records. */
+ aaaa:
+ '123481800001000200000000076578616d706c6503636f6d00001c0001c00c001c0001000000280010' +
+ '260647000010000000000000' +
+ '6814179ac00c001c0001000000280010260647000010000000000000ac4293f3',
+ /** internetsociety.org A with DO set → AD bit set, two A records plus an RRSIG. */
+ adSigned:
+ '123481a000010003000000010f696e7465726e6574736f6369657479036f72670000010001c00c000100010000012c' +
+ '0004681210a6c00c000100010000012c0004681211a6c00c002e00010000012c006700010d020000012c6a828b046a' +
+ '7fcbe486c90f696e7465726e6574736f6369657479036f726700aa0f1b599654eda0634f178815b5f67f2d5aba95b3' +
+ '4ca05c9221a41adca557fb68b90b430805c3b9ddd4caf6ad560f0f63a5c15bd4da2fcc8819dbb9708eeff900002904' +
+ 'd0000080000000',
+ /** dnssec-failed.org A with DO set → SERVFAIL, the resolver refusing a broken chain. */
+ servfail:
+ '1234818200010000000000010d646e737365632d6661696c6564036f7267000001000100002904d0000080000039' +
+ '000f003500096e6f20534550206d61746368696e672074686520445320666f756e6420666f7220646e737365632d' +
+ '6661696c65642e6f72672e',
+ /** www.github.com A → a CNAME followed by the A it resolves to. */
+ cnameChain:
+ '123481800001000200000000037777770667697468756203636f6d0000010001c00c0005000100000aa10002c010' +
+ 'c010000100010000000a00048c527104',
+} as const;
+
+describe('encodeDnsQuery', () => {
+ it('lays out a question exactly as RFC 1035 describes', () => {
+ const q = encodeDnsQuery('google.com', { id: 0x1234, qtype: DNS_TYPE.A });
+ expect(q).not.toBeNull();
+ // 12-byte header + 12-byte name + 4 bytes of QTYPE/QCLASS.
+ expect(q!.length).toBe(28);
+ expect(bytesToHex(q!)).toBe(
+ '1234' + // ID, supplied by the caller
+ '0100' + // RD set, nothing else
+ '0001' + '0000' + '0000' + '0000' + // QDCOUNT 1, everything else empty
+ '06676f6f676c6503636f6d00' + // 6"google" 3"com" root
+ '0001' + '0001', // QTYPE A, QCLASS IN
+ );
+ });
+
+ it('produces different bytes for different ids, and identical bytes for the same id', () => {
+ // This is the cache-buster's entire contract. The benchmark relies on a
+ // fresh ID changing the `dns=` parameter, because an HTTP cache hit would
+ // otherwise be timed as though it were the resolver's answer.
+ const a = toBase64Url(encodeDnsQuery('example.com', { id: 1, qtype: DNS_TYPE.A })!);
+ const b = toBase64Url(encodeDnsQuery('example.com', { id: 2, qtype: DNS_TYPE.A })!);
+ const c = toBase64Url(encodeDnsQuery('example.com', { id: 1, qtype: DNS_TYPE.A })!);
+ expect(a).not.toBe(b);
+ expect(a).toBe(c);
+ });
+
+ it('appends an EDNS0 OPT record with the DO bit only when DNSSEC is asked for', () => {
+ const plain = encodeDnsQuery('example.com', { id: 1, qtype: DNS_TYPE.A })!;
+ const withDo = encodeDnsQuery('example.com', { id: 1, qtype: DNS_TYPE.A, dnssecOk: true })!;
+
+ expect(plain[11]).toBe(0); // ARCOUNT
+ expect(withDo[11]).toBe(1);
+ expect(withDo.length).toBe(plain.length + 11);
+ // OPT: root name, TYPE 41, CLASS = payload size, TTL carrying the DO bit.
+ expect(bytesToHex(withDo.slice(plain.length))).toBe('00' + '0029' + '04d0' + '00008000' + '0000');
+ });
+
+ it('accepts a trailing dot as equivalent to the undotted name', () => {
+ const withDot = encodeDnsQuery('example.com.', { id: 7, qtype: DNS_TYPE.A });
+ const without = encodeDnsQuery('example.com', { id: 7, qtype: DNS_TYPE.A });
+ expect(bytesToHex(withDot!)).toBe(bytesToHex(without!));
+ });
+
+ it('refuses names it cannot represent rather than sanitising them', () => {
+ // Silently correcting the input would mean querying a name the caller never
+ // asked for and then reporting the timing as though it belonged to theirs.
+ const opts = { id: 1, qtype: DNS_TYPE.A };
+ expect(encodeDnsQuery(`${'a'.repeat(64)}.com`, opts)).toBeNull(); // label > 63
+ expect(encodeDnsQuery(`${'a'.repeat(60)}.`.repeat(5) + 'com', opts)).toBeNull(); // name > 255
+ expect(encodeDnsQuery('a..b', opts)).toBeNull(); // empty label
+ expect(encodeDnsQuery('exam ple.com', opts)).toBeNull(); // space
+ expect(encodeDnsQuery('exämple.com', opts)).toBeNull(); // non-ASCII, not punycoded
+ });
+
+ it('refuses an out-of-range id or qtype', () => {
+ expect(encodeDnsQuery('example.com', { id: 0x10000, qtype: DNS_TYPE.A })).toBeNull();
+ expect(encodeDnsQuery('example.com', { id: -1, qtype: DNS_TYPE.A })).toBeNull();
+ expect(encodeDnsQuery('example.com', { id: 1.5, qtype: DNS_TYPE.A })).toBeNull();
+ expect(encodeDnsQuery('example.com', { id: 1, qtype: 0x10000 })).toBeNull();
+ });
+});
+
+describe('toBase64Url', () => {
+ it('emits unpadded base64url', () => {
+ const encoded = toBase64Url(new Uint8Array([0xfb, 0xff, 0xfe, 0x00]));
+ expect(encoded).not.toMatch(/[+/=]/);
+ expect(encoded).toBe('-__-AA');
+ });
+});
+
+describe('decodeDnsMessage — refusing to guess', () => {
+ it('returns null for a buffer too short to hold a header', () => {
+ expect(decodeDnsMessage(new Uint8Array(0))).toBeNull();
+ expect(decodeDnsMessage(new Uint8Array(11))).toBeNull();
+ });
+
+ it('returns null when the declared answer count is not actually present', () => {
+ // The case that matters most. A response claiming three answers and
+ // carrying none has been truncated in transit. Decoding it to a message
+ // with zero answers would convert a transport failure into a statement
+ // about what the resolver said.
+ const truncated = new Uint8Array(12);
+ new DataView(truncated.buffer).setUint16(6, 3); // ANCOUNT 3, no records follow
+ expect(decodeDnsMessage(truncated)).toBeNull();
+ });
+
+ it('returns null when RDLENGTH runs past the end of the buffer', () => {
+ const truncated = hex(FIXTURES.noerrorA).slice(0, -2);
+ expect(decodeDnsMessage(truncated)).toBeNull();
+ });
+
+ it('returns null for a compression pointer that does not move backwards', () => {
+ const forward = hex(FIXTURES.noerrorA);
+ // The first answer's name is at offset 29; point it forwards instead.
+ forward[29] = 0xc0;
+ forward[30] = 0xff;
+ expect(decodeDnsMessage(forward)).toBeNull();
+
+ const selfReferential = hex(FIXTURES.noerrorA);
+ selfReferential[29] = 0xc0;
+ selfReferential[30] = 29;
+ expect(decodeDnsMessage(selfReferential)).toBeNull();
+ });
+
+ it('returns null from readName for a pointer chain that never terminates', () => {
+ // 0 -> points to 2 is forward, so build a legal-looking backwards chain that
+ // still exceeds the hop cap by alternating between two offsets.
+ const looped = new Uint8Array(64);
+ looped[10] = 0xc0;
+ looped[11] = 8;
+ looped[8] = 0xc0;
+ looped[9] = 6;
+ looped[6] = 0xc0;
+ looped[7] = 4;
+ looped[4] = 0xc0;
+ looped[5] = 2;
+ looped[2] = 0xc0;
+ looped[3] = 0; // offset 0 holds a zero length byte: root, terminates legally
+ expect(readName(looped, 10)).toEqual({ name: '', next: 12 });
+
+ const runaway = new Uint8Array(4);
+ runaway[2] = 0xc0;
+ runaway[3] = 2; // points at itself
+ expect(readName(runaway, 2)).toBeNull();
+ });
+
+ it('returns null for a reserved label type', () => {
+ const reserved = hex(FIXTURES.noerrorA);
+ reserved[12] = 0x80;
+ expect(decodeDnsMessage(reserved)).toBeNull();
+ });
+});
+
+describe('decodeDnsMessage — real responses', () => {
+ it('reads a NOERROR answer with its addresses and TTL', () => {
+ const msg = decodeDnsMessage(hex(FIXTURES.noerrorA))!;
+ expect(msg.header.rcode).toBe(0);
+ expect(msg.header.id).toBe(0x1234);
+ expect(msg.header.qr).toBe(true);
+ expect(msg.header.ad).toBe(false);
+ expect(msg.question).toEqual({ name: 'example.com', qtype: 1, qclass: 1 });
+ expect(msg.answers.map((a) => a.data)).toEqual(['172.66.147.243', '104.20.23.154']);
+ expect(msg.answers[0].ttl).toBe(196);
+ });
+
+ it('reads NXDOMAIN without mistaking the authority SOA for an answer', () => {
+ // The SOA that comes back with an NXDOMAIN lives in the authority section.
+ // Counting it as an answer would make a "this name does not exist" reply
+ // look like a resolver that invented an address.
+ const msg = decodeDnsMessage(hex(FIXTURES.nxdomain))!;
+ expect(msg.header.rcode).toBe(3);
+ expect(msg.header.ancount).toBe(0);
+ expect(msg.header.nscount).toBe(1);
+ expect(msg.answers).toEqual([]);
+ });
+
+ it('reads the AD bit from a signed answer, and RRSIG data as absent', () => {
+ const msg = decodeDnsMessage(hex(FIXTURES.adSigned))!;
+ expect(msg.header.ad).toBe(true);
+ expect(msg.answers).toHaveLength(3);
+ expect(msg.answers.filter((a) => a.type === DNS_TYPE.A).map((a) => a.data)).toEqual([
+ '104.18.16.166',
+ '104.18.17.166',
+ ]);
+ // RRSIG (46) is not a type this parser understands, so its data is null
+ // rather than a best-effort reading of bytes whose layout is unconfirmed.
+ expect(msg.answers.find((a) => a.type === 46)!.data).toBeNull();
+ });
+
+ it('reads SERVFAIL, which is how a validating resolver refuses a broken chain', () => {
+ const msg = decodeDnsMessage(hex(FIXTURES.servfail))!;
+ expect(msg.header.rcode).toBe(2);
+ expect(rcodeName(msg.header.rcode)).toBe('SERVFAIL');
+ expect(msg.answers).toEqual([]);
+ });
+
+ it('follows a compression pointer through a CNAME chain', () => {
+ const msg = decodeDnsMessage(hex(FIXTURES.cnameChain))!;
+ expect(msg.answers).toHaveLength(2);
+ expect(msg.answers[0].type).toBe(DNS_TYPE.CNAME);
+ expect(msg.answers[0].data).toBeNull(); // unsupported type, not guessed at
+ expect(msg.answers[1].name).toBe('github.com');
+ expect(msg.answers[1].data).toBe('140.82.113.4');
+ });
+
+ it('renders AAAA as eight uncompressed lowercase groups', () => {
+ const msg = decodeDnsMessage(hex(FIXTURES.aaaa))!;
+ expect(msg.answers.map((a) => a.data)).toEqual([
+ '2606:4700:0010:0000:0000:0000:6814:179a',
+ '2606:4700:0010:0000:0000:0000:ac42:93f3',
+ ]);
+ });
+
+ it('reports an address record with the wrong RDLENGTH as absent, not as garbage', () => {
+ const msg = decodeDnsMessage(hex(FIXTURES.cnameChain))!;
+ // The CNAME's RDLENGTH is 2 and its type is not A; if the parser were
+ // reading RDATA by position rather than by type it would emit an address.
+ expect(msg.answers[0].data).toBeNull();
+ });
+});
+
+describe('rcodeName', () => {
+ it('names the codes this tool acts on', () => {
+ expect(rcodeName(0)).toBe('NOERROR');
+ expect(rcodeName(2)).toBe('SERVFAIL');
+ expect(rcodeName(3)).toBe('NXDOMAIN');
+ expect(rcodeName(5)).toBe('REFUSED');
+ });
+
+ it('surfaces an unknown code rather than mapping it onto a known one', () => {
+ expect(rcodeName(23)).toBe('RCODE-23');
+ });
+});
diff --git a/src/utils/dnsWire.ts b/src/utils/dnsWire.ts
new file mode 100644
index 0000000..dcae494
--- /dev/null
+++ b/src/utils/dnsWire.ts
@@ -0,0 +1,351 @@
+/**
+ * Minimal DNS wireformat codec (RFC 1035) for DNS-over-HTTPS (RFC 8484).
+ *
+ * This module is deliberately pure: no network, no clock, no randomness, no
+ * DOM. The message ID is supplied by the caller rather than generated here,
+ * which is what lets every branch below be tested from a fixed byte array.
+ *
+ * The governing rule is the project's: a message that does not parse cleanly
+ * returns `null`, never a partial decode. A half-read response would let a
+ * caller conclude "the resolver returned no answers" from a buffer that was
+ * merely cut short — which is the same class of mistake as inventing a number.
+ */
+
+export const DNS_TYPE = {
+ A: 1,
+ NS: 2,
+ CNAME: 5,
+ SOA: 6,
+ AAAA: 28,
+ OPT: 41,
+} as const;
+
+const DNS_CLASS_IN = 1;
+
+/** Maximum compression-pointer hops before a chain is treated as malformed. */
+const MAX_POINTER_HOPS = 16;
+
+/** Names longer than this cannot be represented on the wire (RFC 1035 §2.3.4). */
+const MAX_WIRE_NAME_BYTES = 255;
+const MAX_LABEL_BYTES = 63;
+
+const RCODE_NAMES: Record = {
+ 0: 'NOERROR',
+ 1: 'FORMERR',
+ 2: 'SERVFAIL',
+ 3: 'NXDOMAIN',
+ 4: 'NOTIMP',
+ 5: 'REFUSED',
+};
+
+/** Names an RCODE. Unknown codes are rendered as `RCODE-` rather than being
+ * mapped onto the nearest known one — an unrecognised code is information. */
+export function rcodeName(rcode: number): string {
+ return RCODE_NAMES[rcode] ?? `RCODE-${rcode}`;
+}
+
+export interface EncodeOptions {
+ /** 16-bit message ID, supplied by the caller. See `randomMessageId` in
+ * `dnsBenchmark.ts` for why the benchmark randomises it. */
+ id: number;
+ qtype: number;
+ /** Append an EDNS0 OPT record with the DO bit set, asking the resolver to
+ * perform DNSSEC validation (RFC 4035 §4.9.3). */
+ dnssecOk?: boolean;
+ /** Advertised UDP payload size, carried in the OPT record's CLASS field. */
+ udpPayloadSize?: number;
+}
+
+/**
+ * Encodes a single-question query.
+ *
+ * Returns `null` — never a truncated or silently corrected message — for any
+ * name that cannot be represented: an empty label, a label over 63 bytes, a
+ * wire name over 255 bytes, or a byte outside the permitted set. Sanitising the
+ * input instead would mean querying a name the caller did not ask for and
+ * reporting the timing as though it had.
+ */
+export function encodeDnsQuery(name: string, options: EncodeOptions): Uint8Array | null {
+ const encodedName = encodeName(name);
+ if (encodedName === null) return null;
+
+ const { id, qtype, dnssecOk = false, udpPayloadSize = 1232 } = options;
+ if (!Number.isInteger(id) || id < 0 || id > 0xffff) return null;
+ if (!Number.isInteger(qtype) || qtype < 0 || qtype > 0xffff) return null;
+
+ const optLength = dnssecOk ? 11 : 0;
+ const out = new Uint8Array(12 + encodedName.length + 4 + optLength);
+ const view = new DataView(out.buffer);
+
+ view.setUint16(0, id);
+ // RD (recursion desired). These are stub-resolver queries: we are asking the
+ // resolver to do the work, which is the thing being measured.
+ view.setUint16(2, 0x0100);
+ view.setUint16(4, 1); // QDCOUNT
+ view.setUint16(6, 0); // ANCOUNT
+ view.setUint16(8, 0); // NSCOUNT
+ view.setUint16(10, dnssecOk ? 1 : 0); // ARCOUNT
+
+ out.set(encodedName, 12);
+ let offset = 12 + encodedName.length;
+ view.setUint16(offset, qtype);
+ view.setUint16(offset + 2, DNS_CLASS_IN);
+ offset += 4;
+
+ if (dnssecOk) {
+ out[offset] = 0x00; // root NAME
+ view.setUint16(offset + 1, DNS_TYPE.OPT);
+ view.setUint16(offset + 3, udpPayloadSize);
+ // TTL field of an OPT record is extended-rcode(8) | version(8) | flags(16).
+ // 0x00008000 sets the DO bit with everything else zero.
+ view.setUint32(offset + 5, 0x00008000);
+ view.setUint16(offset + 9, 0); // RDLENGTH
+ }
+
+ return out;
+}
+
+function encodeName(name: string): Uint8Array | null {
+ const trimmed = name.endsWith('.') ? name.slice(0, -1) : name;
+ if (trimmed.length === 0) return new Uint8Array([0]); // root
+
+ const labels = trimmed.split('.');
+ const parts: number[] = [];
+
+ for (const labelText of labels) {
+ if (labelText.length === 0) return null; // empty label, e.g. 'a..b'
+ const bytes: number[] = [];
+ for (const char of labelText) {
+ const code = char.codePointAt(0);
+ // ASCII letters, digits, hyphen and underscore only. Anything else —
+ // including a space or a non-ASCII character — is refused rather than
+ // percent-escaped or punycoded, because guessing at the caller's intent
+ // would mean measuring a different name than the one requested.
+ if (
+ code === undefined ||
+ !(
+ (code >= 0x30 && code <= 0x39) ||
+ (code >= 0x41 && code <= 0x5a) ||
+ (code >= 0x61 && code <= 0x7a) ||
+ code === 0x2d ||
+ code === 0x5f
+ )
+ ) {
+ return null;
+ }
+ bytes.push(code);
+ }
+ if (bytes.length > MAX_LABEL_BYTES) return null;
+ parts.push(bytes.length, ...bytes);
+ }
+
+ parts.push(0);
+ if (parts.length > MAX_WIRE_NAME_BYTES) return null;
+ return new Uint8Array(parts);
+}
+
+/** RFC 8484 §6 unpadded base64url, for the `?dns=` query parameter. */
+export function toBase64Url(bytes: Uint8Array): string {
+ let binary = '';
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+export interface DnsHeader {
+ id: number;
+ qr: boolean;
+ opcode: number;
+ aa: boolean;
+ tc: boolean;
+ rd: boolean;
+ ra: boolean;
+ /** Authenticated Data. The resolver *claims* it DNSSEC-validated this answer.
+ * A browser cannot verify the chain itself, so this is reported as a claim
+ * and never as proof. */
+ ad: boolean;
+ cd: boolean;
+ rcode: number;
+ qdcount: number;
+ ancount: number;
+ nscount: number;
+ arcount: number;
+}
+
+export interface DnsResourceRecord {
+ name: string;
+ type: number;
+ class: number;
+ ttl: number;
+ /** Presentation form, for A and AAAA only. `null` for every other type: this
+ * parser does not guess at RDATA it does not understand, and an empty string
+ * would be indistinguishable from a record that genuinely carried nothing. */
+ data: string | null;
+}
+
+export interface DnsMessage {
+ header: DnsHeader;
+ question: { name: string; qtype: number; qclass: number } | null;
+ answers: DnsResourceRecord[];
+}
+
+/**
+ * Reads a (possibly compressed) name.
+ *
+ * Pointers must move strictly backwards and the number of hops is capped, so a
+ * self-referential or circular pointer returns `null` instead of hanging the
+ * caller. `next` is the offset just past the name in the *original* position,
+ * which is not the same as where the name's bytes ended once a pointer is
+ * followed.
+ */
+export function readName(
+ bytes: Uint8Array,
+ offset: number,
+): { name: string; next: number } | null {
+ const labels: string[] = [];
+ let cursor = offset;
+ let next: number | null = null;
+ let hops = 0;
+ let consumed = 0;
+
+ for (;;) {
+ if (cursor < 0 || cursor >= bytes.length) return null;
+ const length = bytes[cursor];
+
+ if ((length & 0xc0) === 0xc0) {
+ if (cursor + 1 >= bytes.length) return null;
+ const target = ((length & 0x3f) << 8) | bytes[cursor + 1];
+ // Strictly backwards: a forward or self-referential pointer is malformed,
+ // and following one is how a decoder ends up in an infinite loop.
+ if (target >= cursor) return null;
+ if (++hops > MAX_POINTER_HOPS) return null;
+ if (next === null) next = cursor + 2;
+ cursor = target;
+ continue;
+ }
+
+ if ((length & 0xc0) !== 0) return null; // reserved label type
+
+ if (length === 0) {
+ if (next === null) next = cursor + 1;
+ return { name: labels.join('.'), next };
+ }
+
+ const start = cursor + 1;
+ const end = start + length;
+ if (end > bytes.length) return null;
+ if (length > MAX_LABEL_BYTES) return null;
+
+ consumed += length + 1;
+ if (consumed > MAX_WIRE_NAME_BYTES) return null;
+
+ let label = '';
+ for (let i = start; i < end; i++) label += String.fromCharCode(bytes[i]);
+ labels.push(label);
+ cursor = end;
+ }
+}
+
+/**
+ * Decodes a wireformat response.
+ *
+ * Returns `null` for anything shorter than the 12-byte header, or whose
+ * question and answer sections do not parse cleanly to their declared counts.
+ * The declared-count check is the important one: a response claiming three
+ * answers but carrying none is truncated, and reporting it as a message with
+ * zero answers would turn a transport failure into a statement about DNS.
+ */
+export function decodeDnsMessage(bytes: Uint8Array): DnsMessage | null {
+ if (bytes.length < 12) return null;
+
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
+ const flags = view.getUint16(2);
+ const header: DnsHeader = {
+ id: view.getUint16(0),
+ qr: (flags & 0x8000) !== 0,
+ opcode: (flags >> 11) & 0x0f,
+ aa: (flags & 0x0400) !== 0,
+ tc: (flags & 0x0200) !== 0,
+ rd: (flags & 0x0100) !== 0,
+ ra: (flags & 0x0080) !== 0,
+ ad: (flags & 0x0020) !== 0,
+ cd: (flags & 0x0010) !== 0,
+ rcode: flags & 0x000f,
+ qdcount: view.getUint16(4),
+ ancount: view.getUint16(6),
+ nscount: view.getUint16(8),
+ arcount: view.getUint16(10),
+ };
+
+ let offset = 12;
+ let question: DnsMessage['question'] = null;
+
+ for (let i = 0; i < header.qdcount; i++) {
+ const read = readName(bytes, offset);
+ if (read === null) return null;
+ offset = read.next;
+ if (offset + 4 > bytes.length) return null;
+ if (i === 0) {
+ question = {
+ name: read.name,
+ qtype: view.getUint16(offset),
+ qclass: view.getUint16(offset + 2),
+ };
+ }
+ offset += 4;
+ }
+
+ const answers: DnsResourceRecord[] = [];
+ for (let i = 0; i < header.ancount; i++) {
+ const read = readName(bytes, offset);
+ if (read === null) return null;
+ offset = read.next;
+ if (offset + 10 > bytes.length) return null;
+
+ const type = view.getUint16(offset);
+ const recordClass = view.getUint16(offset + 2);
+ const ttl = view.getUint32(offset + 4);
+ const rdLength = view.getUint16(offset + 8);
+ offset += 10;
+
+ if (offset + rdLength > bytes.length) return null;
+ answers.push({
+ name: read.name,
+ type,
+ class: recordClass,
+ ttl,
+ data: readRdata(bytes, offset, rdLength, type),
+ });
+ offset += rdLength;
+ }
+
+ return { header, question, answers };
+}
+
+/** Renders RDATA for the two address types. Everything else — and any address
+ * record whose RDLENGTH is wrong for its type — yields `null` rather than a
+ * best-effort reading of bytes whose layout we cannot confirm. */
+function readRdata(
+ bytes: Uint8Array,
+ offset: number,
+ rdLength: number,
+ type: number,
+): string | null {
+ if (type === DNS_TYPE.A) {
+ if (rdLength !== 4) return null;
+ return `${bytes[offset]}.${bytes[offset + 1]}.${bytes[offset + 2]}.${bytes[offset + 3]}`;
+ }
+
+ if (type === DNS_TYPE.AAAA) {
+ if (rdLength !== 16) return null;
+ // Eight full lowercase groups, with no `::` compression. Unambiguous and
+ // trivially correct; a partial RFC 5952 implementation would produce
+ // strings that compare unequal to themselves depending on the input.
+ const groups: string[] = [];
+ for (let i = 0; i < 16; i += 2) {
+ groups.push((((bytes[offset + i] << 8) | bytes[offset + i + 1]) >>> 0).toString(16).padStart(4, '0'));
+ }
+ return groups.join(':');
+ }
+
+ return null;
+}
diff --git a/src/utils/dualStack.ts b/src/utils/dualStack.ts
index 81e4f84..c90330e 100644
--- a/src/utils/dualStack.ts
+++ b/src/utils/dualStack.ts
@@ -4,7 +4,7 @@ import type {
FamilyProbe,
MeasurementFailure,
} from '../types';
-import { createId } from './network';
+import { createId, timeoutSignal } from './network';
/**
* Dual-stack (IPv4 / IPv6) reachability.
@@ -79,21 +79,6 @@ export function familyOfIp(raw: string | null | undefined): AddressFamily | null
return valid ? 'ipv4' : null;
}
-/** AbortSignal that fires on timeout or when the caller aborts. */
-function timeoutSignal(ms: number, external?: AbortSignal): { signal: AbortSignal; done: () => void } {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), ms);
- const onAbort = () => controller.abort();
- external?.addEventListener('abort', onAbort);
- return {
- signal: controller.signal,
- done: () => {
- clearTimeout(timer);
- external?.removeEventListener('abort', onAbort);
- },
- };
-}
-
/**
* Probes one family-pinned endpoint.
*
diff --git a/src/utils/export.test.ts b/src/utils/export.test.ts
index 13b5561..8ac3710 100644
--- a/src/utils/export.test.ts
+++ b/src/utils/export.test.ts
@@ -8,6 +8,7 @@ import {
generateTriageCsv,
generateDualStackCsv,
generateCaptivePortalCsv,
+ generateDnsBenchmarkCsv,
TEST_TYPES,
} from './export';
import type { HistoryItem } from '../types';
@@ -374,6 +375,129 @@ describe('answer-layer exports', () => {
});
});
+describe('generateDnsBenchmarkCsv', () => {
+ const resolver = (over: Record = {}) => ({
+ resolverId: 'cloudflare',
+ label: 'Cloudflare',
+ operator: 'Cloudflare, Inc.',
+ endpoint: 'https://cloudflare-dns.com/dns-query',
+ policyNote: '',
+ cached: { answered: 5, attempted: 5, medianMs: 18, p95Ms: null, minMs: 15, maxMs: 24, stdDevMs: 3 },
+ uncached: { answered: 5, attempted: 5, medianMs: 41, p95Ms: null, minMs: 38, maxMs: 50, stdDevMs: 4 },
+ dotcom: { answered: 5, attempted: 5, medianMs: 33, p95Ms: null, minMs: 30, maxMs: 40, stdDevMs: 3 },
+ uncachedCostMs: 23,
+ nxdomainHonesty: 'honest',
+ nxdomainDetail: '',
+ dnssec: 'validates',
+ dnssecDetail: '',
+ outcome: 'measured',
+ note: 'Cloudflare answered every kind of query.',
+ ...over,
+ });
+
+ const item = (resolvers: unknown[], over: Record = {}): HistoryItem => ({
+ id: 'bench_1',
+ type: 'dnsbench',
+ timestamp: 1,
+ title: 't',
+ summary: 's',
+ data: {
+ resolvers,
+ samplesPerMetric: 5,
+ dnssecRequested: true,
+ fastestCachedResolverId: 'cloudflare',
+ fastestIsWithinNoise: false,
+ failures: [],
+ ...over,
+ },
+ });
+
+ it('writes every field under the name the interface actually uses', () => {
+ // The direct guard against the six-column-silently-blank class of bug: if a
+ // field is renamed in types.ts, the cast in the generator fails to compile
+ // and this assertion fails if it does not.
+ const [, row] = generateDnsBenchmarkCsv([item([resolver()])]).split('\n');
+ expect(row).toContain('"Cloudflare"');
+ expect(row).toContain('"Cloudflare, Inc."');
+ expect(row).toContain('"18"');
+ expect(row).toContain('"41"');
+ expect(row).toContain('"33"');
+ expect(row).toContain('"23"');
+ expect(row).toContain('"honest"');
+ expect(row).toContain('"validates"');
+ });
+
+ it('leaves unmeasured values blank instead of writing 0', () => {
+ // A resolver the browser could not reach would otherwise export as the
+ // fastest one in the file.
+ const empty = { answered: 0, attempted: 5, medianMs: null, p95Ms: null, minMs: null, maxMs: null, stdDevMs: null };
+ const [, row] = generateDnsBenchmarkCsv([
+ item([
+ resolver({
+ cached: empty,
+ uncached: empty,
+ dotcom: empty,
+ uncachedCostMs: null,
+ dnssec: null,
+ outcome: 'no-readable-answer',
+ note: 'No readable answer came back.',
+ }),
+ ]),
+ ]).split('\n');
+ expect(row).toContain('""');
+ expect(row).toContain('"no-readable-answer"');
+ expect(row).toContain('"not checked"');
+ // The answered/attempted counters are genuine zeroes and must survive.
+ expect(row).toContain('"0"');
+ expect(row).not.toContain('"0","0","0","0","0"');
+ });
+
+ it('exports a genuine zero rather than dropping it', () => {
+ // The pair to the test above: this is what proves the generator uses `??`
+ // and not `||`.
+ const [, row] = generateDnsBenchmarkCsv([
+ item([resolver({ uncachedCostMs: 0 })]),
+ ]).split('\n');
+ expect(row).toContain('"0"');
+ });
+
+ it('preserves a negative cost, escaped against formula injection', () => {
+ // A negative is a real observation about TTLs and anycast, and escapeCsv's
+ // leading-minus guard must not swallow it.
+ const [, row] = generateDnsBenchmarkCsv([
+ item([resolver({ uncachedCostMs: -4 })]),
+ ]).split('\n');
+ expect(row).toContain('"\'-4"');
+ });
+
+ it('escapes a formula-injecting resolver note', () => {
+ // Notes embed fetch error messages, i.e. third-party-influenced text.
+ const csv = generateDnsBenchmarkCsv([
+ item([resolver({ note: '=cmd|\'/c calc\'!A1' })]),
+ ]);
+ expect(csv).toContain('"\'=cmd');
+ });
+
+ it('still exports a row for a run that measured nothing', () => {
+ // An offline run has to stay distinguishable from a run that never happened.
+ const csv = generateDnsBenchmarkCsv([
+ item([], {
+ fastestCachedResolverId: null,
+ fastestIsWithinNoise: null,
+ failures: [{ metric: 'all', reason: 'network-offline', detail: 'No network connection.' }],
+ }),
+ ]);
+ const [, row] = csv.split('\n');
+ expect(row).toContain('all: No network connection.');
+ expect(row).toContain('"not determined"');
+ });
+
+ it('routes through the dispatcher and is a declared export type', () => {
+ expect(getCsvForType([item([resolver()])], 'dnsbench')).toContain('Cloudflare');
+ expect(TEST_TYPES.map((t) => t.id)).toContain('dnsbench');
+ });
+});
+
describe('every declared export type produces a CSV with a header row', () => {
it.each(TEST_TYPES.map((t) => t.id))('%s', (type) => {
const csv = getCsvForType([], type);
diff --git a/src/utils/export.ts b/src/utils/export.ts
index fac9112..4c44a5e 100644
--- a/src/utils/export.ts
+++ b/src/utils/export.ts
@@ -8,6 +8,7 @@ import type {
GeoIpResult,
EdgePathResult,
DualStackResult,
+ DnsBenchmarkResult,
CaptivePortalResult,
DnsIntegrityResult,
} from '../types';
@@ -16,6 +17,7 @@ import type { TriageVerdict } from '../analysis/types';
export const TEST_TYPES = [
{ id: 'triage', label: 'Network Triage Verdicts', filename: 'triage_results.csv', icon: 'Stethoscope' },
{ id: 'dualstack', label: 'IPv4 / IPv6 Reachability', filename: 'dualstack_results.csv', icon: 'Network' },
+ { id: 'dnsbench', label: 'DNS Resolver Benchmark', filename: 'dnsbench_results.csv', icon: 'Timer' },
{ id: 'captive', label: 'Portal & DNS Hijack Checks', filename: 'captive_results.csv', icon: 'ShieldQuestion' },
{ id: 'tracert', label: 'Traceroute (TRACERT)', filename: 'tracert_results.csv', icon: 'GitCommit' },
{ id: 'speedtest', label: 'Speed Test Results', filename: 'speedtest_results.csv', icon: 'Gauge' },
@@ -657,6 +659,115 @@ export function generateDualStackCsv(items: HistoryItem[]): string {
return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
}
+/**
+ * DNS resolver benchmark CSV — one row per resolver per run.
+ *
+ * Every millisecond column is a DNS-over-HTTPS round trip, not a UDP DNS
+ * timing, and the header says so rather than leaving a reader to assume the
+ * numbers are comparable to a native tool's.
+ *
+ * Absent figures are blank, never zero. That matters more here than elsewhere:
+ * a resolver a browser could not reach would otherwise export as the fastest
+ * one in the file.
+ */
+export function generateDnsBenchmarkCsv(items: HistoryItem[]): string {
+ const headers = [
+ 'Test ID',
+ 'Timestamp',
+ 'Date',
+ 'Samples Per Metric',
+ 'DNSSEC Checked',
+ 'Fastest Cached Resolver',
+ 'Fastest Within Noise',
+ 'Resolver',
+ 'Operator',
+ 'Endpoint',
+ 'Outcome',
+ 'Cached Median (ms)',
+ 'Cached Min (ms)',
+ 'Cached Max (ms)',
+ 'Cached Answered',
+ 'Cached Attempted',
+ 'Uncached Median (ms)',
+ 'Uncached Answered',
+ 'Uncached Attempted',
+ 'Dotcom Median (ms)',
+ 'Dotcom Answered',
+ 'Dotcom Attempted',
+ 'Extra vs Cached (ms)',
+ 'Bad Name Behaviour',
+ 'DNSSEC',
+ 'Resolver Note',
+ 'Not Measured',
+ ];
+
+ const word = (v: boolean | null | undefined): string => {
+ if (v === null || v === undefined) return 'not determined';
+ return v ? 'yes' : 'no';
+ };
+
+ const rows: string[][] = [];
+
+ items
+ .filter((i) => i.type === 'dnsbench')
+ .forEach((item) => {
+ // Cast at the boundary so tsc checks these field names against the real
+ // interface. Six CSV columns once shipped permanently empty because a
+ // generator read `downloadMbps` from an object holding `downloadSpeed`.
+ const d = (item.data ?? {}) as Partial;
+ const resolvers = d.resolvers ?? [];
+ const fastest = resolvers.find((r) => r.resolverId === d.fastestCachedResolverId);
+
+ const shared = [
+ escapeCsv(item.id),
+ escapeCsv(item.timestamp),
+ escapeCsv(new Date(item.timestamp).toLocaleString()),
+ escapeCsv(d.samplesPerMetric ?? ''),
+ escapeCsv(word(d.dnssecRequested)),
+ escapeCsv(fastest?.label ?? ''),
+ escapeCsv(word(d.fastestIsWithinNoise)),
+ ];
+ const notMeasured = escapeCsv(
+ (d.failures ?? []).map((f) => `${f.metric}: ${f.detail}`).join(' | '),
+ );
+
+ if (resolvers.length === 0) {
+ // An offline run still exports a row, so it stays distinguishable from
+ // a run that never happened.
+ rows.push([...shared, ...Array(19).fill(escapeCsv('')), notMeasured]);
+ return;
+ }
+
+ resolvers.forEach((r) => {
+ rows.push([
+ ...shared,
+ escapeCsv(r.label),
+ escapeCsv(r.operator),
+ escapeCsv(r.endpoint),
+ escapeCsv(r.outcome),
+ escapeCsv(r.cached.medianMs ?? ''),
+ escapeCsv(r.cached.minMs ?? ''),
+ escapeCsv(r.cached.maxMs ?? ''),
+ escapeCsv(r.cached.answered),
+ escapeCsv(r.cached.attempted),
+ escapeCsv(r.uncached.medianMs ?? ''),
+ escapeCsv(r.uncached.answered),
+ escapeCsv(r.uncached.attempted),
+ escapeCsv(r.dotcom.medianMs ?? ''),
+ escapeCsv(r.dotcom.answered),
+ escapeCsv(r.dotcom.attempted),
+ escapeCsv(r.uncachedCostMs ?? ''),
+ escapeCsv(r.nxdomainHonesty),
+ escapeCsv(r.dnssec ?? 'not checked'),
+ escapeCsv(r.note),
+ notMeasured,
+ ]);
+ });
+ });
+
+ return [headers.join(','), ...rows.map((r) => r.join(','))].join('\n');
+}
+
/**
* Captive-portal / DNS-hijack CSV — one row per integrity probe.
*
@@ -776,6 +887,8 @@ export function getCsvForType(items: HistoryItem[], type: string): string {
return generateTriageCsv(items);
case 'dualstack':
return generateDualStackCsv(items);
+ case 'dnsbench':
+ return generateDnsBenchmarkCsv(items);
case 'captive':
return generateCaptivePortalCsv(items);
default:
diff --git a/src/utils/network.test.ts b/src/utils/network.test.ts
index 39ea313..3a66022 100644
--- a/src/utils/network.test.ts
+++ b/src/utils/network.test.ts
@@ -1,5 +1,14 @@
import { describe, it, expect } from 'vitest';
-import { meanConsecutiveDelta, createId, calculateNetReadyScore, parseTargetHosts } from './network';
+import {
+ meanConsecutiveDelta,
+ createId,
+ calculateNetReadyScore,
+ parseTargetHosts,
+ percentile,
+ median,
+ sampleStdDev,
+ summariseSamples,
+} from './network';
import { isPrivateOrLoopback } from './tracert';
import type { SpeedTestResult, PingResult } from '../types';
@@ -162,3 +171,108 @@ describe('isPrivateOrLoopback', () => {
}
});
});
+
+describe('percentile', () => {
+ it('returns null rather than a number when there is nothing to summarise', () => {
+ expect(percentile([], 0.5)).toBeNull();
+ });
+
+ it('returns the only sample when there is one', () => {
+ expect(percentile([5], 0.5)).toBe(5);
+ expect(percentile([5], 0.95)).toBe(5);
+ });
+
+ it('interpolates the way R-7 does', () => {
+ // Pinned to exact values so a future switch to nearest-rank fails loudly
+ // instead of quietly shifting every reported percentile in the app.
+ expect(percentile([1, 2, 3, 4], 0.5)).toBe(2.5);
+ expect(percentile([1, 2, 3, 4, 5], 0.95)).toBeCloseTo(4.8, 10);
+ expect(percentile([1, 2, 3, 4, 5], 0)).toBe(1);
+ expect(percentile([1, 2, 3, 4, 5], 1)).toBe(5);
+ });
+
+ it('does not depend on the caller having sorted the input', () => {
+ expect(percentile([5, 1, 3, 2, 4], 0.5)).toBe(3);
+ });
+
+ it('leaves the caller’s array alone', () => {
+ const samples = [3, 1, 2];
+ percentile(samples, 0.5);
+ expect(samples).toEqual([3, 1, 2]);
+ });
+});
+
+describe('median', () => {
+ it('is null on an empty array and correct on an odd and even count', () => {
+ expect(median([])).toBeNull();
+ expect(median([9, 1, 5])).toBe(5);
+ expect(median([1, 2, 3, 4])).toBe(2.5);
+ });
+});
+
+describe('sampleStdDev', () => {
+ it('returns null below two samples', () => {
+ // The spread of one point is not zero spread, it is no spread. Reporting 0
+ // would claim a consistency that was never observed.
+ expect(sampleStdDev([])).toBeNull();
+ expect(sampleStdDev([7])).toBeNull();
+ });
+
+ it('uses the n-1 divisor', () => {
+ expect(sampleStdDev([2, 4, 4, 4, 5, 5, 7, 9])!).toBeCloseTo(2.13809, 4);
+ });
+
+ it('reports genuinely identical samples as zero spread', () => {
+ expect(sampleStdDev([4, 4, 4])).toBe(0);
+ });
+});
+
+describe('summariseSamples', () => {
+ const many = (n: number): number[] => Array.from({ length: n }, (_, i) => i + 1);
+
+ it('reports the sample count but no statistics below the minimum', () => {
+ // n stays readable so a caller can tell "measured twice" from "never
+ // measured", but nothing is derived from two points.
+ const s = summariseSamples([10, 20], 3);
+ expect(s.n).toBe(2);
+ expect(s.medianMs).toBeNull();
+ expect(s.p95Ms).toBeNull();
+ expect(s.minMs).toBeNull();
+ expect(s.maxMs).toBeNull();
+ expect(s.stdDevMs).toBeNull();
+ });
+
+ it('reports nothing at all for zero samples', () => {
+ const s = summariseSamples([]);
+ expect(s.n).toBe(0);
+ expect(s.medianMs).toBeNull();
+ });
+
+ it('withholds p95 until there are enough samples for it to mean anything', () => {
+ // At n=5 the R-7 p95 sits within one interpolation step of the maximum, so
+ // publishing it would relabel "the slowest sample" as a percentile.
+ const five = summariseSamples(many(5));
+ expect(five.medianMs).toBe(3);
+ expect(five.maxMs).toBe(5);
+ expect(five.p95Ms).toBeNull();
+
+ const ten = summariseSamples(many(10));
+ expect(ten.medianMs).toBe(6); // R-7 median of 1..10 is 5.5, rounded
+ expect(ten.p95Ms).toBe(10);
+ });
+
+ it('preserves a genuine zero instead of treating it as missing', () => {
+ // The `||` trap, in the newest helper: a resolver answering from memory in
+ // under half a millisecond must not read as "not measured".
+ const s = summariseSamples([0, 0, 0]);
+ expect(s.medianMs).toBe(0);
+ expect(s.minMs).toBe(0);
+ expect(s.stdDevMs).toBe(0);
+ });
+
+ it('rounds to whole milliseconds, matching the clock’s actual resolution', () => {
+ const s = summariseSamples([10.4, 10.6, 11.2]);
+ expect(s.medianMs).toBe(11);
+ expect(Number.isInteger(s.minMs)).toBe(true);
+ });
+});
diff --git a/src/utils/network.ts b/src/utils/network.ts
index dade4d9..9600b8f 100644
--- a/src/utils/network.ts
+++ b/src/utils/network.ts
@@ -39,6 +39,134 @@ export function createId(prefix: string): string {
return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
+/**
+ * AbortSignal that fires on timeout or when the caller aborts.
+ *
+ * Lives here because three tools need it. It was copy-pasted verbatim into
+ * `dualStack.ts` and `captivePortal.ts` first; the third caller was the point
+ * to stop duplicating it.
+ *
+ * Always pair with `try { … } finally { gate.done() }` — the timer keeps the
+ * event loop alive otherwise, which in a many-query loop is thousands of live
+ * timers.
+ */
+export function timeoutSignal(
+ ms: number,
+ external?: AbortSignal,
+): { signal: AbortSignal; done: () => void } {
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), ms);
+ const onAbort = () => controller.abort();
+ external?.addEventListener('abort', onAbort);
+ return {
+ signal: controller.signal,
+ done: () => {
+ clearTimeout(timer);
+ external?.removeEventListener('abort', onAbort);
+ },
+ };
+}
+
+/**
+ * Linear-interpolated percentile — the R-7 definition, the one Excel's
+ * PERCENTILE.INC and NumPy's default both use.
+ *
+ * The method is named because "p95" on its own is ambiguous: the three common
+ * definitions (nearest-rank, R-6, R-7) disagree by a whole sample at the sizes
+ * this project works with, and a reader comparing NetReady's figure to another
+ * tool's deserves to know which one they are looking at.
+ *
+ * Returns null for an empty array. `p` is a fraction in 0-1.
+ */
+export function percentile(samples: readonly number[], p: number): number | null {
+ if (samples.length === 0) return null;
+ const sorted = [...samples].sort((a, b) => a - b);
+ if (sorted.length === 1) return sorted[0];
+
+ const clamped = Math.min(1, Math.max(0, p));
+ const rank = clamped * (sorted.length - 1);
+ const lower = Math.floor(rank);
+ const upper = Math.ceil(rank);
+ if (lower === upper) return sorted[lower];
+ return sorted[lower] + (rank - lower) * (sorted[upper] - sorted[lower]);
+}
+
+/** Median. Separate from `percentile(s, 0.5)` only for readability at call
+ * sites; it is the same calculation. Null for an empty array. */
+export function median(samples: readonly number[]): number | null {
+ return percentile(samples, 0.5);
+}
+
+/**
+ * Sample standard deviation (the n-1 divisor).
+ *
+ * Null below two samples, for the same reason `meanConsecutiveDelta` is: the
+ * spread across a single point is not zero spread, it is no spread. Returning 0
+ * would tell a reader the measurement was perfectly consistent when in fact it
+ * happened once.
+ */
+export function sampleStdDev(samples: readonly number[]): number | null {
+ if (samples.length < 2) return null;
+ const mean = samples.reduce((sum, s) => sum + s, 0) / samples.length;
+ const variance =
+ samples.reduce((sum, s) => sum + (s - mean) ** 2, 0) / (samples.length - 1);
+ return Math.sqrt(variance);
+}
+
+export interface SampleSummary {
+ /** Always present, even when every statistic below is null, so a caller can
+ * distinguish "measured once" from "never measured". */
+ n: number;
+ medianMs: number | null;
+ p95Ms: number | null;
+ minMs: number | null;
+ maxMs: number | null;
+ stdDevMs: number | null;
+}
+
+/** Below this many samples, no summary statistic is reported at all. */
+export const MIN_SAMPLES_FOR_SUMMARY = 3;
+
+/** p95 needs more samples than a median does — see the comment in
+ * `summariseSamples`. */
+export const MIN_SAMPLES_FOR_P95 = 10;
+
+/**
+ * Summarises timing samples, refusing to report statistics the sample count
+ * cannot support.
+ *
+ * Below `minSamples` every figure is null, so a resolver that answered once
+ * cannot contribute a confident-looking median to a comparison table.
+ *
+ * p95 stays null below ten samples even when the median exists. At n=5 the R-7
+ * p95 sits within one interpolation step of the maximum, so reporting it would
+ * be relabelling "the slowest sample" as "the 95th percentile" — a different
+ * and much stronger claim than the data supports.
+ *
+ * Milliseconds are rounded to whole numbers throughout. Browsers deliberately
+ * coarsen `performance.now()` (100 µs in Chrome by default, more under some
+ * isolation settings), and the differences this project reports are tens of
+ * milliseconds; a decimal place would imply a resolution the clock does not have.
+ */
+export function summariseSamples(
+ samples: readonly number[],
+ minSamples: number = MIN_SAMPLES_FOR_SUMMARY,
+): SampleSummary {
+ if (samples.length < minSamples) {
+ return { n: samples.length, medianMs: null, p95Ms: null, minMs: null, maxMs: null, stdDevMs: null };
+ }
+
+ const round = (v: number | null): number | null => (v === null ? null : Math.round(v));
+ return {
+ n: samples.length,
+ medianMs: round(median(samples)),
+ p95Ms: samples.length >= MIN_SAMPLES_FOR_P95 ? round(percentile(samples, 0.95)) : null,
+ minMs: round(Math.min(...samples)),
+ maxMs: round(Math.max(...samples)),
+ stdDevMs: round(sampleStdDev(samples)),
+ };
+}
+
export function getNetworkConnectionInfo(): NetworkConnectionInfo {
const nav = navigator as any;
const conn = nav.connection || nav.mozConnection || nav.webkitConnection;