plugin/cache: add prefer_positive stale policy (#8378)

* plugin/cache: add prefer_positive stale policy

Add an opt-in serve_stale_policy that prefers an eligible success-cache
answer over denial-cache entries while serve_stale is enabled. Preserve the
existing ncache-first behavior when the policy is absent.

Also classify SOA-backed CNAME NODATA responses in the cache so incomplete
answers cannot be selected as positive stale responses.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 25da81ab-92dd-4663-b480-efd6262090c6
Signed-off-by: Nitin Nizhawan <nnizhawan@microsoft.com>

* plugin/cache: retain last-known-good positive answers

Keep an answering success-cache item reachable when a later NOERROR or
referral response overwrites the visible cache key without answering the
question. This lets prefer_positive survive empty responses, referrals, and
additional-only data while leaving policy-off lookup behavior unchanged.

Return the exact accepted verify refresh item instead of re-reading an
ambiguous cache key, avoiding expired TTL wraparound for uncacheable replies.
Add regression coverage for non-answer refreshes, NODATA, SERVFAIL, NOTIMP,
stale-window expiry, and bounded verify reply shaping.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 25da81ab-92dd-4663-b480-efd6262090c6
Signed-off-by: Nitin Nizhawan <nnizhawan@microsoft.com>

* plugin/cache: validate preferred stale answers

Reject truncated, DNSSEC-expired, mismatched-class, unrelated ANY, and ambiguous CNAME refreshes before replacing a stale last-known-good answer. Precompute answer eligibility when cache items are created so prefer_positive hits avoid repeated CNAME walks.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 25da81ab-92dd-4663-b480-efd6262090c6
Signed-off-by: Nitin Nizhawan <nnizhawan@microsoft.com>

---------

Signed-off-by: Nitin Nizhawan <nnizhawan@microsoft.com>
Co-authored-by: Nitin Nizhawan <nnizhawan@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 25da81ab-92dd-4663-b480-efd6262090c6
This commit is contained in:
Nitin Nizhawan
2026-08-14 13:48:39 +05:30
committed by GitHub
parent 2eb7d16071
commit 87ccb6f90e
7 changed files with 658 additions and 52 deletions

160
plugin/cache/cache.go vendored
View File

@@ -46,6 +46,7 @@ type Cache struct {
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.
@@ -124,6 +125,74 @@ func hasSOA(m *dns.Msg) bool {
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
@@ -139,39 +208,15 @@ func hasSOA(m *dns.Msg) bool {
// 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.
// ANY queries are excluded because any record answers them. 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.
// 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
}
qtype := m.Question[0].Qtype
if qtype == dns.TypeANY {
return false
}
// A CNAME query is answered by the CNAME itself, so the chain is not
// followed; otherwise resolve it to the terminal owner name.
name := m.Question[0].Name
if qtype != dns.TypeCNAME {
terminal, ok := canonicalName(m.Answer, name)
if !ok {
// The CNAME chain is malformed (an owner with more than one
// distinct target, or a loop) and therefore has no well-defined
// QNAME per RFC 2181 section 10.1 and RFC 1034 section 3.6.2. Such
// a response cannot be shown to answer the question, so treat it as
// NODATA and (being SOA-less) leave it uncacheable.
return true
}
name = terminal
}
for _, r := range m.Answer {
h := r.Header()
if h.Rrtype == qtype && strings.EqualFold(h.Name, name) {
return false
}
}
return true
return !answersQuestion(m)
}
// canonicalName follows the owner-linked CNAME chain in answer starting at name
@@ -185,7 +230,7 @@ func isNODATA(m *dns.Msg) bool {
// 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) (string, bool) {
func canonicalName(answer []dns.RR, name string, qclass uint16) (string, bool) {
visited := nameSet{}
for {
if visited.contains(name) {
@@ -194,7 +239,7 @@ func canonicalName(answer []dns.RR, name string) (string, bool) {
}
visited.add(name)
target, ok := uniqueCNAMETarget(answer, name)
target, ok := uniqueCNAMETarget(answer, name, qclass)
if !ok {
// Owner has more than one distinct canonical name.
return name, false
@@ -212,10 +257,10 @@ func canonicalName(answer []dns.RR, name string) (string, bool) {
// 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) (target string, ok bool) {
func uniqueCNAMETarget(answer []dns.RR, owner string, qclass uint16) (target string, ok bool) {
for _, r := range answer {
c, isCNAME := r.(*dns.CNAME)
if !isCNAME || !strings.EqualFold(c.Header().Name, owner) {
if !isCNAME || !classMatches(c.Header().Class, qclass) || !strings.EqualFold(c.Header().Name, owner) {
continue
}
if target != "" && !strings.EqualFold(target, c.Target) {
@@ -286,6 +331,8 @@ type ResponseWriter struct {
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
@@ -364,7 +411,8 @@ func (w *ResponseWriter) Hijack() {
// WriteMsg implements the dns.ResponseWriter interface.
func (w *ResponseWriter) WriteMsg(res *dns.Msg) error {
res = res.Copy()
mt, _ := response.Typify(res, w.now().UTC())
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)
@@ -392,6 +440,7 @@ func (w *ResponseWriter) WriteMsg(res *dns.Msg) error {
// 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 hasKey && duration > 0 {
if w.state.Match(res) {
@@ -424,11 +473,19 @@ func (w *ResponseWriter) set(m *dns.Msg, key uint64, mt response.Type, 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()
}
// when pre-fetching, remove the negative cache entry if it exists
if w.prefetch {
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)
}
@@ -444,6 +501,7 @@ func (w *ResponseWriter) set(m *dns.Msg, key uint64, mt response.Type, duration
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
@@ -467,24 +525,43 @@ func (w *ResponseWriter) Write(buf []byte) (int, error) {
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), and ignores other responses.
// 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{
w,
false,
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
@@ -493,7 +570,10 @@ func (w *verifyStaleResponseWriter) WriteMsg(res *dns.Msg) error {
return nil
}
w.refreshed = true
return w.ResponseWriter.WriteMsg(res) // stores to the cache and sends to the client
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 (