dns is a minimal DNS wire-format client for Go — single-question queries
against a resolver you choose, with the response rcode, truncation bit, and
typed answer records exposed. No external dependency; only the Go standard
library.
It exists for what net.Resolver cannot do: there is no standard-library CAA
lookup at all, the rcode (SERVFAIL vs REFUSED vs NXDOMAIN) is hidden behind
opaque errors, and pointing a single query at a specific server requires
dialer tricks. This package speaks the wire format directly (RFC 1035; CAA
per RFC 8659; EDNS0 per RFC 6891) and hands back exactly what the server
said.
- Executive summary — what this is and why it matters
- User guide — API, query patterns, and operational notes
- Architecture — wire format, design, trade-offs, and limitations
- Signal — reading resolver filtering as threat intelligence: block mechanisms, the resolver landscape, and the sinkhole and verdict-resolver detectors
- CAA lookups (RFC 8659) — the record type the standard library has no API for
- response rcode exposed (
NOERROR/NXDOMAIN/SERVFAIL/REFUSED/ ...), so definitive answers, throttling, and server failure are distinguishable - truncation bit exposed, with the same
Msgreusable over TCP for the refetch (RFC 1035 §4.2.2 length framing) - DNSSEC-validation signal: queries request the AD bit (RFC 6840), and
Msg.Authenticatedreports whether the resolver validated the answer - Extended DNS Errors (RFC 8914) surfaced as
Msg.EDE— a filtering resolver's Blocked/Censored/Filtered verdict is signal, not discarded - transports: UDP, TCP, and DNS-over-TLS (
"tls", RFC 7858) - per-query resolver targeting — every
Exchangenames its server, so pools, failover, and per-resolver pacing live in the caller; the opt-inpoolsubpackage ships that policy ready-made (spec parsing,@qpspacing, SERVFAIL/REFUSED failover ladder) - EDNS0 advertising a 1232-byte UDP payload (DNS Flag Day 2020), keeping most large RRsets out of TCP
- typed answers: the record type is the concrete Go type, so presence is a type assertion
- hardened parsing: random transaction ids, id + echoed-question matching, backward-only compression pointers with a jump cap, full bounds checks
Nine concrete answer types are modeled; anything else in an answer section is skipped cleanly by its RDLENGTH.
| Type | Go type | Fields |
|---|---|---|
| A | *dns.A |
Addr netip.Addr |
| AAAA | *dns.AAAA |
Addr netip.Addr |
| NS | *dns.NS |
Ns string |
| CNAME | *dns.CNAME |
Target string |
| PTR | *dns.PTR |
Ptr string |
| SOA | *dns.SOA |
Ns, Mbox string + Serial, Refresh, Retry, Expire, MinTTL uint32 |
| MX | *dns.MX |
Pref uint16, Mx string |
| TXT | *dns.TXT |
Txt []string |
| CAA | *dns.CAA |
Flag uint8, Tag string, Value string |
Every record also carries the common header (Name, Type, Class, TTL)
via the embedded Hdr. Names are returned without the trailing root dot
(ns1.example.com). Beyond Answer, a response exposes Authority (where
the RFC 2308 negative-caching SOA lives) and EDE; dns.ReverseName(addr)
builds the in-addr.arpa / ip6.arpa owner name for PTR lookups.
import (
"context"
"fmt"
"time"
"github.com/netstar-labs/dns"
)
c := dns.NewClient()
m := dns.NewMsg("example.com", dns.TypeCAA) // nil if the name can't encode
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
r, err := c.Exchange(ctx, m, "udp", "1.1.1.1:53")
if err != nil {
// transport error: timeout, refused connection, ...
}
if r.Truncated { // large RRset: refetch the same Msg over TCP
if tcp, err := c.Exchange(ctx, m, "tcp", "1.1.1.1:53"); err == nil {
r = tcp
}
}
switch r.Rcode {
case dns.RcodeSuccess, dns.RcodeNameError: // definitive (NODATA / NXDOMAIN included)
for _, rr := range r.Answer {
if caa, ok := rr.(*dns.CAA); ok {
fmt.Println(caa.Tag, caa.Value) // e.g. issue letsencrypt.org
}
}
case dns.RcodeServerFailure, dns.RcodeRefused:
// rejected — usually throttling; try another resolver
}A Client reuses its read buffer, so it is not safe for concurrent use —
give each goroutine its own (they are cheap). A Msg is reusable across
resolvers, retries, and transports; Exchange stamps a fresh random
transaction id per call.
The codex subpackage (standard library only, like the client) folds
a host into a single composite bit flag plus its typed records. Each bit
has a stable name and meaning — the "codex" — so one uint32 per host survives
in logs, columns, and bitmasks without losing detail: the low bits are what the
host has (A, MX, CAA, SPF, NULL_MX, …), the high bits are how the
lookups went (RESOLVED, NXDOMAIN, SERVFAIL, AD, FILTERED, …).
c := dns.NewClient()
res := codex.Probe(ctx, c, "example.com", codex.Options{Server: "1.1.1.1:53"})
// res.Flags = 0x008502bb res.Symbols = [A AAAA NS MX TXT SPF NULL_MX RESOLVED NODATA AD]
json.NewEncoder(os.Stdout).Encode(res) // one JSON record, stream-readycodex.Codex() returns the full legend (bit → symbol → description);
codex.Hosts(r) reads targets from a \n list or JSON (extracting the
host field of each element), so a batch front end is a few lines.
cmd/dnscodex reads hosts on stdin — a newline list or JSON
with a host field, auto-detected — and streams one JSON record per host:
# newline list -> NDJSON stream
printf 'example.com\nwikipedia.org\n' | go run ./cmd/dnscodex
# JSON in, host field extracted
echo '[{"host":"example.com"},{"host":"cloudflare.com"}]' | go run ./cmd/dnscodex
# just the flags: <hex> <symbols> <host>
printf 'example.com\n' | go run ./cmd/dnscodex | jq -r '"\(.hex) \(.symbols|join(",")) \(.host)"'
go run ./cmd/dnscodex -codex # print the flag legend and exitInput streams — a multi-million-host feed starts resolving immediately — and
lookups run through a resolver pool with per-lookup failover; a run summary
(hosts/resolved/nxdomain/filtered/error) lands on stderr.
Flags: -server (pool spec: comma-separated, optional @qps pacing per
resolver, resolv.conf for the system resolvers), -verdict (a filtering
resolver asked one extra A query per host — blocking disagreement with the
primary sets the VERDICT flag), -sinkholes (a JSON {"ip":[],"ns":[]}
resource, path or URL — answers landing in those sets get SINKHOLE),
-retries (failover attempts), -net
(udp/tcp/tls), -types, -c (workers), -timeout, -json,
-array/-pretty, -brief (plain <hex> <symbols> <host> lines),
-no-records, -codex, -version.
echo "example.com" | go run ./cmd/dnscodex -types A,AAAA,NS,MX,TXT,CAA
{"host":"example.com","flags":8716987,"hex":"0x008502bb","symbols":["A","AAAA","NS","MX","TXT","SPF","NULL_MX","RESOLVED","NODATA","AD"],"server":"1.1.1.1:53","rcodes":{"A":"NOERROR","AAAA":"NOERROR","CAA":"NOERROR","MX":"NOERROR","NS":"NOERROR","TXT":"NOERROR"},"records":[{"name":"example.com","type":"A","ttl":36,"value":"172.66.147.243"},{"name":"example.com","type":"A","ttl":36,"value":"104.20.23.154"},{"name":"example.com","type":"AAAA","ttl":277,"value":"2606:4700:10::ac42:93f3"},{"name":"example.com","type":"AAAA","ttl":277,"value":"2606:4700:10::6814:179a"},{"name":"example.com","type":"NS","ttl":83128,"value":"hera.ns.cloudflare.com"},{"name":"example.com","type":"NS","ttl":83128,"value":"elliott.ns.cloudflare.com"},{"name":"example.com","type":"MX","ttl":300,"pref":0},{"name":"example.com","type":"TXT","ttl":300,"value":"v=spf1 -all","txt":["v=spf1 -all"]},{"name":"example.com","type":"TXT","ttl":300,"value":"_k2n1y4vw3qtb4skdx9e7dxt97qrmmq9","txt":["_k2n1y4vw3qtb4skdx9e7dxt97qrmmq9"]}],"elapsed_ms":122}A plain go run/go build reports dev unknown for the version. The
build/dnscodex script produces a release binary with the
version and revision stamped in — git describe into main.Version, the short
commit hash into main.Revision — so -version and the usage header identify
the build:
build/dnscodex # -> build/install/dnscodex (linux/amd64), stamped
build/dnscodex deploy@host # ...then scp + install to /usr/local/bin on host
build/install/dnscodex -version # dnscodex v1.3.0-2-g9f3c1a2 9f3c1a2b7d40The host can also come from a build/host file. To stamp a build by hand:
go build -ldflags "-X main.Version=$(git describe --tags --always --dirty) \
-X main.Revision=$(git rev-parse --short=12 HEAD)" \
-o build/install/dnscodex ./cmd/dnscodexFour ways to drive the codex, each a self-contained main.go — see
example/:
| Example | Shows |
|---|---|
example/library |
the dns package used directly, with the codex on top |
example/http |
an HTTP REST API — single lookups and an NDJSON batch stream |
example/unix |
a Unix-socket line service: host in, JSON line out |
example/mcp |
an MCP stdio server exposing the codex as agent tools |
example/verdict |
resolver-disagreement checks against a filtering resolver (signal.md) |
Three clients, same loopback resolver, same question (Apple M2 Pro, Go 1.25;
bench/compat is a nested module, so miekg/dns never
enters this module's dependency graph):
| Full UDP round trip | time/op | bytes/op | allocs/op |
|---|---|---|---|
| this package | 75–100 µs | 1,296 B | 30 |
miekg/dns v1.1.62 |
75–100 µs | 3,080 B | 36 |
net.Resolver (pure Go) |
75–100 µs | 4,506 B | 52 |
Round-trip time on loopback is syscall-dominated: across repeated runs
the three clients overlap and are statistically indistinguishable — treat
any single-run time delta as noise. The stable signal is memory: 2.4×
less than miekg/dns and 3.5× less than the standard library per lookup —
GC pressure that compounds at millions-of-lookups scale.
The wire-format micro costs are also stable, and CPU-bound enough to compare:
| Micro | this package | miekg/dns |
|---|---|---|
| pack query | 16.9 ns, 1 alloc | 82.8 ns, 1 alloc |
| parse response (compressed CNAME + A chain) | 268 ns, 13 allocs | 405 ns, 11 allocs |
The honest framing stands: a real resolver round trip is 1–100 ms, so no client's CPU is the bottleneck — the reason to use this package is the capability surface (CAA, rcodes, EDE, the AD bit, per-query targeting) with zero dependencies; the allocation profile is the bonus.
Reproduce:
go test -run xxx -bench . -benchmem # in-repo micro + loopback
cd bench/compat && GOWORK=off go test -bench . -benchmem # three-way comparisonThis is a client for single-question IN-class queries — it does not serve zones, perform zone transfers (AXFR/IXFR), validate DNSSEC, or follow referrals (point it at a recursive resolver).
GPL-3.0 — see LICENSE.