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.

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 it said — a referral one zone down
what it cost — the whole hop, retries included
whose network — origin AS, looked up after the walk
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 after the walk
  • 241ms — what the hop cost, retries included
  • NOERROR · referral → com. — what it said, and 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.

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.

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.

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.

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 is reachable, authoritative, and answering correctly out of an older zone, and a walk that stops at the first server to answer never sees it.

disagreeing

A delegation the zone does not recognise

--check-ns asks the zone the walk ends in for its own NS records and holds them against the ones its parent hands out. Only the parent's view is visible from above, so the two drift apart unnoticed: a nameserver the parent still delegates to, or one the zone lists that nobody is told about, is named either way.

no room left

An answer that barely fits

Every hop records how many bytes came back and how many it could have carried. A datagram nearly full is a zone one record away from truncating, which costs every resolver asking a second round trip over TCP — and costs the ones that cannot reach the server over TCP the answer altogether. The size is drawn only where the room has run out; the whole of it is in --format json.

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 published no DS
  • bogus — a DS with no matching key, or a signature that fails
  • indeterminate — an algorithm this build cannot check

An unsigned zone turns the chain grey 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.

Or have it say what happened

--explain writes the walk out in sentences under the tree: what it came to, how long it goes on being served after it changes, what the chain of trust made of it, what the zone's nameservers have in common, which servers made it harder, and whether a recursive resolver agreed.

~ · dnstree --dnssec --explain dnssec-failed.org
...
 bogus in 4.5s · resolver in 453ms (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

Every sentence is read off the trace the walk recorded, so the sentences and the tree above them cannot come to disagree, and none of them claims more than the walk checked: 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 and --format dot refuse the flag — both are read by a program, which has the same facts in fields already.

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: silence would read as nothing having changed when it may mean nothing was remembered. Answers are compared as sets, so a nameserver rotating an RRset is not a change, and a fact only one of the two walks kept is not one either. It is also the only thing in dnstree that writes to the disk — one small file per question under $DNSTREE_CACHE, or $XDG_CACHE_HOME/dnstree, naming what was looked up and when, and safe to delete at any time. Without the flag nothing is read and nothing is kept.

Six ways out

The tree is for reading, and the emoji for enjoying it. Three more are for pasting into a document, a pipeline, or a picture. The last one opens a browser.

~ · 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 draws nothing in the terminal. It serves the finished walk as a page on this machine and opens a browser at it: the same tree with every hop worth clicking on, beside what each server cost, who the addresses belong to grouped by origin AS, and the chain of trust cut by cut. Nothing is fetched from anywhere — the page is in the binary, and the walk behind it is served under /trace.json, byte for byte what --format json writes. It listens on 127.0.0.1 and a free port, and answers only a request that reached it by address; --web-addr moves it and --no-browser leaves the address to be opened by hand.

--format emoji --live: the same walk, arriving.

--schema prints the JSON Schema of that document and stops, so whatever reads the output can be held against the shape of it — and told what a field means — without reading the source. It describes the one version the binary it came out of writes: schema_version is raised whenever a field changes meaning or goes away, never for one that is merely added, so nothing in the schema forbids properties it does not name. It is served from here too, at trace.schema.json, which is the address its $id names.

For a screen, and for a script

Everything above is one walk, read once. Three things take it further: 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 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".

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. The walk's own verdict wins where there is one, so a broken chain of trust still exits 3.

0something answered
1the command line, or the question, could not be read
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, one dependency, no runtime to install.

go go install github.com/rafaeljusto/dnstree/cmd/dnstree@latest
docker docker run --rm ghcr.io/rafaeljusto/dnstree www.example.com A
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/v1.2.0/dnstree_1.2.0_amd64.deb && sudo dpkg -i dnstree_1.2.0_amd64.deb
rpm sudo rpm -i https://github.com/rafaeljusto/dnstree/releases/download/v1.2.0/dnstree-1.2.0-1.x86_64.rpm
apk curl -LO https://github.com/rafaeljusto/dnstree/releases/download/v1.2.0/dnstree_1.2.0_x86_64.apk && sudo apk add --allow-untrusted dnstree_1.2.0_x86_64.apk
binary github.com/rafaeljusto/dnstree/releases — macOS, Linux, FreeBSD, Windows

The three package lines name the release that was current when the page was built, and the page refreshes them from GitHub when it can. Every release carries the same packages for arm64 and armhf, each with a man page, and a checksums.txt over every file.

Set it once

The flags you always type belong in ~/.dnstreerc — or ~/.config/dnstree/config, or whichever file $DNSTREE_CONFIG names. 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.