plugin/etcd: allow disabling legacy apex fallback (#8468)

Signed-off-by: houyuwushang <liuluoqianqiu@outlook.com>
This commit is contained in:
houyuwushang
2026-08-24 08:56:12 +08:00
committed by GitHub
parent 3b9f85bb71
commit b8720090b5
9 changed files with 128 additions and 7 deletions

View File

@@ -37,4 +37,7 @@ type ServiceBackend interface {
}
// Options are extra options that can be specified for a lookup.
type Options struct{}
type Options struct {
// NoApexFallback disables the legacy zone-root lookup when apex records are absent.
NoApexFallback bool
}

View File

@@ -524,7 +524,7 @@ func checkForApex(ctx context.Context, b ServiceBackend, zone string, state requ
services, err := b.Services(ctx, state, false, opt)
state.Req.Question[0].Name = old
if err == nil || !b.IsNameError(err) {
if err == nil || !b.IsNameError(err) || opt.NoApexFallback {
return services, err
}

View File

@@ -604,6 +604,37 @@ func TestCheckForApexFallback(t *testing.T) {
}
}
func TestCheckForApexFallbackDisabled(t *testing.T) {
errName := errors.New("name not found")
calls := 0
b := &mockBackend{
mockServices: func(_ctx context.Context, state request.Request, _exact bool, _opt Options) ([]msg.Service, error) {
calls++
if state.QName() != "apex.dns.example.org." {
t.Fatalf("unexpected fallback lookup for %s", state.QName())
}
return nil, errName
},
mockNameError: func(err error) bool { return errors.Is(err, errName) },
}
req := new(dns.Msg)
req.SetQuestion("example.org.", dns.TypeA)
state := request.Request{Req: req, W: &test.ResponseWriter{}}
services, err := checkForApex(context.Background(), b, "example.org.", state, Options{NoApexFallback: true})
if !errors.Is(err, errName) {
t.Fatalf("expected name error, got %v", err)
}
if len(services) != 0 {
t.Fatalf("expected no services, got %+v", services)
}
if calls != 1 {
t.Fatalf("expected one apex lookup, got %d calls", calls)
}
if state.QName() != "example.org." {
t.Fatalf("query name was not restored: %s", state.QName())
}
}
func TestCheckForApexBackendError(t *testing.T) {
errBackend := context.DeadlineExceeded
calls := 0

View File

@@ -37,6 +37,7 @@ etcd [ZONES...] {
endpoint ENDPOINT...
credentials USERNAME PASSWORD
tls CERT KEY CACERT
no_apex_fallback
}
~~~
@@ -55,6 +56,10 @@ etcd [ZONES...] {
* three arguments - path to cert PEM file, path to client private key PEM file, path to CA PEM
file - if the server certificate is not signed by a system-installed CA and client certificate
is needed.
* `no_apex_fallback` disables the legacy zone-root lookup used by address queries and apex-existence
checks at a configured zone apex. When `apex.dns.ZONE` does not exist, the lookup returns a name
error instead of issuing a prefix range over the complete zone subtree. Migrate zone-apex address
records to the `dns/apex` layout before enabling this option.
CoreDNS sets the minimum TLS version to TLS 1.2. The maximum TLS version, TLS 1.2 cipher suites, and
key exchange mechanisms use the Go `crypto/tls` defaults.
@@ -93,10 +98,11 @@ zone's root prefix for compatibility with older SkyDNS layouts. On a large zone,
the entire zone subtree.
Store zone-apex address records below the `dns/apex` path, as shown in the examples below, to avoid
the zone-wide fallback. The *cache* plugin reduces repeated backend reads for the same DNS question,
but does not reduce the size of the first prefix response. Use the *log* plugin to identify the client
and question name that trigger a lookup, and `coredns_dns_requests_total` from the *prometheus* plugin
to measure query volume by zone and type.
the zone-wide fallback. Once all apex records use that layout, enable `no_apex_fallback` to prevent a
missing or deleted apex entry from triggering a zone-root scan. The *cache* plugin reduces repeated
backend reads for the same DNS question, but does not reduce the size of the first prefix response.
Use the *log* plugin to identify the client and question name that trigger a lookup, and
`coredns_dns_requests_total` from the *prometheus* plugin to measure query volume by zone and type.
## Examples

View File

@@ -40,6 +40,8 @@ type Etcd struct {
Client *etcdcv3.Client
MinLeaseTTL uint32 // minimum TTL for lease-based records
MaxLeaseTTL uint32 // maximum TTL for lease-based records
// NoApexFallback disables the legacy zone-root lookup when apex.dns records are absent.
NoApexFallback bool
endpoints []string // Stored here as well, to aid in testing.
}

View File

@@ -11,7 +11,7 @@ import (
// ServeDNS implements the plugin.Handler interface.
func (e *Etcd) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (int, error) {
opt := plugin.Options{}
opt := plugin.Options{NoApexFallback: e.NoApexFallback}
state := request.Request{W: w, Req: r}
zone := plugin.Zones(e.Zones).Matches(state.Name())

View File

@@ -352,4 +352,46 @@ func TestLookup(t *testing.T) {
}
}
func TestLookupNoApexFallback(t *testing.T) {
etc := newEtcdPlugin()
etc.Zones = []string{"strict.test."}
child := &msg.Service{Host: "10.0.0.1", Key: "x.child.strict.test."}
set(t, etc, child.Key, 0, child)
defer delete(t, etc, child.Key)
query := func() *dns.Msg {
m := new(dns.Msg)
m.SetQuestion("strict.test.", dns.TypeA)
rec := dnstest.NewRecorder(&test.ResponseWriter{})
if _, err := etc.ServeDNS(ctxt, rec, m); err != nil {
t.Fatalf("unexpected lookup error: %v", err)
}
return rec.Msg
}
legacy := query()
if legacy.Rcode != dns.RcodeSuccess || len(legacy.Answer) != 1 {
t.Fatalf("legacy fallback returned rcode %s with %d answers", dns.RcodeToString[legacy.Rcode], len(legacy.Answer))
}
etc.NoApexFallback = true
strict := query()
if strict.Rcode != dns.RcodeNameError || len(strict.Answer) != 0 {
t.Fatalf("disabled fallback returned rcode %s with %d answers", dns.RcodeToString[strict.Rcode], len(strict.Answer))
}
apex := &msg.Service{Host: "192.0.2.1", Key: "x.apex.dns.strict.test."}
set(t, etc, apex.Key, 0, apex)
defer delete(t, etc, apex.Key)
strictWithApex := query()
if strictWithApex.Rcode != dns.RcodeSuccess || len(strictWithApex.Answer) != 1 {
t.Fatalf("dedicated apex lookup returned rcode %s with %d answers", dns.RcodeToString[strictWithApex.Rcode], len(strictWithApex.Answer))
}
if got := strictWithApex.Answer[0].String(); got != "strict.test.\t300\tIN\tA\t192.0.2.1" {
t.Fatalf("unexpected apex answer: %s", got)
}
}
var ctxt context.Context

View File

@@ -62,6 +62,11 @@ func etcdParse(c *caddy.Controller) (*Etcd, error) {
etc.Fall.SetZonesFromArgs(c.RemainingArgs())
case "debug":
/* it is a noop now */
case "no_apex_fallback":
if len(c.RemainingArgs()) != 0 {
return &Etcd{}, c.ArgErr()
}
etc.NoApexFallback = true
case "path":
if !c.NextArg() {
return &Etcd{}, c.ArgErr()

View File

@@ -162,6 +162,38 @@ func TestSetupEtcd(t *testing.T) {
}
}
func TestSetupNoApexFallback(t *testing.T) {
tests := []struct {
name string
input string
expected bool
wantErr bool
}{
{name: "default", input: `etcd`, expected: false},
{name: "disabled", input: "etcd {\nno_apex_fallback\n}", expected: true},
{name: "reject arguments", input: "etcd {\nno_apex_fallback unexpected\n}", wantErr: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := caddy.NewTestController("dns", tc.input)
etcd, err := etcdParse(c)
if tc.wantErr {
if err == nil {
t.Fatal("expected an error")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if etcd.NoApexFallback != tc.expected {
t.Fatalf("NoApexFallback = %t, want %t", etcd.NoApexFallback, tc.expected)
}
})
}
}
func TestParseTTL(t *testing.T) {
tests := []struct {
input string