feat(net/dnsresolver): parallel DNS resolver with fallback servers

Resolves A, CNAME chain, and SRV records via miekg/dns. Queries all
configured servers in parallel and returns the first success. Falls
back to OpenDNS/Cloudflare/Quad9 when /etc/resolv.conf is absent or empty.
This commit is contained in:
AJ ONeal 2026-05-22 11:16:22 -04:00
parent 1db21be047
commit d472c00ef1
No known key found for this signature in database
3 changed files with 209 additions and 0 deletions

13
net/dnsresolver/go.mod Normal file
View File

@ -0,0 +1,13 @@
module github.com/therootcompany/golib/net/dnsresolver
go 1.26.0
require github.com/miekg/dns v1.1.69
require (
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/tools v0.39.0 // indirect
)

14
net/dnsresolver/go.sum Normal file
View File

@ -0,0 +1,14 @@
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/miekg/dns v1.1.69 h1:Kb7Y/1Jo+SG+a2GtfoFUfDkG//csdRPwRLkCsxDG9Sc=
github.com/miekg/dns v1.1.69/go.mod h1:7OyjD9nEba5OkqQ/hB4fy3PIoxafSZJtducccIelz3g=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=

182
net/dnsresolver/resolver.go Normal file
View File

@ -0,0 +1,182 @@
// Package dnsresolver provides a parallel DNS resolver with automatic fallback servers.
package dnsresolver
import (
"cmp"
"context"
"errors"
"fmt"
"log/slog"
"net"
"time"
"github.com/miekg/dns"
)
const QueryTimeout = 750 * time.Millisecond
// DefaultMaxCNAMEHops bounds CNAME-chain following in LookupCNAME.
// Most recursive resolvers cap chain following around 816; production
// chains rarely exceed 23, so 5 leaves a comfortable margin and keeps
// pathological setups from amplifying lookup cost.
const DefaultMaxCNAMEHops = 5
var ErrNoARecord = errors.New("did not resolve to A record")
var FallbackServers = []string{
"208.67.222.123:53", // OpenDNS
"1.1.1.3:53", // Cloudflare
"9.9.9.9:53", // Quad9
}
type Resolver struct {
Servers []string
Timeout time.Duration
MaxCNAMEHops int
}
func New() *Resolver {
r := &Resolver{Timeout: QueryTimeout}
cc, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err == nil {
for _, s := range cc.Servers {
port := cmp.Or(cc.Port, "53")
r.Servers = append(r.Servers, net.JoinHostPort(s, port))
}
}
if len(r.Servers) == 0 {
if err != nil {
slog.Warn("dnsresolver: could not read /etc/resolv.conf, using fallback resolvers", "err", err)
} else {
slog.Warn("dnsresolver: /etc/resolv.conf has no nameservers, using fallback resolvers")
}
r.Servers = FallbackServers
}
return r
}
// LookupCNAME returns the terminal canonical name reached by following the full
// CNAME chain for domain, plus the minimum TTL across the chain. It issues a
// TypeA query so the recursive resolver returns every CNAME hop in the Answer
// section (recursives generally do not chase chains for explicit TypeCNAME).
func (r *Resolver) LookupCNAME(ctx context.Context, domain string) (string, uint32, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
m.RecursionDesired = true
resp, ttl, err := r.Exchange(ctx, m)
if err != nil {
return "", 0, err
}
maxHops := cmp.Or(r.MaxCNAMEHops, DefaultMaxCNAMEHops)
if len(resp.Answer) == 0 || len(resp.Answer) > maxHops {
return "", 0, fmt.Errorf("%s: did not resolve to A record within %d hops: %w", domain, maxHops, ErrNoARecord)
}
if _, ok := resp.Answer[len(resp.Answer)-1].(*dns.A); !ok {
return "", 0, fmt.Errorf("%s: did not resolve to A record within %d hops: %w", domain, maxHops, ErrNoARecord)
}
return resp.Answer[len(resp.Answer)-1].Header().Name, ttl, nil
}
func (r *Resolver) LookupSRV(ctx context.Context, service, proto, domain string) ([]*dns.SRV, uint32, error) {
name := fmt.Sprintf("_%s._%s.%s", service, proto, dns.Fqdn(domain))
m := new(dns.Msg)
m.SetQuestion(name, dns.TypeSRV)
m.RecursionDesired = true
resp, ttl, err := r.Exchange(ctx, m)
if err != nil {
return nil, 0, err
}
var srvs []*dns.SRV
for _, rr := range resp.Answer {
if srv, ok := rr.(*dns.SRV); ok {
srvs = append(srvs, srv)
if ttl == 0 {
ttl = rr.Header().Ttl
}
}
}
if len(srvs) == 0 {
return nil, 0, fmt.Errorf("no SRV records for %s", name)
}
return srvs, ttl, nil
}
func (r *Resolver) LookupIP(ctx context.Context, domain string) ([]net.IP, uint32, error) {
m := new(dns.Msg)
m.SetQuestion(dns.Fqdn(domain), dns.TypeA)
m.RecursionDesired = true
resp, ttl, err := r.Exchange(ctx, m)
if err != nil {
return nil, 0, err
}
var ips []net.IP
for _, rr := range resp.Answer {
if a, ok := rr.(*dns.A); ok {
ips = append(ips, a.A)
if ttl == 0 {
ttl = rr.Header().Ttl
}
}
}
if len(ips) == 0 {
return nil, 0, fmt.Errorf("no A records for %s", domain)
}
return ips, ttl, nil
}
type result struct {
resp *dns.Msg
ttl uint32
err error
}
// Exchange queries all configured servers in parallel, returning the first success.
func (r *Resolver) Exchange(ctx context.Context, m *dns.Msg) (*dns.Msg, uint32, error) {
timeout := cmp.Or(r.Timeout, QueryTimeout)
queryCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
results := make(chan result, len(r.Servers))
for _, server := range r.Servers {
go func() {
c := new(dns.Client)
c.Timeout = timeout
resp, _, err := c.ExchangeContext(queryCtx, m, server)
if err != nil {
results <- result{err: err}
return
}
if resp.Rcode != dns.RcodeSuccess {
results <- result{err: fmt.Errorf("DNS query %s: rcode %s", m.Question[0].Name, dns.RcodeToString[resp.Rcode])}
return
}
var minTTLVal uint32
for _, rr := range resp.Answer {
t := rr.Header().Ttl
if minTTLVal == 0 || t < minTTLVal {
minTTLVal = t
}
}
results <- result{resp: resp, ttl: minTTLVal}
}()
}
var lastErr error
for range len(r.Servers) {
res := <-results
if res.err != nil {
lastErr = res.err
continue
}
return res.resp, res.ttl, nil
}
return nil, 0, fmt.Errorf("all resolvers failed: %w", lastErr)
}