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

View File

@@ -980,6 +980,9 @@ func TestServeFromStaleCacheFetchVerifyTimeoutFastUpstream(t *testing.T) {
if got := rec.Msg.Answer[0].Header().Ttl; got != 200 {
t.Errorf("expected fresh TTL=200, got %d", got)
}
if !rec.Msg.Authoritative {
t.Error("expected cached fresh response to preserve authoritative cache reply shaping")
}
}
func TestNegativeStaleMaskingPositiveCache(t *testing.T) {
@@ -1451,6 +1454,431 @@ func TestServfailDoesNotShadowPositiveCache(t *testing.T) {
}
}
func TestPreferPositiveCachePolicy(t *testing.T) {
c := New()
c.staleUpTo = time.Hour
now := time.Now()
c.now = func() time.Time { return now }
req := new(dns.Msg)
req.SetQuestion("example.org.", dns.TypeA)
state := request.Request{W: &test.ResponseWriter{}, Req: req}
k := hash(state.Name(), state.QType(), state.QClass(), state.Do(), state.Req.CheckingDisabled)
positive := new(dns.Msg)
positive.SetReply(req)
positive.Answer = []dns.RR{test.A("example.org. 60 IN A 192.0.2.1")}
c.pcache.Add(k, newItem(positive, now.Add(-2*time.Minute), time.Minute))
negative := new(dns.Msg)
negative.SetRcode(req, dns.RcodeNameError)
negative.Ns = []dns.RR{test.SOA("example.org. 300 IN SOA ns.example.org. hostmaster.example.org. 1 7200 3600 1209600 300")}
c.ncache.Add(k, newItem(negative, now, 5*time.Minute))
if got := c.getIfNotStale(now, state, "test"); got == nil || got.Rcode != dns.RcodeNameError {
t.Fatalf("default policy should prefer ncache NXDOMAIN, got %+v", got)
}
c.preferPositive = true
if got := c.getIfNotStale(now, state, "test"); got == nil || got.Rcode != dns.RcodeSuccess {
t.Fatalf("prefer_positive should prefer eligible pcache answer, got %+v", got)
}
}
func TestPreferPositiveRejectsNonAnswer(t *testing.T) {
c := New()
c.staleUpTo = time.Hour
c.preferPositive = true
now := time.Now()
req := new(dns.Msg)
req.SetQuestion("alias.example.org.", dns.TypeA)
state := request.Request{W: &test.ResponseWriter{}, Req: req}
k := hash(state.Name(), state.QType(), state.QClass(), state.Do(), state.Req.CheckingDisabled)
incomplete := new(dns.Msg)
incomplete.SetReply(req)
incomplete.Answer = []dns.RR{test.CNAME("alias.example.org. 60 IN CNAME missing.example.org.")}
c.pcache.Add(k, newItem(incomplete, now, time.Minute))
negative := new(dns.Msg)
negative.SetRcode(req, dns.RcodeNameError)
negative.Ns = []dns.RR{test.SOA("example.org. 300 IN SOA ns.example.org. hostmaster.example.org. 1 7200 3600 1209600 300")}
c.ncache.Add(k, newItem(negative, now, 5*time.Minute))
if got := c.getIfNotStale(now, state, "test"); got == nil || got.Rcode != dns.RcodeNameError {
t.Fatalf("non-answer pcache item must not shadow ncache, got %+v", got)
}
}
func TestAnswersQuestionStrictEligibility(t *testing.T) {
chAddress, err := dns.NewRR("cached.org. 60 CH A 192.0.2.20")
if err != nil {
t.Fatal(err)
}
tests := []struct {
name string
qtype uint16
answer []dns.RR
want bool
}{
{
name: "direct A",
qtype: dns.TypeA,
answer: []dns.RR{test.A("cached.org. 60 IN A 192.0.2.10")},
want: true,
},
{
name: "ANY matching owner",
qtype: dns.TypeANY,
answer: []dns.RR{test.A("cached.org. 60 IN A 192.0.2.10")},
want: true,
},
{
name: "ANY unrelated owner",
qtype: dns.TypeANY,
answer: []dns.RR{test.A("unrelated.org. 60 IN A 192.0.2.10")},
want: false,
},
{
name: "duplicate equivalent CNAME targets",
qtype: dns.TypeCNAME,
answer: []dns.RR{
test.CNAME("cached.org. 60 IN CNAME target.org."),
test.CNAME("cached.org. 60 IN CNAME target.org."),
},
want: true,
},
{
name: "multiple CNAME targets",
qtype: dns.TypeCNAME,
answer: []dns.RR{
test.CNAME("cached.org. 60 IN CNAME first.org."),
test.CNAME("cached.org. 60 IN CNAME second.org."),
},
want: false,
},
{
name: "wrong RR class",
qtype: dns.TypeA,
answer: []dns.RR{chAddress},
want: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
req := new(dns.Msg)
req.SetQuestion("cached.org.", tc.qtype)
res := new(dns.Msg)
res.SetReply(req)
res.Answer = tc.answer
if got := answersQuestion(res); got != tc.want {
t.Fatalf("answersQuestion() = %t, want %t", got, tc.want)
}
})
}
}
func TestPreferPositiveRetainsLKGAcrossNonAnswerSuccessRefreshes(t *testing.T) {
c := New()
c.staleUpTo = time.Hour
c.preferPositive = true
now := time.Now()
c.now = func() time.Time { return now }
req := new(dns.Msg)
req.SetQuestion("cached.org.", dns.TypeA)
state := request.Request{W: &test.ResponseWriter{}, Req: req}
writer := &ResponseWriter{Cache: c, state: state, prefetch: true}
positive := new(dns.Msg)
positive.SetReply(req)
positive.Answer = []dns.RR{test.A("cached.org. 60 IN A 192.0.2.10")}
if err := writer.WriteMsg(positive); err != nil {
t.Fatal(err)
}
now = now.Add(2 * time.Minute)
refreshes := []*dns.Msg{
func() *dns.Msg {
m := new(dns.Msg)
m.SetReply(req)
return m
}(),
func() *dns.Msg {
m := new(dns.Msg)
m.SetReply(req)
m.Ns = []dns.RR{test.NS("example.org. 60 IN NS ns.example.org.")}
return m
}(),
func() *dns.Msg {
m := new(dns.Msg)
m.SetReply(req)
m.Ns = []dns.RR{test.NS("example.org. 60 IN NS ns.example.org.")}
m.Extra = []dns.RR{test.A("ns.example.org. 60 IN A 192.0.2.53")}
return m
}(),
func() *dns.Msg {
m := new(dns.Msg)
m.SetReply(req)
m.Extra = []dns.RR{test.A("cached.org. 60 IN A 192.0.2.54")}
return m
}(),
}
for i, refresh := range refreshes {
if err := writer.WriteMsg(refresh); err != nil {
t.Fatal(err)
}
got := c.getIfNotStale(now, state, "test")
if got == nil || !got.answersQuestion(state) {
t.Fatalf("refresh %d lost last-known-good answer: %+v", i, got)
}
if address := got.Answer[0].(*dns.A).A.String(); address != "192.0.2.10" {
t.Fatalf("refresh %d returned %s, want 192.0.2.10", i, address)
}
}
}
func TestPreferPositiveDoesNotServeLKGOutsideStaleWindow(t *testing.T) {
c := New()
c.staleUpTo = time.Hour
c.preferPositive = true
now := time.Now()
req := new(dns.Msg)
req.SetQuestion("cached.org.", dns.TypeA)
state := request.Request{W: &test.ResponseWriter{}, Req: req}
k := hash(state.Name(), state.QType(), state.QClass(), state.Do(), state.Req.CheckingDisabled)
positive := new(dns.Msg)
positive.SetReply(req)
positive.Answer = []dns.RR{test.A("cached.org. 60 IN A 192.0.2.10")}
lastKnownGood := newItem(positive, now.Add(-2*time.Hour), time.Minute)
empty := new(dns.Msg)
empty.SetReply(req)
current := newItem(empty, now, time.Minute)
current.lastKnownGood = lastKnownGood
c.pcache.Add(k, current)
negative := new(dns.Msg)
negative.SetRcode(req, dns.RcodeNameError)
negative.Ns = []dns.RR{test.SOA("example.org. 300 IN SOA ns.example.org. hostmaster.example.org. 1 7200 3600 1209600 300")}
c.ncache.Add(k, newItem(negative, now, 5*time.Minute))
if got := c.getIfNotStale(now, state, "test"); got == nil || got.Rcode != dns.RcodeNameError {
t.Fatalf("expected current NXDOMAIN after LKG stale window, got %+v", got)
}
}
func TestPreferPositiveVerifyKeepsStaleOnNonAnswers(t *testing.T) {
tests := []struct {
name string
backend plugin.Handler
}{
{name: "NXDOMAIN", backend: nxDomainBackend(300)},
{name: "NODATA", backend: plugin.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
m := new(dns.Msg)
m.SetReply(r)
m.Ns = []dns.RR{test.SOA("example.org. 300 IN SOA ns.example.org. hostmaster.example.org. 1 7200 3600 1209600 300")}
return dns.RcodeSuccess, w.WriteMsg(m)
})},
{name: "SERVFAIL", backend: servFailBackend(300)},
{name: "NOTIMP", backend: plugin.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
m := new(dns.Msg)
m.SetRcode(r, dns.RcodeNotImplemented)
return dns.RcodeNotImplemented, w.WriteMsg(m)
})},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := New()
c.staleUpTo = time.Hour
c.verifyStale = true
c.preferPositive = true
c.Next = ttlBackend(60)
req := new(dns.Msg)
req.SetQuestion("cached.org.", dns.TypeA)
ctx := context.Background()
c.ServeDNS(ctx, &test.ResponseWriter{}, req)
c.now = func() time.Time { return time.Now().Add(2 * time.Minute) }
c.Next = tc.backend
rec := dnstest.NewRecorder(&test.ResponseWriter{})
ret, err := c.ServeDNS(ctx, rec, req.Copy())
if err != nil {
t.Fatal(err)
}
if ret != dns.RcodeSuccess || rec.Msg == nil || rec.Msg.Rcode != dns.RcodeSuccess {
t.Fatalf("expected stale positive response, got ret=%d msg=%+v", ret, rec.Msg)
}
if got := rec.Msg.Answer[0].Header().Ttl; got != 0 {
t.Fatalf("expected stale TTL 0, got %d", got)
}
if c.ncache.Len() != 1 {
t.Fatalf("expected verified %s to be retained in ncache, got %d entries", tc.name, c.ncache.Len())
}
})
}
}
func TestPreferPositiveVerifyRejectsInvalidFreshAnswers(t *testing.T) {
modes := []struct {
name string
timeout time.Duration
}{
{name: "blocking"},
{name: "bounded", timeout: time.Second},
}
invalidResponses := []struct {
name string
build func(*dns.Msg) *dns.Msg
}{
{
name: "expired RRSIG",
build: func(req *dns.Msg) *dns.Msg {
m := new(dns.Msg)
m.SetReply(req)
m.SetEdns0(4096, true)
m.Answer = []dns.RR{
test.A("cached.org. 60 IN A 192.0.2.20"),
test.RRSIG("cached.org. 60 IN RRSIG A 8 2 60 20160521031301 20160421031301 12051 cached.org. lAaEzB5teQLLKyDenatmyhca7blLRg9DoGNrhe3NReBZN5C5/pMQk8Jc u25hv2fW23/SLm5IC2zaDpp2Fzgm6Jf7e90/yLcwQPuE7JjS55WMF+HE LEh7Z6AEb+Iq4BWmNhUz6gPxD4d9eRMs7EAzk13o1NYi5/JhfL6IlaYy qkc="),
}
return m
},
},
{
name: "truncated",
build: func(req *dns.Msg) *dns.Msg {
m := new(dns.Msg)
m.SetReply(req)
m.Truncated = true
m.Answer = []dns.RR{test.A("cached.org. 60 IN A 192.0.2.20")}
return m
},
},
}
for _, mode := range modes {
for _, invalid := range invalidResponses {
t.Run(mode.name+"/"+invalid.name, func(t *testing.T) {
c := New()
c.staleUpTo = time.Hour
c.verifyStale = true
c.verifyStaleTimeout = mode.timeout
c.preferPositive = true
now := time.Now().UTC()
c.now = func() time.Time { return now }
c.Next = plugin.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
m := new(dns.Msg)
m.SetReply(r)
m.Answer = []dns.RR{test.A("cached.org. 60 IN A 192.0.2.10")}
return dns.RcodeSuccess, w.WriteMsg(m)
})
req := new(dns.Msg)
req.SetQuestion("cached.org.", dns.TypeA)
req.SetEdns0(4096, true)
if _, err := c.ServeDNS(context.Background(), &test.ResponseWriter{}, req.Copy()); err != nil {
t.Fatal(err)
}
now = now.Add(2 * time.Minute)
c.Next = plugin.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
return dns.RcodeSuccess, w.WriteMsg(invalid.build(r))
})
rec := dnstest.NewRecorder(&test.ResponseWriter{})
ret, err := c.ServeDNS(context.Background(), rec, req.Copy())
if err != nil {
t.Fatal(err)
}
if ret != dns.RcodeSuccess || rec.Msg == nil || rec.Msg.Rcode != dns.RcodeSuccess {
t.Fatalf("expected stale positive response, got ret=%d msg=%+v", ret, rec.Msg)
}
if len(rec.Msg.Answer) == 0 {
t.Fatal("expected retained stale answer")
}
a, ok := rec.Msg.Answer[0].(*dns.A)
if !ok || a.A.String() != "192.0.2.10" {
t.Fatalf("expected retained 192.0.2.10, got %v", rec.Msg.Answer)
}
if got := a.Hdr.Ttl; got != 0 {
t.Fatalf("expected stale TTL 0, got %d", got)
}
})
}
}
}
func TestServeFromStaleCacheFetchVerifyTimeoutUncacheableResponse(t *testing.T) {
c := New()
c.staleUpTo = time.Hour
c.verifyStale = true
c.verifyStaleTimeout = time.Second
c.Next = ttlBackend(60)
req := new(dns.Msg)
req.SetQuestion("cached.org.", dns.TypeA)
c.ServeDNS(context.Background(), &test.ResponseWriter{}, req)
c.now = func() time.Time { return time.Now().Add(2 * time.Minute) }
c.Next = plugin.HandlerFunc(func(_ context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
m := new(dns.Msg)
m.SetRcode(r, dns.RcodeNameError)
return dns.RcodeNameError, w.WriteMsg(m)
})
rec := dnstest.NewRecorder(&test.ResponseWriter{})
ret, err := c.ServeDNS(context.Background(), rec, req.Copy())
if err != nil {
t.Fatal(err)
}
if ret != dns.RcodeSuccess || rec.Msg == nil || rec.Msg.Rcode != dns.RcodeNameError {
t.Fatalf("expected direct uncacheable NXDOMAIN, got ret=%d msg=%+v", ret, rec.Msg)
}
for _, section := range [][]dns.RR{rec.Msg.Answer, rec.Msg.Ns, rec.Msg.Extra} {
for _, rr := range section {
if rr.Header().Ttl > uint32(maxTTL.Seconds()) {
t.Fatalf("unexpected wrapped TTL %d", rr.Header().Ttl)
}
}
}
}
func TestCNAMEWithSOAStoredAsNODATA(t *testing.T) {
c := New()
req := new(dns.Msg)
req.SetQuestion("alias.example.org.", dns.TypeA)
crr := &ResponseWriter{
Cache: c,
state: request.Request{Req: req},
prefetch: true,
}
res := new(dns.Msg)
res.SetReply(req)
res.Answer = []dns.RR{test.CNAME("alias.example.org. 300 IN CNAME missing.example.net.")}
res.Ns = []dns.RR{test.SOA("example.org. 300 IN SOA ns.example.org. hostmaster.example.org. 1 7200 3600 1209600 300")}
if err := crr.WriteMsg(res); err != nil {
t.Fatal(err)
}
if c.ncache.Len() != 1 {
t.Fatalf("expected NODATA in ncache, got %d entries", c.ncache.Len())
}
if c.pcache.Len() != 0 {
t.Fatalf("expected no positive cache entry, got %d", c.pcache.Len())
}
}
func TestServeFromStaleCacheFetchVerifyTimeoutMetadataIsolation(t *testing.T) {
c := New()
c.staleUpTo = time.Hour