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 */} +
setActiveTab('dnsbench')} + className="group bg-slate-900 border border-slate-800 hover:border-sky-500/50 rounded-2xl p-5 cursor-pointer transition-all hover:shadow-lg hover:-translate-y-0.5" + > +
+ +
+
+

+ DNS Resolver Benchmark +

+ + New + +
+

+ 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. +

+
+
+ +
+ + + {isRunning && ( + + )} + + + + {progress !== null && progress.total > 0 && ( + + {progress.done} / {progress.total} queries + + )} +
+
+ + {/* 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. +
  • +
+
+ + {storageWarning !== null && ( +
+ {storageWarning} +
+ )} + + {result && ( + <> +
+

+ {result.verdict === null ? 'Nothing measured' : 'Results'} +

+

{result.explanation}

+
+ + {chartRows.length > 0 && ( +
+
+ Median response time +
+ + + + {/* 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. +

+ )} +
+ )} + +
+
+ + + + + Resolver + + Cached + Uncached + .com + + + + + + + + {sorted.map((row) => { + const badge = OUTCOME_BADGE[row.outcome]; + const muted = + row.outcome === 'blocked-by-browser' || row.outcome === 'not-attempted'; + const answered = + row.cached.answered + row.uncached.answered + row.dotcom.answered; + const attempted = + row.cached.attempted + row.uncached.attempted + row.dotcom.attempted; + const DnssecIcon = row.dnssec === null ? ShieldQuestion : DNSSEC_ICON[row.dnssec]; + + return ( + + + + + + + + + + + ); + })} + +
Extra vs cachedAnsweredBad namesDNSSEC
+
+ {row.label} + {badge !== null && ( + + {badge.text} + + )} +
+
+ {row.endpoint} +
+
+ {signedMs(row.uncachedCostMs)} + + {/* Rendered as a plain string: `{answered && …}` puts a + bare 0 on the page, which has shipped here before. */} + {`${answered} / ${attempted}`} + + + {NXDOMAIN_LABEL[row.nxdomainHonesty]} + + + + + {row.dnssec === null ? 'Not checked' : DNSSEC_LABEL[row.dnssec]} + +
+
+
+ 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. +
+
+ + {result.conclusions.length > 0 && ( +
+
+ Conclusions +
+
    + {result.conclusions.map((line) => ( +
  • + + {line} +
  • + ))} +
+
+ )} + + 0 ? result.failures : undefined} /> + + )} +
+ ); +}; 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;