From 9ff9abeabd2d921093df81099bc42c7b12d6c4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ask=20Bj=C3=B8rn=20Hansen?= Date: Sat, 28 Mar 2026 14:57:37 -0700 Subject: [PATCH] Add also-notify support for BIND domains.conf generation Extend catz.yaml config with a bind-conf section mapping catalog names to also-notify IP lists. Zones in catalogs with also-notify configured get an also-notify directive in the generated domains.conf. IPs are deduplicated and sorted when a zone belongs to multiple catalogs. --- bindconf.go | 39 ++++++++++++++++++++-- bindconf_test.go | 86 ++++++++++++++++++++++++++++++++++++++++++++---- config.go | 13 ++++++++ config_test.go | 30 +++++++++++++++++ main.go | 2 +- 5 files changed, 159 insertions(+), 11 deletions(-) diff --git a/bindconf.go b/bindconf.go index cbdcee4..4f4a0fd 100644 --- a/bindconf.go +++ b/bindconf.go @@ -10,7 +10,8 @@ import ( // generateBindConf produces a BIND domains.conf from all zone entries. // Zones are sorted alphabetically. Each zone block uses 8-space indentation. // DNSSEC zones get dnssec-policy and inline-signing directives on the file line. -func generateBindConf(entries []ZoneEntry) string { +// Zones in catalogs with also-notify configured get an also-notify directive. +func generateBindConf(entries []ZoneEntry, cfg *Config) string { sorted := make([]ZoneEntry, len(entries)) copy(sorted, entries) sort.Slice(sorted, func(i, j int) bool { @@ -38,16 +39,48 @@ func generateBindConf(entries []ZoneEntry) string { fileLine += " dnssec-policy standard; inline-signing yes;" } b.WriteString(fileLine + "\n") + + // Collect also-notify IPs from all catalogs this zone belongs to + ips := alsoNotifyIPs(entry.Catalogs, cfg) + if len(ips) > 0 { + b.WriteString(" also-notify {") + for _, ip := range ips { + fmt.Fprintf(&b, " %s;", ip) + } + b.WriteString(" };\n") + } + b.WriteString("};\n") } return b.String() } +// alsoNotifyIPs returns the deduplicated, sorted list of also-notify IPs +// for a zone based on its catalog memberships. +func alsoNotifyIPs(catalogs []string, cfg *Config) []string { + if len(cfg.BindConf.AlsoNotify) == 0 { + return nil + } + + seen := make(map[string]bool) + var ips []string + for _, catName := range catalogs { + for _, ip := range cfg.BindConf.AlsoNotify[catName] { + if !seen[ip] { + seen[ip] = true + ips = append(ips, ip) + } + } + } + sort.Strings(ips) + return ips +} + // writeBindConf writes the BIND config to path. // Zones without a ZoneFile are skipped (catalog-only zones). -func writeBindConf(path string, entries []ZoneEntry) error { - content := generateBindConf(entries) +func writeBindConf(path string, entries []ZoneEntry, cfg *Config) error { + content := generateBindConf(entries, cfg) if err := os.WriteFile(path, []byte(content), 0o644); err != nil { return fmt.Errorf("writing bind config %s: %w", path, err) } diff --git a/bindconf_test.go b/bindconf_test.go index 7cdf33a..d432b57 100644 --- a/bindconf_test.go +++ b/bindconf_test.go @@ -7,13 +7,15 @@ import ( "testing" ) +var emptyCfg = &Config{} + func TestGenerateBindConf(t *testing.T) { entries := []ZoneEntry{ {Zone: "bitcard.org.", ZoneFile: "data/misc/bitcard.org", DNSSEC: true, ZonesFile: "test", Line: 1}, {Zone: "askask.com.", ZoneFile: "data/ask/askask.com", ZonesFile: "test", Line: 2}, } - got := generateBindConf(entries) + got := generateBindConf(entries, emptyCfg) // Header assertContains(t, got, "# THIS FILE IS GENERATED BY catalog-zone-gen") @@ -44,7 +46,7 @@ func TestGenerateBindConf(t *testing.T) { } func TestGenerateBindConfEmpty(t *testing.T) { - got := generateBindConf(nil) + got := generateBindConf(nil, emptyCfg) // Should just have the header lines := splitLines(got) if len(lines) != 3 { @@ -57,7 +59,7 @@ func TestGenerateBindConfNoDNSSEC(t *testing.T) { {Zone: "example.com.", ZoneFile: "data/example.com", ZonesFile: "test", Line: 1}, } - got := generateBindConf(entries) + got := generateBindConf(entries, emptyCfg) if strings.Contains(got, "dnssec-policy") { t.Error("non-DNSSEC zone should not have dnssec-policy") @@ -73,7 +75,7 @@ func TestValidateBindConf(t *testing.T) { {Zone: "a.example.com.", ZoneFile: "data/a", ZonesFile: "zones.txt", Line: 1}, {Zone: "b.example.com.", ZoneFile: "data/b", ZonesFile: "zones.txt", Line: 2}, } - if err := writeBindConf(filepath.Join(t.TempDir(), "domains.conf"), entries); err != nil { + if err := writeBindConf(filepath.Join(t.TempDir(), "domains.conf"), entries, emptyCfg); err != nil { t.Fatalf("unexpected error: %v", err) } }) @@ -85,7 +87,7 @@ func TestValidateBindConf(t *testing.T) { } dir := t.TempDir() path := filepath.Join(dir, "domains.conf") - if err := writeBindConf(path, entries); err != nil { + if err := writeBindConf(path, entries, emptyCfg); err != nil { t.Fatalf("unexpected error: %v", err) } data, err := os.ReadFile(path) @@ -109,7 +111,7 @@ func TestWriteBindConf(t *testing.T) { {Zone: "example.org.", ZoneFile: "data/example.org", DNSSEC: true, ZonesFile: "test", Line: 2}, } - if err := writeBindConf(path, entries); err != nil { + if err := writeBindConf(path, entries, emptyCfg); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -132,7 +134,7 @@ func TestWriteBindConfSkipsCatalogOnlyZones(t *testing.T) { {Zone: "example.com.", ZoneFile: "", ZonesFile: "zones.txt", Line: 5}, } - if err := writeBindConf(path, entries); err != nil { + if err := writeBindConf(path, entries, emptyCfg); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -148,3 +150,73 @@ func TestWriteBindConfSkipsCatalogOnlyZones(t *testing.T) { t.Error("zone without file= should not appear in BIND config") } } + +func TestGenerateBindConfAlsoNotify(t *testing.T) { + cfg := &Config{ + BindConf: BindConfConfig{ + AlsoNotify: map[string][]string{ + "pch": {"198.51.100.1", "198.51.100.2"}, + }, + }, + } + + t.Run("zone in catalog with also-notify", func(t *testing.T) { + entries := []ZoneEntry{ + {Zone: "example.com.", Catalogs: []string{"pch"}, ZoneFile: "data/example.com", ZonesFile: "test", Line: 1}, + } + got := generateBindConf(entries, cfg) + assertContains(t, got, "also-notify { 198.51.100.1; 198.51.100.2; };") + }) + + t.Run("zone in catalog without also-notify", func(t *testing.T) { + entries := []ZoneEntry{ + {Zone: "example.com.", Catalogs: []string{"standard"}, ZoneFile: "data/example.com", ZonesFile: "test", Line: 1}, + } + got := generateBindConf(entries, cfg) + if strings.Contains(got, "also-notify") { + t.Error("zone in catalog without also-notify config should not have also-notify") + } + }) + + t.Run("zone in multiple catalogs combines IPs", func(t *testing.T) { + cfg := &Config{ + BindConf: BindConfConfig{ + AlsoNotify: map[string][]string{ + "pch": {"198.51.100.1"}, + "extra": {"198.51.100.2"}, + }, + }, + } + entries := []ZoneEntry{ + {Zone: "example.com.", Catalogs: []string{"pch", "extra"}, ZoneFile: "data/example.com", ZonesFile: "test", Line: 1}, + } + got := generateBindConf(entries, cfg) + assertContains(t, got, "also-notify { 198.51.100.1; 198.51.100.2; };") + }) + + t.Run("duplicate IPs across catalogs are deduplicated", func(t *testing.T) { + cfg := &Config{ + BindConf: BindConfConfig{ + AlsoNotify: map[string][]string{ + "pch": {"198.51.100.1", "198.51.100.2"}, + "extra": {"198.51.100.2", "198.51.100.3"}, + }, + }, + } + entries := []ZoneEntry{ + {Zone: "example.com.", Catalogs: []string{"pch", "extra"}, ZoneFile: "data/example.com", ZonesFile: "test", Line: 1}, + } + got := generateBindConf(entries, cfg) + assertContains(t, got, "also-notify { 198.51.100.1; 198.51.100.2; 198.51.100.3; };") + }) + + t.Run("catalog-only zone without file skips also-notify", func(t *testing.T) { + entries := []ZoneEntry{ + {Zone: "example.com.", Catalogs: []string{"pch"}, ZoneFile: "", ZonesFile: "test", Line: 1}, + } + got := generateBindConf(entries, cfg) + if strings.Contains(got, "also-notify") { + t.Error("catalog-only zone should not appear in bind config at all") + } + }) +} diff --git a/config.go b/config.go index c78ce28..8e8c0c4 100644 --- a/config.go +++ b/config.go @@ -19,10 +19,16 @@ type SOAConfig struct { Rname string `yaml:"rname"` } +// BindConfConfig holds BIND-specific configuration for domains.conf generation. +type BindConfConfig struct { + AlsoNotify map[string][]string `yaml:"also-notify"` +} + // Config holds the parsed catz.yaml configuration. type Config struct { Catalogs map[string]CatalogConfig `yaml:"catalogs"` SOA SOAConfig `yaml:"soa"` + BindConf BindConfConfig `yaml:"bind-conf"` } func loadConfig(path string) (*Config, error) { @@ -66,6 +72,13 @@ func validateConfig(cfg *Config) error { if cfg.SOA.Rname == "" { return fmt.Errorf("config: soa.rname is required") } + + for catName := range cfg.BindConf.AlsoNotify { + if _, ok := cfg.Catalogs[catName]; !ok { + return fmt.Errorf("config: bind-conf.also-notify references unknown catalog %q", catName) + } + } + return nil } diff --git a/config_test.go b/config_test.go index 55b35a1..8d37aca 100644 --- a/config_test.go +++ b/config_test.go @@ -122,6 +122,36 @@ func TestValidateConfig(t *testing.T) { t.Fatalf("unexpected error: %v", err) } }) + + t.Run("also-notify references unknown catalog", func(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{"cat1": {Zone: "cat.example.com."}}, + SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."}, + BindConf: BindConfConfig{ + AlsoNotify: map[string][]string{ + "nonexistent": {"198.51.100.1"}, + }, + }, + } + if err := validateConfig(cfg); err == nil { + t.Fatal("expected error for also-notify referencing unknown catalog") + } + }) + + t.Run("also-notify references valid catalog", func(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{"cat1": {Zone: "cat.example.com."}}, + SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."}, + BindConf: BindConfConfig{ + AlsoNotify: map[string][]string{ + "cat1": {"198.51.100.1"}, + }, + }, + } + if err := validateConfig(cfg); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) } func TestLoadConfigWritePermission(t *testing.T) { diff --git a/main.go b/main.go index 4c748e4..9367641 100644 --- a/main.go +++ b/main.go @@ -72,7 +72,7 @@ func main() { } if *bindConf != "" { - if err := writeBindConf(*bindConf, entries); err != nil { + if err := writeBindConf(*bindConf, entries, cfg); err != nil { fmt.Fprintf(os.Stderr, "error: %s\n", err) hasErrors = true } else {