From b8720090b52e5d4918eed088494e6fabca22a8e0 Mon Sep 17 00:00:00 2001 From: houyuwushang Date: Mon, 24 Aug 2026 08:56:12 +0800 Subject: [PATCH] plugin/etcd: allow disabling legacy apex fallback (#8468) Signed-off-by: houyuwushang --- plugin/backend.go | 5 ++++- plugin/backend_lookup.go | 2 +- plugin/backend_lookup_test.go | 31 ++++++++++++++++++++++++++ plugin/etcd/README.md | 14 ++++++++---- plugin/etcd/etcd.go | 2 ++ plugin/etcd/handler.go | 2 +- plugin/etcd/lookup_test.go | 42 +++++++++++++++++++++++++++++++++++ plugin/etcd/setup.go | 5 +++++ plugin/etcd/setup_test.go | 32 ++++++++++++++++++++++++++ 9 files changed, 128 insertions(+), 7 deletions(-) diff --git a/plugin/backend.go b/plugin/backend.go index a0217c961..977429f61 100644 --- a/plugin/backend.go +++ b/plugin/backend.go @@ -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 +} diff --git a/plugin/backend_lookup.go b/plugin/backend_lookup.go index 5ed35958d..c1ad9c952 100644 --- a/plugin/backend_lookup.go +++ b/plugin/backend_lookup.go @@ -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 } diff --git a/plugin/backend_lookup_test.go b/plugin/backend_lookup_test.go index a2bb3f4de..a21ad540a 100644 --- a/plugin/backend_lookup_test.go +++ b/plugin/backend_lookup_test.go @@ -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 diff --git a/plugin/etcd/README.md b/plugin/etcd/README.md index 1404257f9..1f5b807e1 100644 --- a/plugin/etcd/README.md +++ b/plugin/etcd/README.md @@ -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 diff --git a/plugin/etcd/etcd.go b/plugin/etcd/etcd.go index 09e295d5e..640cc3858 100644 --- a/plugin/etcd/etcd.go +++ b/plugin/etcd/etcd.go @@ -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. } diff --git a/plugin/etcd/handler.go b/plugin/etcd/handler.go index 72222dd2a..3b8078e04 100644 --- a/plugin/etcd/handler.go +++ b/plugin/etcd/handler.go @@ -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()) diff --git a/plugin/etcd/lookup_test.go b/plugin/etcd/lookup_test.go index 0b689b083..7dec23110 100644 --- a/plugin/etcd/lookup_test.go +++ b/plugin/etcd/lookup_test.go @@ -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 diff --git a/plugin/etcd/setup.go b/plugin/etcd/setup.go index 2ddbf4597..7bd13c98d 100644 --- a/plugin/etcd/setup.go +++ b/plugin/etcd/setup.go @@ -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() diff --git a/plugin/etcd/setup_test.go b/plugin/etcd/setup_test.go index c88dd1044..b301651e8 100644 --- a/plugin/etcd/setup_test.go +++ b/plugin/etcd/setup_test.go @@ -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