From f8dffa7fc2bb61b7ffa7d78ba203ddacc54b9dd8 Mon Sep 17 00:00:00 2001 From: Ville Vesilehto Date: Wed, 20 May 2026 10:13:03 +0300 Subject: [PATCH] feat(secondary): add fallthrough support (#8041) --- plugin/secondary/README.md | 6 ++ plugin/secondary/setup.go | 18 +++-- plugin/secondary/setup_test.go | 34 +++++++- test/secondary_test.go | 137 +++++++++++++++++++++++++++++++++ 4 files changed, 187 insertions(+), 8 deletions(-) diff --git a/plugin/secondary/README.md b/plugin/secondary/README.md index b22965ef8..01bada625 100644 --- a/plugin/secondary/README.md +++ b/plugin/secondary/README.md @@ -27,6 +27,7 @@ A working syntax would be: ~~~ secondary [zones...] { transfer from ADDRESS [ADDRESS...] + fallthrough [ZONES...] } ~~~ @@ -34,6 +35,11 @@ secondary [zones...] { times; if one does not work, another will be tried. Transferring this zone outwards again can be done by enabling the *transfer* plugin. +* `fallthrough` If a query for a record in the zone results in NXDOMAIN, the query will be passed + to the next plugin in the chain. If **[ZONES...]** are listed, then only queries for those zones + will be subject to fallthrough. This can be useful in split DNS setups where the secondary zone + contains only partial records. + When a zone is due to be refreshed (refresh timer fires) a random jitter of 5 seconds is applied, before fetching. In the case of retry this will be 2 seconds. If there are any errors during the transfer in, the transfer fails; this will be logged. diff --git a/plugin/secondary/setup.go b/plugin/secondary/setup.go index f87e9d949..f813ebc4e 100644 --- a/plugin/secondary/setup.go +++ b/plugin/secondary/setup.go @@ -7,6 +7,7 @@ import ( "github.com/coredns/coredns/core/dnsserver" "github.com/coredns/coredns/plugin" "github.com/coredns/coredns/plugin/file" + "github.com/coredns/coredns/plugin/pkg/fall" clog "github.com/coredns/coredns/plugin/pkg/log" "github.com/coredns/coredns/plugin/pkg/parse" "github.com/coredns/coredns/plugin/pkg/upstream" @@ -18,12 +19,12 @@ var log = clog.NewWithPlugin("secondary") func init() { plugin.Register("secondary", setup) } func setup(c *caddy.Controller) error { - zones, err := secondaryParse(c) + zones, fall, err := secondaryParse(c) if err != nil { return plugin.Error("secondary", err) } - s := &Secondary{file.File{Zones: zones}} + s := &Secondary{file.File{Zones: zones, Fall: fall}} var x *transfer.Transfer c.OnStartup(func() error { t := dnsserver.GetConfig(c).Handler("transfer") @@ -84,9 +85,10 @@ func setup(c *caddy.Controller) error { return nil } -func secondaryParse(c *caddy.Controller) (file.Zones, error) { +func secondaryParse(c *caddy.Controller) (file.Zones, fall.F, error) { z := make(map[string]*file.Zone) names := []string{} + fall := fall.F{} for c.Next() { if c.Val() == "secondary" { // secondary [origin] @@ -105,11 +107,13 @@ func secondaryParse(c *caddy.Controller) (file.Zones, error) { var err error f, err = parse.TransferIn(c) if err != nil { - return file.Zones{}, err + return file.Zones{}, fall, err } hasTransfer = true + case "fallthrough": + fall.SetZonesFromArgs(c.RemainingArgs()) default: - return file.Zones{}, c.Errf("unknown property '%s'", c.Val()) + return file.Zones{}, fall, c.Errf("unknown property '%s'", c.Val()) } for _, origin := range origins { @@ -120,9 +124,9 @@ func secondaryParse(c *caddy.Controller) (file.Zones, error) { } } if !hasTransfer { - return file.Zones{}, c.Err("secondary zones require a transfer from property") + return file.Zones{}, fall, c.Err("secondary zones require a transfer from property") } } } - return file.Zones{Z: z, Names: names}, nil + return file.Zones{Z: z, Names: names}, fall, nil } diff --git a/plugin/secondary/setup_test.go b/plugin/secondary/setup_test.go index dbf1cb663..58b7a15cb 100644 --- a/plugin/secondary/setup_test.go +++ b/plugin/secondary/setup_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/coredns/caddy" + "github.com/coredns/coredns/plugin/pkg/fall" ) func TestSecondaryParse(t *testing.T) { @@ -12,6 +13,7 @@ func TestSecondaryParse(t *testing.T) { shouldErr bool transferFrom string zones []string + fall fall.F }{ { `secondary { @@ -20,6 +22,7 @@ func TestSecondaryParse(t *testing.T) { false, "127.0.0.1:53", nil, + fall.F{}, }, { `secondary example.org { @@ -28,12 +31,14 @@ func TestSecondaryParse(t *testing.T) { false, "127.0.0.1:53", []string{"example.org."}, + fall.F{}, }, { `secondary`, true, "", nil, + fall.F{}, }, { `secondary example.org { @@ -42,12 +47,35 @@ func TestSecondaryParse(t *testing.T) { true, "", nil, + fall.F{}, + }, + // fallthrough: bare (all zones) + { + `secondary { + transfer from 127.0.0.1 + fallthrough + }`, + false, + "127.0.0.1:53", + nil, + fall.Root, + }, + // fallthrough: specific zone + { + `secondary example.org { + transfer from 127.0.0.1 + fallthrough example.org + }`, + false, + "127.0.0.1:53", + []string{"example.org."}, + fall.F{Zones: []string{"example.org."}}, }, } for i, test := range tests { c := caddy.NewTestController("dns", test.inputFileRules) - s, err := secondaryParse(c) + s, f, err := secondaryParse(c) if err == nil && test.shouldErr { t.Fatalf("Test %d expected errors, but got no error", i) @@ -67,5 +95,9 @@ func TestSecondaryParse(t *testing.T) { t.Fatalf("Test %d transform from names don't match expected %q, but got %q", i, test.transferFrom, x) } } + + if !f.Equal(test.fall) { + t.Fatalf("Test %d fallthrough not equal: expected %v, got %v", i, test.fall, f) + } } } diff --git a/test/secondary_test.go b/test/secondary_test.go index 39f32ad9a..7e8134955 100644 --- a/test/secondary_test.go +++ b/test/secondary_test.go @@ -10,6 +10,143 @@ import ( "github.com/miekg/dns" ) +func TestSecondaryFallthrough(t *testing.T) { + // Create zone file for primary - has www.example.org A 127.0.0.1 + primaryZone, rm1, err := test.TempFile(".", `$ORIGIN example.org. +@ 3600 IN SOA sns.dns.icann.org. noc.dns.icann.org. ( + 2017042745 ; serial + 7200 ; refresh (2 hours) + 3600 ; retry (1 hour) + 1209600 ; expire (2 weeks) + 3600 ; minimum (1 hour) +) + + 3600 IN NS a.iana-servers.net. + 3600 IN NS b.iana-servers.net. + +www IN A 127.0.0.1 +`) + if err != nil { + t.Fatalf("Failed to create primary zone: %s", err) + } + defer rm1() + + // Create zone file for fallback server - has other.example.org A 10.10.10.10 + fallbackZone, rm2, err := test.TempFile(".", `$ORIGIN example.org. +@ 3600 IN SOA sns.dns.icann.org. noc.dns.icann.org. ( + 2017042745 ; serial + 7200 ; refresh (2 hours) + 3600 ; retry (1 hour) + 1209600 ; expire (2 weeks) + 3600 ; minimum (1 hour) +) + + 3600 IN NS a.iana-servers.net. + 3600 IN NS b.iana-servers.net. + +other IN A 10.10.10.10 +`) + if err != nil { + t.Fatalf("Failed to create fallback zone: %s", err) + } + defer rm2() + + // Start primary server (serves zone via AXFR) + primaryCorefile := `example.org:0 { + file ` + primaryZone + ` + transfer { + to * + } + }` + primary, _, primaryTCP, err := CoreDNSServerAndPorts(primaryCorefile) + if err != nil { + t.Fatalf("Could not get primary CoreDNS instance: %s", err) + } + defer primary.Stop() + + // Start fallback server (answers queries forwarded by secondary) + fallbackCorefile := `example.org:0 { + file ` + fallbackZone + ` + }` + fallback, fallbackUDP, _, err := CoreDNSServerAndPorts(fallbackCorefile) + if err != nil { + t.Fatalf("Could not get fallback CoreDNS instance: %s", err) + } + defer fallback.Stop() + + // Start secondary with fallthrough + forward to fallback + secondaryCorefile := `example.org:0 { + secondary { + transfer from ` + primaryTCP + ` + fallthrough + } + forward . ` + fallbackUDP + ` + }` + sec, secUDP, _, err := CoreDNSServerAndPorts(secondaryCorefile) + if err != nil { + t.Fatalf("Could not get secondary CoreDNS instance: %s", err) + } + defer sec.Stop() + + // Wait for zone transfer to complete + m := new(dns.Msg) + m.SetQuestion("example.org.", dns.TypeSOA) + var r *dns.Msg + for range 10 { + r, _ = dns.Exchange(m, secUDP) + if r != nil && len(r.Answer) != 0 { + break + } + time.Sleep(100 * time.Millisecond) + } + if r == nil || len(r.Answer) == 0 { + t.Fatal("Zone transfer did not complete") + } + + // Test 1: www.example.org exists in secondary zone - should return answer from zone + m = new(dns.Msg) + m.SetQuestion("www.example.org.", dns.TypeA) + r, err = dns.Exchange(m, secUDP) + if err != nil { + t.Fatalf("Expected to receive reply for www.example.org, but got error: %s", err) + } + if r.Rcode != dns.RcodeSuccess { + t.Fatalf("Expected NOERROR for www.example.org, got %s", dns.RcodeToString[r.Rcode]) + } + if len(r.Answer) != 1 { + t.Fatalf("Expected 1 answer for www.example.org, got %d", len(r.Answer)) + } + a, ok := r.Answer[0].(*dns.A) + if !ok { + t.Fatalf("Expected A record for www.example.org, got %T", r.Answer[0]) + } + if a.A.String() != "127.0.0.1" { + t.Fatalf("Expected www.example.org to be 127.0.0.1, got %s", a.A.String()) + } + + // Test 2: other.example.org does NOT exist in secondary zone + // With fallthrough, query should pass to forward plugin which queries fallback server + m = new(dns.Msg) + m.SetQuestion("other.example.org.", dns.TypeA) + r, err = dns.Exchange(m, secUDP) + if err != nil { + t.Fatalf("Expected to receive reply for other.example.org, but got error: %s", err) + } + if r.Rcode != dns.RcodeSuccess { + t.Fatalf("Expected NOERROR for fallthrough query other.example.org, got %s", dns.RcodeToString[r.Rcode]) + } + if len(r.Answer) != 1 { + t.Fatalf("Expected 1 answer from fallback for other.example.org, got %d", len(r.Answer)) + } + a, ok = r.Answer[0].(*dns.A) + if !ok { + t.Fatalf("Expected A record from fallback for other.example.org, got %T", r.Answer[0]) + } + if a.A.String() != "10.10.10.10" { + t.Fatalf("Expected fallback answer 10.10.10.10, got %s", a.A.String()) + } +} + func TestEmptySecondaryZone(t *testing.T) { // Corefile that fails to transfer example.org. corefile := `example.org:0 {