From b3acbe5046581e07fad45dd252b92ed9416aaf4e Mon Sep 17 00:00:00 2001 From: vdbe <44153531+vdbe@users.noreply.github.com> Date: Thu, 29 May 2025 02:34:16 +0200 Subject: [PATCH] feat(plugin/file): fallthrough (#7327) * feat(plugin/file): fallthrough implement and test fallthrough for the file plugin Signed-off-by: vdbe * docs(plugin/file): fallthrough Signed-off-by: vdbe * docs(plugin/file): regenerate man page `make -f Makefile.doc man/coredns-file.7` Signed-off-by: vdbe --------- Signed-off-by: vdbe --- man/coredns-file.7 | 8 +++- plugin/file/README.md | 5 +++ plugin/file/file.go | 10 +++++ plugin/file/lookup_test.go | 80 ++++++++++++++++++++++++++++++++++++++ plugin/file/setup.go | 24 +++++++----- plugin/file/setup_test.go | 38 +++++++++++++++--- 6 files changed, 149 insertions(+), 16 deletions(-) diff --git a/man/coredns-file.7 b/man/coredns-file.7 index 9ba8a7e64..972ac3d47 100644 --- a/man/coredns-file.7 +++ b/man/coredns-file.7 @@ -1,5 +1,5 @@ .\" Generated by Mmark Markdown Processer - mmark.miek.nl -.TH "COREDNS-FILE" 7 "March 2021" "CoreDNS" "CoreDNS Plugins" +.TH "COREDNS-FILE" 7 "May 2025" "CoreDNS" "CoreDNS Plugins" .SH "NAME" .PP @@ -39,6 +39,7 @@ If you want to round-robin A and AAAA responses look at the \fIloadbalance\fP pl .nf file DBFILE [ZONES... ] { reload DURATION + fallthrough [ZONES...] } .fi @@ -48,6 +49,11 @@ file DBFILE [ZONES... ] { \fB\fCreload\fR interval to perform a reload of the zone if the SOA version changes. Default is one minute. Value of \fB\fC0\fR means to not scan for changes and reload. For example, \fB\fC30s\fR checks the zonefile every 30 seconds and reloads the zone when serial changes. +.IP \(bu 4 +\fB\fCfallthrough\fR If zone matches and no record can be generated, pass request to the next plugin. +If \fB[ZONES...]\fP is omitted, then fallthrough happens for all zones for which the plugin +is authoritative. If specific zones are listed (for example \fB\fCin-addr.arpa\fR and \fB\fCip6.arpa\fR), then only +queries for those zones will be subject to fallthrough. .PP diff --git a/plugin/file/README.md b/plugin/file/README.md index d1bd42531..ce49827d2 100644 --- a/plugin/file/README.md +++ b/plugin/file/README.md @@ -27,12 +27,17 @@ If you want to round-robin A and AAAA responses look at the *loadbalance* plugin ~~~ file DBFILE [ZONES... ] { reload DURATION + fallthrough [ZONES...] } ~~~ * `reload` interval to perform a reload of the zone if the SOA version changes. Default is one minute. Value of `0` means to not scan for changes and reload. For example, `30s` checks the zonefile every 30 seconds and reloads the zone when serial changes. +* `fallthrough` If zone matches and no record can be generated, pass request to the next plugin. + If **[ZONES...]** is omitted, then fallthrough happens for all zones for which the plugin + is authoritative. If specific zones are listed (for example `in-addr.arpa` and `ip6.arpa`), then only + queries for those zones will be subject to fallthrough. If you need outgoing zone transfers, take a look at the *transfer* plugin. diff --git a/plugin/file/file.go b/plugin/file/file.go index 323aa69ae..8ff893ba8 100644 --- a/plugin/file/file.go +++ b/plugin/file/file.go @@ -7,6 +7,7 @@ import ( "io" "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/fall" clog "github.com/coredns/coredns/plugin/pkg/log" "github.com/coredns/coredns/plugin/transfer" "github.com/coredns/coredns/request" @@ -22,6 +23,8 @@ type ( Next plugin.Handler Zones transfer *transfer.Transfer + + Fall fall.F } // Zones maps zone names to a *Zone. @@ -86,6 +89,13 @@ func (f File) ServeDNS(ctx context.Context, w dns.ResponseWriter, r *dns.Msg) (i answer, ns, extra, result := z.Lookup(ctx, state, qname) + // Only on NXDOMAIN we will fallthrough. + // `z.Lookup` can also return NOERROR for NXDOMAIN see comment see comment "Hacky way to get around empty-non-terminals" inside `Zone.Lookup`. + // It's safe to fallthrough with `result` Sucess (NOERROR) since all other return points in Lookup with Success have answer(s). + if len(answer) == 0 && (result == NameError || result == Success) && f.Fall.Through(qname) { + return plugin.NextOrFailure(f.Name(), f.Next, ctx, w, r) + } + m := new(dns.Msg) m.SetReply(r) m.Authoritative = true diff --git a/plugin/file/lookup_test.go b/plugin/file/lookup_test.go index 79e560428..6c01226ca 100644 --- a/plugin/file/lookup_test.go +++ b/plugin/file/lookup_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/coredns/coredns/plugin/pkg/dnstest" + "github.com/coredns/coredns/plugin/pkg/fall" "github.com/coredns/coredns/plugin/test" "github.com/coredns/coredns/request" @@ -216,6 +217,85 @@ func TestLookUpNoDataResult(t *testing.T) { } } +func TestLookupFallthrough(t *testing.T) { + zone, err := Parse(strings.NewReader(dbMiekNL), testzone, "stdin", 0) + if err != nil { + t.Fatalf("Expected no error when reading zone, got %q", err) + } + + type FallWithTestCases struct { + Fall fall.F + Cases []test.Case + } + var fallsWithTestCases = []FallWithTestCases{ + { + Fall: fall.Root, + Cases: []test.Case{ + { + Qname: "doesnotexist.miek.nl.", Qtype: dns.TypeA, + Rcode: dns.RcodeServerFailure, + }, + { + Qname: "x.miek.nl.", Qtype: dns.TypeA, + Rcode: dns.RcodeServerFailure, + }, + }, + }, + { + Fall: fall.F{Zones: []string{"a.miek.nl."}}, + Cases: []test.Case{ + { + Qname: "a.miek.nl.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + }, + { + Qname: "doesnotexist.miek.nl.", Qtype: dns.TypeA, + Rcode: dns.RcodeNameError, + }, + { + Qname: "passthrough.a.miek.nl.", Qtype: dns.TypeA, + Rcode: dns.RcodeServerFailure, + Answer: []dns.RR{}, + }, + }, + }, + { + Fall: fall.F{Zones: []string{"x.miek.nl."}}, + Cases: []test.Case{ + { + Qname: "x.miek.nl.", Qtype: dns.TypeA, + Rcode: dns.RcodeServerFailure, + }, + { + Qname: "wildcard.x.miek.nl.", Qtype: dns.TypeA, + Rcode: dns.RcodeSuccess, + }, + }, + }, + } + + for _, fallWithTestCases := range fallsWithTestCases { + fm := File{Next: test.ErrorHandler(), Zones: Zones{Z: map[string]*Zone{testzone: zone}, Names: []string{testzone}}, Fall: fallWithTestCases.Fall} + ctx := context.TODO() + + for _, tc := range fallWithTestCases.Cases { + m := tc.Msg() + + rec := dnstest.NewRecorder(&test.ResponseWriter{}) + _, err := fm.ServeDNS(ctx, rec, m) + if err != nil { + t.Errorf("Expected no error, got %v", err) + return + } + + if rec.Msg.Rcode != tc.Rcode { + t.Errorf("rcode is %q, expected %q", dns.RcodeToString[rec.Msg.Rcode], dns.RcodeToString[tc.Rcode]) + return + } + } + } +} + func BenchmarkFileLookup(b *testing.B) { zone, err := Parse(strings.NewReader(dbMiekNL), testzone, "stdin", 0) if err != nil { diff --git a/plugin/file/setup.go b/plugin/file/setup.go index 73a2a233e..eabfcc855 100644 --- a/plugin/file/setup.go +++ b/plugin/file/setup.go @@ -9,6 +9,7 @@ import ( "github.com/coredns/caddy" "github.com/coredns/coredns/core/dnsserver" "github.com/coredns/coredns/plugin" + "github.com/coredns/coredns/plugin/pkg/fall" "github.com/coredns/coredns/plugin/pkg/upstream" "github.com/coredns/coredns/plugin/transfer" ) @@ -16,12 +17,12 @@ import ( func init() { plugin.Register("file", setup) } func setup(c *caddy.Controller) error { - zones, err := fileParse(c) + zones, fall, err := fileParse(c) if err != nil { return plugin.Error("file", err) } - f := File{Zones: zones} + f := File{Zones: zones, Fall: fall} // get the transfer plugin, so we can send notifies and send notifies on startup as well. c.OnStartup(func() error { t := dnsserver.GetConfig(c).Handler("transfer") @@ -67,9 +68,10 @@ func setup(c *caddy.Controller) error { return nil } -func fileParse(c *caddy.Controller) (Zones, error) { +func fileParse(c *caddy.Controller) (Zones, fall.F, error) { z := make(map[string]*Zone) names := []string{} + fall := fall.F{} config := dnsserver.GetConfig(c) @@ -79,7 +81,7 @@ func fileParse(c *caddy.Controller) (Zones, error) { for c.Next() { // file db.file [zones...] if !c.NextArg() { - return Zones{}, c.ArgErr() + return Zones{}, fall, c.ArgErr() } fileName := c.Val() @@ -112,19 +114,21 @@ func fileParse(c *caddy.Controller) (Zones, error) { }() if err != nil { - return Zones{}, err + return Zones{}, fall, err } for c.NextBlock() { switch c.Val() { + case "fallthrough": + fall.SetZonesFromArgs(c.RemainingArgs()) case "reload": t := c.RemainingArgs() if len(t) < 1 { - return Zones{}, errors.New("reload duration value is expected") + return Zones{}, fall, errors.New("reload duration value is expected") } d, err := time.ParseDuration(t[0]) if err != nil { - return Zones{}, plugin.Error("file", err) + return Zones{}, fall, plugin.Error("file", err) } reload = d case "upstream": @@ -132,7 +136,7 @@ func fileParse(c *caddy.Controller) (Zones, error) { c.RemainingArgs() default: - return Zones{}, c.Errf("unknown property '%s'", c.Val()) + return Zones{}, fall, c.Errf("unknown property '%s'", c.Val()) } } @@ -145,9 +149,9 @@ func fileParse(c *caddy.Controller) (Zones, error) { if openErr != nil { if reload == 0 { // reload hasn't been set make this a fatal error - return Zones{}, plugin.Error("file", openErr) + return Zones{}, fall, plugin.Error("file", openErr) } log.Warningf("Failed to open %q: trying again in %s", openErr, reload) } - return Zones{Z: z, Names: names}, nil + return Zones{Z: z, Names: names}, fall, nil } diff --git a/plugin/file/setup_test.go b/plugin/file/setup_test.go index 1d3b8dc88..d7c1c5887 100644 --- a/plugin/file/setup_test.go +++ b/plugin/file/setup_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/coredns/caddy" + "github.com/coredns/coredns/plugin/pkg/fall" "github.com/coredns/coredns/plugin/test" ) @@ -22,24 +23,44 @@ func TestFileParse(t *testing.T) { defer rm() tests := []struct { - inputFileRules string - shouldErr bool - expectedZones Zones + inputFileRules string + shouldErr bool + expectedZones Zones + expectedFallthrough fall.F }{ { `file ` + zoneFileName1 + ` miek.nl.`, false, Zones{Names: []string{"miek.nl."}}, + fall.Zero, }, { `file ` + zoneFileName2 + ` dnssex.nl.`, false, Zones{Names: []string{"dnssex.nl."}}, + fall.Zero, }, { `file ` + zoneFileName2 + ` 10.0.0.0/8`, false, Zones{Names: []string{"10.in-addr.arpa."}}, + fall.Zero, + }, + { + `file ` + zoneFileName2 + ` example.org. { + fallthrough + }`, + false, + Zones{Names: []string{"example.org."}}, + fall.Root, + }, + { + `file ` + zoneFileName2 + ` example.org. { + fallthrough www.example.org + }`, + false, + Zones{Names: []string{"example.org."}}, + fall.F{Zones: []string{"www.example.org."}}, }, // errors. { @@ -48,11 +69,13 @@ func TestFileParse(t *testing.T) { }`, true, Zones{}, + fall.Zero, }, { `file`, true, Zones{}, + fall.Zero, }, { `file ` + zoneFileName1 + ` example.net. { @@ -60,6 +83,7 @@ func TestFileParse(t *testing.T) { }`, true, Zones{}, + fall.Zero, }, { `file ` + zoneFileName1 + ` example.net. { @@ -67,12 +91,13 @@ func TestFileParse(t *testing.T) { }`, true, Zones{}, + fall.Zero, }, } for i, test := range tests { c := caddy.NewTestController("dns", test.inputFileRules) - actualZones, err := fileParse(c) + actualZones, actualFallthrough, err := fileParse(c) if err == nil && test.shouldErr { t.Fatalf("Test %d expected errors, but got no error", i) @@ -87,6 +112,9 @@ func TestFileParse(t *testing.T) { t.Fatalf("Test %d expected %v for %d th zone, got %v", i, name, j, actualZones.Names[j]) } } + if !actualFallthrough.Equal(test.expectedFallthrough) { + t.Errorf("Test %d expected fallthrough of %v, got %v", i, test.expectedFallthrough, actualFallthrough) + } } } } @@ -116,7 +144,7 @@ func TestParseReload(t *testing.T) { for i, test := range tests { c := caddy.NewTestController("dns", test.input) - z, _ := fileParse(c) + z, _, _ := fileParse(c) if x := z.Z["example.org."].ReloadInterval; x != test.reload { t.Errorf("Test %d expected reload to be %s, but got %s", i, test.reload, x) }