
You probably trigger your first query to a DNS resolver before putting socks on in the morning (and so do I).
What happens when you open “lkml.org” in your browser is fairly predictable, but what happens when said request hits the resolver varies widely. This is usually opaque to both you, the user, and the DNS client. RFC 9606 with its RESINFO record is intended to solve this, but it is about as widely implemented as RFC 2549.
In this post, we’ll pick up the slack for RFC9606 and gain visibility into resolver behavior by writing our own CoreDNS plugin and running our own authoritative nameserver.
What’s observable
The RFC 9606 specifies the following “keys”:
- qnamemin - Limits how much of the FQDN is shared with upstream servers
- exterr - List of supported Extended DNS Error (EDE) codes
- infourl - Link text/html formatted docs
Out of these, we can only reliably provide qnamemin support status passively. Exterr requires actively querying the server to provoke errors, and infourl cannot be inferred from DNS queries at all.
Beyond RFC 9606, we can assert the following privacy-oriented behaviors from incoming queries:
- EDNS0-Client-Subnet (ECS) - whether the resolver shares client IP
- EDNS0 Padding - makes packet length uniform to avoid third parties inferring packet contents
- Transport - Whether the query is sent plaintext or encrypted with DNS over TLS/DNS over HTTPS
We can also observe attributes that show how resolvers protect integrity and authenticity:
- DNSSEC Flags (DO/AD/CD) - Indicates whether DNSSEC validation is requested and/or performed.
- 0x20 Case randomization - adds entropy to queries to mitigate spoofing & cache poisoning
- EDNS0 Cookie - allows validating client/server relationship to reduce spoofing risk
Finally, these protocol/operational tidbits are useful to be able to observe:
- UDP Buffer size - Affects fragmentation behavior and performance.
- Resolver identity - Provides operator transparency — who is actually answering your queries.
Implementation
Our nameserver is built as a monolithic CoreDNS binary with two custom plugins compiled directly into it: resinfo and lego.
This architecture keeps the deployment incredibly simple while allowing us to utilize CoreDNS’s efficient processing pipeline.
CoreDNS plugins implement a chaining pattern. When you compile them into the binary, you explicitly define their order.
Our diagnostic plugin resinfo sits right before the file plugin, allowing it to intercept diagnostic queries before they hit the standard zone file handler.
// Register custom plugins
_ "torbbang/resinfo/plugin/lego"
_ "torbbang/resinfo/plugin/resinfo"
func init() {
dnsserver.Directives = []string{
"debug", "lego", "tls", "reload", "health",
"errors", "log",
"resinfo", // <-- our plugin intercepts here
"file", "whoami", "bind", // ...
}
}
When a query comes in, it passes down the chain until it reaches our plugin. Every CoreDNS plugin must implement the plugin.Handler interface, which means defining a ServeDNS() method.
func (ri *ResInfo) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
state := request.Request{W: w, Req: r}
// Check if query is for our configured zone
if !plugin.Name(ri.Zone).Matches(state.Name()) {
return plugin.NextOrFailure(ri.Name(), ri.Next, ctx, w, r)
}
// ... core logic to assemble the report ...
}
The ServeDNS method takes the raw DNS request r and a ResponseWriter w. If the query isn’t meant for our diagnostic zone, we simply pass it to the next plugin in the chain (ri.Next). If it is for us, the core logic takes over to assemble the report line-by-line.
Inferring privacy and integrity
Inspecting EDNS0 options and DNSSEC flags is straightforward. The plugin directly walks the raw DNS message structure:
opt := r.IsEdns0()
do := false
cookie, padding, ecs := false, false, false
if opt != nil {
do = opt.Do() // DNSSEC OK flag
for _, o := range opt.Option {
switch o.Option() {
case dns.EDNS0COOKIE: cookie = true
case dns.EDNS0PADDING: padding = true
case dns.EDNS0SUBNET: ecs = true
}
}
}
To detect 0x20 case randomization (where a resolver sends cHeCk.mY.DnS instead of check.my.dns), we just iterate through the query bytes looking for uppercase letters. If they exist, 0x20 encoding is in use.
QNAME Minimization detection
This is the clever bit. According to RFC 7816, a QNAME-minimizing resolver doesn’t just blast the full query (check.my.dns.resinfo.net) to the root servers and walk down. Instead, it queries intermediate labels one by one.
ResInfo passively watches for these intermediate probes. When it receives an NS, A, or DS query for a subdomain (e.g., dns.resinfo.net) that isn’t the final TXT query, it caches the source IP and immediately returns a clean NODATA response.
// Passive QNAME Minimization Tracking
if isProperSubdomain && !isFinalQuery {
qtype := state.QType()
if qtype == dns.TypeNS || qtype == dns.TypeA || qtype == dns.TypeAAAA || qtype == dns.TypeDS {
ri.qminMu.Lock()
ri.qminCache[state.IP()] = time.Now().Unix()
ri.qminMu.Unlock()
}
// Return NODATA to let the resolver proceed down the tree
m := new(dns.Msg)
m.SetReply(r)
m.Authoritative = true
m.Ns = []dns.RR{&dns.SOA{ /* ... */ }}
w.WriteMsg(m)
return dns.RcodeSuccess, nil
}
When the final TXT query for the full domain arrives, the plugin checks the cache. If it saw an intermediate query from that same IP within the last 10 seconds, it confidently asserts that QNAME-Minimization: YES. Purely passive observation, no special encoding required!
Bootstrapping TLS (The lego plugin)
Providing DNS-over-TLS (DoT) on port 853 is a must for our use case. To do this without becoming a slave to the machine, swapping certs every 3 months, I wrote a lego wrapper plugin. It acts as an ACME DNS-01 challenge provider that serves the validation records from CoreDNS directly. At startup, it registers with Let’s Encrypt, intercepts the ACME validation queries to serve the TXT tokens directly from memory, and then triggers a hot-reload of the CoreDNS process once the real certificates are issued.
Deployment & Security
To make life easy, I packaged our server in a multi-stage Docker image based on Alpine Linux, which is deployed on a simple VPS.
The entrypoint script templates a minimal Corefile and zone file from environment variables before dropping into the unprivileged (uid 1000) CoreDNS process.
The templated zone file only requires the minimum records needed for delegation:
$ORIGIN {{DOMAIN}}.
@ 3600 IN SOA ns1.{{DOMAIN}}. admin.{{DOMAIN}}. ({{SERIAL}} ...)
3600 IN NS ns1.{{DOMAIN}}.
ns1 3600 IN A {{IP}}
@ 3600 IN A {{IP}}
To protect against unintended use of our NS, I disabled recursion, such that it can only act as an authoritative server for our domain. We also implement rate limiting managed by a background sweeping goroutine to ensure it can’t be weaponized.
Result
The nameserver works great! We can now check all of our favorite public DNS resolvers. Examples:
Google 8.8.8.8
$ dig TXT @8.8.8.8 check.my.dns.resinfo.net +subnet=203.0.113.1/24 +short
"Transport: udp"
"EDNS0-Client-Subnet: YES"
"QNAME-Minimization: NO"
"UDP-Buffer-Size: 1400"
"Resolver: 192.178.94.19 [AS15169 Google LLC, US]"
"EDNS0-Padding: NO"
"EDNS0-Cookie: NO"
"0x20-Case-Randomization: YES"
"Learn more: https://resinfo.net"
"DNSSEC: DO=YES AD=NO CD=YES"
Cloudflare 1.1.1.1
$ dig TXT @1.1.1.1 check.my.dns.resinfo.net +subnet=203.0.113.1/24 +short
"Resolver: 172.64.210.7 [AS13335 Cloudflare, US]"
"Transport: udp"
"DNSSEC: DO=YES AD=NO CD=NO"
"EDNS0-Cookie: NO"
"EDNS0-Padding: NO"
"EDNS0-Client-Subnet: NO"
"QNAME-Minimization: YES"
"0x20-Case-Randomization: NO"
"UDP-Buffer-Size: 1452"
"Learn more: https://resinfo.net"
DNS4EU 86.54.11.100
$ dig TXT @86.54.11.100 check.my.dns.resinfo.net +subnet=203.0.113.1/24 +short
"0x20-Case-Randomization: YES"
"Learn more: https://resinfo.net"
"Resolver: 79.127.249.74 [AS60068 CDN77, GB]"
"Transport: udp"
"EDNS0-Cookie: NO"
"EDNS0-Padding: NO"
"UDP-Buffer-Size: 1400"
"EDNS0-Client-Subnet: NO"
"QNAME-Minimization: YES"
"DNSSEC: DO=YES AD=NO CD=YES"
A fun fact about DNS4EU is that they actually implement RFC9606 RESINFO.
You can check it yourself using dig RESINFO resolver.dns4all.eu
Summary
Deploying our own authoritative NS with a custom CoreDNS plugin lets us peel back the curtain and see how resolvers behave.
What we are observing:
- Privacy-oriented behaviors: QNAME Minimization, EDNS0 Client Subnet, EDNS0 Padding, and Transport (UDP/TCP/TLS).
- Integrity and authenticity: 0x20 case randomization, DNSSEC flags (DO/AD/CD), and EDNS0 cookies.
- Protocol/operational tidbits: UDP buffer size and resolver identity.
Test your own resolver:
dig TXT check.my.dns.resinfo.net +short
Note: The nameserver responds with diagnostic TXT records for any subdomain of resinfo.net. If you encounter caching issues while testing, simply query a different subdomain (e.g., test1.resinfo.net, 123.my.dns.resinfo.net).
See Also
- Tuning Vibe CLI for Network Engineering 🔧🌐
- Building your own Containerlab node kinds 🛠️
- Containerlab Cisco images simplified 🐳✨
- EEM in Catalyst Center templates 📅👨💼
- PNP Licence level change 🔀🪪
Got feedback or a question?
Feel free to contact me at hello@torbjorn.dev