From 3c2c33bb50d9d0a1e59ba7a416cf05b4e2b91dae Mon Sep 17 00:00:00 2001 From: houyuwushang Date: Thu, 30 Jul 2026 09:12:07 +0800 Subject: [PATCH] plugin/file: return referrals after alias resolution (#8341) Detect zone cuts while following CNAME and DNAME targets so partial answers carry the child NS and glue records instead of returning SERVFAIL or apex authority. Keep AA set for authoritative alias partial answers while clearing it for referral-only responses. Fixes #6405 Fixes #6627 Signed-off-by: houyuwushang --- plugin/file/alias_delegation_test.go | 272 +++++++++++++++++++++++++++ plugin/file/file.go | 6 +- plugin/file/lookup.go | 74 +++++--- 3 files changed, 329 insertions(+), 23 deletions(-) create mode 100644 plugin/file/alias_delegation_test.go diff --git a/plugin/file/alias_delegation_test.go b/plugin/file/alias_delegation_test.go new file mode 100644 index 000000000..8211b6c7a --- /dev/null +++ b/plugin/file/alias_delegation_test.go @@ -0,0 +1,272 @@ +package file + +import ( + "context" + "strings" + "testing" + + "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/test" + + "github.com/miekg/dns" +) + +func TestAliasToDelegation(t *testing.T) { + zone, err := Parse(strings.NewReader(dbAliasDelegation), "example.org.", "stdin", 0) + if err != nil { + t.Fatalf("Expected no error when reading zone, got %q", err) + } + + fm := File{ + Next: test.ErrorHandler(), + Zones: Zones{ + Z: map[string]*Zone{"example.org.": zone}, + Names: []string{"example.org."}, + }, + } + + tests := []struct { + name string + qname string + qtype uint16 + authoritative bool + answer []dns.RR + authority []dns.RR + additional []dns.RR + }{ + { + name: "CNAME to delegated child", + qname: "alias.example.org.", + qtype: dns.TypeA, + authoritative: true, + answer: []dns.RR{ + test.CNAME("alias.example.org. 300 IN CNAME host.child.example.org."), + }, + authority: []dns.RR{ + test.NS("child.example.org. 300 IN NS ns.child.example.org."), + }, + additional: []dns.RR{ + test.A("ns.child.example.org. 300 IN A 192.0.2.2"), + test.AAAA("ns.child.example.org. 300 IN AAAA 2001:db8::2"), + }, + }, + { + name: "CNAME chain to delegated child", + qname: "chain.example.org.", + qtype: dns.TypeA, + authoritative: true, + answer: []dns.RR{ + test.CNAME("alias.example.org. 300 IN CNAME host.child.example.org."), + test.CNAME("chain.example.org. 300 IN CNAME alias.example.org."), + }, + authority: []dns.RR{ + test.NS("child.example.org. 300 IN NS ns.child.example.org."), + }, + additional: []dns.RR{ + test.A("ns.child.example.org. 300 IN A 192.0.2.2"), + test.AAAA("ns.child.example.org. 300 IN AAAA 2001:db8::2"), + }, + }, + { + name: "wildcard CNAME to delegated child", + qname: "name.wild.example.org.", + qtype: dns.TypeA, + authoritative: true, + answer: []dns.RR{ + test.CNAME("name.wild.example.org. 300 IN CNAME host.child.example.org."), + }, + authority: []dns.RR{ + test.NS("child.example.org. 300 IN NS ns.child.example.org."), + }, + additional: []dns.RR{ + test.A("ns.child.example.org. 300 IN A 192.0.2.2"), + test.AAAA("ns.child.example.org. 300 IN AAAA 2001:db8::2"), + }, + }, + { + name: "DNAME to delegated child", + qname: "host.mapped.example.org.", + qtype: dns.TypeA, + authoritative: true, + answer: []dns.RR{ + test.CNAME("host.mapped.example.org. 300 IN CNAME host.example.org."), + test.DNAME("mapped.example.org. 300 IN DNAME example.org."), + }, + authority: []dns.RR{ + test.NS("host.example.org. 300 IN NS ns.host.example.org."), + }, + additional: []dns.RR{ + test.A("ns.host.example.org. 300 IN A 192.0.2.3"), + test.AAAA("ns.host.example.org. 300 IN AAAA 2001:db8::3"), + }, + }, + { + name: "DNAME NS query to delegated child", + qname: "host.mapped.example.org.", + qtype: dns.TypeNS, + authoritative: true, + answer: []dns.RR{ + test.CNAME("host.mapped.example.org. 300 IN CNAME host.example.org."), + test.DNAME("mapped.example.org. 300 IN DNAME example.org."), + }, + authority: []dns.RR{ + test.NS("host.example.org. 300 IN NS ns.host.example.org."), + }, + additional: []dns.RR{ + test.A("ns.host.example.org. 300 IN A 192.0.2.3"), + test.AAAA("ns.host.example.org. 300 IN AAAA 2001:db8::3"), + }, + }, + { + name: "direct delegated child", + qname: "host.child.example.org.", + qtype: dns.TypeA, + authoritative: false, + authority: []dns.RR{ + test.NS("child.example.org. 300 IN NS ns.child.example.org."), + }, + additional: []dns.RR{ + test.A("ns.child.example.org. 300 IN A 192.0.2.2"), + test.AAAA("ns.child.example.org. 300 IN AAAA 2001:db8::2"), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := new(dns.Msg) + req.SetQuestion(tc.qname, tc.qtype) + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + + if _, err := fm.ServeDNS(context.Background(), rec, req); err != nil { + t.Fatalf("ServeDNS() error = %v", err) + } + + want := test.Case{ + Qname: tc.qname, + Qtype: tc.qtype, + Answer: tc.answer, + Ns: tc.authority, + Extra: tc.additional, + } + if err := test.SortAndCheck(rec.Msg, want); err != nil { + t.Error(err) + } + if rec.Msg.Authoritative != tc.authoritative { + t.Errorf("Authoritative = %t, want %t", rec.Msg.Authoritative, tc.authoritative) + } + }) + } +} + +const dbAliasDelegation = ` +$TTL 300 +$ORIGIN example.org. +@ IN SOA ns.example.org. admin.example.org. 1 3600 600 86400 300 + IN NS ns.example.org. +ns IN A 192.0.2.1 + +alias IN CNAME host.child.example.org. +chain IN CNAME alias.example.org. +*.wild IN CNAME host.child.example.org. +child IN NS ns.child.example.org. +ns.child IN A 192.0.2.2 + IN AAAA 2001:db8::2 + +mapped IN DNAME example.org. +host IN NS ns.host.example.org. +ns.host IN A 192.0.2.3 + IN AAAA 2001:db8::3 +` + +func TestSignedAliasToDelegation(t *testing.T) { + zone, err := Parse(strings.NewReader(exampleOrgSigned), "example.org.", "stdin", 0) + if err != nil { + t.Fatalf("Expected no error when reading zone, got %q", err) + } + if err := zone.Insert(test.CNAME("alias.example.org. 1800 IN CNAME a.delegated.example.org.")); err != nil { + t.Fatalf("Insert() error = %v", err) + } + if err := zone.Insert(test.CNAME("ds-alias.example.org. 1800 IN CNAME delegated.example.org.")); err != nil { + t.Fatalf("Insert() error = %v", err) + } + + fm := File{ + Next: test.ErrorHandler(), + Zones: Zones{ + Z: map[string]*Zone{"example.org.": zone}, + Names: []string{"example.org."}, + }, + } + + referral := secureDelegationCase(t, "a.delegated.example.org.", dns.TypeTXT) + dsAtCut := secureDelegationCase(t, "delegated.example.org.", dns.TypeDS) + tests := []struct { + name string + qname string + qtype uint16 + answer []dns.RR + authority []dns.RR + additional []dns.RR + }{ + { + name: "signed partial referral", + qname: "alias.example.org.", + qtype: dns.TypeA, + answer: []dns.RR{ + test.CNAME("alias.example.org. 1800 IN CNAME a.delegated.example.org."), + }, + authority: referral.Ns, + additional: referral.Extra, + }, + { + name: "DS at zone cut remains authoritative", + qname: "ds-alias.example.org.", + qtype: dns.TypeDS, + answer: append( + append([]dns.RR{}, dsAtCut.Answer...), + test.CNAME("ds-alias.example.org. 1800 IN CNAME delegated.example.org."), + ), + authority: dsAtCut.Ns, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + req := new(dns.Msg) + req.SetQuestion(tc.qname, tc.qtype) + req.SetEdns0(4096, true) + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + + if _, err := fm.ServeDNS(context.Background(), rec, req); err != nil { + t.Fatalf("ServeDNS() error = %v", err) + } + + want := test.Case{ + Qname: tc.qname, + Qtype: tc.qtype, + Do: true, + Answer: tc.answer, + Ns: tc.authority, + Extra: tc.additional, + } + if err := test.SortAndCheck(rec.Msg, want); err != nil { + t.Error(err) + } + if !rec.Msg.Authoritative { + t.Error("Authoritative = false, want true") + } + }) + } +} + +func secureDelegationCase(t *testing.T, qname string, qtype uint16) test.Case { + t.Helper() + for _, tc := range secureDelegationTestCases { + if tc.Qname == qname && tc.Qtype == qtype { + return tc + } + } + t.Fatalf("secure delegation fixture has no %s/%s case", qname, dns.TypeToString[qtype]) + return test.Case{} +} diff --git a/plugin/file/file.go b/plugin/file/file.go index 41e615131..aa3827968 100644 --- a/plugin/file/file.go +++ b/plugin/file/file.go @@ -117,7 +117,11 @@ func (f File) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (i case NameError: m.Rcode = dns.RcodeNameError case Delegation: - m.Authoritative = false + // A referral-only response is not authoritative. A partial answer + // containing an authoritative alias keeps AA set for the original QNAME. + if len(m.Answer) == 0 { + m.Authoritative = false + } case ServerFailure: // If the result is SERVFAIL and the answer is non-empty, then the SERVFAIL came from an // external CNAME lookup and the answer contains the CNAME with no target record. We should diff --git a/plugin/file/lookup.go b/plugin/file/lookup.go index 6bbc310b3..6dc2f9b04 100644 --- a/plugin/file/lookup.go +++ b/plugin/file/lookup.go @@ -126,7 +126,7 @@ func (z *Zone) Lookup(ctx context.Context, state request.Request, qname string) rcode = Success } else { ctx = context.WithValue(ctx, dnsserver.LoopKey{}, loop+1) - answer, ns, extra, rcode = z.externalLookup(ctx, state, elem, []dns.RR{cname}) + answer, ns, extra, rcode = z.externalLookup(ctx, state, tr, elem, []dns.RR{cname}) } if do { @@ -146,22 +146,8 @@ func (z *Zone) Lookup(ctx context.Context, state request.Request, qname string) // with (e.g. another DNAME). So there is nothing special left here. } - // If we see NS records, it means the name as been delegated, and we should return the delegation. - if nsrrs := elem.Type(dns.TypeNS); nsrrs != nil { - // If the query is specifically for DS and the qname matches the delegated name, we should - // return the DS in the answer section and leave the rest empty, i.e. just continue the loop - // and continue searching. - if qtype == dns.TypeDS && elem.Name() == qname { - i++ - continue - } - - glue := tr.Glue(nsrrs, do) - if do { - dss := typeFromElem(elem, dns.TypeDS, do) - nsrrs = append(nsrrs, dss...) - } - + // If we see NS records, it means the name has been delegated. + if nsrrs, glue, ok := delegationFromElem(tr, elem, qname, qtype, do); ok { return nil, nsrrs, glue, Delegation } @@ -172,7 +158,7 @@ func (z *Zone) Lookup(ctx context.Context, state request.Request, qname string) if found && shot { if rrs := elem.Type(dns.TypeCNAME); len(rrs) > 0 && qtype != dns.TypeCNAME { ctx = context.WithValue(ctx, dnsserver.LoopKey{}, loop+1) - return z.externalLookup(ctx, state, elem, rrs) + return z.externalLookup(ctx, state, tr, elem, rrs) } rrs := elem.Type(qtype) @@ -211,7 +197,7 @@ func (z *Zone) Lookup(ctx context.Context, state request.Request, qname string) if rrs := wildElem.TypeForWildcard(dns.TypeCNAME, qname); len(rrs) > 0 && qtype != dns.TypeCNAME { ctx = context.WithValue(ctx, dnsserver.LoopKey{}, loop+1) - return z.externalLookup(ctx, state, wildElem, rrs) + return z.externalLookup(ctx, state, tr, wildElem, rrs) } rrs := wildElem.TypeForWildcard(qtype, qname) @@ -353,7 +339,7 @@ func (z *Zone) authority(do bool, result Result) []dns.RR { } // externalLookup adds signatures and tries to resolve CNAMEs that point to external names. -func (z *Zone) externalLookup(ctx context.Context, state request.Request, elem *tree.Elem, rrs []dns.RR) ([]dns.RR, []dns.RR, []dns.RR, Result) { +func (z *Zone) externalLookup(ctx context.Context, state request.Request, tr *tree.Tree, elem *tree.Elem, rrs []dns.RR) ([]dns.RR, []dns.RR, []dns.RR, Result) { qtype := state.QType() do := state.Do() @@ -364,7 +350,10 @@ func (z *Zone) externalLookup(ctx context.Context, state request.Request, elem * } targetName := rrs[0].(*dns.CNAME).Target - elem, _ = z.Search(targetName) + elem, _ = tr.Search(targetName) + if ns, extra, ok := z.findDelegation(tr, targetName, qtype, do, elem); ok { + return rrs, ns, extra, Delegation + } if elem == nil || (qtype == dns.TypeNS || qtype == dns.TypeSOA && targetName == z.origin) { lookupRRs, result := z.doLookup(ctx, state, targetName, qtype) rrs = append(rrs, lookupRRs...) @@ -384,7 +373,10 @@ Redo: rrs = append(rrs, sigs...) } targetName := cname[0].(*dns.CNAME).Target - elem, _ = z.Search(targetName) + elem, _ = tr.Search(targetName) + if ns, extra, ok := z.findDelegation(tr, targetName, qtype, do, elem); ok { + return rrs, ns, extra, Delegation + } if elem == nil || (qtype == dns.TypeNS || qtype == dns.TypeSOA && targetName == z.origin) { lookupRRs, result := z.doLookup(ctx, state, targetName, qtype) rrs = append(rrs, lookupRRs...) @@ -413,6 +405,44 @@ Redo: return rrs, z.ns(do), nil, Success } +// findDelegation returns the first zone cut between the zone apex and qname. +func (z *Zone) findDelegation(tr *tree.Tree, qname string, qtype uint16, do bool, exact *tree.Elem) (ns, extra []dns.RR, ok bool) { + for i := 1; ; i++ { + name, shot := z.nameFromRight(qname, i) + if shot { + return nil, nil, false + } + if name == qname { + if exact == nil { + return nil, nil, false + } + return delegationFromElem(tr, exact, qname, qtype, do) + } + elem, found := tr.Search(name) + if !found { + continue + } + if ns, extra, ok := delegationFromElem(tr, elem, qname, qtype, do); ok { + return ns, extra, true + } + } +} + +// delegationFromElem builds a referral from a zone-cut element. A DS query at +// the cut itself is answered by the parent zone instead of returning a referral. +func delegationFromElem(tr *tree.Tree, elem *tree.Elem, qname string, qtype uint16, do bool) (ns, extra []dns.RR, ok bool) { + ns = elem.Type(dns.TypeNS) + if ns == nil || (qtype == dns.TypeDS && elem.Name() == qname) { + return nil, nil, false + } + + extra = tr.Glue(ns, do) + if do { + ns = append(ns, typeFromElem(elem, dns.TypeDS, do)...) + } + return ns, extra, true +} + func (z *Zone) doLookup(ctx context.Context, state request.Request, target string, qtype uint16) ([]dns.RR, Result) { m, e := z.Upstream.Lookup(ctx, state, target, qtype) if e != nil {