A Windows DNS diagnostic tool that lets you choose which network interface and which source IP address a DNS query originates from.
dnsprobe speaks the DNS protocol itself. It builds and parses the packets, opens the socket,
binds it, and pins it to an interface. It never shells out to nslookup.exe, Resolve-DnsName or
System.Net.Dns.
Requirements: Windows 10 / 11 / Server 2016 or newer, x64. .NET 8 SDK to build; the published binary can be self-contained and needs nothing installed on the machine that runs it. No administrator rights required. The interface pinning and routing inspection are Windows-specific, so this tool is Windows-only by design.
- What the tool does
- Why nslookup is not enough
- Build and install
- Recipes
- Usage and output
- Interface and source IP selection
- DNS server selection
- Transport, EDNS and record classes
- Delegation trace
- Routing, ARP and the part everyone gets wrong
- Diagnosis, stages and observations
- Scripting: JSON, --short and exit codes
- Limitations
- Troubleshooting
- Verifying with Wireshark
- Security considerations
- Architecture
- Tests
- Licence
- Lists every network adapter with its index, addresses, gateways and configured DNS servers, classified as Ethernet, Wi-Fi, VPN, tunnel, Hyper-V, container/WSL or VM adapter.
- Sends a real DNS query (A, AAAA, CNAME, MX, NS, TXT, PTR, SOA, SRV, or any numeric type) over UDP or TCP, in class IN, CH or HS.
- Binds the socket to a source IP and pins it to an interface index with
IP_UNICAST_IF/IPV6_UNICAST_IF. - Inspects the real Windows routing table (
GetBestRoute2,GetBestInterfaceEx) and the neighbour cache (GetIpNetEntry2), and warns when your selection disagrees with them. - Runs the same query from every eligible interface, compares the results, and states what can be deduced from the comparison.
- Walks the delegation chain from the root servers down (
--trace). - Repeats a query and reports loss / min / max / average / jitter.
- Supports EDNS(0): payload size, the DNSSEC DO bit, NSID and Extended DNS Errors, with an automatic retry when a middlebox will not tolerate EDNS.
- Shows how far a failing query actually got, with the evidence behind each verdict.
- Emits JSON for monitoring scripts, or a single value for shell pipelines.
- Dumps the raw packets in hex.
nslookup lets you choose the server, but gives you no control over the client side of the
socket:
| Question | nslookup |
dnsprobe |
|---|---|---|
| Which DNS server do I ask? | yes | yes |
| Which local IP is the query sent from? | no | --source-ip |
| Which adapter does the packet leave through? | no | --interface / --interface-index |
| Does the routing table agree with my choice? | no | --route-check |
| Is the gateway even answering at layer 2? | no | shown in the stage list |
| Which of my NICs can actually reach this resolver? | no | --compare |
| Where does the delegation chain break? | no | --trace |
| Is something rewriting my DNS answers? | no | flag and EDNS checks |
| Can a script consume the result? | poorly | --json, --short, exit codes |
On a box with a LAN NIC, a VPN tunnel, a Hyper-V switch and Wi-Fi all up at once, "the DNS server
does not answer" is almost never a DNS problem - it is a path problem. nslookup cannot show you
the path. That is the gap this tool fills.
git clone https://github.com/HTakafouyan/dnsprobe.git
cd dnsprobe
dotnet build src\DnsProbe\DnsProbe.csproj -c Release
# self-contained single file, nothing to install on the target machine
dotnet publish src\DnsProbe\DnsProbe.csproj -c Release -r win-x64 `
-p:PublishSingleFile=true -p:SelfContained=true -o publishOr just run build.cmd, which does both and falls back to a framework-dependent build if the
self-contained one is not possible.
The executable is named dnsprobe.exe. The projects target net8.0; with the .NET 10 SDK change
<TargetFramework> in both .csproj files to net10.0, no source changes needed.
The application itself has no third-party dependencies. Only the test project needs packages
(xunit), so dotnet build on the application works on a machine with no NuGet access;
dotnet test does not.
Administrator rights are not required. Binding a source address, setting IP_UNICAST_IF and
sending UDP/TCP to port 53 are all unprivileged operations.
The fastest way to see what the tool is for. Each of these answers a question that is awkward or impossible with the built-in Windows tools.
1. Which of my adapters can actually reach this resolver?
dnsprobe intranet.example.com --server 10.10.10.53 --compareOne row per interface, and a Diagnosis block stating what follows from the results.
2. Is this a network problem or a DNS problem?
dnsprobe example.com --server 10.10.10.53 --verboseIf it fails, the Probe Stages block shows exactly how far the query got: routing, the gateway's
ARP entry, the socket, the send, the reply.
3. Force the query out of the VPN tunnel, not the Wi-Fi.
dnsprobe intranet.example.com --interface "Ethernet 4" --system-dns --verbose4. Prove that source binding is not interface selection.
dnsprobe example.com --interface "Wi-Fi" --server 8.8.8.8 --route-check --verbose
dnsprobe example.com --interface "Wi-Fi" --server 8.8.8.8 --route-check --verbose --no-unicast-ifSame source address, different mechanism. On a machine whose routing table prefers another adapter these two produce different results, and different Windows errors.
5. Is the link flaky, or is the server slow?
dnsprobe example.com --server 10.10.10.53 --count 20 --interval 500Loss, min, max, average and jitter. Jitter separates "the server is slow" from "the path is bad".
6. Where does this domain's delegation break?
dnsprobe broken.example.com --traceRoot → TLD → the zone's own servers, one line per hop, stopping where the chain does.
7. Is something intercepting my DNS?
dnsprobe example.com --server 8.8.8.8 --verbose
dnsprobe example.com --trace
dnsprobe example.com --server 8.8.8.8 --count 10 --interval 2000Three independent signals: an EDNS query answered without an OPT record, a root server that answers questions it should only refer, and a TTL that never counts down.
8. Which anycast backend answered me?
dnsprobe example.com --server 9.9.9.9 --nsid --verbose9. Why did this resolver return SERVFAIL?
dnsprobe dnssec-failed.example.com --server 10.10.10.53 --dnssec --verboseExtended DNS Errors turn a bare SERVFAIL into "signature expired" or "no reachable authority".
10. Ask a server to identify itself.
dnsprobe version.bind --type TXT --class CH --server 10.10.10.53 --no-recurseClass CH, not IN. In class IN every server returns NXDOMAIN for that name.
11. Get just the IP address, for a script.
$ip = dnsprobe example.com --server 10.10.10.53 --short12. Monitor a resolver from a scheduled task.
dnsprobe example.com --server 10.10.10.53 --json | ConvertFrom-Json
if ($LASTEXITCODE -ne 0) { Send-Alert }13. Reverse-lookup an address on the internal DNS.
dnsprobe 10.10.10.90 --server 10.10.10.53The --type PTR and the in-addr.arpa name are implied.
14. Check the mail records of a domain from two different vantage points.
dnsprobe example.com --type MX --server 10.10.10.53
dnsprobe example.com --type MX --server 9.9.9.9A split-horizon zone answers these differently, which is usually the explanation for "mail works inside but not outside".
15. Retrieve a large TXT record set.
dnsprobe example.com --type TXT --server 9.9.9.9 --tcp-fallback --verboseEDNS usually keeps it on UDP; if the answer is still truncated the query is repeated over TCP and says so.
16. See the actual bytes on both directions.
dnsprobe example.com --server 10.10.10.53 --debug17. Find out what a fresh machine is even configured with.
dnsprobe --interfaces --all18. Let the tool walk you through it.
dnsprobeA menu-driven session that prints the equivalent command line before it runs.
dnsprobe <name> [options]
dnsprobe <ip-address> [--type PTR] [options]
dnsprobe --interfaces [--all]
dnsprobe (interactive mode)
dnsprobe --help | --version
Run dnsprobe --help for the complete option list.
> dnsprobe --interfaces
Available Network Interfaces
----------------------------------------
[1] Ethernet
Description : Intel(R) Ethernet Controller
Kind : Ethernet (physical) (Ethernet)
Status : Up
Index : IPv4 12, IPv6 12
IPv4 : 192.168.1.20
Gateway : 192.168.1.1
DNS : 192.168.1.1
[2] Ethernet 2
Description : Intel(R) Ethernet Controller #2
Kind : Ethernet (physical) (Ethernet)
Status : Up
Index : IPv4 15, IPv6 15
IPv4 : 10.10.10.20
Gateway : 10.10.10.1
DNS : 10.10.10.53
2 loopback/inactive interface(s) hidden. Use --all to show them.
VPN and tunnel adapters are highlighted; Hyper-V, Docker/WSL and VM adapters are dimmed, so the physical paths stand out at a glance.
> dnsprobe example.com --interface "Ethernet 2" --server 10.10.10.53 --verbose
DNS Probe
----------------------------------------
Query : example.com
Record Type : A
Protocol : UDP
Address Family : IPv4
Interface : Ethernet 2
Interface Index : 15
Source IP : 10.10.10.20
DNS Server : 10.10.10.53
Server Source : command line (--server)
Destination Port: 53
Timeout : 2000 ms
EDNS : version 0, UDP payload 1232
Retries : 1
Interface Pin : IP_UNICAST_IF
Local endpoint : 10.10.10.20:53142
Remote endpoint : 10.10.10.53:53
Response received.
Round Trip Time : 7.10 ms
Transaction ID : 0xA31F
Response Code : NOERROR
Transport : UDP
Flags : qr aa rd ra
Authoritative : yes
Truncated : no
Recursion Des.: yes
Recursion Av. : yes
Counts : 1 question, 1 answer, 0 authority, 1 additional
EDNS Response : version 0, UDP payload 4000
Answer:
example.com. -> 10.10.10.90
Type : A
TTL : 3600 s
Notes:
- IP_UNICAST_IF was set to interface index 15 (network byte order).
RESULT: OK - 10.10.10.90 in 7.10 ms via Ethernet 2 (10.10.10.20)
> dnsprobe intranet.example.com --server 10.10.10.53 --compare
Interface Source IP Result RTT
--------------------------------------------------
Ethernet 2 10.10.10.20 SUCCESS 16.1 ms
Wi-Fi 192.168.1.20 NET-UNREACH -
Ethernet 2: 10.10.10.90
Wi-Fi: Network is unreachable: Windows has no route from the selected source address to 10.10.10.53.
Diagnosis
----------------------------------------
10.10.10.53 is reachable: Ethernet 2 got an answer. The failure on Wi-Fi is therefore specific
to that path, not to the DNS server.
Wi-Fi had no route to the server, so nothing was ever sent. Run with --route-check on that
interface to see the routing decision.
RESULT: PARTIAL - 1 of 2 tested interface(s) reached 10.10.10.53: Ethernet 2
Column widths are computed from the data, so long Windows adapter names are not truncated.
Host-internal virtual switches - Hyper-V, WSL, Docker, VMware, VirtualBox - are skipped, and the
tool says how many. They exist to connect the host to its guests and have no route to an external
resolver by design, so testing them produces a guaranteed failure that carries no information.
--compare-all includes them.
> dnsprobe example.com --server 10.10.10.53 --count 5
Query 1: 8.10 ms
Query 2: 7.30 ms
Query 3: TIMEOUT
Query 4: 9.00 ms
Query 5: 7.60 ms
Statistics
----------------------------------------
Sent : 5
Received : 4
Lost : 1 (20%)
Min : 7.30 ms
Max : 9.00 ms
Average : 8.00 ms
Jitter : 0.60 ms
RESULT: PARTIAL LOSS - 4/5 received, avg 8.00 ms via routing table
Output is colour coded when the console supports it: green for successful results, red for definite failures, yellow for answers that arrived but deserve a second look, and cyan for the values you selected yourself. Round trip times are green below 50 ms, yellow below 200 ms and red above that.
Every run ends with a single RESULT: line:
RESULT: OK - 10.10.10.90 in 14.8 ms via Ethernet 2 (10.10.10.20)
RESULT: NODATA - no MX record for example.com (13.4 ms)
RESULT: TIMEOUT - no usable answer from 10.10.10.53 via Ethernet (192.168.1.20)
RESULT: IF-UNREACH - no usable answer from 8.8.8.8 via Wi-Fi (192.168.1.20)
RESULT: OK - 10/10 received, avg 46.5 ms via Ethernet 2 (10.10.10.20)
RESULT: PARTIAL - 1 of 2 tested interface(s) reached 10.10.10.53, 1 skipped: Ethernet 2
Colour is never the only carrier of meaning - the same information is in the text - so nothing is
lost when it is switched off. It is disabled automatically when the output is redirected, when
NO_COLOR is set, or when the console does not support ANSI escapes. --no-color forces it off.
Running dnsprobe with no arguments asks what you want to do first, then only the questions that
task needs:
dnsprobe - what would you like to do?
[1] Look up a name
[2] Compare every interface against one DNS server
[3] Test reliability (repeat a query and show statistics)
[4] Look up a name and show the routing path
[5] List network interfaces
[6] Reverse lookup an IP address
Before running, it prints the command line that would do the same thing:
Equivalent command:
dnsprobe example.com --interface "Ethernet 2" --server 10.10.10.53 --verbose
That line is the point of the mode: after a few sessions you no longer need it.
dnsprobe example.com --interface "Ethernet 2"
dnsprobe example.com --interface-index 15
dnsprobe example.com --source-ip 10.10.10.20--interface matches, in this order: the exact connection name, the exact adapter description, the
adapter GUID, then a unique substring. An ambiguous match is an error, never a guess:
ERROR: "Ethernet" is ambiguous - it matches "Ethernet 4" (index 15), "Ethernet 3" (index 17).
Use the exact name or --interface-index.
If you give only --source-ip, the owning adapter is looked up for you. If you give both, they are
cross-checked:
ERROR: Source IP 192.168.1.20 does not belong to interface "Ethernet 2".
It is configured on "Ethernet".
The chosen adapter must be operational; --allow-down overrides that. If an interface has several
addresses of the chosen family, the first non-link-local one is used and you are told, so you can
override with --source-ip.
dnsprobe example.com --server 10.10.10.53
dnsprobe example.com --server 10.10.10.53#5353
dnsprobe example.com --server 2001:4860:4860::8888
dnsprobe example.com --server "[2001:db8::1]:5353"
dnsprobe example.com --interface "Ethernet 2" --system-dnsThe server must be an IP address. dnsprobe deliberately refuses to resolve a server name,
because doing so would require the very resolver you are trying to diagnose.
With --system-dns, or with no --server at all, the tool reports where the server came from:
DNS Server : 10.10.10.53
Server Source : DNS configuration of Ethernet 2
If you selected an interface, only that interface's DNS servers are considered. If it has none
for the chosen family you get an error rather than a silent fallback to another adapter's resolver -
quietly querying 192.168.1.1 while you asked for Ethernet 2 would invalidate the whole test.
dnsprobe example.com --protocol udp # default
dnsprobe example.com --tcp # shorthand for --protocol tcp
dnsprobe example.com --tcp-fallback # retry over TCP if TC=1DNS over TCP uses the two-byte big-endian length prefix from RFC 1035 §4.2.2. The reader loops until exactly that many bytes have arrived, and a premature close is reported as such.
The UDP socket is intentionally left unconnected, so a response from an unexpected sender is visible to the tool and reported rather than silently dropped by the stack.
--ipv4 / --ipv6 force a family. Otherwise it is inferred, in order, from --source-ip, then
--server, then defaults to IPv4. Families are never mixed: a v6 socket is created with
DualMode = false, and any impossible v4/v6 combination is rejected up front with an explanation.
EDNS(0) (RFC 6891) is on by default. It attaches a small OPT pseudo-record advertising how large a UDP answer this client accepts, which lifts the 512 byte limit plain RFC 1035 DNS imposes.
dnsprobe example.com --server 9.9.9.9 --verbose # EDNS on, 1232 byte payload
dnsprobe example.com --server 9.9.9.9 --edns 4096 # advertise a larger buffer
dnsprobe example.com --server 9.9.9.9 --no-edns # plain RFC 1035 query
dnsprobe example.com --server 9.9.9.9 --dnssec # set the DO bit
dnsprobe example.com --server 9.9.9.9 --nsid # ask the server to identify itselfThe default 1232 bytes is not arbitrary: it keeps the response below the smallest MTU IPv6 guarantees, so the answer never has to be fragmented. Fragmented DNS is both a reliability and a security problem, so bigger is not better. The accepted range is 512 to 4096.
What the server sends back is reported too:
EDNS : version 0, UDP payload 1232, DO
EDNS Response : version 0, UDP payload 4000
Server NSID : dns-node-7
Extended Error : 6 (DNSSEC Bogus) - signature verification failed
- Extended DNS Errors (RFC 8914) turn a bare
SERVFAILinto an actual explanation. They are shown even without--verbose, because they are usually the whole answer to "why did this fail". - NSID (RFC 5001) tells you which server answered. Behind an anycast address that is often the only way to tell one backend from another.
- The extended RCODE adds 8 bits to the 4 bit header code, which is how
BADVERSand friends can be reported at all.
Some firewalls and older resolvers mishandle queries carrying an OPT record. When an EDNS query
times out or comes back FORMERR/NOTIMP, dnsprobe repeats it without EDNS and says what it found:
The query failed while carrying an EDNS(0) OPT record, but the same query without EDNS succeeded.
Something on this path does not tolerate EDNS - a firewall, a middlebox, or an old resolver.
Use --no-edns against this server.
--class selects the query class. It defaults to IN and almost always should be, with one
practical exception: version.bind and id.server are only meaningful in class CH, and asking
for them in class IN returns NXDOMAIN from every server.
dnsprobe version.bind --type TXT --class CH --server 10.10.10.53 --no-recurse
dnsprobe id.server --type TXT --class CH --server 10.10.10.53 --no-recurse--trace walks the chain from the root servers down to the authoritative server, asking each level
itself with recursion disabled - the way a resolver does it:
> dnsprobe example.com --trace
Delegation trace for example.com (A)
----------------------------------------
. a.root-servers.net (198.41.0.4) 31.2 ms -> com (13 name server(s))
com e.gtld-servers.net (192.12.94.30) 44.8 ms -> example.com (2 name server(s))
example.com a.iana-servers.net (199.43.135.53) 58.1 ms -> ANSWER
93.184.216.34 (A, TTL 86400s)
3 step(s), 134.1 ms total, authoritative
One line per hop, on purpose. dig +trace prints every NS record of every level, which runs to
hundreds of lines and buries the single fact the reader came for. Add --verbose to list the name
servers at each level when you do want them.
When the chain breaks, the last line says where and why:
The chain stops at zone ".": none of the 3 name server(s) tried would answer.
Header flags are checked against what each step should have produced, and shown only when they depart from it:
. a.root-servers.net (198.41.0.4) 41.2 ms -> ANSWER
FLAGS qr ra
unexpected: aa absent (the answer did not come from a server for this zone)
unexpected: ra set on a root server, which never performs recursion
A referral should carry qr alone; an authoritative answer should carry qr aa. Root and TLD
servers never set ra, because they do not perform recursion - so ra at those levels means
something other than that server replied. Normal flags are noise on a healthy chain, so they only
appear under --verbose.
Root server addresses are hard-coded, because asking a resolver where the root servers are would defeat the point of starting from the root. A referral with no glue records stops the walk and says so rather than quietly falling back to a resolver.
Each level is asked of one server, because every server for a zone holds the same data and thirteen identical referrals would bury the useful line. If that server does not answer, the next one is tried, and the dead one is shown in red rather than hidden:
. b.root-servers.net (170.247.170.2) 38.4 ms -> com (13 name server(s))
FAILED no answer from a.root-servers.net (no reply)
--trace-servers <n> sets how many to try per level, from 1 to 13. The default is 3: enough to
survive one dead server, few enough that a fully blocked path reports something in a few seconds
instead of spending thirteen timeouts on the root alone.
Three things make the trace worth having:
- It localises a resolution failure. A name that will not resolve fails at the root, at the TLD, at the registrar's delegation, or at the zone's own servers - four different people to talk to. The trace says which.
- It respects the interface selection.
--trace --interface "Ethernet 2"walks the whole chain from that one adapter, which no other tool will do. - It exposes interception. A trace talks to root and TLD servers directly with recursion off. Anything that answers those queries on their behalf is not a healthy path.
This is the core of the tool, so it is worth being precise.
- Source IP selection - which address goes into the source field of the IP header.
- Interface selection - which adapter the frame is actually handed to.
- The Windows routing decision - which route entry Windows picks, by longest prefix then metric.
- Next-hop resolution - whether the gateway actually answers at layer 2.
They are not the same thing, and controlling one does not control the others.
Socket.Bind(new IPEndPoint(10.10.10.20, 0)) fixes the source address of the socket. It does
not tell the TCP/IP stack which interface to use. The route lookup still happens against the
full routing table, so if the best route for 10.10.10.53 points out of Ethernet, the packet can
leave through Ethernet carrying 10.10.10.20 as its source. That is exactly the situation that
produces "I bound to the right IP and still got no answer".
So this claim, which you will see in a lot of sample code, is false:
"Binding the source IP guarantees the packet will leave through this interface."
dnsprobe does not make that claim anywhere.
Windows exposes a socket option for it:
- IPv4:
IP_UNICAST_IF(option 31, levelIPPROTO_IP) - IPv6:
IPV6_UNICAST_IF(option 31, levelIPPROTO_IPV6)
Setting it makes the stack perform the route lookup constrained to that interface, which is the supported user-mode way of forcing outbound unicast traffic onto a specific adapter.
There is a byte-order trap that silently breaks naive implementations:
| Option | Byte order of the interface index |
|---|---|
IP_UNICAST_IF (IPv4) |
network byte order |
IPV6_UNICAST_IF (IPv6) |
host byte order |
Passing the wrong one does not fail - it pins the socket to a nonsense interface index.
Windows also reports the two situations differently, which the tool preserves: a pinned socket with
no route fails with WSAEINVAL and is reported as IF-UNREACH, while an unpinned one fails with a
routing error and is reported as NET-UNREACH. Running the same query with and without
--no-unicast-if and getting two different errors is the practical proof that these mechanisms are
not the same.
> dnsprobe example.com --interface "Ethernet 2" --server 10.10.10.53 --route-check
Route Check
----------------------------------------
Destination : 10.10.10.53
Source : 10.10.10.20
Interface : Ethernet 2
Gateway : On-link
Route Egress: Ethernet 2 (index 15, metric 271)
Route Source: 10.10.10.20 (what Windows would pick without an explicit bind)
Green when the routing table agrees with your selection, yellow when it does not, with a warning
explaining the disagreement. Routing data comes from GetBestRoute2 and GetBestInterfaceEx.
Nothing is invented: if the API fails the tool prints the Win32 error. Codes 1231/1232 are
translated, because "no route to the destination" is a diagnostic answer, not an API malfunction.
A route can exist on paper while the gateway never answers at layer 2. When that happens every
packet is dropped locally, and the result is indistinguishable from a filtered path - unless you
look. dnsprobe reads the Windows neighbour cache (the ARP table for IPv4, neighbour discovery for
IPv6) for the next hop:
Next hop OK neighbour cache: reachable (00-1C-B1-FA-4C-00)
Next hop FAILED neighbour cache: not in cache
Three separate blocks, with three different levels of confidence. Keeping them apart is deliberate.
Shown when a query fails, or with --verbose:
Probe Stages
----------------------------------------
Interface OK Ethernet 2, index 15, pinned with IP_UNICAST_IF
Route OK GetBestRoute2: index 15 via 10.10.10.1, metric 281
Next hop FAILED neighbour cache: not in cache
Socket OK bound to 10.10.10.20:53142
Send OK 28 bytes accepted by the stack (not proof that a frame reached the wire)
Receive FAILED DNS request timed out after 2000 ms.
The evidence column is not decoration. Each line says how the verdict was reached, because a bare
OK invites the reader to believe more was verified than actually was.
The Send stage is deliberately not called "packet transmitted". A successful send() means the
stack accepted the datagram, not that a frame left the adapter. Only a capture can confirm that,
and claiming otherwise would contradict the distinction this whole tool is built on.
There is no "likely causes" list. Listing four possibilities is not a diagnosis, and a tool that presents guesses in the shape of conclusions is worse than one that stays quiet.
With --compare, results from several paths become a deduction rather than a guess:
Diagnosis
----------------------------------------
10.10.10.53 is reachable: Ethernet 2 got an answer. The failure on Wi-Fi is therefore specific
to that path, not to the DNS server.
Wi-Fi had no route to the server, so nothing was ever sent.
Every sentence follows from something measured. If one interface got an answer, the server is
demonstrably up; if another got NET-UNREACH, nothing was ever sent. Those are deductions, not
hypotheses, which is the only reason this block exists.
Things visible in a response that are not errors:
Observations
----------------------------------------
- An EDNS(0) OPT record was sent but the response carried none. A server that supports EDNS is
required to echo one, so either this server is old, or something between you and it rewrote
the response. Compare with --tcp and with a different server.
Single-response checks: an EDNS query answered without an OPT record, an answer truncated despite a large advertised payload, a server advertising a UDP payload above 1500 bytes (which will be fragmented on most links), and recursion requested but unavailable.
With --count there is one more, and it is the most useful: whether the TTL counts down between
queries. A caching resolver decrements it, so a TTL that never moves suggests the answer is being
generated rather than served from a real cache.
That check is skipped entirely when any response has aa=1. An authoritative server reads the TTL
straight out of its zone file and never counts it down, so applying the check there would produce a
false alarm on every correctly configured internal DNS server. It also needs a window of at least
five seconds; below that it says so instead of guessing:
dnsprobe example.com --server 9.9.9.9 --count 10 --interval 2000Every observation is a heuristic. The wording says "suggests" rather than "is", on purpose - a diagnostic tool that overstates its conclusions is worse than one that stays quiet, because people act on what it says.
Answer values only, one per line, nothing else - the equivalent of dig's +short:
> dnsprobe example.com --server 10.10.10.53 --short
10.10.10.90One JSON document instead of all human readable output. Nothing else is written to stdout and the exit code is unchanged.
dnsprobe example.com --server 10.10.10.53 --json
dnsprobe example.com --server 10.10.10.53 --count 10 --json
dnsprobe example.com --server 10.10.10.53 --compare --json
dnsprobe example.com --trace --json
dnsprobe --interfaces --jsonEvery document carries tool, version, timestamp, status and exitCode. Consumers should
branch on status and exitCode rather than parsing prose fields. Failures, including usage
errors, are reported as JSON too:
{
"tool": "dnsprobe",
"status": "error",
"exitCode": 3,
"error": "Source IP 192.168.1.20 does not belong to interface \"Ethernet 2\"."
}A successful query adds query, probe, fallbacks, response and observations; --count
replaces response with statistics; --compare replaces it with results plus diagnosis,
interfacesTested and interfacesSkipped; --trace adds a trace object with one entry per hop,
each carrying its flags and any flagAnomalies.
| Code | Meaning |
|---|---|
| 0 | response received, RCODE = NOERROR |
| 1 | response received with a non-zero RCODE (SERVFAIL, NXDOMAIN, REFUSED, …) |
| 2 | no usable response (timeout, unreachable, malformed) |
| 3 | invalid command line, or an impossible interface/source selection |
- Verification is your job, and Wireshark is the referee.
dnsprobereports the socket's local endpoint and the options it set; it cannot observe the wire. See §15. - Pinning the interface does not create connectivity. If that path cannot reach the resolver you get a timeout - which is usually the answer you were looking for.
- Policy-based routing, third-party WFP/LSP filter drivers, VPN clients that redirect traffic in the kernel, and NRPT rules can all override what the socket asked for. Such drivers are outside anything a user-mode tool can inspect.
- EDNS(0) covers payload size, the DO bit, NSID and Extended DNS Errors. Other options (cookies, client subnet, padding) are preserved but not decoded.
- DNSSEC records can be requested with
--dnssecand are displayed, but signatures are not validated - that is a resolver's job, not a probe's. - No DoT / DoH / DoQ, no zone transfers, no dynamic updates.
--tracefollows one server per level and stops at a referral with no glue rather than falling back to a resolver.--comparesends one query per interface sequentially, so it is not a load test, and a slow interface delays the ones after it.- Interface classification (VPN / Hyper-V / Docker / WSL) is a keyword heuristic. Windows does not expose "this is a VPN adapter" as a first-class property.
- The routing and neighbour structures are read at fixed field offsets that are correct for
x86/x64/ARM64 Windows; a future change to
MIB_IPFORWARD_ROW2orMIB_IPNET_ROW2would require updatingNativeMethods.
| Symptom | Meaning |
|---|---|
TIMEOUT |
The query left the stack and nothing came back. Filtering, an ACL, or a reply that took a different route home. Try --compare. |
PORT-UNREACH |
ICMP port unreachable: the host exists but nothing is listening on that port. A socket-level result, not a DNS one. |
NET-UNREACH |
No route from the bound source address. Run with --route-check. |
IF-UNREACH |
The interface you pinned has no route to the server. Windows reports WSAEINVAL because IP_UNICAST_IF forbids falling back. Re-run with --no-unicast-if to see what routing would have done. |
Next hop FAILED in the stage list |
The gateway is not answering ARP / neighbour discovery. Packets are dropped locally, before the wire. |
REFUSED |
A real DNS server answered and declined. Almost always an ACL keyed to your source address - which is exactly what --compare diagnoses. |
SERVFAIL |
Server-side failure: broken forwarder, DNSSEC failure, unreachable upstream. Add --dnssec to see if an Extended DNS Error explains it. |
NODATA |
The name exists but has no record of that type. Not a network problem. |
Source IP … does not belong to interface … |
Exactly what it says. Check --interfaces. |
"X" is ambiguous |
Two adapters match that substring. Use the full name or --interface-index. |
Response is truncated (TC=1) |
Add --tcp-fallback or --tcp. |
PORT-UNREACH and REFUSED are deliberately different labels. REFUSED is the DNS RCODE: a real
server received the query and declined it. PORT-UNREACH happens one layer lower - the packet
reached the host but nothing was listening, so no DNS conversation took place at all. The fix
differs in each case, so the tool never uses one name for both.
This is the only way to prove the packet left where you wanted it to.
Test bed:
NIC 1 "Ethernet" 192.168.1.20/24 gateway 192.168.1.1
NIC 2 "Ethernet 2" 10.10.10.20/24 gateway 10.10.10.1
DNS server 10.10.10.53
-
Start Wireshark and select the
Ethernet 2adapter - the specific NIC, not "any". -
Apply the display filter
udp.port == 53(ortcp.port == 53for TCP tests). -
Run:
dnsprobe example.com --interface "Ethernet 2" --server 10.10.10.53 --verbose
-
In the captured query packet confirm: source IP = 10.10.10.20, destination IP = 10.10.10.53, destination port 53, the source port matching the
Local endpointline, and the transaction ID matching the one printed.A precise filter:
ip.src == 10.10.10.20 && ip.dst == 10.10.10.53 && udp.dstport == 53 -
Capture on
Ethernetat the same time and confirm nothing appears there. That negative result is the real proof of interface selection. -
Repeat with
--no-unicast-if. On a machine whose routing table prefers the other NIC you will see the difference between binding a source address and pinning an interface - the whole point of §10.
Without Wireshark:
netsh trace start capture=yes tracefile=C:\temp\dns.etl
dnsprobe example.com --interface "Ethernet 2" --server 10.10.10.53
netsh trace stopDNS responses are unauthenticated data from the network and are treated as hostile:
- Every read is bounds-checked before it happens;
RDLENGTHis validated against the remaining buffer before any RDATA is touched. - Name decoding is iterative, not recursive - no stack overflow path exists.
- A compression pointer must point strictly backwards. That single rule makes pointer loops impossible; a jump counter and a 255-byte total-length cap are layered on top.
- The two reserved label-type bit patterns are rejected rather than ignored.
- The record cursor always advances by
RDLENGTH, so a lying RDATA cannot desynchronise the parser. A single undecodable record degrades to a raw record plus a warning. - EDNS option lists are length-checked the same way; a truncated or lying option is reported and the rest ignored.
- Transaction IDs come from
RandomNumberGeneratorand the source port is a random ephemeral port. Responses with the wrong ID, the wrong sender endpoint, or a length below 12 bytes are discarded and counted. - Names and TXT strings are escaped (
\ddd) before display, so a hostile response cannot inject control sequences into your terminal. - No
unsafecode anywhere. Native buffers are handled throughMarshalwith explicit sizes and are always freed in afinally. - The tool only ever sends queries; it never opens a listening socket.
- A fuzz-style test feeds 500 random byte arrays to the parser and asserts that nothing but
DnsProtocolExceptionescapes.
See SECURITY.md for how to report a vulnerability and what is in scope.
dnsprobe/
├── DnsProbe.sln
├── build.cmd
├── src/DnsProbe/
│ ├── Program.cs entry point, top-level error handling
│ ├── ProbeRunner.cs orchestration + exit codes
│ ├── Cli/
│ │ ├── ProbeOptions.cs the validated command line
│ │ ├── CommandLineParser.cs parsing + combination validation
│ │ ├── HelpText.cs
│ │ └── InteractiveSession.cs task-oriented zero-argument mode
│ ├── Network/
│ │ ├── InterfaceInfo.cs adapter snapshot (plain data)
│ │ ├── NetworkInterfaceProvider.cs enumeration + classification
│ │ ├── InterfaceSelector.cs name/index/source-IP resolution
│ │ ├── SocketFactory.cs Bind() + IP_UNICAST_IF / IPV6_UNICAST_IF
│ │ ├── RouteInspector.cs GetBestRoute2 / GetBestInterfaceEx / GetIpNetEntry2
│ │ ├── NeighbourInfo.cs ARP / neighbour discovery result model
│ │ └── NativeMethods.cs P/Invoke, no unsafe
│ ├── Dns/
│ │ ├── DnsEnums.cs, DnsMessage.cs, DnsRecord.cs
│ │ ├── DnsName.cs safe encode/decode
│ │ ├── DnsPacketBuilder.cs query construction, OPT record, TCP framing
│ │ ├── DnsPacketParser.cs defensive response decoding
│ │ ├── EdnsInfo.cs EDNS(0) options, NSID, Extended DNS Errors
│ │ ├── DnsQuery.cs request/result model
│ │ ├── DnsClient.cs UDP/TCP transport, retries, TCP + EDNS fallback
│ │ └── DnsTracer.cs delegation chain walk from the root servers
│ └── Diagnostics/
│ ├── DiagnosticReporter.cs all console output
│ ├── ConsoleTheme.cs ANSI colour policy and detection
│ ├── ProbeStages.cs how far the query got, with evidence
│ ├── ComparisonDiagnosis.cs deductions across interfaces
│ ├── Observations.cs single-response and over-time heuristics
│ ├── TraceFlagCheck.cs expected vs actual header flags per trace step
│ ├── InterfaceComparer.cs --compare
│ ├── QueryStatistics.cs
│ ├── JsonOutput.cs --json
│ └── HexDump.cs --debug
└── tests/DnsProbe.Tests/
├── PacketWriter.cs synthetic packet builder
├── DnsPacketBuilderTests.cs
├── DnsPacketParserTests.cs
├── DnsNameTests.cs
├── EdnsTests.cs
├── InterfaceSelectorTests.cs
└── CommandLineParserTests.cs
Design rules: DNS parsing knows nothing about sockets; the network layer knows nothing about the
console; all output lives in DiagnosticReporter; interface selection is pure logic behind
INetworkInterfaceProvider, which is why it can be unit-tested against a synthetic five-NIC
machine; expected failures are return values (ParseResult, InterfaceSelectionResult,
DnsQueryAttempt) carrying a human readable explanation, not exceptions.
dotnet testCovered: query construction for A/AAAA/MX/PTR/NS/SOA, the OPT record with payload size, the DO bit
and NSID, TCP framing, name encoding including non-ASCII, oversized and empty labels; response
parsing for single and multiple answers, CNAME chains with compression, MX/TXT/SOA/PTR RDATA, every
response code, the truncated flag, short messages, lying RDLENGTH, forward and self-referencing
and mutual compression pointers, reserved label types, unknown record types, EDNS option lists
including truncated and lying lengths, extended RCODEs, and a 500-iteration random-input fuzz test;
interface lookup by name, exact-over-partial matching, lookup by index, name/index conflicts,
source-IP ownership validation, adapters without IPv4, down adapters, family inference and
mismatch; and the full CLI surface including every invalid combination.
Integration testing against real hardware is described in §15 and cannot be automated here - it needs two NICs and a packet capture.
MIT. See LICENSE.