mirror of
https://github.com/therootcompany/golib.git
synced 2026-11-04 06:55:00 +00:00
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.
183 lines
4.7 KiB
Go
183 lines
4.7 KiB
Go
// 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)
|
||
}
|