diff --git a/README.md b/README.md index db0565b..8a0ffcd 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Blank lines are ignored. | Bare names | identifier | Catalog assignments — must match a key in the config `catalogs` map. At least one required. | | `group=` | key=value | RFC 9432 group property. Tells consumers to apply shared configuration to grouped zones. | | `coo=` | key=value | RFC 9432 change-of-ownership property. Points to the old catalog zone during migration. | -| `file=` | key=value | Zone data file path for BIND config output. Required when `--bind-conf` is used. | +| `file=` | key=value | Zone data file path for BIND config output. Zones without `file=` are included in catalog zones but skipped in `--bind-conf` output. | | `dnssec` | bare flag | Adds `dnssec-policy standard; inline-signing yes;` to the BIND config for this zone. | A zone can appear in multiple catalogs (for distributing to different server groups). @@ -169,7 +169,7 @@ error: zones.txt:5: zone example.com. already assigned to catalog "catalog1" at - Unknown properties (anything other than `group`, `coo`, `file`) — error - Empty `file=` value — error - `dnssec=` (dnssec is a bare flag, not a key=value) — error -- When `--bind-conf` is used: any zone missing `file=` — error +- When `--bind-conf` is used: zones without `file=` are silently skipped (included in catalog output only) ## BIND Config Output @@ -197,4 +197,4 @@ zone "bitcard.org" { - 8-space indentation - DNSSEC zones (marked with `dnssec` in the input) get `dnssec-policy standard; inline-signing yes;` on the same line as `file` -- Every zone must have a `file=` property when `--bind-conf` is used +- Zones without a `file=` property are skipped (they appear in catalog zones only) diff --git a/bindconf.go b/bindconf.go index 1d5f0cd..cbdcee4 100644 --- a/bindconf.go +++ b/bindconf.go @@ -23,6 +23,10 @@ func generateBindConf(entries []ZoneEntry) string { b.WriteString("#\n") for _, entry := range sorted { + if entry.ZoneFile == "" { + continue // catalog-only zone, no BIND config needed + } + // Strip trailing dot for BIND zone name zoneName := strings.TrimSuffix(entry.Zone, ".") @@ -40,22 +44,9 @@ func generateBindConf(entries []ZoneEntry) string { return b.String() } -// validateBindConf checks that every zone entry has a non-empty ZoneFile. -func validateBindConf(entries []ZoneEntry) error { - for _, entry := range entries { - if entry.ZoneFile == "" { - return fmt.Errorf("%s:%d: zone %s missing file= property (required for --bind-conf)", - entry.ZonesFile, entry.Line, entry.Zone) - } - } - return nil -} - -// writeBindConf validates entries and writes the BIND config to path. +// writeBindConf writes the BIND config to path. +// Zones without a ZoneFile are skipped (catalog-only zones). func writeBindConf(path string, entries []ZoneEntry) error { - if err := validateBindConf(entries); err != nil { - return err - } content := generateBindConf(entries) 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 6802f45..7cdf33a 100644 --- a/bindconf_test.go +++ b/bindconf_test.go @@ -73,23 +73,30 @@ 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 := validateBindConf(entries); err != nil { + if err := writeBindConf(filepath.Join(t.TempDir(), "domains.conf"), entries); err != nil { t.Fatalf("unexpected error: %v", err) } }) - t.Run("missing file", func(t *testing.T) { + t.Run("zones without file are skipped", func(t *testing.T) { entries := []ZoneEntry{ {Zone: "a.example.com.", ZoneFile: "data/a", ZonesFile: "zones.txt", Line: 1}, - {Zone: "b.example.com.", ZoneFile: "", ZonesFile: "zones.txt", Line: 3}, + {Zone: "dyn.example.com.", ZoneFile: "", ZonesFile: "zones.txt", Line: 3}, } - err := validateBindConf(entries) - if err == nil { - t.Fatal("expected error for missing file") + dir := t.TempDir() + path := filepath.Join(dir, "domains.conf") + if err := writeBindConf(path, entries); err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := string(data) + assertContains(t, got, `zone "a.example.com"`) + if strings.Contains(got, "dyn.example.com") { + t.Error("catalog-only zone without file= should not appear in BIND config") } - assertContains(t, err.Error(), "zones.txt:3:") - assertContains(t, err.Error(), "b.example.com.") - assertContains(t, err.Error(), "missing file=") }) } @@ -117,7 +124,7 @@ func TestWriteBindConf(t *testing.T) { assertContains(t, got, "dnssec-policy standard") } -func TestWriteBindConfValidationError(t *testing.T) { +func TestWriteBindConfSkipsCatalogOnlyZones(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "domains.conf") @@ -125,14 +132,19 @@ func TestWriteBindConfValidationError(t *testing.T) { {Zone: "example.com.", ZoneFile: "", ZonesFile: "zones.txt", Line: 5}, } - err := writeBindConf(path, entries) - if err == nil { - t.Fatal("expected validation error") + if err := writeBindConf(path, entries); err != nil { + t.Fatalf("unexpected error: %v", err) } - assertContains(t, err.Error(), "zones.txt:5:") - // File should not have been written - if _, statErr := os.Stat(path); statErr == nil { - t.Error("file should not be written when validation fails") + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + got := string(data) + + // Should have header but no zone blocks + assertContains(t, got, "# THIS FILE IS GENERATED BY catalog-zone-gen") + if strings.Contains(got, "example.com") { + t.Error("zone without file= should not appear in BIND config") } } diff --git a/main_test.go b/main_test.go index 854af57..eac0a1a 100644 --- a/main_test.go +++ b/main_test.go @@ -482,7 +482,7 @@ zone.example.com catalog1, file=data/zones/example.com } } -func TestIntegrationCLIBindConfMissingFile(t *testing.T) { +func TestIntegrationCLIBindConfCatalogOnlyZone(t *testing.T) { binary := filepath.Join(t.TempDir(), "catalog-zone-gen") cmd := exec.Command("go", "build", "-o", binary, ".") cmd.Dir = projectDir(t) @@ -502,7 +502,7 @@ soa: ` writeTestFile(t, dir, "catz.yaml", configContent) - // Zone without file= property + // Zone without file= property (catalog-only, e.g. ddns zone) inputContent := "zone.example.org catalog1\n" writeTestFile(t, dir, "zones.txt", inputContent) @@ -514,8 +514,26 @@ soa: "--bind-conf", bindConfPath, filepath.Join(dir, "zones.txt")) out, err := cmd.CombinedOutput() - if err == nil { - t.Fatal("expected error when file= is missing with --bind-conf") + if err != nil { + t.Fatalf("unexpected error: %v\n%s", err, out) } - assertContains(t, string(out), "missing file=") + + // domains.conf should exist but not contain the catalog-only zone + data, readErr := os.ReadFile(bindConfPath) + if readErr != nil { + t.Fatalf("failed to read domains.conf: %v", readErr) + } + got := string(data) + assertContains(t, got, "# THIS FILE IS GENERATED BY catalog-zone-gen") + if strings.Contains(got, "zone.example.org") { + t.Error("catalog-only zone without file= should not appear in domains.conf") + } + + // Catalog zone should still be generated + catZonePath := filepath.Join(dir, "catalog1.example.com.zone") + catData, catErr := os.ReadFile(catZonePath) + if catErr != nil { + t.Fatalf("catalog zone not written: %v", catErr) + } + assertContains(t, string(catData), "zone.example.org") }