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
+335
View File
@@ -0,0 +1,335 @@
// Package dnsengine contains the UDP and TCP listeners and the query pipeline
// that ties the authoritative index, the policy engine, the cache and the
// recursive resolver together.
package dnsengine
import (
"context"
"net"
"net/netip"
"strconv"
"strings"
"time"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/cache"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/netutil"
"github.com/owen/vibedns/internal/policy"
"github.com/owen/vibedns/internal/runtimecfg"
"github.com/owen/vibedns/internal/version"
)
// outcome records everything the query log and metrics need about one query.
type outcome struct {
source string
rcode int
cacheHit bool
blocked bool
answerCount int
decision policy.Decision
upstream string
}
// ServeDNS implements dns.Handler. It is the single entry point for every
// query, over both UDP and TCP.
func (s *Server) ServeDNS(w dns.ResponseWriter, req *dns.Msg) {
start := time.Now()
protocol := "udp"
if _, ok := w.RemoteAddr().(*net.TCPAddr); ok {
protocol = "tcp"
}
client := netutil.AddrFromNetAddr(w.RemoteAddr())
snap := s.runtime.Current()
// Rate limiting happens before any work is done. Exceeding clients are
// dropped without a response: replying would let an attacker use us as an
// amplifier, which is exactly what the limiter exists to prevent.
if !s.limiter.Allow(client) {
s.metrics.RateLimited.Add(1)
s.metrics.ObserveQuery(qtypeName(req), "DROPPED", models.SourceRateLimited, protocol, time.Since(start))
s.logQuery(req, client, protocol, outcome{source: models.SourceRateLimited, rcode: -1}, snap, start)
return
}
resp, out := s.respond(req, client, snap)
if resp == nil {
return
}
s.finalise(req, resp, protocol, snap)
if err := w.WriteMsg(resp); err != nil {
s.log.Debug("could not write DNS response", "client", client.String(), "error", err)
}
out.rcode = resp.Rcode
out.answerCount = len(resp.Answer)
elapsed := time.Since(start)
s.metrics.ObserveQuery(qtypeName(req), dns.RcodeToString[resp.Rcode], out.source, protocol, elapsed)
s.logQueryOutcome(req, client, protocol, out, snap, elapsed)
}
// respond produces the reply message and describes how it was produced.
func (s *Server) respond(req *dns.Msg, client netip.Addr, snap *runtimecfg.Snapshot) (*dns.Msg, outcome) {
if req.Opcode != dns.OpcodeQuery {
return errorReply(req, dns.RcodeNotImplemented), outcome{source: models.SourceError}
}
if len(req.Question) != 1 {
// Multiple questions in one message are not defined by any RFC and no
// real client sends them.
return errorReply(req, dns.RcodeFormatError), outcome{source: models.SourceError}
}
q := req.Question[0]
qname := strings.ToLower(dns.Fqdn(q.Name))
do := requestDO(req)
if q.Qclass == dns.ClassCHAOS {
return s.chaosReply(req, snap), outcome{source: models.SourceLocal}
}
if q.Qclass != dns.ClassINET {
return errorReply(req, dns.RcodeRefused), outcome{source: models.SourceRefused}
}
// 1. Client policy. Blocking runs first so a policy applies even to names
// that a local zone would otherwise answer.
decision := snap.Policy.Evaluate(client, qname)
if decision.Blocked {
s.metrics.Blocked.Add(1)
return s.blockReply(req, decision), outcome{
source: models.SourceBlocked, blocked: true, decision: decision,
}
}
// 2. Authoritative zones always win over recursion.
if resp := snap.Zones.Answer(req, do); resp != nil {
s.metrics.Authoritative.Add(1)
return resp, outcome{source: models.SourceAuthoritative, decision: decision}
}
// 3. Recursion, subject to the ACL. Authoritative answers above remain
// available to clients that are not allowed to recurse.
if !snap.Settings.DNS.Recursion {
s.metrics.Refused.Add(1)
return errorReply(req, dns.RcodeRefused), outcome{source: models.SourceRefused, decision: decision}
}
if !snap.ACL.Allowed(client) {
s.metrics.Refused.Add(1)
s.log.Debug("recursion denied by ACL", "client", client.String(), "name", qname)
return errorReply(req, dns.RcodeRefused), outcome{source: models.SourceRefused, decision: decision}
}
// 4. Cache.
key := cache.KeyFor(dns.Question{Name: qname, Qtype: q.Qtype, Qclass: q.Qclass}, do)
if res := s.cache.Get(key, req); res.Hit {
s.metrics.CacheHits.Add(1)
src := models.SourceCache
if res.Stale {
s.metrics.StaleServed.Add(1)
src = models.SourceStale
}
res.Msg.RecursionAvailable = true
return res.Msg, outcome{source: src, cacheHit: true, decision: decision}
}
s.metrics.CacheMisses.Add(1)
// 5. Forward upstream.
ctx, cancel := context.WithTimeout(s.ctx, s.forwardBudget(snap))
defer cancel()
fstart := time.Now()
result, err := s.resolver.Resolve(ctx, req)
s.metrics.ObserveResolver(time.Since(fstart), err != nil)
if err != nil {
s.log.Debug("recursive resolution failed", "name", qname, "type", qtypeName(req), "error", err)
s.metrics.Errors.Add(1)
return errorReply(req, dns.RcodeServerFailure), outcome{source: models.SourceError, decision: decision}
}
s.metrics.Recursive.Add(1)
// 6. Cache the answer.
s.cache.Put(key, result.Msg)
resp := result.Msg
resp.Id = req.Id
resp.Question = req.Question
resp.RecursionAvailable = true
return resp, outcome{source: models.SourceRecursive, decision: decision, upstream: result.Upstream}
}
// forwardBudget bounds the total time spent forwarding one query, leaving the
// client's own timeout some headroom.
func (s *Server) forwardBudget(snap *runtimecfg.Snapshot) time.Duration {
per := time.Duration(snap.Settings.Resolver.TimeoutMS) * time.Millisecond
attempts := snap.Settings.Resolver.Retries + 1
total := per * time.Duration(attempts)
if total > 15*time.Second {
total = 15 * time.Second
}
if total < per {
total = per
}
return total
}
// blockReply builds the response for a policy-blocked query.
func (s *Server) blockReply(req *dns.Msg, d policy.Decision) *dns.Msg {
q := req.Question[0]
m := new(dns.Msg)
m.SetReply(req)
m.RecursionAvailable = true
m.Authoritative = true
ttl := uint32(60)
if d.Policy != nil && d.Policy.TTL > 0 {
ttl = d.Policy.TTL
}
switch d.Action() {
case models.BlockRefused:
m.Rcode = dns.RcodeRefused
return m
case models.BlockSinkhole:
switch q.Qtype {
case dns.TypeA:
if d.Policy != nil && d.Policy.SinkholeV4.IsValid() {
m.Answer = append(m.Answer, &dns.A{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: ttl},
A: d.Policy.SinkholeV4.AsSlice(),
})
}
case dns.TypeAAAA:
if d.Policy != nil && d.Policy.SinkholeV6.IsValid() {
m.Answer = append(m.Answer, &dns.AAAA{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: ttl},
AAAA: d.Policy.SinkholeV6.AsSlice(),
})
}
}
if len(m.Answer) == 0 {
// Sinkholing only makes sense for address queries; everything else
// gets an empty NOERROR so clients do not retry in a loop.
m.Ns = append(m.Ns, syntheticSOA(q.Name, ttl))
}
return m
default: // NXDOMAIN
m.Rcode = dns.RcodeNameError
m.Ns = append(m.Ns, syntheticSOA(q.Name, ttl))
return m
}
}
// syntheticSOA gives a blocked or synthesised negative answer something for the
// client to derive a negative cache TTL from.
func syntheticSOA(name string, ttl uint32) *dns.SOA {
return &dns.SOA{
Hdr: dns.RR_Header{
Name: dns.Fqdn(name), Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl,
},
Ns: "localhost.",
Mbox: "hostmaster." + dns.Fqdn(name),
Serial: 1,
Refresh: 3600,
Retry: 600,
Expire: 86400,
Minttl: ttl,
}
}
// chaosReply answers version.bind and hostname.bind in the CHAOS class.
func (s *Server) chaosReply(req *dns.Msg, snap *runtimecfg.Snapshot) *dns.Msg {
q := req.Question[0]
m := new(dns.Msg)
m.SetReply(req)
m.Authoritative = true
if q.Qtype != dns.TypeTXT {
m.Rcode = dns.RcodeRefused
return m
}
name := strings.ToLower(q.Name)
if !snap.Settings.DNS.ExposeVersion {
// Revealing the software version by default only helps an attacker.
m.Rcode = dns.RcodeRefused
return m
}
var value string
switch name {
case "version.bind.", "version.server.":
value = version.Name + " " + version.Version
case "hostname.bind.", "id.server.":
value = s.hostname
default:
m.Rcode = dns.RcodeRefused
return m
}
m.Answer = append(m.Answer, &dns.TXT{
Hdr: dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassCHAOS, Ttl: 0},
Txt: []string{value},
})
return m
}
// finalise applies EDNS to the response and truncates it if it will not fit in
// the client's UDP buffer.
func (s *Server) finalise(req, resp *dns.Msg, protocol string, snap *runtimecfg.Snapshot) {
resp.Compress = true
advertised := uint16(dns.MinMsgSize) // 512, the pre-EDNS limit
if opt := req.IsEdns0(); opt != nil && snap.Settings.DNS.EDNSEnabled {
clientSize := opt.UDPSize()
if clientSize < dns.MinMsgSize {
clientSize = dns.MinMsgSize
}
ourSize := uint16(snap.Settings.DNS.EDNSUDPSize)
if clientSize < ourSize {
advertised = clientSize
} else {
advertised = ourSize
}
// Echo an OPT record so the client knows we speak EDNS, mirroring the
// DO bit it asked for.
resp.SetEdns0(ourSize, opt.Do())
}
if protocol == "tcp" {
return // TCP carries up to 64 KiB; no truncation needed
}
maxUDP := uint16(snap.Settings.DNS.MaxUDPResponse)
if advertised < maxUDP {
maxUDP = advertised
}
if resp.Len() > int(maxUDP) {
resp.Truncate(int(maxUDP))
if resp.Truncated {
s.metrics.TruncatedResp.Add(1)
}
}
}
func errorReply(req *dns.Msg, rcode int) *dns.Msg {
m := new(dns.Msg)
m.SetRcode(req, rcode)
m.RecursionAvailable = true
return m
}
func requestDO(req *dns.Msg) bool {
opt := req.IsEdns0()
return opt != nil && opt.Do()
}
func qtypeName(req *dns.Msg) string {
if len(req.Question) == 0 {
return "NONE"
}
if s, ok := dns.TypeToString[req.Question[0].Qtype]; ok {
return s
}
return "TYPE" + strconv.Itoa(int(req.Question[0].Qtype))
}
+315
View File
@@ -0,0 +1,315 @@
package dnsengine
import (
"context"
"errors"
"fmt"
"log/slog"
"net/netip"
"os"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/cache"
"github.com/owen/vibedns/internal/metrics"
"github.com/owen/vibedns/internal/models"
"github.com/owen/vibedns/internal/querylog"
"github.com/owen/vibedns/internal/ratelimit"
"github.com/owen/vibedns/internal/resolver"
"github.com/owen/vibedns/internal/runtimecfg"
)
// Server runs the UDP and TCP DNS listeners.
type Server struct {
runtime *runtimecfg.Manager
cache *cache.Cache
resolver *resolver.Resolver
limiter *ratelimit.Limiter
metrics *metrics.Metrics
qlog *querylog.Logger
log *slog.Logger
hostname string
ctx context.Context
cancel context.CancelFunc
mu sync.Mutex
udp *dns.Server
tcp *dns.Server
running bool
udpAddr string
tcpAddr string
startErr chan error
wg sync.WaitGroup
}
// Options bundles the dependencies the DNS server needs.
type Options struct {
Runtime *runtimecfg.Manager
Cache *cache.Cache
Resolver *resolver.Resolver
Limiter *ratelimit.Limiter
Metrics *metrics.Metrics
QueryLog *querylog.Logger
Log *slog.Logger
}
// New creates a DNS server. Call Start to bind the listeners.
func New(opts Options) *Server {
host, err := os.Hostname()
if err != nil || host == "" {
host = "vibedns"
}
s := &Server{
runtime: opts.Runtime,
cache: opts.Cache,
resolver: opts.Resolver,
limiter: opts.Limiter,
metrics: opts.Metrics,
qlog: opts.QueryLog,
log: opts.Log,
hostname: host,
startErr: make(chan error, 2),
}
// Refreshing an ageing cache entry keeps popular names warm without the
// client ever waiting on the upstream.
s.cache.SetPrefetcher(s.prefetch)
return s
}
// Start binds both listeners. It returns once they are accepting queries, or
// with an error explaining which address could not be bound.
func (s *Server) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return errors.New("DNS server is already running")
}
s.ctx, s.cancel = context.WithCancel(ctx)
snap := s.runtime.Current()
s.udpAddr = snap.Settings.DNS.UDPListen
s.tcpAddr = snap.Settings.DNS.TCPListen
udpSize := snap.Settings.DNS.EDNSUDPSize
if udpSize < dns.MinMsgSize {
udpSize = dns.MinMsgSize
}
idle := time.Duration(snap.Settings.DNS.TCPIdleSeconds) * time.Second
ready := make(chan struct{}, 2)
s.udp = &dns.Server{
Addr: s.udpAddr,
Net: "udp",
Handler: s,
UDPSize: udpSize,
NotifyStartedFunc: func() { ready <- struct{}{} },
}
s.tcp = &dns.Server{
Addr: s.tcpAddr,
Net: "tcp",
Handler: s,
IdleTimeout: func() time.Duration { return idle },
ReadTimeout: idle + 2*time.Second,
NotifyStartedFunc: func() { ready <- struct{}{} },
}
errCh := make(chan error, 2)
s.wg.Add(2)
go func() {
defer s.wg.Done()
if err := s.udp.ListenAndServe(); err != nil {
errCh <- fmt.Errorf("DNS UDP listener on %s: %w", s.udpAddr, describeBindError(err, s.udpAddr))
}
}()
go func() {
defer s.wg.Done()
if err := s.tcp.ListenAndServe(); err != nil {
errCh <- fmt.Errorf("DNS TCP listener on %s: %w", s.tcpAddr, describeBindError(err, s.tcpAddr))
}
}()
// Wait for both listeners to report ready, or for one to fail.
started := 0
deadline := time.After(10 * time.Second)
for started < 2 {
select {
case <-ready:
started++
case err := <-errCh:
s.cancel()
return err
case <-deadline:
s.cancel()
return fmt.Errorf("DNS listeners did not become ready within 10 seconds")
}
}
s.running = true
s.log.Info("DNS listeners started", "udp", s.udpAddr, "tcp", s.tcpAddr)
// Surface a listener that dies later.
go func() {
select {
case err := <-errCh:
s.log.Error("DNS listener stopped unexpectedly", "error", err)
case <-s.ctx.Done():
}
}()
return nil
}
// describeBindError turns a raw bind failure into something actionable.
func describeBindError(err error, addr string) error {
msg := err.Error()
switch {
case strings.Contains(msg, "permission denied"):
return fmt.Errorf("%w (binding a port below 1024 needs root, or grant the "+
"binary CAP_NET_BIND_SERVICE with: setcap 'cap_net_bind_service=+ep' ./vibedns)", err)
case strings.Contains(msg, "address already in use"):
return fmt.Errorf("%w (another DNS server is already listening on %s; on many "+
"systems that is systemd-resolved, which can be disabled with: "+
"systemctl disable --now systemd-resolved)", err, addr)
default:
return err
}
}
// Shutdown stops both listeners.
func (s *Server) Shutdown(ctx context.Context) error {
s.mu.Lock()
udp, tcp, running := s.udp, s.tcp, s.running
s.running = false
s.mu.Unlock()
if !running {
return nil
}
if s.cancel != nil {
s.cancel()
}
var firstErr error
if udp != nil {
if err := udp.ShutdownContext(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
if tcp != nil {
if err := tcp.ShutdownContext(ctx); err != nil && firstErr == nil {
firstErr = err
}
}
s.wg.Wait()
s.log.Info("DNS listeners stopped")
return firstErr
}
// Running reports whether the listeners are up.
func (s *Server) Running() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.running
}
// ListenAddrs returns the bound addresses for the status page.
func (s *Server) ListenAddrs() (udp, tcp string) {
s.mu.Lock()
defer s.mu.Unlock()
return s.udpAddr, s.tcpAddr
}
// prefetch refreshes a cache entry in the background.
func (s *Server) prefetch(k cache.Key) {
snap := s.runtime.Current()
if !snap.Settings.DNS.Recursion {
return
}
ctx, cancel := context.WithTimeout(s.ctx, s.forwardBudget(snap))
defer cancel()
req := new(dns.Msg)
req.SetQuestion(k.Name, k.Type)
req.Question[0].Qclass = k.Class
req.RecursionDesired = true
if k.DO {
req.SetEdns0(uint16(snap.Settings.DNS.EDNSUDPSize), true)
}
res, err := s.resolver.Resolve(ctx, req)
if err != nil {
s.log.Debug("cache prefetch failed", "name", k.Name, "error", err)
return
}
s.cache.Put(k, res.Msg)
}
// Resolve performs a query through the full pipeline on behalf of the UI's
// "test a lookup" tool, without going over the network.
func (s *Server) Resolve(ctx context.Context, name string, qtype uint16, client netip.Addr, do bool) (*dns.Msg, string, error) {
snap := s.runtime.Current()
req := new(dns.Msg)
req.SetQuestion(dns.Fqdn(name), qtype)
req.RecursionDesired = true
if do {
req.SetEdns0(uint16(snap.Settings.DNS.EDNSUDPSize), true)
}
resp, out := s.respond(req, client, snap)
if resp == nil {
return nil, "", errors.New("no response was produced")
}
return resp, out.source, nil
}
// logQueryOutcome writes one query log entry.
func (s *Server) logQueryOutcome(req *dns.Msg, client netip.Addr, protocol string,
out outcome, snap *runtimecfg.Snapshot, elapsed time.Duration) {
if s.qlog == nil || !s.qlog.Enabled() {
return
}
q := req.Question[0]
rcode := "DROPPED"
if out.rcode >= 0 {
rcode = dns.RcodeToString[out.rcode]
}
e := models.QueryLogEntry{
Timestamp: time.Now(),
ClientIP: client.String(),
QName: strings.ToLower(dns.Fqdn(q.Name)),
QType: qtypeName(req),
Rcode: rcode,
Source: out.source,
CacheHit: out.cacheHit,
Blocked: out.blocked,
Protocol: protocol,
DurationUS: elapsed.Microseconds(),
AnswerCount: out.answerCount,
}
d := out.decision
e.NetworkID = d.NetworkID()
e.NetworkName = d.NetworkName()
e.PolicyID = d.PolicyID()
e.PolicyName = d.PolicyName()
if out.blocked {
e.BlacklistID = d.ListRef()
e.BlacklistName = d.ListName
e.MatchedRule = d.MatchedDomain
}
s.qlog.Log(e)
}
// logQuery records a query that never produced a response, such as one dropped
// by the rate limiter.
func (s *Server) logQuery(req *dns.Msg, client netip.Addr, protocol string,
out outcome, snap *runtimecfg.Snapshot, start time.Time) {
if len(req.Question) == 0 {
return
}
s.logQueryOutcome(req, client, protocol, out, snap, time.Since(start))
}