Files
coredns/plugin/cache/cache.go
Ahmad Mehrabi 16b53a46c1 plugin/cache: cache SERVFAIL when the resolver fails without a response (#8629)
* plugin/cache: cache SERVFAIL when the resolver fails without a response

The forward plugin returns (SERVFAIL, err) on an upstream timeout without
calling WriteMsg, so the response never reaches the cache ResponseWriter
and is not stored. Every client retry then hits the upstream again and
pays the full forward timeout, while a SERVFAIL sent by the upstream is
cached normally.

Store a SERVFAIL generated from the failure, bounded by the existing
servfail TTL, so identical queries are answered from the cache while it is
valid. A servfail TTL of 0 keeps the previous behaviour.

Signed-off-by: Ahmad Mehrabi <mehrabi@post.com>

* plugin/cache: do not cache SERVFAIL caused by a canceled request

forward also returns SERVFAIL together with an error when the request
context is canceled. DoH propagates the HTTP request context, so caching
that failure would make every subsequent request with the same cache key
fail until the servfail TTL expires, even though the upstream is healthy.

Only insert the synthetic SERVFAIL when the request context is still live,
and add a regression test that cancels the first request and then verifies
that a subsequent healthy request reaches the backend.

Signed-off-by: Ahmad Mehrabi <mehrabi@post.com>

---------

Signed-off-by: Ahmad Mehrabi <mehrabi@post.com>
2026-10-15 19:29:41 -07:00

624 lines
20 KiB
Go

// Package cache implements a cache.
package cache
import (
"encoding/binary"
"fmt"
"hash/fnv"
"net"
"strings"
"time"
"github.com/coredns/coredns/plugin"
"github.com/coredns/coredns/plugin/pkg/cache"
"github.com/coredns/coredns/plugin/pkg/dnsutil"
"github.com/coredns/coredns/plugin/pkg/response"
"github.com/coredns/coredns/request"
"github.com/miekg/dns"
)
// Cache is a plugin that looks up responses in a cache and caches replies.
// It has a success and a denial of existence cache.
type Cache struct {
Next plugin.Handler
Zones []string
zonesMetricLabel string
viewMetricLabel string
ncache *cache.Cache[*item]
ncap int
nttl time.Duration
minnttl time.Duration
pcache *cache.Cache[*item]
pcap int
pttl time.Duration
minpttl time.Duration
failttl time.Duration // TTL for caching SERVFAIL responses
// Prefetch.
prefetch int
duration time.Duration
percentage int
// Stale serve
staleUpTo time.Duration
verifyStale bool
verifyStaleTimeout time.Duration // 0 means wait for upstream until its own timeout (current default).
preferPositive bool
staleTTL time.Duration // TTL returned with stale responses; 0 preserves the legacy behavior.
staleRecheck time.Duration // Delay after a failed refresh before another attempt; 0 preserves the legacy behavior.
// Positive/negative zone exceptions
pexcept []string
nexcept []string
bypass []string // mutable authoritative zones discovered at startup
// Keep ttl option
keepttl bool
// Testing.
now func() time.Time
}
// ZoneBypasser identifies canonical authoritative zones that must always be
// queried directly. Cache discovers implementations in its server block at startup.
type ZoneBypasser interface {
CacheBypassZones() []string
}
// New returns an initialized Cache with default settings. It's up to the
// caller to set the Next handler.
func New() *Cache {
return &Cache{
Zones: []string{"."},
pcap: defaultCap,
pcache: cache.New[*item](defaultCap),
pttl: maxTTL,
minpttl: minTTL,
ncap: defaultCap,
ncache: cache.New[*item](defaultCap),
nttl: maxNTTL,
minnttl: minNTTL,
failttl: minNTTL,
prefetch: 0,
duration: 1 * time.Minute,
percentage: 10,
now: time.Now,
}
}
// key returns key under which we store the item, -1 will be returned if we don't store the message.
// Currently we do not cache Truncated, errors zone transfers or dynamic update messages.
// qname holds the already lowercased qname.
func key(qname string, m *dns.Msg, t response.Type, do, cd bool) (bool, uint64) {
// We don't store truncated responses.
if m.Truncated {
return false, 0
}
// A response with no question cannot be keyed or cached and would panic
// on m.Question[0] below. Some plugins can emit such a malformed message
// (e.g. during prefetch); warn and skip it rather than crash the server.
if len(m.Question) == 0 {
log.Warningf("Not caching malformed response with an empty question section for %q", qname)
return false, 0
}
// Nor errors or Meta or Update.
if t == response.OtherError || t == response.Meta || t == response.Update {
return false, 0
}
// Negative caching requires an SOA record to determine the denial TTL.
if t == response.NameError && !hasSOA(m) {
return false, 0
}
// An upstream may return NOERROR with a non-empty answer that still does not
// resolve the question and without an SOA to bound a negative TTL: a CNAME
// chain that does not terminate in the queried type (an incomplete recursion
// result from a forwarder). This is effectively an SOA-less NODATA response,
// which per RFC 2308 section 5 SHOULD NOT be cached. response.Typify classifies
// it as NoError because the answer section is non-empty, so caching it in the
// positive cache would replay the non-answer to clients until it expires. Skip
// caching so the next query is resolved upstream again. An empty answer section
// is deliberately left cacheable: it is indistinguishable from a legitimate
// NOERROR positive response that carries its data outside the answer section
// (for example the whoami plugin, which answers in the additional section).
if t == response.NoError && !hasSOA(m) && isNODATA(m) {
return false, 0
}
return true, hash(qname, m.Question[0].Qtype, m.Question[0].Qclass, do, cd)
}
func hasSOA(m *dns.Msg) bool {
for _, r := range m.Ns {
if r.Header().Rrtype == dns.TypeSOA {
return true
}
}
return false
}
// cacheResponseType returns the response type used by the cache. Typify treats
// any NOERROR response with a non-empty answer section as NoError, but RFC 2308
// NODATA responses may contain a CNAME chain. Reclassify those responses when
// an SOA provides the negative cache TTL.
func cacheResponseType(m *dns.Msg, now time.Time) response.Type {
t, _ := response.Typify(m, now)
if t == response.NoError && hasSOA(m) && isNODATA(m) {
return response.NoData
}
return t
}
// answersQuestion reports whether a NOERROR response contains an answer to its
// question. For types other than CNAME and ANY, the queried type must exist at
// the terminal owner reached by following the CNAME chain from QNAME.
func answersQuestion(m *dns.Msg) bool {
if m == nil || m.Rcode != dns.RcodeSuccess || len(m.Question) == 0 || len(m.Answer) == 0 {
return false
}
q := m.Question[0]
return answerHasType(m.Answer, q.Name, q.Qtype, q.Qclass)
}
func answerHasType(answer []dns.RR, name string, qtype, qclass uint16) bool {
if len(answer) == 0 {
return false
}
if qtype == dns.TypeANY {
for _, r := range answer {
h := r.Header()
if classMatches(h.Class, qclass) && strings.EqualFold(h.Name, name) {
return true
}
}
return false
}
if qtype == dns.TypeCNAME {
target, ok := uniqueCNAMETarget(answer, name, qclass)
return ok && target != ""
}
terminal, ok := canonicalName(answer, name, qclass)
if !ok {
return false
}
name = terminal
for _, r := range answer {
h := r.Header()
if h.Rrtype == qtype && classMatches(h.Class, qclass) && strings.EqualFold(h.Name, name) {
return true
}
}
return false
}
func classMatches(rrClass, qclass uint16) bool {
return qclass == dns.ClassANY || rrClass == qclass
}
// usableAnswer reports whether m is a complete, cache-valid positive response
// that answers its question. It intentionally permits TTL-zero responses:
// they are usable for the current client even though they are not retained.
func usableAnswer(m *dns.Msg, now time.Time) bool {
if m == nil || m.Truncated || cacheResponseType(m, now) != response.NoError {
return false
}
return answersQuestion(m)
}
// isNODATA reports whether a NOERROR response with a non-empty answer section
// does not answer the question. Following RFC 1034 section 3.6.2 and RFC 2308
// sections 1 and 2.2, a query of any type other than CNAME (and ANY) is
// restarted along the CNAME chain, so the effective owner name is the target at
// the end of the CNAME chain that starts at the question name. The response
// answers the question only if it carries a record of the queried type at that
// terminal name (records at any other owner name are irrelevant, and per RFC
// 1034 a CNAME's owner never co-locates other data). This rule is independent of
// the queried type: an MX, TXT, SRV, etc. chain that does not reach the queried
// type is NODATA just like an A or AAAA one. When the chain is malformed (an
// owner with more than one distinct CNAME target, or a loop) it has no
// well-defined terminal name, so the response is treated as NODATA, which errs
// toward re-querying upstream rather than caching a non-answer. An empty answer
// section returns false so that legitimate positive responses carrying data
// outside the answer section (for example the whoami plugin) remain cacheable.
// An ANY query is answered only by a record at the queried owner and in the
// requested class. Note: a bare DNAME (RFC 6672) without its synthesized CNAME
// is treated as NODATA; standard responses include the synthesized CNAME, which
// the chain walk follows.
func isNODATA(m *dns.Msg) bool {
if len(m.Answer) == 0 {
return false
}
return !answersQuestion(m)
}
// canonicalName follows the owner-linked CNAME chain in answer starting at name
// and returns the terminal target name together with a validity flag. Records
// whose owner is not on the chain are ignored. The chain is invalid (ok=false)
// when it is not a single unambiguous path to a terminal name: an owner that has
// more than one distinct CNAME target violates RFC 2181 section 10.1 (an alias
// has exactly one canonical name), and a revisited owner is a CNAME loop, which
// RFC 1034 section 3.6.2 says must be signalled as an error. Reporting validity
// rather than silently stopping keeps the classification order-independent and
// fail-closed: callers treat a malformed chain as a non-answer. Duplicate CNAME
// records that name the same target are tolerated, since they still describe a
// single canonical name.
func canonicalName(answer []dns.RR, name string, qclass uint16) (string, bool) {
visited := nameSet{}
for {
if visited.contains(name) {
// Revisited owner: the chain contains a CNAME loop.
return name, false
}
visited.add(name)
target, ok := uniqueCNAMETarget(answer, name, qclass)
if !ok {
// Owner has more than one distinct canonical name.
return name, false
}
if target == "" {
// Terminal owner reached: no CNAME continues the chain.
return name, true
}
name = target
}
}
// uniqueCNAMETarget returns the canonical name that owner is aliased to by a
// CNAME record in answer. ok is false when owner carries more than one distinct
// CNAME target, which violates RFC 2181 section 10.1. When owner has no CNAME the
// returned target is empty and ok is true, marking a terminal owner. Duplicate
// CNAME records naming the same target are tolerated.
func uniqueCNAMETarget(answer []dns.RR, owner string, qclass uint16) (target string, ok bool) {
for _, r := range answer {
c, isCNAME := r.(*dns.CNAME)
if !isCNAME || !classMatches(c.Header().Class, qclass) || !strings.EqualFold(c.Header().Name, owner) {
continue
}
if target != "" && !strings.EqualFold(target, c.Target) {
return "", false
}
target = c.Target
}
return target, true
}
// nameSet is a set of domain names compared case-insensitively, used to detect
// revisited owners (loops) while walking a CNAME chain.
type nameSet map[string]struct{}
func (s nameSet) contains(name string) bool {
_, ok := s[strings.ToLower(name)]
return ok
}
func (s nameSet) add(name string) {
s[strings.ToLower(name)] = struct{}{}
}
var one = []byte("1")
var zero = []byte("0")
func hash(qname string, qtype, qclass uint16, do, cd bool) uint64 {
h := fnv.New64()
if do {
h.Write(one)
} else {
h.Write(zero)
}
if cd {
h.Write(one)
} else {
h.Write(zero)
}
var qtypeBytes [2]byte
binary.BigEndian.PutUint16(qtypeBytes[:], qtype)
h.Write(qtypeBytes[:])
var qclassBytes [2]byte
binary.BigEndian.PutUint16(qclassBytes[:], qclass)
h.Write(qclassBytes[:])
h.Write([]byte(qname))
return h.Sum64()
}
func computeTTL(msgTTL, minTTL, maxTTL time.Duration) time.Duration {
ttl := min(max(msgTTL, minTTL), maxTTL)
return ttl
}
// ResponseWriter is a response writer that caches the reply message.
type ResponseWriter struct {
dns.ResponseWriter
*Cache
state request.Request
server string // Server handling the request.
do bool // When true the original request had the DO bit set.
cd bool // When true the original request had the CD bit set.
ad bool // When true the original request had the AD bit set.
prefetch bool // When true write nothing back to the client.
remoteAddr net.Addr
wildcardFunc func() string // function to retrieve wildcard name that synthesized the result.
lastResponse *dns.Msg // last response after cache TTL and DNSSEC adjustments.
lastItem *item // cache item written by the last response, if cacheable.
pexcept []string // positive zone exceptions
nexcept []string // negative zone exceptions
}
// prefetchAddr is the synthetic remote address for prefetch requests. There is
// no client connection, and per request.Proto the address type is what selects
// the response-size budget; TCP ensures upstream replies aren't truncated.
var prefetchAddr = &net.TCPAddr{}
// newPrefetchResponseWriter returns a ResponseWriter for prefetch requests.
// Prefetch has no client connection: the inner ResponseWriter is nil, WriteMsg
// short-circuits after caching when w.prefetch is true, and the nil-safe
// overrides below make the remaining dns.ResponseWriter methods well-defined.
func newPrefetchResponseWriter(server string, req *dns.Msg, do, cd bool, c *Cache) *ResponseWriter {
req = req.Copy()
req.AuthenticatedData = true
cw := &ResponseWriter{
Cache: c,
server: server,
do: do,
cd: cd,
ad: true,
prefetch: true,
remoteAddr: prefetchAddr,
}
cw.state = request.Request{Req: req}
return cw
}
// RemoteAddr implements the dns.ResponseWriter interface.
func (w *ResponseWriter) RemoteAddr() net.Addr {
if w.remoteAddr != nil {
return w.remoteAddr
}
return w.ResponseWriter.RemoteAddr()
}
// The following overrides make a nil inner ResponseWriter well-defined.
// Prefetch constructs a ResponseWriter with no client connection; WriteMsg
// and Write already short-circuit on w.prefetch before delegating, and
// RemoteAddr uses w.remoteAddr. These cover the rest of the interface.
func (w *ResponseWriter) LocalAddr() net.Addr {
if w.ResponseWriter == nil {
return prefetchAddr
}
return w.ResponseWriter.LocalAddr()
}
func (w *ResponseWriter) Close() error {
if w.ResponseWriter == nil {
return nil
}
return w.ResponseWriter.Close()
}
func (w *ResponseWriter) TsigStatus() error {
if w.ResponseWriter == nil {
return nil
}
return w.ResponseWriter.TsigStatus()
}
func (w *ResponseWriter) TsigTimersOnly(b bool) {
if w.ResponseWriter == nil {
return
}
w.ResponseWriter.TsigTimersOnly(b)
}
func (w *ResponseWriter) Hijack() {
if w.ResponseWriter == nil {
return
}
w.ResponseWriter.Hijack()
}
// WriteMsg implements the dns.ResponseWriter interface.
func (w *ResponseWriter) WriteMsg(res *dns.Msg) error {
if res == nil {
return fmt.Errorf("cache: response message is nil")
}
res = res.Copy()
w.lastItem = nil
mt := cacheResponseType(res, w.now().UTC())
// key returns empty string for anything we don't want to cache.
hasKey, key := key(w.state.Name(), res, mt, w.do, w.cd)
var duration time.Duration
switch mt {
case response.NameError, response.NoData:
msgTTL := dnsutil.MinimalTTLWithMaximum(res, mt, w.nttl)
duration = computeTTL(msgTTL, w.minnttl, w.nttl)
case response.ServerError:
duration = w.failttl
default:
msgTTL := dnsutil.MinimalTTLWithMaximum(res, mt, w.pttl)
duration = computeTTL(msgTTL, w.minpttl, w.pttl)
}
// Apply capped TTL to this reply to avoid jarring TTL experience 1799 -> 8 (e.g.)
ttl := uint32(duration.Seconds())
res.Answer = filterRRSlice(res.Answer, ttl, false)
res.Ns = filterRRSlice(res.Ns, ttl, false)
res.Extra = filterRRSlice(res.Extra, ttl, false)
if hasKey && duration > 0 {
if w.state.Match(res) {
w.set(res, key, mt, duration)
cacheSize.WithLabelValues(w.server, Success, w.zonesMetricLabel, w.viewMetricLabel).Set(float64(w.pcache.Len()))
cacheSize.WithLabelValues(w.server, Denial, w.zonesMetricLabel, w.viewMetricLabel).Set(float64(w.ncache.Len()))
} else {
// Don't log it, but increment counter
cacheDrops.WithLabelValues(w.server, w.zonesMetricLabel, w.viewMetricLabel).Inc()
}
}
if !w.do && !w.ad {
// unset AD bit if requester is not OK with DNSSEC
// But retain AD bit if requester set the AD bit in the request, per RFC6840 5.7-5.8
res.AuthenticatedData = false
}
w.lastResponse = res.Copy()
if w.prefetch {
return nil
}
return w.ResponseWriter.WriteMsg(res)
}
// cacheServerFailure stores a SERVFAIL response for req in the cache without
// writing it to the client. It is used when the next plugin fails without
// producing a response, so the failure can be served from the cache while the
// servfail TTL is valid.
func (w *ResponseWriter) cacheServerFailure(req *dns.Msg) {
resp := new(dns.Msg)
resp.SetRcode(req, dns.RcodeServerFailure)
w.prefetch = true
_ = w.WriteMsg(resp)
}
func (w *ResponseWriter) set(m *dns.Msg, key uint64, mt response.Type, duration time.Duration) {
// duration is expected > 0
// and key is valid
switch mt {
case response.NoError, response.Delegation:
if plugin.Zones(w.pexcept).Contains(m.Question[0].Name) {
// zone is in exception list, do not cache
return
}
i := newItem(m, w.now(), duration)
if w.wildcardFunc != nil {
i.wildcard = w.wildcardFunc()
}
if w.preferPositive && !i.answering {
if previous, ok := w.pcache.Get(key); ok {
i.lastKnownGood = previous.answeringItem(w.state)
}
}
if w.pcache.Add(key, i) {
evictions.WithLabelValues(w.server, Success, w.zonesMetricLabel, w.viewMetricLabel).Inc()
}
w.lastItem = i
// A positive refresh is the newest state for this key. Under the
// prefer_positive policy, only remove the denial when this response
// actually answers the question.
if (!w.preferPositive && w.prefetch) || (w.preferPositive && i.answering) {
w.ncache.Remove(key)
}
case response.NameError, response.NoData, response.ServerError:
if plugin.Zones(w.nexcept).Contains(m.Question[0].Name) {
// zone is in exception list, do not cache
return
}
i := newItem(m, w.now(), duration)
if w.wildcardFunc != nil {
i.wildcard = w.wildcardFunc()
}
if w.ncache.Add(key, i) {
evictions.WithLabelValues(w.server, Denial, w.zonesMetricLabel, w.viewMetricLabel).Inc()
}
w.lastItem = i
case response.OtherError:
// don't cache these
default:
log.Warningf("Caching called with unknown classification: %d", mt)
}
}
// Write implements the dns.ResponseWriter interface.
func (w *ResponseWriter) Write(buf []byte) (int, error) {
log.Warning("Caching called with Write: not caching reply")
if w.prefetch {
return 0, nil
}
n, err := w.ResponseWriter.Write(buf)
return n, err
}
// verifyStaleResponseWriter is a response writer that only writes messages if they should replace a
// stale cache entry, and otherwise discards them.
type verifyStaleResponseWriter struct {
*ResponseWriter
refreshed bool // set to true if the last WriteMsg wrote to ResponseWriter, false otherwise.
response *dns.Msg
item *item
}
// newVerifyStaleResponseWriter returns a ResponseWriter to be used when verifying stale cache
// entries. It only forwards matching, complete responses that successfully refresh the data
// according to RFC8767, section 4 (response is NoError or NXDomain). With prefer_positive, only
// a usable positive answer is forwarded; other matching responses are cached without being sent
// to the client.
func newVerifyStaleResponseWriter(w *ResponseWriter) *verifyStaleResponseWriter {
return &verifyStaleResponseWriter{
ResponseWriter: w,
}
}
// WriteMsg implements the dns.ResponseWriter interface.
func (w *verifyStaleResponseWriter) WriteMsg(res *dns.Msg) error {
w.refreshed = false
w.response = nil
w.item = nil
if res == nil || res.Truncated || !w.state.Match(res) {
return nil
}
if w.preferPositive {
if usableAnswer(res, w.now().UTC()) {
w.refreshed = true
err := w.ResponseWriter.WriteMsg(res)
w.response = w.lastResponse
w.item = w.lastItem
return err
}
prefetch := w.prefetch
w.prefetch = true
err := w.ResponseWriter.WriteMsg(res)
w.prefetch = prefetch
return err
}
responseType, _ := response.Typify(res, w.now().UTC())
if responseType == response.OtherError || responseType == response.Meta || responseType == response.Update {
return nil
}
if res.Rcode != dns.RcodeSuccess && res.Rcode != dns.RcodeNameError {
return nil
}
w.refreshed = true
err := w.ResponseWriter.WriteMsg(res) // stores to the cache and sends to the client
w.response = w.lastResponse
w.item = w.lastItem
return err
}
const (
maxTTL = dnsutil.MaximumDefaultTTL
minTTL = dnsutil.MinimalDefaultTTL
maxNTTL = dnsutil.MaximumDefaultTTL / 2
minNTTL = dnsutil.MinimalDefaultTTL
defaultCap = 10000 // default capacity of the cache.
// Success is the class for caching positive caching.
Success = "success"
// Denial is the class defined for negative caching.
Denial = "denial"
)