dnstree

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.

lame

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.

broken

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.

unreachable

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.

bailiwick

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.

out of date

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.

exposed

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.

unreachable

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.

certificates

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.

mail

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.

unverified

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.

mail

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.

stale

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.

gone

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.

hidden

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.

expiring

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.

health

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.

propagation

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.

rollover

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.

disagreeing

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.

no room left

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.

stuck

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.

report

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.

minimised

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.

no backup

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.

moving

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.

moving

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.

DS

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.

running out

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.

bounded

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.

~ · dnstree --dnssec --explain dnssec-failed.org
...
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.

~ · dnstree --diff www.example.com
...
✔ 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.

~ · unicode branches, colour when the terminal wants 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

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

0something answered
1the command line, a file it names, or the address to serve on could not be used
2the walk ended without an answer
3the chain of trust is broken
4an expectation given with --expect was not met

Get it

One static binary, no runtime to install.

go go install github.com/rafaeljusto/dnstree/v2/cmd/dnstree@latest
docker docker run --rm ghcr.io/rafaeljusto/dnstree www.example.com A
web docker run --rm -p 8080:8080 ghcr.io/rafaeljusto/dnstree-web
brew curl -LO https://github.com/rafaeljusto/dnstree/releases/latest/download/dnstree.rb && brew install --formula ./dnstree.rb
deb 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
rpm sudo rpm -i https://github.com/rafaeljusto/dnstree/releases/download/v2.18.0/dnstree-2.18.0-1.x86_64.rpm
apk 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
binary 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.

~/.dnstreerc
# 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:

claude /plugin marketplace add rafaeljusto/dnstree
claude /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.

try dnstree --dnssec --explain dnssec-failed.org