From 35391dd8a9a3d413991f3c3b0c121d8443bff749 Mon Sep 17 00:00:00 2001 From: Endre Szabo Date: Wed, 20 May 2026 04:43:46 +0200 Subject: [PATCH] plugin/file: trigger reload of zones based on mtime (#8085) * Added fs.FileInfo.ModTime() based reload feature Signed-off-by: Endre Szabo * Updated the plugin documentation. Signed-off-by: Endre Szabo --------- Signed-off-by: Endre Szabo --- plugin/file/README.md | 3 + plugin/file/file.go | 10 ++ plugin/file/reload.go | 15 ++- plugin/file/reload_test.go | 247 +++++++++++++++++++++++++++++++++++++ plugin/file/setup.go | 4 + plugin/file/zone.go | 8 +- 6 files changed, 283 insertions(+), 4 deletions(-) diff --git a/plugin/file/README.md b/plugin/file/README.md index ce49827d2..ff26c8c7a 100644 --- a/plugin/file/README.md +++ b/plugin/file/README.md @@ -27,6 +27,7 @@ If you want to round-robin A and AAAA responses look at the *loadbalance* plugin ~~~ file DBFILE [ZONES... ] { reload DURATION + reload_by_mtime fallthrough [ZONES...] } ~~~ @@ -34,6 +35,8 @@ file DBFILE [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. +* `reload_by_mtime` if set, decision to reload the zone will be based on the zone file modification time, + instead of change in the SOA serial. * `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 diff --git a/plugin/file/file.go b/plugin/file/file.go index c3b541e6c..a86c90991 100644 --- a/plugin/file/file.go +++ b/plugin/file/file.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "os" "github.com/coredns/coredns/plugin" "github.com/coredns/coredns/plugin/pkg/fall" @@ -148,6 +149,15 @@ func Parse(f io.Reader, origin, fileName string, serial int64) (*Zone, error) { zp := dns.NewZoneParser(f, dns.Fqdn(origin), fileName) zp.SetIncludeAllowed(true) z := NewZone(origin, fileName) + + if z.ReloadByMtime { + fi, err := os.Stat(fileName) + if err != nil { + return nil, fmt.Errorf("failed to stat file %q with error %v", fileName, err) + } + z.file_mtime = fi.ModTime() + } + seenSOA := false for rr, ok := zp.Next(); ok; rr, ok = zp.Next() { if !seenSOA { diff --git a/plugin/file/reload.go b/plugin/file/reload.go index afba78a34..f822e5cdb 100644 --- a/plugin/file/reload.go +++ b/plugin/file/reload.go @@ -20,13 +20,26 @@ func (z *Zone) Reload(t *transfer.Transfer) error { select { case <-tick.C: zFile := z.File() + serial := z.SOASerialIfDefined() + + if z.ReloadByMtime { + fi, err := os.Stat(zFile) + if err != nil { + log.Errorf("Failed to stat zone %q in %q: %v", z.origin, zFile, err) + continue + } + if !fi.ModTime().After(z.file_mtime) { + continue + } + serial = 0 // force reload of the zone + } + reader, err := os.Open(filepath.Clean(zFile)) if err != nil { log.Errorf("Failed to open zone %q in %q: %v", z.origin, zFile, err) continue } - serial := z.SOASerialIfDefined() zone, err := Parse(reader, z.origin, zFile, serial) reader.Close() if err != nil { diff --git a/plugin/file/reload_test.go b/plugin/file/reload_test.go index c404bc443..0dc7c5b65 100644 --- a/plugin/file/reload_test.go +++ b/plugin/file/reload_test.go @@ -77,6 +77,253 @@ func TestZoneReloadSOAChange(t *testing.T) { } } +func TestZoneReloadByMtime(t *testing.T) { + // Test 1: Basic mtime trigger - file modification should trigger reload + t.Run("BasicMtimeTrigger", func(t *testing.T) { + fileName, rm, err := test.TempFile(".", reloadZoneTest) + if err != nil { + t.Fatalf("Failed to create zone: %s", err) + } + defer rm() + + reader, err := os.Open(fileName) + if err != nil { + t.Fatalf("Failed to open zone: %s", err) + } + z, err := Parse(reader, "miek.nl", fileName, 0) + if err != nil { + t.Fatalf("Failed to parse zone: %s", err) + } + reader.Close() + + // Enable mtime-based reload + z.ReloadInterval = 10 * time.Millisecond + z.ReloadByMtime = true + z.Reload(&transfer.Transfer{}) + + // Wait for initial load to complete + time.Sleep(20 * time.Millisecond) + + // Verify initial content (5 records) + rrs, err := z.ApexIfDefined() + if err != nil { + t.Fatal(err) + } + if len(rrs) != 5 { + t.Fatalf("Expected 5 initial RRs, got %d", len(rrs)) + } + + // Modify the zone file (this changes mtime) + if err := os.WriteFile(fileName, []byte(reloadZone2Test), 0644); err != nil { + t.Fatalf("Failed to write new zone data: %s", err) + } + + // Wait for reload to trigger + time.Sleep(30 * time.Millisecond) + + // Verify reload occurred (3 records now) + rrs, err = z.ApexIfDefined() + if err != nil { + t.Fatal(err) + } + if len(rrs) != 3 { + t.Fatalf("Expected 3 RRs after reload, got %d", len(rrs)) + } + }) + + // Test 2: No reload when mtime unchanged + t.Run("NoReloadWhenMtimeUnchanged", func(t *testing.T) { + fileName, rm, err := test.TempFile(".", reloadZoneTest) + if err != nil { + t.Fatalf("Failed to create zone: %s", err) + } + defer rm() + + reader, err := os.Open(fileName) + if err != nil { + t.Fatalf("Failed to open zone: %s", err) + } + z, err := Parse(reader, "miek.nl", fileName, 0) + if err != nil { + t.Fatalf("Failed to parse zone: %s", err) + } + reader.Close() + + // Enable mtime-based reload + z.ReloadInterval = 10 * time.Millisecond + z.ReloadByMtime = true + z.Reload(&transfer.Transfer{}) + + // Wait for initial load + time.Sleep(20 * time.Millisecond) + + // Record initial SOA serial + initialSerial := z.SOASerialIfDefined() + if initialSerial == -1 { + t.Fatal("Failed to get initial SOA serial") + } + + // Record initial record count + rrs, err := z.ApexIfDefined() + if err != nil { + t.Fatal(err) + } + initialCount := len(rrs) + + // Wait for multiple reload intervals WITHOUT modifying the file + time.Sleep(50 * time.Millisecond) + + // Verify no reload occurred + currentSerial := z.SOASerialIfDefined() + if currentSerial != initialSerial { + t.Fatalf("SOA serial changed unexpectedly: %d -> %d", initialSerial, currentSerial) + } + + rrs, err = z.ApexIfDefined() + if err != nil { + t.Fatal(err) + } + if len(rrs) != initialCount { + t.Fatalf("Record count changed unexpectedly: %d -> %d", initialCount, len(rrs)) + } + }) + + // Test 3: Content verification after reload + t.Run("ContentVerificationAfterReload", func(t *testing.T) { + fileName, rm, err := test.TempFile(".", reloadZoneTest) + if err != nil { + t.Fatalf("Failed to create zone: %s", err) + } + defer rm() + + reader, err := os.Open(fileName) + if err != nil { + t.Fatalf("Failed to open zone: %s", err) + } + z, err := Parse(reader, "miek.nl", fileName, 0) + if err != nil { + t.Fatalf("Failed to parse zone: %s", err) + } + reader.Close() + + // Enable mtime-based reload + z.ReloadInterval = 10 * time.Millisecond + z.ReloadByMtime = true + z.Reload(&transfer.Transfer{}) + + ctx := context.TODO() + + // Query initial content + r := new(dns.Msg) + r.SetQuestion("miek.nl", dns.TypeNS) + state := request.Request{W: &test.ResponseWriter{}, Req: r} + + records, _, _, res := z.Lookup(ctx, state, "miek.nl.") + if res != Success { + t.Fatalf("Failed to lookup initial NS records, got %d", res) + } + + // Initial zone has 4 NS records + if len(records) != 4 { + t.Fatalf("Expected 4 initial NS records, got %d", len(records)) + } + + // Modify to new zone content (only 2 NS records) + if err := os.WriteFile(fileName, []byte(reloadZone2Test), 0644); err != nil { + t.Fatalf("Failed to write new zone data: %s", err) + } + + // Wait for reload + time.Sleep(30 * time.Millisecond) + + // Query new content + records, _, _, res = z.Lookup(ctx, state, "miek.nl.") + if res != Success { + t.Fatalf("Failed to lookup reloaded NS records, got %d", res) + } + + // Reloaded zone has 2 NS records + if len(records) != 2 { + t.Fatalf("Expected 2 reloaded NS records, got %d", len(records)) + } + + // Verify the actual NS record names match the new zone + nsNames := make([]string, len(records)) + for i, rr := range records { + nsNames[i] = rr.(*dns.NS).Ns + } + + expectedNS := []string{"ext.ns.whyscream.net.", "omval.tednet.nl."} + for i, expected := range expectedNS { + if nsNames[i] != expected { + t.Errorf("Expected NS record %d to be %s, got %s", i, expected, nsNames[i]) + } + } + }) + + // Test 4: File deleted/missing during reload + t.Run("FileMissingDuringReload", func(t *testing.T) { + fileName, rm, err := test.TempFile(".", reloadZoneTest) + if err != nil { + t.Fatalf("Failed to create zone: %s", err) + } + defer rm() + + reader, err := os.Open(fileName) + if err != nil { + t.Fatalf("Failed to open zone: %s", err) + } + z, err := Parse(reader, "miek.nl", fileName, 0) + if err != nil { + t.Fatalf("Failed to parse zone: %s", err) + } + reader.Close() + + // Enable mtime-based reload + z.ReloadInterval = 10 * time.Millisecond + z.ReloadByMtime = true + z.Reload(&transfer.Transfer{}) + + // Wait for initial load + time.Sleep(20 * time.Millisecond) + + // Verify initial content is loaded + rrs, err := z.ApexIfDefined() + if err != nil { + t.Fatal(err) + } + initialCount := len(rrs) + + // Delete the zone file + if err := os.Remove(fileName); err != nil { + t.Fatalf("Failed to remove zone file: %s", err) + } + + // Wait for reload interval (reload should fail gracefully) + time.Sleep(30 * time.Millisecond) + + // Verify zone still serves old content (didn't crash) + rrs, err = z.ApexIfDefined() + if err != nil { + t.Fatal(err) + } + if len(rrs) != initialCount { + t.Fatalf("Zone content changed unexpectedly after file deletion: %d -> %d", initialCount, len(rrs)) + } + + // Verify DNS queries still work + ctx := context.TODO() + r := new(dns.Msg) + r.SetQuestion("miek.nl", dns.TypeSOA) + state := request.Request{W: &test.ResponseWriter{}, Req: r} + + _, _, _, res := z.Lookup(ctx, state, "miek.nl.") + if res != Success { + t.Fatalf("Zone should still serve queries after file deletion, got result %d", res) + } + }) +} + const reloadZoneTest = `miek.nl. 1627 IN SOA linode.atoom.net. miek.miek.nl. 1460175181 14400 3600 604800 14400 miek.nl. 1627 IN NS ext.ns.whyscream.net. miek.nl. 1627 IN NS omval.tednet.nl. diff --git a/plugin/file/setup.go b/plugin/file/setup.go index f863ddf84..049ae868a 100644 --- a/plugin/file/setup.go +++ b/plugin/file/setup.go @@ -77,6 +77,7 @@ func fileParse(c *caddy.Controller) (Zones, fall.F, error) { var openErr error reload := 1 * time.Minute + reload_by_mtime := false for c.Next() { // file db.file [zones...] @@ -131,6 +132,8 @@ func fileParse(c *caddy.Controller) (Zones, fall.F, error) { return Zones{}, fall, plugin.Error("file", err) } reload = d + case "reload_by_mtime": + reload_by_mtime = true case "upstream": // remove soon c.RemainingArgs() @@ -143,6 +146,7 @@ func fileParse(c *caddy.Controller) (Zones, fall.F, error) { for i := range origins { z[origins[i]].ReloadInterval = reload z[origins[i]].Upstream = upstream.New() + z[origins[i]].ReloadByMtime = reload_by_mtime } } diff --git a/plugin/file/zone.go b/plugin/file/zone.go index 342f47c6f..c2cd6843a 100644 --- a/plugin/file/zone.go +++ b/plugin/file/zone.go @@ -15,9 +15,10 @@ import ( // Zone is a structure that contains all data related to a DNS zone. type Zone struct { - origin string - origLen int - file string + origin string + origLen int + file string + file_mtime time.Time *tree.Tree Apex Expired bool @@ -28,6 +29,7 @@ type Zone struct { TransferFrom []string ReloadInterval time.Duration + ReloadByMtime bool reloadShutdown chan bool Upstream *upstream.Upstream // Upstream for looking up external names during the resolution process.