initial commit
This commit is contained in:
@@ -0,0 +1,436 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user