DNS diagnostics · Go · MIT
Every referral
from the root ↓
dnstree resolves a name the way a resolver does — one hop at a time, from the root servers down — and draws the path it took, with what each hop cost and whether it can be trusted. Where a resolver says SERVFAIL, dnstree shows the hop that caused it.
Not a stage trick: that is --live, recorded off a real walk. It draws
the tree in place as the walk makes it, points at the hop that just landed and
names the query it is waiting on.
One line, six facts
Every hop is a question put to one nameserver. The answer is drawn where it happened, so a slow server, a foreign network or a broken zone shows up next to the delegation that led there.
a.root-servers.net. 198.41.0.4 AS19836 241ms NOERROR referral → com.
what that meant — a referral one zone down what it answered — the response code what it cost — the whole hop, retries included whose network — origin AS, looked up as the walk reaches it at which address — glue, only from a server entitled to give it who was asked — the name from the delegation
- a.root-servers.net. — who was asked, named by the delegation
- 198.41.0.4 — glue, taken only from a server entitled to give it
- AS19836 — whose network, looked up as the walk reaches it
- 241ms — what the hop cost, retries included
- NOERROR — what it answered
- referral → com. — what that meant
--nsid adds one more: @a.r.ams5.nlams-0, beside the
address. One anycast address is a great many machines in a great many places,
and the identifier a server publishes for itself (RFC 5001) is the only
thing in a reply that says which of them answered — so two hops that look like
the same server may be a continent apart. --software asks each
one which software it runs, the way dig CH TXT version.bind does:
version "ATLAS", or version hidden for a server
whose operator would rather not say.
--repeat 20 asks every hop's question twenty more times, the way
mtr does for a route, and says how many came back and how long they
took: 20/20 answered 225ms best 234ms median 302ms worst. A fault
that shows one time in ten, such as one anycast instance that drops queries,
is one a single walk can miss.
--cookie sends every server a DNS cookie (RFC 7873) and says how
it answered: cookie where it sent ours back with one of its own,
no cookie where it sent none, and cookie not ours,
cookie malformed or cookie rejected where it got it wrong.
A cookie is what lets a client tell a forged answer from a real one, and it rides
along on queries that are being made anyway.
How the question travels is a separate matter from what it asks.
--tcp, --dot and --doh carry every hop over
TCP, or encrypted over TLS or HTTPS, all the way down from the root servers — and
not every authoritative server will take one, so --fallback lets plain
DNS pick up a hop the chosen transport could not reach rather than leaving a gap
where the walk should be.
-x 8.8.8.8 walks to the PTR of an address instead, the way
dig -x does: down in-addr.arpa. or ip6.arpa.,
whose nameservers are usually named somewhere else entirely, so every side walk to
find one is drawn as a branch of its own. Then it looks each name up again, to say
whether it points back to the address, the way a mail receiver checks a sender.
It is loudest when something is wrong
A resolver hides all of this and hands you an answer or a SERVFAIL. dnstree keeps going and shows the parts a working answer papers over. The flags below are each explained in the guide.
Servers that do not serve the zone
Delegated, reachable, and refusing the question. The walk moves to the next one and marks the hop rather than failing.
Delegations nothing can reach
A nameserver inside the zone it serves, with no glue, can only be found by asking itself. That is reported, not looped on.
Addresses nobody can reach
Glue or a lookup that hands out a private, loopback or documentation address looks like a server that went quiet. The hop says what kind of address it is, and the warning says who can fix it.
Glue a server had no right to give
A server may vouch for names at or below its own zone and no further. Anything else is dropped and resolved separately, drawn as a branch of its own.
Nameservers holding different zones
--serial asks every nameserver of the zone which copy it is serving. A secondary a transfer left behind answers with authority, out of an older zone. It judges the SOA's timers too.
Nameservers that give strangers too much
--check-axfr asks every nameserver for the whole zone, and whether its NSEC denials list the names anyway; --check-recursion asks each to look up somebody else's name. Only the start of a transfer is read, and none of it is kept.
Nameservers that choke on EDNS
--check-edns asks every nameserver the RFC 8906 tests: a version, an option and a flag nobody has defined, and the same question over TCP. A server that drops them is unreachable for resolvers since DNS Flag Day 2019, and one closed to TCP fails once an answer stops fitting in a packet.
Who may issue a certificate
--caa climbs from the name towards the root the way a certificate authority does, and says which CAA set decides who may issue. A lookup that fails is said too, since in a signed zone it makes every authority refuse.
An SPF policy one include from failing
--spf draws the policy as the tree of lookups a mail server makes, and counts them against the limit of ten. The count creeps up in a provider's record, and past ten every check is a permerror.
A backup MX nobody can verify
--mail looks up each MX host the way a sender that checks DANE does, and says which ones a signed TLSA set covers. A TLSA lookup that fails holds the mail; a backup left out lets it go unverified. --tlsa then connects to each of them and matches the certificate it presents, which catches a key renewed without its TLSA record.
A DKIM key nobody published
--dkim reads the key a selector names the way a receiver does: whether it is there, through the alias to a mail provider, whether it parses, and whether it is long enough and not withdrawn. A new mail service set up without its key fails DKIM on every message it sends.
A hint that points at an old server
--svcb follows a name's HTTPS records down their aliases to the servers they name, and holds the ipv4hint and ipv6hint a browser may connect on against where those servers really are.
A service record that points nowhere
--srv follows a service's SRV records, the way a chat, phone or directory client finds its server, to the address of every host they name, and says which has none, which is an alias where none may be, and which sits at an address no client can reach.
A zone nobody chose to depend on
--deps follows every nameserver of every zone on the way, and every nameserver of theirs, and lists each zone the name depends on with the nameserver that brought it in. Whoever controls any of them can make the name resolve somewhere else.
A registration about to run out
--rdap asks the registry when the domain expires and whether it is on hold, and compares the nameservers and DS it holds with the TLD's. A lapsed domain drops out of its TLD everywhere at once.
Every check, one verdict
--check runs dnstree's checks on a zone together and grades it one area at a time: the answer, delegation, consistency, DNSSEC, servers, EDNS, CAA, mail and the registration. --expect check:ok holds a zone to it in CI.
A move that takes two days
--propagation reads the TTLs the walk saw and says how long a new answer, new nameservers, a new DS or new keys take to reach every cache. The parent's NS TTL is the one people forget.
A key rollover forgotten halfway
--rollover reads the keys and the parent's DS, names the method and the step a key rollover has reached, and says what the next step waits on. With --diff it counts that wait from when each key was first seen.
A delegation the zone does not recognise
--check-ns holds the zone's own NS records, and its nameservers' addresses, against the ones its parent hands out, and asks for the referral again over TCP to catch glue the parent left out. Only the parent's view is visible from above, so the two drift apart unnoticed.
An answer that barely fits
Every hop records the bytes that came back against the room the query allowed. A datagram nearly full is one record from truncating, which costs every resolver a retry over TCP.
A key rollover waiting on the parent
--check-ds holds the CDS and CDNSKEY a zone publishes against the DS its parent hands out. A request nobody acted on is a rollover stuck halfway, under a chain that reads secure all the while. It also says where the parent takes a notice to come and look (RFC 9859), or that it names nowhere.
Telling a broken zone it is broken
A zone can name an agent to hear about its failures (RFC 9567), and every hop that says so shows it. --report sends the agent the report a validating resolver would, when the chain is bogus.
A name denied for being empty
--qmin asks each zone for one label more than it has, the way resolvers do now. A server that answers NXDOMAIN for an empty name stops such a resolver dead; the walk asks again in full and names the server.
A second nameserver that falls with the first
--without walks as though a nameserver, an address or a whole prefix were down. A backup whose own zone is served by the server it backs up stops answering along with it.
A move to a new provider, tried first
--try-ns walks as though a zone were already delegated to new nameservers. The parent's DS stays what it is unless --try-ds replaces it, so a move that would break the chain of trust breaks it here, before the registry is told.
A record the new provider was never given
--compare-ns asks every name on a list of the zone's servers and of the new ones, and prints a line to each: the same, or the TXT, DKIM key or CAA set the copy lost, and the TTL that went from minutes to a day.
A DS checked before the registrar has it
--try-ds walks as though the parent already held the DS you are about to submit. The DS of the wrong key, or of a key the zone does not publish, breaks the chain here, with the key the DS should point at named, instead of in an outage that lasts as long as its TTL; a digest cut short in the copying is refused outright.
Signatures late in their life
A zone whose signer stopped validates to the last second and then fails all at once. A verdict resting on a signature with less than a fifth of its life left says when it runs out, while there is still time to re-sign.
A walk that always ends
Queries, zone cuts, aliases and nameserver lookups each have a budget, so a hostile delegation wastes time and nothing more.
bogus.nlnetlabs.nl is NLnet Labs' standing test name. The zone answers, the answer is drawn, and the signature over it does not verify — so it is marked rather than handed over, and the command exits 3.The chain, link by link
With --dnssec, each zone is asked for its keys and checked against the DS
its parent published — starting at the trust anchors compiled into the binary.
- secure — every link from the anchor down verified
- insecure — the parent proved it has no DS
- bogus — a DS with no matching key, or a signature that fails; the command exits
3 - indeterminate — an algorithm this build cannot check
An unsigned zone reads insecure from that point down; nothing below it can be secure again. Denial of existence is proved rather than taken on trust: NSEC and NSEC3 are read, opt-out included, so an answer with nothing in it is checked like any other. A denial resting on an opt-out range reads insecure, since the range may hide an unsigned delegation.
Or have it say what happened
--explain writes the walk out in sentences under the tree: what it came
to, how long it stays cached, whether the chain of trust held, whether a name
points at something nobody holds, and whether a resolver agreed — or, where it
answered SERVFAIL, why.
... servfail: 8.8.8.8 answers SERVFAIL where the walk found 96.99.227.255: it fails validation as the walk does, so the zone's chain of trust is what to fix (it says: DNSKEY Missing (9): No DNSKEY matches DS RRs of dnssec-failed.org) ✘ bogus in 4.5s · resolver in 422ms (SERVFAIL) · 12 queries · 5 servers · dnssec-failed.org. A is 96.99.227.255, answered by dns101.comcast.net. for dnssec-failed.org. · a cache may hold this answer for 5 minutes, and the delegation to dnssec-failed.org. for 1 hour · the chain of trust breaks at dnssec-failed.org.: no DNSKEY of the zone matches the DS its parent published, so a resolver that validates answers SERVFAIL for this name · 8.8.8.8 answers SERVFAIL because it fails validation, as the walk does: it is doing its job, and the broken chain is what to fix (it says: DNSKEY Missing (9): No DNSKEY matches DS RRs of dnssec-failed.org)
Every sentence is read off the walk the tree draws, so the two cannot disagree,
and a chain this build could not check reads as unchecked, never as broken. It is
also why the nameservers of a zone are only weighed against each other where the walk
asked all of them — --all does, and a set that is not all accounted for
leaves the question unanswered rather than half answered.
--format json, dot, mermaid, waterfall-mermaid,
openmetrics and dig refuse the flag — all six are read by a program, which has the same facts in fields
already. A chain whose signatures are late in the life they were made for is said too,
with how long it has left: a zone whose signer stopped validates to the last second.
So is a secure zone set up the way the advice has moved on from: a retired algorithm,
a short RSA key, a SHA-1 DS, or a key or a DS left with nothing to do.
So is a name somebody else could take over: a nameserver or an alias target that does
not exist, or a zone every one of whose nameservers turns it away, the way a hosting
service answers for a zone nobody has created there.
And what has changed since last time
Most of diagnosis is working out what is different. --diff holds the walk
against the last one it remembers of the same question — the answer, the nameservers,
the zone cuts, the chain of trust over each of them — and remembers this one in its
place.
... ✔ answered in 754ms · resolver in 261ms · 3 queries · 3 servers · nothing has changed since the walk of www.example.com. A moments ago
It always says something, since silence could mean nothing was remembered. Besides
--pcap, it is the only thing in dnstree that writes to the disk: one small file per question, naming what
was looked up and when, under $DNSTREE_CACHE, or
$XDG_CACHE_HOME/dnstree, or ~/.cache/dnstree. Without the flag
nothing is read and nothing is kept.
--against walk.json holds the walk against one --format json
saved instead, and writes nothing. With --from it compares two saved walks:
before and after a change, or from two places.
Fifteen ways out
The tree is for reading, the emoji for enjoying it, and the waterfall for seeing where the time went. Seven more are for pasting into a document, a ticket, a pipeline, a picture or an issue, one for a monitoring system, one for asking the same questions with dig, and one for somebody learning DNS. The last two open a browser: one to read the walk in, one to fly round it.
. (root) ├── a.root-servers.net. 198.41.0.4 AS19836 240ms NOERROR referral → com. │ ├── l.gtld-servers.net. 192.41.162.30 AS19836 242ms NOERROR referral → example.com. │ │ ├── hera.ns.cloudflare.com. 108.162.192.162 AS13335 226ms NOERROR AA │ │ │ ├── www.example.com. 300 A 172.66.147.243 │ │ │ └── www.example.com. 300 A 104.20.23.154 │ │ ├── hera.ns.cloudflare.com. 172.64.32.162 (not queried) │ │ ├── hera.ns.cloudflare.com. 173.245.58.162 (not queried) │ │ ├── hera.ns.cloudflare.com. 2606:4700:50::adf5:3aa2 (not queried) │ │ └── (and 8 more not queried) │ ├── l.gtld-servers.net. 2001:500:d937::30 (not queried) │ ├── j.gtld-servers.net. 192.48.79.30 (not queried) │ ├── j.gtld-servers.net. 2001:502:7094::30 (not queried) │ └── (and 22 more not queried) ├── a.root-servers.net. 2001:503:ba3e::2:30 (not queried) ├── b.root-servers.net. 170.247.170.2 (not queried) ├── b.root-servers.net. 2801:1b8:10::b (not queried) └── (and 22 more not queried) ✔ answered in 709ms · resolver in 235ms · 3 queries · 3 servers
🌍 . (root) ├── 🛰️ a.root-servers.net. 198.41.0.4 AS19836 250ms NOERROR referral → com. │ ├── 🛰️ l.gtld-servers.net. 192.41.162.30 AS19836 246ms NOERROR referral → example.com. │ │ ├── 🎯 hera.ns.cloudflare.com. 108.162.192.162 AS13335 221ms NOERROR AA │ │ │ ├── 📍 www.example.com. 300 A 172.66.147.243 │ │ │ └── 📍 www.example.com. 300 A 104.20.23.154 │ │ ├── 💤 hera.ns.cloudflare.com. 172.64.32.162 (not queried) │ │ ├── 💤 hera.ns.cloudflare.com. 173.245.58.162 (not queried) │ │ ├── 💤 hera.ns.cloudflare.com. 2606:4700:50::adf5:3aa2 (not queried) │ │ └── 💤 (and 8 more not queried) │ ├── 💤 l.gtld-servers.net. 2001:500:d937::30 (not queried) │ ├── 💤 j.gtld-servers.net. 192.48.79.30 (not queried) │ ├── 💤 j.gtld-servers.net. 2001:502:7094::30 (not queried) │ └── 💤 (and 22 more not queried) ├── 💤 a.root-servers.net. 2001:503:ba3e::2:30 (not queried) ├── 💤 b.root-servers.net. 170.247.170.2 (not queried) ├── 💤 b.root-servers.net. 2801:1b8:10::b (not queried) └── 💤 (and 22 more not queried) ✔ answered in 717ms · resolver in 237ms · 3 queries · 3 servers
. (root) |-- a.root-servers.net. 198.41.0.4 AS19836 244ms NOERROR referral -> com. | |-- l.gtld-servers.net. 192.41.162.30 AS19836 245ms NOERROR referral -> example.com. | | |-- hera.ns.cloudflare.com. 108.162.192.162 AS13335 224ms NOERROR AA | | | |-- www.example.com. 300 A 172.66.147.243 | | | `-- www.example.com. 300 A 104.20.23.154 | | |-- hera.ns.cloudflare.com. 172.64.32.162 (not queried) | | |-- hera.ns.cloudflare.com. 173.245.58.162 (not queried) | | |-- hera.ns.cloudflare.com. 2606:4700:50::adf5:3aa2 (not queried) | | `-- (and 8 more not queried) | |-- l.gtld-servers.net. 2001:500:d937::30 (not queried) | |-- j.gtld-servers.net. 192.48.79.30 (not queried) | |-- j.gtld-servers.net. 2001:502:7094::30 (not queried) | `-- (and 22 more not queried) |-- a.root-servers.net. 2001:503:ba3e::2:30 (not queried) |-- b.root-servers.net. 170.247.170.2 (not queried) |-- b.root-servers.net. 2801:1b8:10::b (not queried) `-- (and 22 more not queried) answered in 714ms | resolver in 235ms | 3 queries | 3 servers
0 1s 2s a.root-servers.net. ████ 289ms referral → com. a.root-servers.net. ░░░░░░░░░░ 791ms answer DNSKEY . (truncated over udp; DNSKEY of .) l.gtld-servers.net. ████ 268ms referral → example.com. l.gtld-servers.net. ░░░░ 268ms answer DNSKEY com. (DNSKEY of com.) hera.ns.cloudflare.com. ███ 241ms answer hera.ns.cloudflare.com. ░░░ 227ms answer DNSKEY example.com. (DNSKEY of example.com.) ✔ answered in 2.1s · resolver in 252ms · 6 queries · 3 servers
0 500ms 1s a.root-servers.net. ##### 243ms referral -> com. a.root-servers.net. .... 242ms answer DNSKEY . (DNSKEY of .) l.gtld-servers.net. ##### 251ms referral -> example.com. l.gtld-servers.net. ..... 257ms answer DNSKEY com. (DNSKEY of com.) hera.ns.cloudflare.com. ##### 237ms answer hera.ns.cloudflare.com. .... 232ms answer DNSKEY example.com. (DNSKEY of example.com.) answered in 1.5s | resolver in 251ms | 6 queries | 3 servers - www.example.com. A is 104.20.23.154 and 172.66.147.243, answered by hera.ns.cloudflare.com. for example.com. …
---
title: "dnstree www.example.com. A"
---
gantt
dateFormat x
axisFormat %S.%L s
todayMarker off
section .
a.root-servers.net. referral to com.:0, 242
a.root-servers.net. answer, DNSKEY .:done, 242, 485
section com.
l.gtld-servers.net. referral to example.com.:485, 758
l.gtld-servers.net. answer, DNSKEY com.:done, 758, 1002
section example.com.
hera.ns.cloudflare.com. answer:active, 1003, 1258
hera.ns.cloudflare.com. answer, DNSKEY example.com.:done, 1257, 1492
### `www.example.com. A`: answered, secure walked on 2026-10-29 18:21 UTC ``` . (root) [secure RSASHA256/SHA256] ├── a.root-servers.net. 198.41.0.4 AS19836 244ms 1175 of 1232 bytes NOERROR DO referral → com. [secure ECDSAP256SHA256/SHA256] │ ├── a.root-servers.net. 198.41.0.4 AS19836 243ms NOERROR AA DO (DNSKEY of .) │ ├── l.gtld-servers.net. 192.41.162.30 AS19836 246ms NOERROR DO referral → example.com. [secure ECDSAP256SHA256/SHA256] │ │ ├── l.gtld-servers.net. 192.41.162.30 AS19836 245ms NOERROR AA DO (DNSKEY of com.) │ │ ├── hera.ns.cloudflare.com. 108.162.192.162 AS13335 227ms NOERROR AA DO [secure ECDSAP256SHA256] │ │ │ ├── www.example.com. 300 A 104.20.23.154 │ │ │ ├── www.example.com. 300 A 172.66.147.243 │ │ │ └── hera.ns.cloudflare.com. 108.162.192.162 AS13335 256ms NOERROR AA DO (DNSKEY of example.com.) … ✔ answered in 1.5s · resolver in 241ms · 6 queries · 3 servers ``` **what happened** - www.example.com. A is 104.20.23.154 and 172.66.147.243, answered by hera.ns.cloudflare.com. for example.com. - a cache may hold this answer for 5 minutes, and the delegation to example.com. for 2 days - the chain of trust holds from the root to example.com., signed with ECDSAP256SHA256 … **resolvers** | resolver | answer | time | | --- | --- | --- | | 8.8.8.8 | agrees | 241ms |
how dnstree found www.example.com. A, one hop at a time 1. dnstree starts at the root, the zone at the top of every name. it knows the addresses of the root servers from the list it starts with, and asked a.root-servers.net. (198.41.0.4) for www.example.com. A. the root's key is the one every validating resolver is set up to trust (the trust anchor), so the chain of trust starts here. the root does not hold the answer, but knows who runs com.: it answered with a referral to 13 servers: l.gtld-servers.net., j.gtld-servers.net., h.gtld-servers.net. and 10 more. it gave their addresses along with their names (glue), so there was nothing more to look up. a resolver may keep this referral for 2 days, so a change of nameservers takes that long to reach everyone. the chain of trust reaches com.: the zone above signed a DS record, a digest of com.'s key, and the key matched it. that is how one zone vouches for the next. to check signatures, it also asked the same server for the keys of the root (DNSKEY). 2. it asked l.gtld-servers.net. (192.41.162.30), one of the 13 servers the referral named for com., for www.example.com. A. com. does not hold the answer, but knows who runs example.com.: it answered with a referral to 2 servers: hera.ns.cloudflare.com. and elliott.ns.cloudflare.com. it gave their addresses too. a resolver may keep it for 2 days. the chain of trust reaches example.com.: the DS the zone above signed matched its key. it asked for the keys of com. too. 3. it asked hera.ns.cloudflare.com. (108.162.192.162), one of the 2 servers the referral named for example.com., for www.example.com. A. example.com. holds the answer: www.example.com. 300 A 172.66.147.243 and www.example.com. 300 A 104.20.23.154. it said so with authority (the AA flag), since it serves the zone itself. it asked for the keys of example.com. too. that is the end of the walk: www.example.com. A is 172.66.147.243 and 104.20.23.154. ✔ answered in 1.4s · resolver in 237ms · 6 queries · 3 servers
{
"schema_version": 5,
"question": { "name": "www.example.com.", "type": "A", "class": "IN" },
"elapsed_ms": 745.333,
"started": "2026-09-25T12:43:35.754956Z",
"resolvers": [ { "server": { "ip": "8.8.8.8", "port": 53 }, "elapsed_ms": 257.778, "match": "same", … } ],
"root": {
"zone": ".",
"kind": "zone",
"children": [
{
"zone": ".",
"kind": "referral",
"server": { "name": "a.root-servers.net.", "ip": "198.41.0.4", "port": 53, "asn": { "number": 19836, … } },
"asked": { "name": "www.example.com.", "type": "A" },
"proto": "udp",
"rtt_ms": 257.226,
"size_bytes": 840,
"limit_bytes": 1232,
"rcode": "NOERROR",
"flags": { "edns": true },
"delegation": { "zone": "com.", "ttl": 172800, "ns": [ "l.gtld-servers.net.", … ], … },
"children": [ … ]
},
…
]
}
}
digraph dnstree {
rankdir=LR;
node [shape=box, style=rounded, fontname="monospace", fontsize=10];
edge [fontname="monospace", fontsize=9];
label="dnstree www.example.com. A\n8.8.8.8 answered in 234ms";
labelloc=t;
subgraph cluster_0 {
label=".";
style=dashed;
color=gray60;
n0 [label=".", shape=oval, color=gray40];
n1 [label="a.root-servers.net.\n198.41.0.4\nAS19836\nNOERROR referral"];
…
}
subgraph cluster_1 {
label="com.";
style=dashed;
color=gray60;
n2 [label="l.gtld-servers.net.\n192.41.162.30\nAS19836\nNOERROR referral"];
…
}
subgraph cluster_2 {
label="example.com.";
style=dashed;
color=gray60;
n3 [label="hera.ns.cloudflare.com.\n108.162.192.162\nAS13335\nNOERROR answer\nwww.example.com. A 104.20.23.154\nwww.example.com. A 172.66.147.243", color=darkgreen];
…
}
n0 -> n1 [label="248ms"];
n1 -> n2 [label="244ms"];
n2 -> n3 [label="222ms"];
…
}
--- title: "dnstree www.example.com. A" --- flowchart LR subgraph z0 ["."] n0([". (root)"]) n1["a.root-servers.net.<br/>198.41.0.4<br/>NOERROR referral to com."] … end subgraph z1 ["com."] n2["l.gtld-servers.net.<br/>192.41.162.30<br/>NOERROR referral to example.com.<br/>(asked again after a silence)"] … end subgraph z2 ["example.com."] n3["hera.ns.cloudflare.com.<br/>108.162.192.162<br/>NOERROR answer<br/>www.example.com. A 172.66.147.243<br/>www.example.com. A 104.20.23.154"] … end n0 -->|"175ms"| n1 n1 -->|"2.19s"| n2 n2 -->|"14ms"| n3 …
# TYPE dnstree_result gauge
dnstree_result{name="www.example.com.",type="A",kind="answer"} 1
…
# TYPE dnstree_walk_seconds gauge
dnstree_walk_seconds{name="www.example.com.",type="A"} 1.9746477919999998
# TYPE dnstree_queries gauge
dnstree_queries{name="www.example.com.",type="A"} 6
# TYPE dnstree_failed_queries gauge
dnstree_failed_queries{name="www.example.com.",type="A"} 0
# TYPE dnstree_hop_seconds gauge
dnstree_hop_seconds{name="www.example.com.",type="A",zone=".",server="a.root-servers.net.",address="198.41.0.4",asked="www.example.com."} 0.245787625
dnstree_hop_seconds{name="www.example.com.",type="A",zone="com.",server="l.gtld-servers.net.",address="192.41.162.30",asked="www.example.com."} 0.244679917
dnstree_hop_seconds{name="www.example.com.",type="A",zone="example.com.",server="hera.ns.cloudflare.com.",address="108.162.192.162",asked="www.example.com."} 0.232406958
# TYPE dnstree_dnssec gauge
dnstree_dnssec{name="www.example.com.",type="A",state="secure"} 1
…
# TYPE dnstree_signature_left_seconds gauge
dnstree_signature_left_seconds{name="www.example.com.",type="A"} 90001.49201
# TYPE dnstree_answer_ttl_seconds gauge
dnstree_answer_ttl_seconds{name="www.example.com.",type="A"} 300
# TYPE dnstree_resolver_seconds gauge
dnstree_resolver_seconds{name="www.example.com.",type="A",resolver="8.8.8.8"} 0.244470625
# TYPE dnstree_resolver_agrees gauge
dnstree_resolver_agrees{name="www.example.com.",type="A",resolver="8.8.8.8"} 1
# TYPE dnstree_resolver_ttl_seconds gauge
dnstree_resolver_ttl_seconds{name="www.example.com.",type="A",resolver="8.8.8.8"} 300
# TYPE dnstree_warnings gauge
dnstree_warnings{name="www.example.com.",type="A"} 0
# EOF
# dnstree www.example.com. A, as dig commands # . at a.root-servers.net.: referral to com. dig @198.41.0.4 www.example.com. A +norecurse +bufsize=1232 +dnssec # . at a.root-servers.net.: aside, answer, DNSKEY of . dig @198.41.0.4 . DNSKEY +norecurse +bufsize=1232 +dnssec # com. at l.gtld-servers.net.: referral to example.com. dig @192.41.162.30 www.example.com. A +norecurse +bufsize=1232 +dnssec # com. at l.gtld-servers.net.: aside, answer, DNSKEY of com. dig @192.41.162.30 com. DNSKEY +norecurse +bufsize=1232 +dnssec # example.com. at hera.ns.cloudflare.com.: answer dig @108.162.192.162 www.example.com. A +norecurse +bufsize=1232 +dnssec # example.com. at hera.ns.cloudflare.com.: aside, answer, DNSKEY of example.com. dig @108.162.192.162 example.com. DNSKEY +norecurse +bufsize=1232 +dnssec
$ dnstree --format web --dnssec www.example.com ⣶⡄ ⢀⣶ ⢀ ⠈⢿⣆⣠⡿⠃⢀⣤⡀ ⣀⡀⢿⣷⡀ ⣿⣿⠁⣠⣿⠟⣀⣀ ⠉⠛⠳⢿⣿⣆⣿⣿⣼⡿⠟⠛⠋⠉ www.example.com. A · drawn as a page to read ⠈⢿⣿⣿⠟ the walk is at http://127.0.0.1:59981/ ⣿⣿ served until ctrl-c ⣿⣿ ⣿⣿⡆ ⠸⠿⠿⠇
$ dnstree --format web-3d --dnssec www.example.com ⣶⡄ ⢀⣶ ⢀ ⠈⢿⣆⣠⡿⠃⢀⣤⡀ ⣀⡀⢿⣷⡀ ⣿⣿⠁⣠⣿⠟⣀⣀ ⠉⠛⠳⢿⣿⣆⣿⣿⣼⡿⠟⠛⠋⠉ www.example.com. A · drawn as a scene to turn around ⠈⢿⣿⣿⠟ the walk is at http://127.0.0.1:59983/ ⣿⣿ served until ctrl-c ⣿⣿ ⣿⣿⡆ ⠸⠿⠿⠇
--format web serves the finished walk as a page on this machine and opens
a browser at it: every hop worth clicking on, what each server cost, the addresses by
origin AS and the chain of trust cut by cut. The page is in the binary and fetches
nothing. It listens on 127.0.0.1 and a free port; --web-addr
moves it. --format web-3d serves the same walk as a scene to turn around:
the hops float in a cone, one ring a zone cut, and assemble in the order and at the pace
the walk was made, with sound placed where they are. Each ring of trust burns down to
the life its signatures had left, every server stands on the floor in the colour of
its network, and with --diff what changed hangs on as a ghost.
--format web-3d: the same walk, to turn around.--format emoji --live: the same walk, arriving.
--format waterfall puts every query on one timeline, so a slow server, a
retry after a silence or a detour for a nameserver's address shows where it cost the
time. waterfall-ascii draws it in # and . for a
document, waterfall-mermaid writes it as a Mermaid gantt chart, and the
page --format web serves draws it under its timing tab.
--format markdown writes the walk as a report to paste into a ticket or an
incident write-up: what it came to, the tree in a code fence, the sentences
--explain would say, and what the resolvers answered.
--format dig writes the dig commands that ask what the walk asked, one per
query and each with the options it was sent with, for whoever has to fix the zone and
has no dnstree.
--from walk.json draws a walk --format json saved, in any of
these, without asking any server anything: a walk made from a machine nobody else can
reach can be drawn, explained and checked with --expect somewhere else.
--schema prints the JSON Schema of that document, so a program can check
the output without reading the source. schema_version is raised whenever a
field changes meaning or goes away. The schema is also served here, at
trace.schema.json.
--pcap walk.pcap keeps the bytes as well: every query the walk sent and
every answer, as a capture for Wireshark or tcpdump -r, with no root and no
filter. The headers around the messages are rebuilt, and your side of them is written
as a documentation address.
--replay walk.pcap walks again from that capture, offline: every server
answers what it answered then, and signatures are judged as of when it was made. A
problem seen from one network can be sent with a bug report, or tried on a newer
dnstree a month later. A question the capture does not hold fails its hop and says
so.
For a screen, and for a script
Everything above is one walk, read once. Four things take it further: several questions in one run, the same question put from several places at the same moment, the same question asked again until something moves, and a walk read by something that is not a person.
Ask several questions in one run
dnstree example.com A AAAA MX TXT walks each type
in turn, every one from the root servers down, and draws a tree for each under the
question it answers. --names reads a list of them from a file, a name and
its types to a line, which is the domain checked whole before a change and again after
it. The run exits with the worst any of them earned. With --format json
each walk is a line of its own, and with --format openmetrics the whole
list is one file for Prometheus to alert on.
Ask from several places at once
Repeat --resolver and the question goes to all of
them at the same moment. Two that answer differently are two views of one name, and
which of them somebody gets depends on nothing but which resolver they use; the summary
says how far apart they were and how many disagreed. With --subnet beside
it, that is most of "does this name look the same from where my users are".
--ddr asks each of them whether it can be used
encrypted too (RFC 9462), and says under the tree what it offers: DoT, DoH or DoQ,
at which name and port. It only asks, and connects to none of it, so every offer is
marked as not verified.
--check-resolver sees what each of them does with
what it is asked: whether it refuses dnssec-failed.org, whose chain of
trust is broken on purpose, whether it trusts the key the root signs with, and whether a made-up
name comes back NXDOMAIN or as the address of a page of its own. It also asks zones that report what reaches them whether
it passes your subnet on (ECS) and whether it asks a name a label at a time (QNAME
minimisation), and whether it makes up IPv6 addresses for names that have only IPv4 ones
(DNS64), so its answer is not taken for one the zone gave. For a resolver at a public
address it sets the network its questions reach zones from beside its own: a different
one can be a partner network, a forwarder, or something on the path answering in its
place. What the answers leave open is said as "may or may not", never
guessed.
Leave it running
--watch 30s draws the tree once and then keeps
walking the same question, saying only what has changed since the walk before it. A
round that finds nothing changed says nothing: it is meant to sit in the corner of a
screen through a change window. With --expect it stops as soon as what was
asked for holds, which is how to wait for a change to arrive rather than to keep asking
whether it has.
What it tells a script
--expect is how a script asks rather than reads:
give it an address, a name, or one of secure, bogus,
nxdomain and the rest, repeat it for everything that has to hold, and a
walk that does not meet it says so on stderr and exits 4. fresh:3d asks
that no signature under the answer runs out in the next three days, which is a cron job
that catches a stopped signer before a resolver does. The walk's own verdict wins
where there is one, so a broken chain of trust still exits 3.
| 0 | something answered |
| 1 | the command line, a file it names, or the address to serve on could not be used |
| 2 | the walk ended without an answer |
| 3 | the chain of trust is broken |
| 4 | an expectation given with --expect was not met |
Get it
One static binary, no runtime to install.
go install github.com/rafaeljusto/dnstree/v2/cmd/dnstree@latest
docker run --rm ghcr.io/rafaeljusto/dnstree www.example.com A
docker run --rm -p 8080:8080 ghcr.io/rafaeljusto/dnstree-web
curl -LO https://github.com/rafaeljusto/dnstree/releases/latest/download/dnstree.rb && brew install --formula ./dnstree.rb
curl -LO https://github.com/rafaeljusto/dnstree/releases/download/v2.18.0/dnstree_2.18.0_amd64.deb && sudo dpkg -i dnstree_2.18.0_amd64.deb
sudo rpm -i https://github.com/rafaeljusto/dnstree/releases/download/v2.18.0/dnstree-2.18.0-1.x86_64.rpm
curl -LO https://github.com/rafaeljusto/dnstree/releases/download/v2.18.0/dnstree_2.18.0_x86_64.apk && sudo apk add --allow-untrusted dnstree_2.18.0_x86_64.apk
github.com/rafaeljusto/dnstree/releases — macOS, Linux, FreeBSD, Windows
The three package lines
name the latest release. Every release carries the same
packages for arm64 and 32-bit arm, each with a man page, and a
checksums.txt over every file.
Set it once
The flags you always type belong in the first of
$DNSTREE_CONFIG, $XDG_CONFIG_HOME/dnstree/config
(or ~/.config/dnstree/config) and
~/.dnstreerc. One long flag name per
line, with the value it takes. The command line still wins over it, and
--no-config ignores it altogether.
# the walk, the way I like to read it
format = emoji
dnssec
timeout = 3s
A line opening with
# is a comment, and a flag that stands on its own needs no
value.
dnstreerc.example
carries every setting worth making, annotated and commented out.
Hand it to your assistant
A skill teaches a model which flags answer which question, how to read the exit codes, and what not to run without asking. In Claude Code:
/plugin marketplace add rafaeljusto/dnstree
/plugin install dnstree@dnstree
Other agents that read
SKILL.md folders can copy
the skill
as it is. The model runs the dnstree you installed above.
Point it at something broken
A resolver says SERVFAIL. dnstree says which link of the chain broke and why, and how long a cache may hold what it was given.
dnstree --dnssec --explain dnssec-failed.org