Skip to content

netstar-labs/dns

Repository files navigation

dns wire client

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.

Documentation

  • 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

Features

  • 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 Msg reusable over TCP for the refetch (RFC 1035 §4.2.2 length framing)
  • DNSSEC-validation signal: queries request the AD bit (RFC 6840), and Msg.Authenticated reports 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 Exchange names its server, so pools, failover, and per-resolver pacing live in the caller; the opt-in pool subpackage ships that policy ready-made (spec parsing, @qps pacing, 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

Record types

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.

Usage

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.

Composite flag codex

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-ready

codex.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.

Command-line

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 exit

Input 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}

Build

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 9f3c1a2b7d40

The 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/dnscodex

Examples

Four 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)

Benchmarks

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 comparison

Scope

This 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).

License

GPL-3.0 — see LICENSE.

About

Minimal zero-dependency DNS wire-format client for Go — CAA, rcodes, EDE, AD bit, DoT; the single-question lookups net.Resolver can't do, plus a composite-flag codex and resolver-filtering detection.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors