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.
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.
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 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.
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.
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.
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.
... ✘ 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.
... ✔ 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.
. (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
{
"schema_version": 3,
"question": { "name": "www.example.com.", "type": "A", "class": "IN" },
"elapsed_ms": 41.5,
"root": {
"zone": ".",
"kind": "zone",
"children": [
{
"zone": ".",
"kind": "referral",
"server": { "name": "a.root-servers.net.", "ip": "198.41.0.4", "port": 53 },
"proto": "udp",
"rtt_ms": 12,
"rcode": "NOERROR",
"delegation": { "zone": "com.", "ds_present": true }
}
]
}
}
digraph dnstree {
rankdir=LR;
label="dnstree www.example.com. A";
subgraph cluster_0 {
label=".";
n1 [label="a.root-servers.net.\n198.41.0.4\nNOERROR referral"];
}
subgraph cluster_1 {
label="com.";
n3 [label="a.gtld-servers.net.\n192.5.6.30\nNOERROR referral"];
}
n1 -> n3 [label="18ms"];
}
$ dnstree --format web --dnssec www.example.com the walk is at http://127.0.0.1:52341/ it is served until this command is interrupted
--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.
| 0 | something answered |
| 1 | the command line, or the question, could not be read |
| 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, one dependency, no runtime to install.
go install github.com/rafaeljusto/dnstree/cmd/dnstree@latest
docker run --rm ghcr.io/rafaeljusto/dnstree www.example.com A
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/v1.2.0/dnstree_1.2.0_amd64.deb && sudo dpkg -i dnstree_1.2.0_amd64.deb
sudo rpm -i https://github.com/rafaeljusto/dnstree/releases/download/v1.2.0/dnstree-1.2.0-1.x86_64.rpm
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
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.
# 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.