initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
package resolver
import (
"fmt"
"net/netip"
"strings"
)
// ACL decides which clients may use recursion.
//
// It is deliberately closed by default: an empty allow list denies everyone.
// Denies are evaluated before allows, so a narrow exclusion can be carved out
// of a broad allowance.
type ACL struct {
allow []netip.Prefix
deny []netip.Prefix
}
// NewACL compiles allow and deny lists. Entries may be CIDR blocks or bare
// addresses; an invalid entry is reported rather than silently dropped,
// because a typo in an ACL is a security-relevant mistake.
func NewACL(allow, deny []string) (*ACL, error) {
a := &ACL{}
var err error
if a.allow, err = parsePrefixes(allow, "allowed"); err != nil {
return nil, err
}
if a.deny, err = parsePrefixes(deny, "denied"); err != nil {
return nil, err
}
return a, nil
}
func parsePrefixes(in []string, label string) ([]netip.Prefix, error) {
var out []netip.Prefix
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if strings.Contains(s, "/") {
p, err := netip.ParsePrefix(s)
if err != nil {
return nil, fmt.Errorf("%s network %q is not a valid CIDR block", label, s)
}
out = append(out, p.Masked())
continue
}
addr, err := netip.ParseAddr(s)
if err != nil {
return nil, fmt.Errorf("%s network %q is not a valid IP address or CIDR block", label, s)
}
addr = addr.Unmap()
out = append(out, netip.PrefixFrom(addr, addr.BitLen()))
}
return out, nil
}
// Allowed reports whether addr may use recursion.
func (a *ACL) Allowed(addr netip.Addr) bool {
if a == nil {
return false
}
ip := addr.Unmap()
for _, p := range a.deny {
if p.Addr().Is4() == ip.Is4() && p.Contains(ip) {
return false
}
}
for _, p := range a.allow {
if p.Addr().Is4() == ip.Is4() && p.Contains(ip) {
return true
}
}
return false
}
// Describe renders the ACL for the settings page.
func (a *ACL) Describe() (allow, deny []string) {
if a == nil {
return nil, nil
}
for _, p := range a.allow {
allow = append(allow, p.String())
}
for _, p := range a.deny {
deny = append(deny, p.String())
}
return allow, deny
}
// Prefix sets for non-security lists (rate-limit exemptions, query-log ignore
// lists) live in package netutil. This package keeps only the recursion ACL,
// whose constructor deliberately reports errors instead of skipping entries.
+436
View File
@@ -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
}