build / build-push (push) Successful in 30s
The query log classified every non-drop blocklist match as a block, even when an RPZ A or CNAME rule returned a successful redirect. Record redirect results with their own action so NOERROR address overrides are not presented or counted as blocked. Expose the new action in the Logs filter.
1195 lines
33 KiB
Go
1195 lines
33 KiB
Go
// Package engine is the query pipeline: the single place where a DNS question
|
|
// is turned into an answer.
|
|
//
|
|
// The order of operations matters and is the whole design:
|
|
//
|
|
// rate limit -> ACL policy -> local zone -> blocklist -> cache -> upstream
|
|
//
|
|
// Everything in front of the cache is cheap and lock-light; the cache is a
|
|
// single shard-local lookup; only a miss reaches the network. The pipeline is
|
|
// a plain method call chain rather than a plugin stack, because the hot path
|
|
// runs millions of times a second and a plugin registry is pure overhead.
|
|
package engine
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/miekg/dns"
|
|
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/acl"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/blocklist"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/cache"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/config"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/localzone"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/metrics"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/qlog"
|
|
"git.k8s-git-k6n.net/mats/fancy-dns/internal/resolver"
|
|
)
|
|
|
|
// upstreamNSID is the nameserver identifier we advertise to upstreams when a
|
|
// client asks for one. It is hex-encoded on the way into the EDNS0 option:
|
|
// dns.EDNS0_NSID.Nsid is a hex string in this miekg/dns version, not the
|
|
// opaque bytes RFC 5001 describes.
|
|
const upstreamNSID = "fancy-dns"
|
|
|
|
// Request describes one inbound query.
|
|
type Request struct {
|
|
// Msg is the parsed query. The engine may modify it (to attach an OPT or
|
|
// strip EDNS) so the transport can write it back out.
|
|
Msg *dns.Msg
|
|
// Remote is the client address.
|
|
Remote netip.Addr
|
|
// Transport is "udp", "tcp", "dot" or "doh", used for metrics labels and
|
|
// the log.
|
|
Transport string
|
|
// TCP reports whether the response may exceed the UDP size limit.
|
|
TCP bool
|
|
// Proto is the negotiated protocol string, kept for the log.
|
|
Proto string
|
|
// Start is when the request arrived.
|
|
Start time.Time
|
|
// ClientNames are reverse-DNS names for the client, if known. The server
|
|
// fills these in from its reverse cache so the ACL can match on names
|
|
// without a synchronous PTR lookup on the query path.
|
|
ClientNames []string
|
|
// ClientTags are derived labels for tag-based ACL matching.
|
|
ClientTags []string
|
|
// DoHPath records which DoH endpoint served the request, for the log.
|
|
DoHPath string
|
|
}
|
|
|
|
// Response is the outcome plus the metadata the caller logs.
|
|
type Response struct {
|
|
Msg *dns.Msg
|
|
// Action is the pipeline classification.
|
|
Action qlog.Action
|
|
// Group is the matched ACL group.
|
|
Group string
|
|
// BlockSource is the blocklist/rule that blocked the query, for the log.
|
|
BlockSource string
|
|
// CacheHit reports whether the answer came from cache.
|
|
CacheHit bool
|
|
// CacheKind is "hit", "stale" or "miss".
|
|
CacheKind string
|
|
// Upstream is the address that answered.
|
|
Upstream string
|
|
// Local reports that the answer came from the local zone rather than from
|
|
// the cache or an upstream. It is what makes metrics.OutcomeLocal
|
|
// reachable: an authoritative local answer is a real, distinct outcome
|
|
// and the dashboard has a column for it, but nothing set it, so every
|
|
// locally answered query was counted as an upstream one and the
|
|
// outcome="local" series was never published.
|
|
Local bool
|
|
// Duration is the total handling time.
|
|
Duration time.Duration
|
|
// Client is the resolved client string.
|
|
Client string
|
|
// Do reports whether the response carried the DNSSEC OK bit.
|
|
Do bool
|
|
// Truncated is set when the answer was shortened to fit the client buffer.
|
|
Truncated bool
|
|
// Drop is true when no response should be sent at all.
|
|
Drop bool
|
|
// GroupPolicy is the effective policy, for the UI.
|
|
GroupPolicy string
|
|
}
|
|
|
|
// Engine holds the pipeline's swappable components.
|
|
type Engine struct {
|
|
cfgStore *config.Store
|
|
|
|
acl atomic.Pointer[acl.Engine]
|
|
blocks *blocklist.Holder
|
|
cache *cache.Manager
|
|
aclH *acl.Holder
|
|
local *localzone.Holder
|
|
pool atomic.Pointer[resolver.Pool]
|
|
logger *qlog.Logger
|
|
reverse *ReverseCache
|
|
|
|
// concurrency limiter: a buffered channel as a semaphore, which is
|
|
// cheaper than a sync.WaitGroup and gives us "try or fail" semantics.
|
|
// Held in an atomic pointer because a config swap replaces it while
|
|
// queries are in flight.
|
|
sem atomic.Pointer[chan struct{}]
|
|
semSize atomic.Int64
|
|
|
|
// perClient semaphores for clients with an explicit cap.
|
|
clientMu sync.Mutex
|
|
clientSems map[string]*clientLimiter
|
|
// rate limiters for clients with an explicit cap.
|
|
rateMu sync.Mutex
|
|
rateState map[string]*rateState
|
|
|
|
// inflight is the current in-flight count for the gauge.
|
|
inflight atomic.Int64
|
|
// dropped counts queries rejected by the concurrency cap.
|
|
dropped atomic.Uint64
|
|
|
|
// upstreamTimeout bounds a single upstream exchange.
|
|
upstreamTimeout atomic.Int64
|
|
|
|
// groupStats tracks per-group counters for the UI.
|
|
groupStats *groupStatTable
|
|
|
|
// flusher is the app's flush callback, used by the admin API.
|
|
flusher atomic.Pointer[func()]
|
|
}
|
|
|
|
type clientLimiter struct {
|
|
sem chan struct{}
|
|
}
|
|
|
|
type rateState struct {
|
|
windowStart time.Time
|
|
count int
|
|
}
|
|
|
|
// Options configures a new Engine.
|
|
type Options struct {
|
|
// Config is the hot-reloadable config store.
|
|
Config *config.Store
|
|
// Logger receives query entries; may be nil.
|
|
Logger *qlog.Logger
|
|
// Blocklists is the compiled blocklist holder.
|
|
Blocklists *blocklist.Holder
|
|
// Cache is the answer cache.
|
|
Cache *cache.Manager
|
|
// ACL is the compiled ACL engine.
|
|
ACL *acl.Holder
|
|
// Local is the local zone store.
|
|
Local *localzone.Holder
|
|
// Pool is the upstream pool.
|
|
Pool *resolver.Pool
|
|
// Reverse is the reverse-DNS cache used for name-based ACL matching.
|
|
Reverse *ReverseCache
|
|
}
|
|
|
|
// New builds an Engine and wires the config-store hooks so a reload updates
|
|
// every swappable piece.
|
|
func New(opt Options) *Engine {
|
|
e := &Engine{
|
|
cfgStore: opt.Config,
|
|
logger: opt.Logger,
|
|
blocks: opt.Blocklists,
|
|
cache: opt.Cache,
|
|
aclH: opt.ACL,
|
|
local: opt.Local,
|
|
reverse: opt.Reverse,
|
|
groupStats: newGroupStatTable(),
|
|
clientSems: make(map[string]*clientLimiter),
|
|
rateState: make(map[string]*rateState),
|
|
}
|
|
e.pool.Store(opt.Pool)
|
|
if e.cfgStore != nil {
|
|
cfg := e.cfgStore.Get()
|
|
e.applyConfig(cfg)
|
|
e.cfgStore.OnSwap(e.applyConfig)
|
|
} else if opt.Pool != nil {
|
|
e.upstreamTimeout.Store(int64(2 * time.Second))
|
|
}
|
|
// Prefetching a stale entry re-runs the pipeline for just that key.
|
|
if e.cache != nil {
|
|
e.cache.SetPrefetcher(func(k cache.Key) { e.prefetch(k) })
|
|
}
|
|
return e
|
|
}
|
|
|
|
// applyConfig hot-swaps the components affected by a config change.
|
|
func (e *Engine) applyConfig(cfg *config.Config) {
|
|
semSize := cfg.Server.MaxConcurrent
|
|
if semSize < 1 {
|
|
semSize = 1024
|
|
}
|
|
sem := make(chan struct{}, semSize)
|
|
e.sem.Store(&sem)
|
|
e.semSize.Store(int64(semSize))
|
|
e.upstreamTimeout.Store(int64(cfg.Server.Timeouts.Upstre.D()))
|
|
|
|
if e.aclH != nil {
|
|
e.aclH.Set(acl.Compile(cfg.ACL))
|
|
metrics.SetACLGroups(len(cfg.ACL.Groups))
|
|
}
|
|
// Local zones answer the query before the blocklist, so the gate is the
|
|
// store itself rather than the configured file list: a zone the console
|
|
// created in the managed directory is served whether or not
|
|
// blocklist.local_zones names it.
|
|
if e.local != nil && (len(cfg.Block.LocalZones) > 0 || e.local.Get().Count() > 0) {
|
|
metrics.SetLocalRecords(e.local.Get().Count())
|
|
}
|
|
if e.cache != nil {
|
|
e.cache.Resize(cfg.Cache.Size, cfg.Cache.Shards)
|
|
}
|
|
metrics.SetConcurrent(0)
|
|
}
|
|
|
|
// Logger exposes the query logger (may be nil).
|
|
func (e *Engine) Logger() *qlog.Logger { return e.logger }
|
|
|
|
// Cache exposes the answer cache (may be nil).
|
|
func (e *Engine) Cache() *cache.Manager { return e.cache }
|
|
|
|
// Pool returns the current upstream pool.
|
|
func (e *Engine) Pool() *resolver.Pool { return e.pool.Load() }
|
|
|
|
// Inflight is the number of queries currently being handled.
|
|
func (e *Engine) Inflight() int64 { return e.inflight.Load() }
|
|
|
|
// Dropped is the number of queries rejected by the concurrency cap.
|
|
func (e *Engine) Dropped() uint64 { return e.dropped.Load() }
|
|
|
|
// GroupStats returns per-group counters.
|
|
func (e *Engine) GroupStats() []acl.GroupSummary {
|
|
base := e.aclH.Get().Summaries()
|
|
return e.groupStats.merge(base)
|
|
}
|
|
|
|
// Handle runs the full pipeline for one request and returns the response to
|
|
// send. It never returns nil: a synthetic SERVFAIL is the worst case.
|
|
func (e *Engine) Handle(ctx context.Context, req Request) Response {
|
|
start := req.Start
|
|
if start.IsZero() {
|
|
start = time.Now()
|
|
}
|
|
done := e.inflight.Add(1)
|
|
metrics.SetConcurrent(int(e.inflight.Load()))
|
|
defer func() {
|
|
e.inflight.Add(-1)
|
|
metrics.SetConcurrent(int(e.inflight.Load()))
|
|
_ = done
|
|
}()
|
|
|
|
client := req.Remote.String()
|
|
decision := e.decide(req)
|
|
res := Response{Client: client, Group: decision.Group, GroupPolicy: string(decision.Policy)}
|
|
q := req.Msg
|
|
|
|
// A malformed or empty query is answered with FORMERR before anything
|
|
// else looks at it.
|
|
if err := validate(q); err != nil {
|
|
res.Msg = formErr(q)
|
|
res.Action = qlog.ActionError
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
}
|
|
|
|
qname := q.Question[0].Name
|
|
qtype := q.Question[0].Qtype
|
|
|
|
// ACL deny wins over everything, including the local zone: an operator who
|
|
// denied a client means it.
|
|
if !decision.Allows() {
|
|
res.Msg = refuse(q, decision)
|
|
res.Action = qlog.ActionRefused
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
}
|
|
|
|
// Concurrency cap. A per-client cap is checked first so one noisy client
|
|
// cannot exhaust the global budget.
|
|
if cl := decision.MaxConcurrent; cl > 0 {
|
|
if !e.acquireClient(client, cl) {
|
|
res.Msg = servFail(q, "client concurrency limit")
|
|
res.Action = qlog.ActionError
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
}
|
|
defer e.releaseClient(client)
|
|
}
|
|
// A nil semaphore means "no limit configured yet", which only happens
|
|
// before the first applyConfig; treat it as unbounded rather than dropping
|
|
// every query.
|
|
sem := e.sem.Load()
|
|
if sem != nil {
|
|
select {
|
|
case *sem <- struct{}{}:
|
|
defer func() { <-*sem }()
|
|
default:
|
|
e.dropped.Add(1)
|
|
res.Msg = servFail(q, "server overloaded")
|
|
res.Action = qlog.ActionError
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
}
|
|
}
|
|
|
|
// Rate limiting.
|
|
if decision.RateLimit > 0 && !e.allowRate(client, decision.RateLimit) {
|
|
res.Msg = refuse(q, decision)
|
|
res.Action = qlog.ActionRefused
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
}
|
|
|
|
// Group-level domain policy.
|
|
if e.groupBlocks(decision, qname) {
|
|
res.Msg = e.finalize(blockedResponse(q, e.blockCfg(), nil), q, req)
|
|
res.Action = qlog.ActionBlock
|
|
res.BlockSource = "acl:" + decision.Group
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
}
|
|
|
|
// Local zone data.
|
|
if store := e.local.Get(); store != nil && store.Count() > 0 {
|
|
if local, ok := e.answerLocal(q, store); ok {
|
|
res.Msg = e.finalize(local, q, req)
|
|
res.Action = qlog.ActionAllow
|
|
// Authoritative local data is ActionAllow for the query log (the
|
|
// query was permitted and answered), but it is its own metrics
|
|
// outcome: counting it as upstream inflated the upstream column
|
|
// with queries that never left the process.
|
|
res.Local = true
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
}
|
|
}
|
|
|
|
// Blocklist.
|
|
var blocked *blocklist.Entry
|
|
if !decision.SkipBlocklist && e.blocks != nil {
|
|
if set := e.blocks.Get(); set != nil && set.Len() > 0 {
|
|
blocked = set.Match(qname)
|
|
}
|
|
}
|
|
if blocked != nil && !blocked.IsAllow() {
|
|
switch blocked.Action {
|
|
case blocklist.ActionDrop:
|
|
res.Action = qlog.ActionDrop
|
|
res.BlockSource = blocked.Source
|
|
res.Drop = true
|
|
res.Duration = time.Since(start)
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
case blocklist.ActionRedirect, blocklist.ActionCNAME:
|
|
res.Msg = e.finalize(e.blockedMsg(q, blocked), q, req)
|
|
res.Action = qlog.ActionRedirect
|
|
res.BlockSource = blocked.Source
|
|
res.CacheHit = false
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
default:
|
|
res.Msg = e.finalize(e.blockedMsg(q, blocked), q, req)
|
|
res.Action = qlog.ActionBlock
|
|
res.BlockSource = blocked.Source
|
|
res.CacheHit = false
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
}
|
|
}
|
|
|
|
// Cache. A group that skips the cache still gets served from it on a
|
|
// prefetched entry only if the entry is fresh; we deliberately skip the
|
|
// lookup entirely to keep the guarantee honest.
|
|
if e.cache != nil && !decision.SkipCache {
|
|
key := cache.QueryKey(qname, qtype, q.Question[0].Qclass, cache.Do(q), 0)
|
|
hit := e.cache.Get(key)
|
|
switch hit.Kind {
|
|
case cache.Fresh, cache.Stale:
|
|
res.Msg = hit.Msg
|
|
res.CacheHit = true
|
|
res.CacheKind = hit.Kind.String()
|
|
res.Upstream = hit.Upstream
|
|
if hit.Kind == cache.Stale {
|
|
metrics.RecordStaleHit()
|
|
}
|
|
res.Msg = e.finalize(res.Msg, q, req)
|
|
res.Action = qlog.ActionCached
|
|
if hit.Kind == cache.Stale {
|
|
// The cached answer is expired, so its TTL is 0; hand back a
|
|
// short one so clients do not cache the stale copy forever.
|
|
res.Msg.Answer = ageTo(res.Msg.Answer, 1)
|
|
res.Msg.Ns = ageTo(res.Msg.Ns, 1)
|
|
}
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
default:
|
|
metrics.RecordMiss()
|
|
}
|
|
}
|
|
|
|
// Upstream.
|
|
pool := e.pool.Load()
|
|
if pool == nil {
|
|
res.Msg = servFail(q, "no upstream configured")
|
|
res.Action = qlog.ActionError
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
}
|
|
fwd := e.prepareUpstream(q, req)
|
|
upStart := time.Now()
|
|
up, addr, err := pool.Exchange(ctx, fwd, decision.Upstreams, time.Duration(e.upstreamTimeout.Load()))
|
|
res.Duration = time.Since(start)
|
|
if err != nil || up == nil {
|
|
metrics.RecordUpstreamError()
|
|
res.Msg = servFail(q, "upstream failure: "+shortErr(err))
|
|
res.Action = qlog.ActionError
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
}
|
|
metrics.RecordUpstreamLatency(addr, time.Since(upStart))
|
|
// finalize is what makes the answer safe to put on the wire: it echoes the
|
|
// client's question and id verbatim and truncates to the client's
|
|
// advertised buffer. Without it an uncached multi-kilobyte answer left
|
|
// here oversized for a 512-byte UDP client, which no responder can deliver
|
|
// and the client silently drops. (The cache-hit path above has always
|
|
// done this; the uncached path did not.)
|
|
reply := e.finalize(e.fromUpstream(q, up, req), q, req)
|
|
res.Msg = reply
|
|
res.Upstream = addr
|
|
res.Action = qlog.ActionAllow
|
|
// A non-success RCODE is reported as an error through finish's metrics
|
|
// call, so the counters stay in one place.
|
|
if reply.Rcode != dns.RcodeServerFailure && reply.Rcode != dns.RcodeSuccess {
|
|
res.Action = qlog.ActionError
|
|
} else if reply.Rcode == dns.RcodeServerFailure {
|
|
res.Action = qlog.ActionError
|
|
}
|
|
if e.cache != nil && !decision.SkipCache {
|
|
key := cache.QueryKey(qname, qtype, q.Question[0].Qclass, cache.Do(q), 0)
|
|
// Cache the untruncated upstream answer so a small client later does
|
|
// not poison the entry with a truncated copy.
|
|
if !up.Truncated {
|
|
e.cache.PutResponse(key, up, addr)
|
|
}
|
|
}
|
|
e.finish(req, res, start, decision)
|
|
return res
|
|
}
|
|
|
|
// finish attaches duration, records metrics and writes the query log.
|
|
func (e *Engine) finish(req Request, res Response, start time.Time, d acl.Decision) {
|
|
if res.Duration == 0 {
|
|
res.Duration = time.Since(start)
|
|
}
|
|
metrics.Record(req.Transport, outcomeFor(res), res.Duration)
|
|
if res.Action == qlog.ActionAllow && res.CacheHit {
|
|
metrics.Record(req.Transport, metrics.OutcomeCache, res.Duration)
|
|
}
|
|
e.groupStats.observe(d.Group, res)
|
|
if e.logger == nil {
|
|
return
|
|
}
|
|
if !e.logger.ShouldLog(res.Action) && !d.LogQueries {
|
|
return
|
|
}
|
|
entry := qlog.Entry{
|
|
TS: start,
|
|
Client: res.Client,
|
|
Group: res.Group,
|
|
Duration: res.Duration.Milliseconds(),
|
|
Action: res.Action,
|
|
Upstream: res.Upstream,
|
|
List: res.BlockSource,
|
|
Protocol: req.Transport,
|
|
}
|
|
if res.Msg != nil {
|
|
entry.FromMsg(res.Msg)
|
|
}
|
|
e.logger.Log(entry)
|
|
}
|
|
|
|
// outcomeFor maps a finished query onto its metrics outcome. The action alone
|
|
// is not enough: ActionAllow covers both an upstream answer and a local-zone
|
|
// answer, and the two are different things for an operator reading the
|
|
// dashboard, so Response.Local is consulted first.
|
|
func outcomeFor(res Response) string {
|
|
if res.Local {
|
|
return metrics.OutcomeLocal
|
|
}
|
|
switch res.Action {
|
|
case qlog.ActionCached:
|
|
return metrics.OutcomeCache
|
|
case qlog.ActionBlock:
|
|
return metrics.OutcomeBlocked
|
|
case qlog.ActionRedirect:
|
|
return metrics.OutcomeLocal
|
|
case qlog.ActionError, qlog.ActionDrop:
|
|
return metrics.OutcomeError
|
|
case qlog.ActionRefused:
|
|
return metrics.OutcomeRefused
|
|
default:
|
|
return metrics.OutcomeUpstream
|
|
}
|
|
}
|
|
|
|
// decide resolves the ACL decision for a request.
|
|
func (e *Engine) decide(req Request) acl.Decision {
|
|
eng := e.aclH.Get()
|
|
if eng == nil || eng.Count() == 0 {
|
|
return acl.Decision{Policy: acl.PolicyAllow, Default: true}
|
|
}
|
|
return eng.Match(acl.Client{
|
|
IP: req.Remote,
|
|
Names: req.ClientNames,
|
|
Tags: req.ClientTags,
|
|
})
|
|
}
|
|
|
|
// groupBlocks applies a group's allow/deny domain lists.
|
|
func (e *Engine) groupBlocks(d acl.Decision, qname string) bool {
|
|
if len(d.DenyDomains) > 0 {
|
|
if acl.NewDomainSet(d.DenyDomains).Match(qname) {
|
|
return true
|
|
}
|
|
}
|
|
if len(d.AllowDomains) > 0 {
|
|
return !acl.NewDomainSet(d.AllowDomains).Match(qname)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// acquireClient takes a per-client slot, creating the limiter on demand.
|
|
func (e *Engine) acquireClient(client string, limit int) bool {
|
|
e.clientMu.Lock()
|
|
cl, ok := e.clientSems[client]
|
|
if !ok || cap(cl.sem) != limit {
|
|
cl = &clientLimiter{sem: make(chan struct{}, limit)}
|
|
e.clientSems[client] = cl
|
|
}
|
|
e.clientMu.Unlock()
|
|
select {
|
|
case cl.sem <- struct{}{}:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (e *Engine) releaseClient(client string) {
|
|
e.clientMu.Lock()
|
|
cl, ok := e.clientSems[client]
|
|
e.clientMu.Unlock()
|
|
if !ok {
|
|
return
|
|
}
|
|
select {
|
|
case <-cl.sem:
|
|
default:
|
|
}
|
|
}
|
|
|
|
// allowRate applies a fixed-window rate limit. A fixed window is fine here:
|
|
// the ACL cap exists to stop runaway clients, and it costs one map lookup.
|
|
func (e *Engine) allowRate(client string, perSec int) bool {
|
|
now := time.Now()
|
|
e.rateMu.Lock()
|
|
defer e.rateMu.Unlock()
|
|
st, ok := e.rateState[client]
|
|
if !ok {
|
|
st = &rateState{windowStart: now}
|
|
e.rateState[client] = st
|
|
}
|
|
if now.Sub(st.windowStart) >= time.Second {
|
|
st.windowStart = now
|
|
st.count = 0
|
|
}
|
|
if st.count >= perSec {
|
|
return false
|
|
}
|
|
st.count++
|
|
return true
|
|
}
|
|
|
|
// answerLocal serves a question from the local zone store, converting a
|
|
// negative result into NXDOMAIN or NODATA with the zone's SOA.
|
|
func (e *Engine) answerLocal(q *dns.Msg, store *localzone.Store) (*dns.Msg, bool) {
|
|
if len(q.Question) == 0 {
|
|
return nil, false
|
|
}
|
|
question := q.Question[0]
|
|
res := store.Lookup(question)
|
|
if res.Delegated {
|
|
return nil, false
|
|
}
|
|
m := new(dns.Msg)
|
|
m.SetReply(q)
|
|
m.Authoritative = true
|
|
m.RecursionAvailable = true
|
|
if !res.Found {
|
|
// Only claim NXDOMAIN when we are actually authoritative for the name.
|
|
if res.Zone == "" {
|
|
return nil, false
|
|
}
|
|
if res.Authority != nil {
|
|
m.Rcode = dns.RcodeNameError
|
|
m.Ns = []dns.RR{res.Authority}
|
|
} else {
|
|
m.Rcode = dns.RcodeSuccess // NODATA without an SOA
|
|
}
|
|
return m, true
|
|
}
|
|
if len(res.Records) == 0 {
|
|
if res.Authority != nil {
|
|
m.Ns = []dns.RR{res.Authority}
|
|
}
|
|
return m, true
|
|
}
|
|
// CNAME answers for other types need the target resolved too; we do not
|
|
// chase here (the caller re-queries upstream if the target is not local),
|
|
// but a CNAME-only answer for a CNAME question is complete.
|
|
if len(m.Question) > 0 && m.Question[0].Qtype != res.Type {
|
|
if cname, ok := firstOfType(res.Records, dns.TypeCNAME); ok {
|
|
m.Answer = []dns.RR{cname}
|
|
return m, true
|
|
}
|
|
}
|
|
m.Answer = res.Records
|
|
return m, true
|
|
}
|
|
|
|
func firstOfType(rrs []dns.RR, t uint16) (dns.RR, bool) {
|
|
for _, rr := range rrs {
|
|
if rr.Header().Rrtype == t {
|
|
return rr, true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// blockedMsg synthesises the blocklist answer.
|
|
func (e *Engine) blockedMsg(q *dns.Msg, entry *blocklist.Entry) *dns.Msg {
|
|
cfg := e.blockCfg()
|
|
if entry != nil && entry.TTL > 0 {
|
|
c := cfg
|
|
c.ResponseTTL = config.Duration(entry.TTL) * config.Duration(time.Second)
|
|
cfg = c
|
|
}
|
|
if entry != nil {
|
|
switch entry.Action {
|
|
case blocklist.ActionNXDomain:
|
|
return nxDomain(q, uint32(cfg.ResponseTTL))
|
|
case blocklist.ActionZero:
|
|
c := cfg
|
|
c.DefaultAction = "zero"
|
|
if entry.Response != "" {
|
|
c.ResponseIP = entry.Response
|
|
}
|
|
return sinkhole(q, c)
|
|
case blocklist.ActionRedirect:
|
|
return redirect(q, entry.Response, uint32(cfg.ResponseTTL))
|
|
case blocklist.ActionCNAME:
|
|
return redirect(q, entry.Response, uint32(cfg.ResponseTTL))
|
|
}
|
|
}
|
|
return blockedResponse(q, cfg, entry)
|
|
}
|
|
|
|
func (e *Engine) blockCfg() config.Block {
|
|
if e.cfgStore == nil {
|
|
return config.Default().Block
|
|
}
|
|
return e.cfgStore.Get().Block
|
|
}
|
|
|
|
// prepareUpstream builds the message to forward: the question plus the
|
|
// client's EDNS0 preferences, and nothing else (we never leak our own OPT to
|
|
// upstream, and we never forward the client's AD bit).
|
|
func (e *Engine) prepareUpstream(q *dns.Msg, req Request) *dns.Msg {
|
|
cfg := e.cfgStore.Get()
|
|
m := new(dns.Msg)
|
|
m.SetQuestion(q.Question[0].Name, q.Question[0].Qtype)
|
|
m.Question[0].Qclass = q.Question[0].Qclass
|
|
m.RecursionDesired = true
|
|
m.CheckingDisabled = q.CheckingDisabled
|
|
|
|
var maxSize uint16 = cfg.DNS.MaxUDPSize
|
|
if o := q.IsEdns0(); o != nil {
|
|
if cfg.DNS.EDNS.PreserveEDNS0 {
|
|
for _, opt := range o.Option {
|
|
switch opt.Option() {
|
|
case dns.EDNS0SUBNET:
|
|
// Forward the client's ECS verbatim: the upstream uses it
|
|
// to pick a nearby anycast location, which is the whole
|
|
// point of the feature.
|
|
m.SetEdns0(maxSize, o.Do())
|
|
if opt4, ok := opt.(*dns.EDNS0_SUBNET); ok {
|
|
appendECS(m, opt4)
|
|
}
|
|
continue
|
|
case dns.EDNS0PADDING, dns.EDNS0COOKIE:
|
|
// Cookie and padding are hop-by-hop; forwarding padding
|
|
// upstream is harmless, cookies are not.
|
|
if opt.Option() == dns.EDNS0PADDING {
|
|
m.SetEdns0(maxSize, o.Do())
|
|
po := new(dns.EDNS0_PADDING)
|
|
po.Padding = opt4Padding(opt)
|
|
m.IsEdns0().Option = append(m.IsEdns0().Option, po)
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
if o.Do() {
|
|
m.SetEdns0(maxSize, true)
|
|
}
|
|
if opt := o.Option; opt != nil {
|
|
for _, oo := range opt {
|
|
if oo.Option() == dns.EDNS0NSID {
|
|
// Our own NSID, not the client's — but only when the
|
|
// operator has not asked us to hide. dns.EDNS0_NSID.Nsid
|
|
// is hex on the wire (the library hex-decodes it in pack
|
|
// and hex-encodes it in unpack), so a plain string here
|
|
// does not pack and every NSID query gets SERVFAIL.
|
|
if e.cfgStore == nil || !e.cfgStore.Get().DNS.HideIdentity {
|
|
nsid := new(dns.EDNS0_NSID)
|
|
nsid.Nsid = hex.EncodeToString([]byte(upstreamNSID))
|
|
if m.IsEdns0() == nil {
|
|
m.SetEdns0(maxSize, o.Do())
|
|
}
|
|
m.IsEdns0().Option = append(m.IsEdns0().Option, nsid)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if m.IsEdns0() == nil {
|
|
m.SetEdns0(maxSize, false)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func appendECS(m *dns.Msg, src *dns.EDNS0_SUBNET) {
|
|
sub := new(dns.EDNS0_SUBNET)
|
|
sub.Code = src.Code
|
|
sub.Family = src.Family
|
|
sub.SourceNetmask = src.SourceNetmask
|
|
sub.SourceScope = src.SourceScope
|
|
sub.Address = src.Address
|
|
if m.IsEdns0() == nil {
|
|
m.SetEdns0(1232, false)
|
|
}
|
|
m.IsEdns0().Option = append(m.IsEdns0().Option, sub)
|
|
}
|
|
|
|
func opt4Padding(opt dns.EDNS0) []byte {
|
|
if p, ok := opt.(*dns.EDNS0_PADDING); ok {
|
|
return p.Padding
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// fromUpstream converts an upstream answer into a client reply: the question
|
|
// and ID come from the client, the payload from upstream.
|
|
func (e *Engine) fromUpstream(q, up *dns.Msg, req Request) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetReply(q)
|
|
m.Rcode = up.Rcode
|
|
if up.CheckingDisabled {
|
|
m.CheckingDisabled = true
|
|
}
|
|
if up.AuthenticatedData {
|
|
m.AuthenticatedData = true
|
|
}
|
|
if up.RecursionAvailable {
|
|
m.RecursionAvailable = true
|
|
}
|
|
m.Answer = up.Answer
|
|
m.Ns = up.Ns
|
|
// Keep the client's EDNS0 shape in the reply, but carry upstream's DO/extended
|
|
// rcode when present.
|
|
if o := up.IsEdns0(); o != nil {
|
|
m.SetEdns0(0, o.Do())
|
|
if o.ExtendedRcode() != 0 || o.Version() != 0 {
|
|
ue := m.IsEdns0()
|
|
ue.SetExtendedRcode(uint16(o.ExtendedRcode()))
|
|
ue.SetVersion(o.Version())
|
|
ue.SetDo(o.Do())
|
|
}
|
|
} else if o := q.IsEdns0(); o != nil {
|
|
m.SetEdns0(0, o.Do())
|
|
} else if e.cfgStore != nil && e.cfgStore.Get().DNS.MaxUDPSize > 512 {
|
|
m.SetEdns0(0, false)
|
|
}
|
|
if len(up.Question) > 0 {
|
|
m.Question = up.Question
|
|
}
|
|
return m
|
|
}
|
|
|
|
// finalize prepares a response for the wire: attach EDNS0, apply the client's
|
|
// UDP size limit, and set the AD/CD bits as needed.
|
|
func (e *Engine) finalize(msg, q *dns.Msg, req Request) *dns.Msg {
|
|
if msg == nil {
|
|
return servFail(q, "nil response")
|
|
}
|
|
// Preserve the client's question verbatim (including the 0x20 case we may
|
|
// have canonicalised away).
|
|
if len(q.Question) > 0 {
|
|
msg.Question = q.Question
|
|
}
|
|
msg.Id = q.Id
|
|
msg.Response = true
|
|
if q.Opcode == dns.OpcodeQuery {
|
|
msg.RecursionDesired = q.RecursionDesired
|
|
}
|
|
if !req.TCP {
|
|
limit := clientUDPSize(q)
|
|
if limit > 0 && wouldOverflow(msg, limit) {
|
|
// RFC 2181 §9: TC means "ask again over TCP". Truncating to a
|
|
// question-only response is the correct, interoperable choice.
|
|
trunc := new(dns.Msg)
|
|
trunc.SetReply(q)
|
|
trunc.Truncated = true
|
|
if o := q.IsEdns0(); o != nil {
|
|
trunc.SetEdns0(0, o.Do())
|
|
}
|
|
return trunc
|
|
}
|
|
}
|
|
return msg
|
|
}
|
|
|
|
func clientUDPSize(q *dns.Msg) uint16 {
|
|
if o := q.IsEdns0(); o != nil {
|
|
if s := o.UDPSize(); s >= 512 {
|
|
return s
|
|
}
|
|
}
|
|
return 512
|
|
}
|
|
|
|
func wouldOverflow(m *dns.Msg, limit uint16) bool {
|
|
b, err := m.Pack()
|
|
if err != nil {
|
|
return true
|
|
}
|
|
return len(b) > int(limit)
|
|
}
|
|
|
|
func ageTo(rrs []dns.RR, ttl uint32) []dns.RR {
|
|
for _, rr := range rrs {
|
|
rr.Header().Ttl = ttl
|
|
}
|
|
return rrs
|
|
}
|
|
|
|
// prefetch re-resolves a stale key in the background.
|
|
func (e *Engine) prefetch(k cache.Key) {
|
|
if e.cache == nil {
|
|
return
|
|
}
|
|
pool := e.pool.Load()
|
|
if pool == nil {
|
|
return
|
|
}
|
|
q := new(dns.Msg)
|
|
q.SetQuestion(dns.Fqdn(k.Name), k.Type)
|
|
q.RecursionDesired = true
|
|
if k.DO {
|
|
q.SetEdns0(1232, true)
|
|
}
|
|
timeout := time.Duration(e.upstreamTimeout.Load())
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
up, addr, err := pool.Exchange(ctx, q, nil, timeout)
|
|
if err != nil || up == nil || up.Truncated {
|
|
return
|
|
}
|
|
e.cache.PutResponse(k, up, addr)
|
|
}
|
|
|
|
// validate rejects malformed queries early.
|
|
func validate(q *dns.Msg) error {
|
|
if q == nil {
|
|
return errMalformed
|
|
}
|
|
if q.Response {
|
|
return errMalformed
|
|
}
|
|
if len(q.Question) != 1 {
|
|
return errMalformed
|
|
}
|
|
if q.Opcode != dns.OpcodeQuery && q.Opcode != dns.OpcodeUpdate {
|
|
return errMalformed
|
|
}
|
|
name := q.Question[0].Name
|
|
if name == "" || len(name) > 255*4 {
|
|
return errMalformed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var errMalformed = errString("malformed query")
|
|
|
|
type errString string
|
|
|
|
func (e errString) Error() string { return string(e) }
|
|
|
|
func formErr(q *dns.Msg) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetRcode(q, dns.RcodeFormatError)
|
|
return m
|
|
}
|
|
|
|
func servFail(q *dns.Msg, reason string) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetRcode(q, dns.RcodeServerFailure)
|
|
return m
|
|
}
|
|
|
|
func nxDomain(q *dns.Msg, ttl uint32) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetRcode(q, dns.RcodeNameError)
|
|
m.Ns = []dns.RR{syntheticSOA(q, ttl)}
|
|
return m
|
|
}
|
|
|
|
// syntheticSOA is the authority record attached to synthesised negative
|
|
// answers. RFC 2308 requires a SOA in NXDOMAIN's authority section for the TTL
|
|
// to be honoured, and clients use it to cache the negative result.
|
|
func syntheticSOA(q *dns.Msg, ttl uint32) dns.RR {
|
|
name := "."
|
|
if len(q.Question) > 0 {
|
|
labels := strings.Split(strings.TrimSuffix(q.Question[0].Name, "."), ".")
|
|
if len(labels) >= 2 {
|
|
name = strings.Join(labels[len(labels)-2:], ".") + "."
|
|
}
|
|
}
|
|
if ttl == 0 {
|
|
ttl = 60
|
|
}
|
|
soa := &dns.SOA{
|
|
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: ttl},
|
|
Ns: "ns.fancy-dns.local.",
|
|
Mbox: "hostmaster.fancy-dns.local.",
|
|
Serial: uint32(time.Now().Unix()),
|
|
Refresh: 3600,
|
|
Retry: 600,
|
|
Expire: 604800,
|
|
Minttl: ttl,
|
|
}
|
|
return soa
|
|
}
|
|
|
|
// blockedResponse builds the configured blocklist answer.
|
|
func blockedResponse(q *dns.Msg, cfg config.Block, entry *blocklist.Entry) *dns.Msg {
|
|
ttl := uint32(cfg.ResponseTTL.D())
|
|
if ttl == 0 {
|
|
ttl = 60
|
|
}
|
|
switch cfg.DefaultAction {
|
|
case "drop":
|
|
// A drop is handled by the caller, which knows not to answer; reaching
|
|
// here means the caller wants a body, so return a timeout-style empty
|
|
// NOERROR.
|
|
m := new(dns.Msg)
|
|
m.SetReply(q)
|
|
return m
|
|
case "refused":
|
|
return refuseConfig(q)
|
|
case "zero":
|
|
return sinkhole(q, cfg)
|
|
case "redirect":
|
|
return redirect(q, cfg.RedirectName, ttl)
|
|
default:
|
|
return nxDomain(q, ttl)
|
|
}
|
|
}
|
|
|
|
func refuseConfig(q *dns.Msg) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetRcode(q, dns.RcodeRefused)
|
|
return m
|
|
}
|
|
|
|
func refuse(q *dns.Msg, _ acl.Decision) *dns.Msg { return refuseConfig(q) }
|
|
|
|
// sinkhole answers with the configured zero address, choosing the family from
|
|
// the question so a v4 client never gets a v6 answer.
|
|
func sinkhole(q *dns.Msg, cfg config.Block) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetReply(q)
|
|
m.Authoritative = true
|
|
ttl := uint32(cfg.ResponseTTL.D())
|
|
if ttl == 0 {
|
|
ttl = 60
|
|
}
|
|
if len(q.Question) == 0 {
|
|
return m
|
|
}
|
|
switch q.Question[0].Qtype {
|
|
case dns.TypeA:
|
|
ip := cfg.ResponseIP
|
|
if ip == "" {
|
|
ip = "0.0.0.0"
|
|
}
|
|
rr, _ := dns.NewRR(q.Question[0].Name + " 0 IN A " + ip)
|
|
if rr != nil {
|
|
rr.Header().Ttl = ttl
|
|
m.Answer = []dns.RR{rr}
|
|
}
|
|
case dns.TypeAAAA:
|
|
ip := cfg.ResponseIPv6
|
|
if ip == "" {
|
|
ip = "::"
|
|
}
|
|
rr, _ := dns.NewRR(q.Question[0].Name + " 0 IN AAAA " + ip)
|
|
if rr != nil {
|
|
rr.Header().Ttl = ttl
|
|
m.Answer = []dns.RR{rr}
|
|
}
|
|
default:
|
|
m.Ns = []dns.RR{syntheticSOA(q, ttl)}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// redirect answers with the configured address when target is an IP literal.
|
|
// Otherwise it answers with a CNAME to a configured name, plus an A/AAAA for
|
|
// the target when available.
|
|
func redirect(q *dns.Msg, target string, ttl uint32) *dns.Msg {
|
|
m := new(dns.Msg)
|
|
m.SetReply(q)
|
|
m.Authoritative = true
|
|
if target == "" {
|
|
return m
|
|
}
|
|
if len(q.Question) == 0 {
|
|
return m
|
|
}
|
|
if addr, err := netip.ParseAddr(target); err == nil {
|
|
addr = addr.Unmap()
|
|
var rrType uint16
|
|
switch {
|
|
case addr.Is4() && q.Question[0].Qtype == dns.TypeA:
|
|
rrType = dns.TypeA
|
|
case addr.Is6() && q.Question[0].Qtype == dns.TypeAAAA:
|
|
rrType = dns.TypeAAAA
|
|
default:
|
|
m.Ns = []dns.RR{syntheticSOA(q, ttl)}
|
|
return m
|
|
}
|
|
rr, err := dns.NewRR(q.Question[0].Name + " 0 IN " + dns.TypeToString[rrType] + " " + addr.String())
|
|
if err == nil {
|
|
rr.Header().Ttl = ttl
|
|
m.Answer = []dns.RR{rr}
|
|
} else {
|
|
m.Ns = []dns.RR{syntheticSOA(q, ttl)}
|
|
}
|
|
return m
|
|
}
|
|
cname, err := dns.NewRR(q.Question[0].Name + " 0 IN CNAME " + dns.Fqdn(target))
|
|
if err != nil {
|
|
return m
|
|
}
|
|
cname.Header().Ttl = ttl
|
|
m.Answer = []dns.RR{cname}
|
|
switch q.Question[0].Qtype {
|
|
case dns.TypeA:
|
|
rr, _ := dns.NewRR(dns.Fqdn(target) + " 0 IN A 127.0.0.1")
|
|
if rr != nil {
|
|
rr.Header().Ttl = ttl
|
|
m.Answer = append(m.Answer, rr)
|
|
}
|
|
case dns.TypeAAAA:
|
|
rr, _ := dns.NewRR(dns.Fqdn(target) + " 0 IN AAAA ::1")
|
|
if rr != nil {
|
|
rr.Header().Ttl = ttl
|
|
m.Answer = append(m.Answer, rr)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func shortErr(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
s := err.Error()
|
|
if len(s) > 120 {
|
|
s = s[:120]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// groupStatTable tracks per-group query and block counts for the UI.
|
|
type groupStatTable struct {
|
|
mu sync.RWMutex
|
|
stats map[string]*groupStat
|
|
}
|
|
|
|
type groupStat struct {
|
|
queries uint64
|
|
blocked uint64
|
|
errors uint64
|
|
cache uint64
|
|
}
|
|
|
|
func newGroupStatTable() *groupStatTable {
|
|
return &groupStatTable{stats: make(map[string]*groupStat)}
|
|
}
|
|
|
|
func (t *groupStatTable) observe(group string, res Response) {
|
|
if group == "" {
|
|
group = "(default)"
|
|
}
|
|
t.mu.Lock()
|
|
s, ok := t.stats[group]
|
|
if !ok {
|
|
s = &groupStat{}
|
|
t.stats[group] = s
|
|
}
|
|
s.queries++
|
|
switch res.Action {
|
|
case qlog.ActionBlock:
|
|
s.blocked++
|
|
case qlog.ActionError, qlog.ActionDrop:
|
|
s.errors++
|
|
case qlog.ActionCached:
|
|
s.cache++
|
|
}
|
|
t.mu.Unlock()
|
|
}
|
|
|
|
func (t *groupStatTable) merge(base []acl.GroupSummary) []acl.GroupSummary {
|
|
t.mu.RLock()
|
|
defer t.mu.RUnlock()
|
|
byName := make(map[string]acl.GroupSummary, len(base))
|
|
for _, g := range base {
|
|
byName[g.Name] = g
|
|
}
|
|
// Include groups that were removed from the config but still have traffic.
|
|
for name := range t.stats {
|
|
if _, ok := byName[name]; ok {
|
|
continue
|
|
}
|
|
byName[name] = acl.GroupSummary{Name: name, Policy: string(acl.PolicyAllow)}
|
|
}
|
|
out := make([]acl.GroupSummary, 0, len(byName))
|
|
for name, g := range byName {
|
|
if s, ok := t.stats[name]; ok {
|
|
g.QueryCount = s.queries
|
|
g.BlockedCount = s.blocked
|
|
}
|
|
out = append(out, g)
|
|
}
|
|
return out
|
|
}
|
|
|
|
var _ = strconv.Itoa
|
|
|
|
// Flush persists buffered state: the cache snapshot and the query log. It is
|
|
// wired to the server's flusher interface so the admin API can force a flush.
|
|
func (e *Engine) Flush() {
|
|
if p := e.flusher.Load(); p != nil {
|
|
(*p)()
|
|
}
|
|
}
|
|
|
|
// SetFlusher installs the flush callback (set once during app wiring).
|
|
func (e *Engine) SetFlusher(fn func()) {
|
|
if fn == nil {
|
|
e.flusher.Store(nil)
|
|
return
|
|
}
|
|
e.flusher.Store(&fn)
|
|
}
|