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.
This commit is contained in:
39
bindconf.go
39
bindconf.go
@@ -10,7 +10,8 @@ import (
|
|||||||
// generateBindConf produces a BIND domains.conf from all zone entries.
|
// generateBindConf produces a BIND domains.conf from all zone entries.
|
||||||
// Zones are sorted alphabetically. Each zone block uses 8-space indentation.
|
// Zones are sorted alphabetically. Each zone block uses 8-space indentation.
|
||||||
// DNSSEC zones get dnssec-policy and inline-signing directives on the file line.
|
// 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))
|
sorted := make([]ZoneEntry, len(entries))
|
||||||
copy(sorted, entries)
|
copy(sorted, entries)
|
||||||
sort.Slice(sorted, func(i, j int) bool {
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
@@ -38,16 +39,48 @@ func generateBindConf(entries []ZoneEntry) string {
|
|||||||
fileLine += " dnssec-policy standard; inline-signing yes;"
|
fileLine += " dnssec-policy standard; inline-signing yes;"
|
||||||
}
|
}
|
||||||
b.WriteString(fileLine + "\n")
|
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")
|
b.WriteString("};\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.String()
|
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.
|
// writeBindConf writes the BIND config to path.
|
||||||
// Zones without a ZoneFile are skipped (catalog-only zones).
|
// Zones without a ZoneFile are skipped (catalog-only zones).
|
||||||
func writeBindConf(path string, entries []ZoneEntry) error {
|
func writeBindConf(path string, entries []ZoneEntry, cfg *Config) error {
|
||||||
content := generateBindConf(entries)
|
content := generateBindConf(entries, cfg)
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
return fmt.Errorf("writing bind config %s: %w", path, err)
|
return fmt.Errorf("writing bind config %s: %w", path, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var emptyCfg = &Config{}
|
||||||
|
|
||||||
func TestGenerateBindConf(t *testing.T) {
|
func TestGenerateBindConf(t *testing.T) {
|
||||||
entries := []ZoneEntry{
|
entries := []ZoneEntry{
|
||||||
{Zone: "bitcard.org.", ZoneFile: "data/misc/bitcard.org", DNSSEC: true, ZonesFile: "test", Line: 1},
|
{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},
|
{Zone: "askask.com.", ZoneFile: "data/ask/askask.com", ZonesFile: "test", Line: 2},
|
||||||
}
|
}
|
||||||
|
|
||||||
got := generateBindConf(entries)
|
got := generateBindConf(entries, emptyCfg)
|
||||||
|
|
||||||
// Header
|
// Header
|
||||||
assertContains(t, got, "# THIS FILE IS GENERATED BY catalog-zone-gen")
|
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) {
|
func TestGenerateBindConfEmpty(t *testing.T) {
|
||||||
got := generateBindConf(nil)
|
got := generateBindConf(nil, emptyCfg)
|
||||||
// Should just have the header
|
// Should just have the header
|
||||||
lines := splitLines(got)
|
lines := splitLines(got)
|
||||||
if len(lines) != 3 {
|
if len(lines) != 3 {
|
||||||
@@ -57,7 +59,7 @@ func TestGenerateBindConfNoDNSSEC(t *testing.T) {
|
|||||||
{Zone: "example.com.", ZoneFile: "data/example.com", ZonesFile: "test", Line: 1},
|
{Zone: "example.com.", ZoneFile: "data/example.com", ZonesFile: "test", Line: 1},
|
||||||
}
|
}
|
||||||
|
|
||||||
got := generateBindConf(entries)
|
got := generateBindConf(entries, emptyCfg)
|
||||||
|
|
||||||
if strings.Contains(got, "dnssec-policy") {
|
if strings.Contains(got, "dnssec-policy") {
|
||||||
t.Error("non-DNSSEC zone should not have 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: "a.example.com.", ZoneFile: "data/a", ZonesFile: "zones.txt", Line: 1},
|
||||||
{Zone: "b.example.com.", ZoneFile: "data/b", ZonesFile: "zones.txt", Line: 2},
|
{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)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -85,7 +87,7 @@ func TestValidateBindConf(t *testing.T) {
|
|||||||
}
|
}
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
path := filepath.Join(dir, "domains.conf")
|
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)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(path)
|
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},
|
{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)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +134,7 @@ func TestWriteBindConfSkipsCatalogOnlyZones(t *testing.T) {
|
|||||||
{Zone: "example.com.", ZoneFile: "", ZonesFile: "zones.txt", Line: 5},
|
{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)
|
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")
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
13
config.go
13
config.go
@@ -19,10 +19,16 @@ type SOAConfig struct {
|
|||||||
Rname string `yaml:"rname"`
|
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.
|
// Config holds the parsed catz.yaml configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Catalogs map[string]CatalogConfig `yaml:"catalogs"`
|
Catalogs map[string]CatalogConfig `yaml:"catalogs"`
|
||||||
SOA SOAConfig `yaml:"soa"`
|
SOA SOAConfig `yaml:"soa"`
|
||||||
|
BindConf BindConfConfig `yaml:"bind-conf"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadConfig(path string) (*Config, error) {
|
func loadConfig(path string) (*Config, error) {
|
||||||
@@ -66,6 +72,13 @@ func validateConfig(cfg *Config) error {
|
|||||||
if cfg.SOA.Rname == "" {
|
if cfg.SOA.Rname == "" {
|
||||||
return fmt.Errorf("config: soa.rname is required")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,36 @@ func TestValidateConfig(t *testing.T) {
|
|||||||
t.Fatalf("unexpected error: %v", err)
|
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) {
|
func TestLoadConfigWritePermission(t *testing.T) {
|
||||||
|
|||||||
2
main.go
2
main.go
@@ -72,7 +72,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if *bindConf != "" {
|
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)
|
fmt.Fprintf(os.Stderr, "error: %s\n", err)
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user