mirror of
https://github.com/therootcompany/golib.git
synced 2026-08-09 22:10:24 +00:00
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:
parent
1db21be047
commit
d472c00ef1
13
net/dnsresolver/go.mod
Normal file
13
net/dnsresolver/go.mod
Normal 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
14
net/dnsresolver/go.sum
Normal 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
182
net/dnsresolver/resolver.go
Normal 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 8–16; production
|
||||
// chains rarely exceed 2–3, 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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user