336 lines
9.8 KiB
Go
336 lines
9.8 KiB
Go
// 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))
|
|
}
|