437 lines
11 KiB
Go
437 lines
11 KiB
Go
// Package resolver performs recursive resolution by forwarding to configured
|
|
// upstream servers.
|
|
//
|
|
// Forwarding rather than full iteration is a deliberate choice for an
|
|
// appliance of this size: it is far simpler to get right, it inherits the
|
|
// upstream's own cache and DNSSEC validation, and it avoids shipping a root
|
|
// hints file that goes stale. The interface is narrow enough that a full
|
|
// iterative resolver could be dropped in behind it later.
|
|
package resolver
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math/rand"
|
|
"sort"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
|
|
"github.com/owen/vibedns/internal/config"
|
|
)
|
|
|
|
// Config controls upstream behaviour. It is replaced wholesale on change.
|
|
type Config struct {
|
|
Upstreams []string
|
|
Timeout time.Duration
|
|
Retries int
|
|
Strategy string
|
|
DNSSEC bool
|
|
EDNSUDPSize uint16
|
|
MaxConcurrent int
|
|
}
|
|
|
|
// Upstream tracks one configured server and its observed health.
|
|
type Upstream struct {
|
|
Addr string
|
|
order int
|
|
|
|
// latencyUS is an exponentially weighted moving average in microseconds.
|
|
latencyUS atomic.Int64
|
|
queries atomic.Int64
|
|
failures atomic.Int64
|
|
// consecutive failures; after failureThreshold the server is rested.
|
|
consecutive atomic.Int64
|
|
downUntil atomic.Int64 // unix nanos
|
|
lastError atomic.Value // string
|
|
lastUsed atomic.Int64 // unix nanos
|
|
}
|
|
|
|
const (
|
|
failureThreshold = 3
|
|
restPeriod = 20 * time.Second
|
|
// initialLatency seeds the EWMA so an unqueried server is neither
|
|
// unfairly preferred nor permanently ignored.
|
|
initialLatencyUS = 50_000
|
|
)
|
|
|
|
func newUpstream(addr string, order int) *Upstream {
|
|
u := &Upstream{Addr: addr, order: order}
|
|
u.latencyUS.Store(initialLatencyUS)
|
|
u.lastError.Store("")
|
|
return u
|
|
}
|
|
|
|
func (u *Upstream) healthy() bool {
|
|
until := u.downUntil.Load()
|
|
return until == 0 || time.Now().UnixNano() >= until
|
|
}
|
|
|
|
func (u *Upstream) recordSuccess(d time.Duration) {
|
|
// EWMA with alpha = 1/4, cheap and stable enough for server selection.
|
|
prev := u.latencyUS.Load()
|
|
next := (prev*3 + d.Microseconds()) / 4
|
|
u.latencyUS.Store(next)
|
|
u.queries.Add(1)
|
|
u.consecutive.Store(0)
|
|
u.downUntil.Store(0)
|
|
u.lastUsed.Store(time.Now().UnixNano())
|
|
}
|
|
|
|
func (u *Upstream) recordFailure(err error) {
|
|
u.failures.Add(1)
|
|
u.queries.Add(1)
|
|
u.lastUsed.Store(time.Now().UnixNano())
|
|
if err != nil {
|
|
u.lastError.Store(err.Error())
|
|
}
|
|
if u.consecutive.Add(1) >= failureThreshold {
|
|
u.downUntil.Store(time.Now().Add(restPeriod).UnixNano())
|
|
}
|
|
}
|
|
|
|
// Status is a point-in-time view of one upstream for the resolver page.
|
|
type Status struct {
|
|
Address string `json:"address"`
|
|
Healthy bool `json:"healthy"`
|
|
Queries int64 `json:"queries"`
|
|
Failures int64 `json:"failures"`
|
|
LatencyMS float64 `json:"latency_ms"`
|
|
LastError string `json:"last_error,omitempty"`
|
|
LastUsed *time.Time `json:"last_used,omitempty"`
|
|
}
|
|
|
|
// Status renders the upstream's current state.
|
|
func (u *Upstream) Status() Status {
|
|
s := Status{
|
|
Address: u.Addr,
|
|
Healthy: u.healthy(),
|
|
Queries: u.queries.Load(),
|
|
Failures: u.failures.Load(),
|
|
LatencyMS: float64(u.latencyUS.Load()) / 1000,
|
|
}
|
|
if v, ok := u.lastError.Load().(string); ok {
|
|
s.LastError = v
|
|
}
|
|
if n := u.lastUsed.Load(); n > 0 {
|
|
t := time.Unix(0, n)
|
|
s.LastUsed = &t
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Resolver forwards queries to upstream servers.
|
|
type Resolver struct {
|
|
mu sync.RWMutex
|
|
cfg Config
|
|
upstreams []*Upstream
|
|
|
|
rrCounter atomic.Uint64
|
|
sem chan struct{}
|
|
semMu sync.Mutex
|
|
|
|
udpClient *dns.Client
|
|
tcpClient *dns.Client
|
|
|
|
queries atomic.Int64
|
|
failures atomic.Int64
|
|
truncated atomic.Int64
|
|
latencyUS atomic.Int64 // EWMA across all upstreams
|
|
}
|
|
|
|
// Common resolver errors surfaced to the operator.
|
|
var (
|
|
ErrNoUpstreams = errors.New("no upstream resolvers are configured")
|
|
ErrAllFailed = errors.New("every upstream resolver failed to answer")
|
|
)
|
|
|
|
// New creates a resolver with the given configuration.
|
|
func New(cfg Config) *Resolver {
|
|
r := &Resolver{}
|
|
r.udpClient = &dns.Client{Net: "udp"}
|
|
r.tcpClient = &dns.Client{Net: "tcp"}
|
|
r.SetConfig(cfg)
|
|
return r
|
|
}
|
|
|
|
// SetConfig replaces the resolver configuration. Upstreams that are still
|
|
// present keep their health statistics so a settings change does not discard
|
|
// what we have learned about them.
|
|
func (r *Resolver) SetConfig(cfg Config) {
|
|
if cfg.Timeout <= 0 {
|
|
cfg.Timeout = 2 * time.Second
|
|
}
|
|
if cfg.MaxConcurrent <= 0 {
|
|
cfg.MaxConcurrent = 256
|
|
}
|
|
if cfg.EDNSUDPSize == 0 {
|
|
cfg.EDNSUDPSize = 1232
|
|
}
|
|
|
|
r.mu.Lock()
|
|
prev := map[string]*Upstream{}
|
|
for _, u := range r.upstreams {
|
|
prev[u.Addr] = u
|
|
}
|
|
ups := make([]*Upstream, 0, len(cfg.Upstreams))
|
|
for i, addr := range cfg.Upstreams {
|
|
if u, ok := prev[addr]; ok {
|
|
u.order = i
|
|
ups = append(ups, u)
|
|
continue
|
|
}
|
|
ups = append(ups, newUpstream(addr, i))
|
|
}
|
|
r.cfg = cfg
|
|
r.upstreams = ups
|
|
r.udpClient.Timeout = cfg.Timeout
|
|
r.tcpClient.Timeout = cfg.Timeout
|
|
r.mu.Unlock()
|
|
|
|
r.semMu.Lock()
|
|
r.sem = make(chan struct{}, cfg.MaxConcurrent)
|
|
r.semMu.Unlock()
|
|
}
|
|
|
|
// Config returns the active configuration.
|
|
func (r *Resolver) Config() Config {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
return r.cfg
|
|
}
|
|
|
|
// Upstreams returns the configured upstreams in configuration order.
|
|
func (r *Resolver) Upstreams() []*Upstream {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
out := make([]*Upstream, len(r.upstreams))
|
|
copy(out, r.upstreams)
|
|
sort.Slice(out, func(i, j int) bool { return out[i].order < out[j].order })
|
|
return out
|
|
}
|
|
|
|
// Statuses renders every upstream's health for the UI.
|
|
func (r *Resolver) Statuses() []Status {
|
|
ups := r.Upstreams()
|
|
out := make([]Status, 0, len(ups))
|
|
for _, u := range ups {
|
|
out = append(out, u.Status())
|
|
}
|
|
return out
|
|
}
|
|
|
|
// order returns the upstreams to try, in the order dictated by the strategy.
|
|
func (r *Resolver) order() []*Upstream {
|
|
r.mu.RLock()
|
|
strategy := r.cfg.Strategy
|
|
ups := make([]*Upstream, len(r.upstreams))
|
|
copy(ups, r.upstreams)
|
|
r.mu.RUnlock()
|
|
|
|
switch strategy {
|
|
case config.StrategyRandom:
|
|
rand.Shuffle(len(ups), func(i, j int) { ups[i], ups[j] = ups[j], ups[i] })
|
|
case config.StrategyRoundRobin:
|
|
if len(ups) > 1 {
|
|
n := int(r.rrCounter.Add(1)-1) % len(ups)
|
|
ups = append(ups[n:], ups[:n]...)
|
|
}
|
|
case config.StrategyFastest:
|
|
sort.SliceStable(ups, func(i, j int) bool {
|
|
return ups[i].latencyUS.Load() < ups[j].latencyUS.Load()
|
|
})
|
|
default: // sequential
|
|
sort.SliceStable(ups, func(i, j int) bool { return ups[i].order < ups[j].order })
|
|
}
|
|
|
|
// Regardless of strategy, servers that are resting go last rather than
|
|
// being removed: if every server is unhealthy we must still try something.
|
|
sort.SliceStable(ups, func(i, j int) bool {
|
|
return ups[i].healthy() && !ups[j].healthy()
|
|
})
|
|
return ups
|
|
}
|
|
|
|
// Result carries a forwarded answer and where it came from.
|
|
type Result struct {
|
|
Msg *dns.Msg
|
|
Upstream string
|
|
RTT time.Duration
|
|
Attempts int
|
|
TCP bool
|
|
}
|
|
|
|
// Resolve forwards a query upstream and returns the first usable answer.
|
|
//
|
|
// The request is copied before being modified, so the caller's message is never
|
|
// mutated.
|
|
func (r *Resolver) Resolve(ctx context.Context, req *dns.Msg) (*Result, error) {
|
|
cfg := r.Config()
|
|
ups := r.order()
|
|
if len(ups) == 0 {
|
|
return nil, ErrNoUpstreams
|
|
}
|
|
|
|
if err := r.acquire(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
defer r.release()
|
|
|
|
out := req.Copy()
|
|
out.Id = dns.Id()
|
|
out.RecursionDesired = true
|
|
r.applyEDNS(out, cfg)
|
|
|
|
attempts := cfg.Retries + 1
|
|
if attempts > len(ups) {
|
|
attempts = len(ups)
|
|
}
|
|
if attempts < 1 {
|
|
attempts = 1
|
|
}
|
|
|
|
var lastErr error
|
|
for i := 0; i < attempts; i++ {
|
|
u := ups[i%len(ups)]
|
|
res, err := r.exchange(ctx, u, out, cfg)
|
|
if err != nil {
|
|
lastErr = err
|
|
u.recordFailure(err)
|
|
r.failures.Add(1)
|
|
if ctx.Err() != nil {
|
|
break
|
|
}
|
|
continue
|
|
}
|
|
u.recordSuccess(res.RTT)
|
|
r.queries.Add(1)
|
|
prev := r.latencyUS.Load()
|
|
r.latencyUS.Store((prev*3 + res.RTT.Microseconds()) / 4)
|
|
res.Attempts = i + 1
|
|
return res, nil
|
|
}
|
|
|
|
if lastErr == nil {
|
|
lastErr = ErrAllFailed
|
|
}
|
|
return nil, fmt.Errorf("%w: %v", ErrAllFailed, lastErr)
|
|
}
|
|
|
|
// exchange performs one upstream query, falling back to TCP when the UDP
|
|
// answer comes back truncated.
|
|
func (r *Resolver) exchange(ctx context.Context, u *Upstream, req *dns.Msg, cfg Config) (*Result, error) {
|
|
qctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
|
|
defer cancel()
|
|
|
|
msg, rtt, err := r.udpClient.ExchangeContext(qctx, req, u.Addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query %s over UDP: %w", u.Addr, err)
|
|
}
|
|
if msg.Truncated {
|
|
r.truncated.Add(1)
|
|
tctx, tcancel := context.WithTimeout(ctx, cfg.Timeout)
|
|
defer tcancel()
|
|
tmsg, trtt, terr := r.tcpClient.ExchangeContext(tctx, req, u.Addr)
|
|
if terr != nil {
|
|
// The truncated UDP answer is still better than nothing.
|
|
return &Result{Msg: msg, Upstream: u.Addr, RTT: rtt}, nil
|
|
}
|
|
return &Result{Msg: tmsg, Upstream: u.Addr, RTT: rtt + trtt, TCP: true}, nil
|
|
}
|
|
return &Result{Msg: msg, Upstream: u.Addr, RTT: rtt}, nil
|
|
}
|
|
|
|
// applyEDNS attaches our own OPT record, replacing whatever the client sent.
|
|
// The client's advertised buffer size describes its link, not ours.
|
|
func (r *Resolver) applyEDNS(m *dns.Msg, cfg Config) {
|
|
m.Extra = stripOPT(m.Extra)
|
|
opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
|
|
opt.SetUDPSize(cfg.EDNSUDPSize)
|
|
if cfg.DNSSEC {
|
|
opt.SetDo(true)
|
|
}
|
|
m.Extra = append(m.Extra, opt)
|
|
}
|
|
|
|
func stripOPT(rrs []dns.RR) []dns.RR {
|
|
out := make([]dns.RR, 0, len(rrs))
|
|
for _, rr := range rrs {
|
|
if rr.Header().Rrtype == dns.TypeOPT {
|
|
continue
|
|
}
|
|
out = append(out, rr)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (r *Resolver) acquire(ctx context.Context) error {
|
|
r.semMu.Lock()
|
|
sem := r.sem
|
|
r.semMu.Unlock()
|
|
select {
|
|
case sem <- struct{}{}:
|
|
return nil
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("resolver is at its concurrency limit: %w", ctx.Err())
|
|
}
|
|
}
|
|
|
|
func (r *Resolver) release() {
|
|
r.semMu.Lock()
|
|
sem := r.sem
|
|
r.semMu.Unlock()
|
|
select {
|
|
case <-sem:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// Stats summarises resolver activity.
|
|
type Stats struct {
|
|
Queries int64 `json:"queries"`
|
|
Failures int64 `json:"failures"`
|
|
Truncated int64 `json:"truncated"`
|
|
AvgLatencyMS float64 `json:"avg_latency_ms"`
|
|
Upstreams int `json:"upstreams"`
|
|
Healthy int `json:"healthy"`
|
|
}
|
|
|
|
// Stats returns the resolver counters.
|
|
func (r *Resolver) Stats() Stats {
|
|
s := Stats{
|
|
Queries: r.queries.Load(),
|
|
Failures: r.failures.Load(),
|
|
Truncated: r.truncated.Load(),
|
|
AvgLatencyMS: float64(r.latencyUS.Load()) / 1000,
|
|
}
|
|
for _, u := range r.Upstreams() {
|
|
s.Upstreams++
|
|
if u.healthy() {
|
|
s.Healthy++
|
|
}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// Check performs a one-off probe against a single upstream, used by the
|
|
// "test resolver" button in the UI.
|
|
func Check(ctx context.Context, addr, qname string, timeout time.Duration) (time.Duration, string, error) {
|
|
c := &dns.Client{Net: "udp", Timeout: timeout}
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(dns.Fqdn(qname), dns.TypeA)
|
|
m.RecursionDesired = true
|
|
m.SetEdns0(1232, true)
|
|
|
|
qctx, cancel := context.WithTimeout(ctx, timeout)
|
|
defer cancel()
|
|
|
|
resp, rtt, err := c.ExchangeContext(qctx, m, addr)
|
|
if err != nil {
|
|
return 0, "", fmt.Errorf("%s did not answer: %w", addr, err)
|
|
}
|
|
return rtt, dns.RcodeToString[resp.Rcode], nil
|
|
}
|