diff --git a/README.md b/README.md index 05afc45..db0565b 100644 --- a/README.md +++ b/README.md @@ -22,13 +22,14 @@ go build -o catalog-zone-gen . ## Usage ``` -catalog-zone-gen [--config path] [--output-dir path] +catalog-zone-gen [--config path] [--output-dir path] [--bind-conf path] ``` **Flags:** - `--config` — path to YAML config file (default: `catz.yaml` next to the input file) - `--output-dir` — directory for output zone files (default: same directory as the input file) +- `--bind-conf` — path to write a BIND `domains.conf` file (optional; see [BIND Config Output](#bind-config-output)) ## Configuration File @@ -64,7 +65,7 @@ Whitespace and comma delimited. Lines starting with `#` are comments. Blank lines are ignored. ``` - [, ...] [, group=] [, coo=] + [, ...] [, group=] [, coo=] [, file=] [, dnssec] ``` ### Fields @@ -75,6 +76,8 @@ 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. | +| `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). @@ -82,10 +85,10 @@ A zone can appear in multiple catalogs (for distributing to different server gro ``` # Production zones -zone.example.org catalog1, catalog2 -zone.example.com catalog2, coo=old-catalog.example.com. -test.example.net catalog1, group=internal -app.example.org catalog1, group=external, coo=migrated.example.com. +zone.example.org catalog1, catalog2, file=data/zones/example.org +zone.example.com catalog2, coo=old-catalog.example.com., file=data/zones/example.com +test.example.net catalog1, group=internal, file=data/zones/example.net, dnssec +app.example.org catalog1, group=external, coo=migrated.example.com., file=data/zones/app.example.org ``` Whitespace and comma placement is flexible. These are all equivalent: @@ -163,4 +166,35 @@ error: zones.txt:5: zone example.com. already assigned to catalog "catalog1" at - Same zone assigned to the same catalog more than once — error - Hash collision (two zone names produce the same hash within a catalog) — error - Missing required config fields — error -- Unknown properties (anything other than `group` and `coo`) — error +- 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 + +## BIND Config Output + +When `--bind-conf ` is specified, a BIND `domains.conf` file is written +in addition to the catalog zone files. This file defines all zones as `type +master` with their `file` paths from the `file=` input property. + +**Example output:** + +``` +# THIS FILE IS GENERATED BY catalog-zone-gen +#============================================= +# +zone "askask.com" { + type master; + file "data/ask/askask.com"; +}; +zone "bitcard.org" { + type master; + file "data/misc/bitcard.org"; dnssec-policy standard; inline-signing yes; +}; +``` + +- Zones are sorted alphabetically by name +- 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 diff --git a/bindconf.go b/bindconf.go new file mode 100644 index 0000000..1d5f0cd --- /dev/null +++ b/bindconf.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "os" + "sort" + "strings" +) + +// 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 { + sorted := make([]ZoneEntry, len(entries)) + copy(sorted, entries) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Zone < sorted[j].Zone + }) + + var b strings.Builder + b.WriteString("# THIS FILE IS GENERATED BY catalog-zone-gen\n") + b.WriteString("#=============================================\n") + b.WriteString("#\n") + + for _, entry := range sorted { + // Strip trailing dot for BIND zone name + zoneName := strings.TrimSuffix(entry.Zone, ".") + + fmt.Fprintf(&b, "zone \"%s\" {\n", zoneName) + b.WriteString(" type master;\n") + + fileLine := fmt.Sprintf(" file \"%s\";", entry.ZoneFile) + if entry.DNSSEC { + fileLine += " dnssec-policy standard; inline-signing yes;" + } + b.WriteString(fileLine + "\n") + b.WriteString("};\n") + } + + 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. +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) + } + return nil +} diff --git a/bindconf_test.go b/bindconf_test.go new file mode 100644 index 0000000..6802f45 --- /dev/null +++ b/bindconf_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +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) + + // Header + assertContains(t, got, "# THIS FILE IS GENERATED BY catalog-zone-gen") + assertContains(t, got, "#=============================================") + + // Zones should be sorted alphabetically (askask.com before bitcard.org) + askIdx := strings.Index(got, "askask.com") + bitIdx := strings.Index(got, "bitcard.org") + if askIdx == -1 || bitIdx == -1 { + t.Fatal("expected both zones in output") + } + if askIdx >= bitIdx { + t.Error("expected askask.com before bitcard.org (alphabetical sort)") + } + + // Non-DNSSEC zone + assertContains(t, got, `zone "askask.com" {`) + assertContains(t, got, ` type master;`) + assertContains(t, got, ` file "data/ask/askask.com";`) + + // DNSSEC zone has directives on file line + assertContains(t, got, ` file "data/misc/bitcard.org"; dnssec-policy standard; inline-signing yes;`) + + // No trailing dot in zone name + if strings.Contains(got, `zone "askask.com."`) { + t.Error("zone name should not have trailing dot in BIND config") + } +} + +func TestGenerateBindConfEmpty(t *testing.T) { + got := generateBindConf(nil) + // Should just have the header + lines := splitLines(got) + if len(lines) != 3 { + t.Errorf("expected 3 header lines for empty input, got %d", len(lines)) + } +} + +func TestGenerateBindConfNoDNSSEC(t *testing.T) { + entries := []ZoneEntry{ + {Zone: "example.com.", ZoneFile: "data/example.com", ZonesFile: "test", Line: 1}, + } + + got := generateBindConf(entries) + + if strings.Contains(got, "dnssec-policy") { + t.Error("non-DNSSEC zone should not have dnssec-policy") + } + if strings.Contains(got, "inline-signing") { + t.Error("non-DNSSEC zone should not have inline-signing") + } +} + +func TestValidateBindConf(t *testing.T) { + t.Run("all zones have file", func(t *testing.T) { + entries := []ZoneEntry{ + {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 { + t.Fatalf("unexpected error: %v", err) + } + }) + + t.Run("missing file", 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}, + } + err := validateBindConf(entries) + if err == nil { + t.Fatal("expected error for missing file") + } + assertContains(t, err.Error(), "zones.txt:3:") + assertContains(t, err.Error(), "b.example.com.") + assertContains(t, err.Error(), "missing file=") + }) +} + +func TestWriteBindConf(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "domains.conf") + + entries := []ZoneEntry{ + {Zone: "example.com.", ZoneFile: "data/example.com", ZonesFile: "test", Line: 1}, + {Zone: "example.org.", ZoneFile: "data/example.org", DNSSEC: true, ZonesFile: "test", Line: 2}, + } + + 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 "example.com"`) + assertContains(t, got, `zone "example.org"`) + assertContains(t, got, "dnssec-policy standard") +} + +func TestWriteBindConfValidationError(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "domains.conf") + + entries := []ZoneEntry{ + {Zone: "example.com.", ZoneFile: "", ZonesFile: "zones.txt", Line: 5}, + } + + err := writeBindConf(path, entries) + if err == nil { + t.Fatal("expected validation error") + } + 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") + } +} diff --git a/catalog.go b/catalog.go index 301eb42..c07441c 100644 --- a/catalog.go +++ b/catalog.go @@ -85,7 +85,7 @@ func generateCatalogZone(catName string, cfg *Config, members []ZoneEntry, seria h := hashZoneName(entry.Zone) if existing, ok := hashToZone[h]; ok && existing != entry.Zone { return "", fmt.Errorf("%s:%d: hash collision between %s and %s in catalog %q", - entry.File, entry.Line, existing, entry.Zone, catName) + entry.ZonesFile, entry.Line, existing, entry.Zone, catName) } hashToZone[h] = entry.Zone zoneHash[entry.Zone] = h diff --git a/catalog_test.go b/catalog_test.go index 5daf8a1..cfd8fd6 100644 --- a/catalog_test.go +++ b/catalog_test.go @@ -130,8 +130,8 @@ func TestGenerateCatalogZone(t *testing.T) { } members := []ZoneEntry{ - {Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1}, - {Zone: "a.example.com.", Catalogs: []string{"cat1"}, Group: "mygroup", File: "test", Line: 2}, + {Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1}, + {Zone: "a.example.com.", Catalogs: []string{"cat1"}, Group: "mygroup", ZonesFile: "test", Line: 2}, } content, err := generateCatalogZone("cat1", cfg, members, 2026030201) @@ -203,7 +203,7 @@ func TestGenerateCatalogZoneCOO(t *testing.T) { } members := []ZoneEntry{ - {Zone: "a.example.com.", Catalogs: []string{"cat1"}, COO: "old.example.com.", File: "test", Line: 1}, + {Zone: "a.example.com.", Catalogs: []string{"cat1"}, COO: "old.example.com.", ZonesFile: "test", Line: 1}, } content, err := generateCatalogZone("cat1", cfg, members, 2026030201) diff --git a/input.go b/input.go index 650e5ec..02a4665 100644 --- a/input.go +++ b/input.go @@ -9,21 +9,23 @@ import ( // ZoneEntry represents a parsed line from the input file. type ZoneEntry struct { - Zone string // Normalized FQDN - Catalogs []string // Catalog names (bare names from input) - Group string // Optional RFC 9432 group property - COO string // Optional RFC 9432 change-of-ownership FQDN - File string // Source file - Line int // Source line number + Zone string // Normalized FQDN + Catalogs []string // Catalog names (bare names from input) + Group string // Optional RFC 9432 group property + COO string // Optional RFC 9432 change-of-ownership FQDN + ZoneFile string // file= property: zone data path for BIND config + DNSSEC bool // dnssec flag: adds dnssec-policy to BIND config + ZonesFile string // Input file path (for error messages) + Line int // Input line number (for error messages) } // CatalogMembers groups zone entries by catalog name. type CatalogMembers map[string][]ZoneEntry -func parseInput(path string, cfg *Config) (CatalogMembers, error) { +func parseInput(path string, cfg *Config) ([]ZoneEntry, CatalogMembers, error) { f, err := os.Open(path) if err != nil { - return nil, fmt.Errorf("opening input: %w", err) + return nil, nil, fmt.Errorf("opening input: %w", err) } defer f.Close() @@ -39,15 +41,19 @@ func parseInput(path string, cfg *Config) (CatalogMembers, error) { entry, err := parseLine(line, path, lineNum) if err != nil { - return nil, err + return nil, nil, err } entries = append(entries, entry) } if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("reading %s: %w", path, err) + return nil, nil, fmt.Errorf("reading %s: %w", path, err) } - return buildCatalogMembers(entries, cfg) + members, err := buildCatalogMembers(entries, cfg) + if err != nil { + return nil, nil, err + } + return entries, members, nil } func parseLine(line, file string, lineNum int) (ZoneEntry, error) { @@ -58,9 +64,9 @@ func parseLine(line, file string, lineNum int) (ZoneEntry, error) { } entry := ZoneEntry{ - Zone: normalizeFQDN(tokens[0]), - File: file, - Line: lineNum, + Zone: normalizeFQDN(tokens[0]), + ZonesFile: file, + Line: lineNum, } for _, tok := range tokens[1:] { @@ -77,9 +83,18 @@ func parseLine(line, file string, lineNum int) (ZoneEntry, error) { return ZoneEntry{}, fmt.Errorf("%s:%d: empty coo value", file, lineNum) } entry.COO = normalizeFQDN(value) + case "file": + if value == "" { + return ZoneEntry{}, fmt.Errorf("%s:%d: empty file value", file, lineNum) + } + entry.ZoneFile = value + case "dnssec": + return ZoneEntry{}, fmt.Errorf("%s:%d: dnssec is a flag, use without =", file, lineNum) default: return ZoneEntry{}, fmt.Errorf("%s:%d: unknown property %q", file, lineNum, key) } + } else if tok == "dnssec" { + entry.DNSSEC = true } else { // Bare name = catalog assignment entry.Catalogs = append(entry.Catalogs, tok) @@ -107,7 +122,7 @@ func buildCatalogMembers(entries []ZoneEntry, cfg *Config) (CatalogMembers, erro for _, entry := range entries { for _, catName := range entry.Catalogs { if _, ok := cfg.Catalogs[catName]; !ok { - return nil, fmt.Errorf("%s:%d: unknown catalog %q", entry.File, entry.Line, catName) + return nil, fmt.Errorf("%s:%d: unknown catalog %q", entry.ZonesFile, entry.Line, catName) } if seen[catName] == nil { @@ -115,7 +130,7 @@ func buildCatalogMembers(entries []ZoneEntry, cfg *Config) (CatalogMembers, erro } if prevLine, dup := seen[catName][entry.Zone]; dup { return nil, fmt.Errorf("%s:%d: zone %s already assigned to catalog %q at line %d", - entry.File, entry.Line, entry.Zone, catName, prevLine) + entry.ZonesFile, entry.Line, entry.Zone, catName, prevLine) } seen[catName][entry.Zone] = entry.Line diff --git a/input_test.go b/input_test.go index a0c3ca7..064bef6 100644 --- a/input_test.go +++ b/input_test.go @@ -13,6 +13,8 @@ func TestParseLine(t *testing.T) { wantCats []string wantGrp string wantCOO string + wantFile string + wantDNS bool wantErr bool }{ { @@ -55,6 +57,28 @@ func TestParseLine(t *testing.T) { wantZone: "zone.example.org.", wantCats: []string{"catalog1"}, }, + { + name: "with file property", + line: "zone.example.org catalog1, file=data/zones/example.org", + wantZone: "zone.example.org.", + wantCats: []string{"catalog1"}, + wantFile: "data/zones/example.org", + }, + { + name: "with dnssec flag", + line: "zone.example.org catalog1, dnssec", + wantZone: "zone.example.org.", + wantCats: []string{"catalog1"}, + wantDNS: true, + }, + { + name: "with file and dnssec", + line: "zone.example.org catalog1, file=data/zones/example.org, dnssec", + wantZone: "zone.example.org.", + wantCats: []string{"catalog1"}, + wantFile: "data/zones/example.org", + wantDNS: true, + }, { name: "no catalog", line: "zone.example.org", @@ -80,6 +104,16 @@ func TestParseLine(t *testing.T) { line: "zone.example.org catalog1, coo=", wantErr: true, }, + { + name: "empty file value", + line: "zone.example.org catalog1, file=", + wantErr: true, + }, + { + name: "dnssec with equals", + line: "zone.example.org catalog1, dnssec=yes", + wantErr: true, + }, } for _, tt := range tests { @@ -111,6 +145,12 @@ func TestParseLine(t *testing.T) { if entry.COO != tt.wantCOO { t.Errorf("coo = %q, want %q", entry.COO, tt.wantCOO) } + if entry.ZoneFile != tt.wantFile { + t.Errorf("zonefile = %q, want %q", entry.ZoneFile, tt.wantFile) + } + if entry.DNSSEC != tt.wantDNS { + t.Errorf("dnssec = %v, want %v", entry.DNSSEC, tt.wantDNS) + } }) } } @@ -150,8 +190,8 @@ func TestBuildCatalogMembers(t *testing.T) { t.Run("valid input", func(t *testing.T) { entries := []ZoneEntry{ - {Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 1}, - {Zone: "b.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 2}, + {Zone: "a.example.com.", Catalogs: []string{"catalog1"}, ZonesFile: "test", Line: 1}, + {Zone: "b.example.com.", Catalogs: []string{"catalog1", "catalog2"}, ZonesFile: "test", Line: 2}, } members, err := buildCatalogMembers(entries, cfg) @@ -169,7 +209,7 @@ func TestBuildCatalogMembers(t *testing.T) { t.Run("unknown catalog", func(t *testing.T) { entries := []ZoneEntry{ - {Zone: "a.example.com.", Catalogs: []string{"unknown"}, File: "test", Line: 1}, + {Zone: "a.example.com.", Catalogs: []string{"unknown"}, ZonesFile: "test", Line: 1}, } _, err := buildCatalogMembers(entries, cfg) if err == nil { @@ -179,8 +219,8 @@ func TestBuildCatalogMembers(t *testing.T) { t.Run("duplicate zone in same catalog", func(t *testing.T) { entries := []ZoneEntry{ - {Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 1}, - {Zone: "a.example.com.", Catalogs: []string{"catalog1"}, File: "test", Line: 2}, + {Zone: "a.example.com.", Catalogs: []string{"catalog1"}, ZonesFile: "test", Line: 1}, + {Zone: "a.example.com.", Catalogs: []string{"catalog1"}, ZonesFile: "test", Line: 2}, } _, err := buildCatalogMembers(entries, cfg) if err == nil { @@ -207,7 +247,7 @@ test.example.net catalog1, group=internal ` writeTestFile(t, dir, "zones.txt", content) - members, err := parseInput(inputPath, cfg) + _, members, err := parseInput(inputPath, cfg) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -228,7 +268,7 @@ func TestParseInputErrors(t *testing.T) { } t.Run("missing file", func(t *testing.T) { - _, err := parseInput("/nonexistent/zones.txt", cfg) + _, _, err := parseInput("/nonexistent/zones.txt", cfg) if err == nil { t.Fatal("expected error for missing file") } @@ -238,7 +278,7 @@ func TestParseInputErrors(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "zones.txt") writeTestFile(t, dir, "zones.txt", "zone-with-no-catalog\n") - _, err := parseInput(path, cfg) + _, _, err := parseInput(path, cfg) if err == nil { t.Fatal("expected error for invalid line") } @@ -255,7 +295,7 @@ func TestBuildCatalogMembersSameZoneDifferentCatalogs(t *testing.T) { // Same zone in different catalogs is OK entries := []ZoneEntry{ - {Zone: "a.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 1}, + {Zone: "a.example.com.", Catalogs: []string{"catalog1", "catalog2"}, ZonesFile: "test", Line: 1}, } members, err := buildCatalogMembers(entries, cfg) diff --git a/main.go b/main.go index f279a92..4c748e4 100644 --- a/main.go +++ b/main.go @@ -12,8 +12,9 @@ import ( func main() { configPath := flag.String("config", "", "path to YAML config (default: catz.yaml next to input file)") outputDir := flag.String("output-dir", "", "directory for output zone files (default: same as input file)") + bindConf := flag.String("bind-conf", "", "path to write BIND domains.conf") flag.Usage = func() { - fmt.Fprintf(os.Stderr, "Usage: catalog-zone-gen [--config path] [--output-dir path] \n") + fmt.Fprintf(os.Stderr, "Usage: catalog-zone-gen [--config path] [--output-dir path] [--bind-conf path] \n") flag.PrintDefaults() } flag.Parse() @@ -39,7 +40,7 @@ func main() { os.Exit(1) } - members, err := parseInput(inputFile, cfg) + entries, members, err := parseInput(inputFile, cfg) if err != nil { fmt.Fprintf(os.Stderr, "error: %s\n", err) os.Exit(1) @@ -70,6 +71,15 @@ func main() { } } + if *bindConf != "" { + if err := writeBindConf(*bindConf, entries); err != nil { + fmt.Fprintf(os.Stderr, "error: %s\n", err) + hasErrors = true + } else { + fmt.Fprintf(os.Stderr, "%s: written\n", *bindConf) + } + } + if hasErrors { os.Exit(1) } diff --git a/main_test.go b/main_test.go index 82ff143..854af57 100644 --- a/main_test.go +++ b/main_test.go @@ -38,7 +38,7 @@ app.example.org catalog1, group=external, coo=migrated.example.com. t.Fatalf("loadConfig: %v", err) } - members, err := parseInput(filepath.Join(dir, "zones.txt"), cfg) + _, members, err := parseInput(filepath.Join(dir, "zones.txt"), cfg) if err != nil { t.Fatalf("parseInput: %v", err) } @@ -120,7 +120,7 @@ soa: } members1 := []ZoneEntry{ - {Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1}, + {Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1}, } now := fixedTime(2026, 1, 15) @@ -139,8 +139,8 @@ soa: // Second run with different input (same day) members2 := []ZoneEntry{ - {Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1}, - {Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2}, + {Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1}, + {Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2}, } changed, err = processCatalog("cat1", cfg, members2, dir, now) @@ -195,9 +195,9 @@ soa: // Input in reverse order members := []ZoneEntry{ - {Zone: "z.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1}, - {Zone: "m.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2}, - {Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 3}, + {Zone: "z.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1}, + {Zone: "m.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2}, + {Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 3}, } now := fixedTime(2026, 1, 15) @@ -371,7 +371,7 @@ soa: } members := []ZoneEntry{ - {Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1}, + {Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1}, } day1 := fixedTime(2026, 1, 15) @@ -385,8 +385,8 @@ soa: // Add a zone on day 2 members2 := []ZoneEntry{ - {Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1}, - {Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2}, + {Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1}, + {Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2}, } day2 := fixedTime(2026, 1, 16) @@ -404,9 +404,9 @@ soa: // Simulate not using the tool for a while, running on a much later date day3 := fixedTime(2026, 6, 15) members3 := []ZoneEntry{ - {Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1}, - {Zone: "b.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 2}, - {Zone: "c.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 3}, + {Zone: "a.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 1}, + {Zone: "b.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 2}, + {Zone: "c.example.com.", Catalogs: []string{"cat1"}, ZonesFile: "test", Line: 3}, } changed, err = processCatalog("cat1", cfg, members3, dir, day3) @@ -420,3 +420,102 @@ soa: content3 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone")) assertContains(t, content3, "2026061501") } + +func TestIntegrationCLIBindConf(t *testing.T) { + binary := filepath.Join(t.TempDir(), "catalog-zone-gen") + cmd := exec.Command("go", "build", "-o", binary, ".") + cmd.Dir = projectDir(t) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go build failed: %v\n%s", err, out) + } + + dir := t.TempDir() + + configContent := `catalogs: + catalog1: + zone: catalog1.example.com. + +soa: + mname: ns1.example.com. + rname: hostmaster.example.com. +` + writeTestFile(t, dir, "catz.yaml", configContent) + + inputContent := `zone.example.org catalog1, file=data/zones/example.org, dnssec +zone.example.com catalog1, file=data/zones/example.com +` + writeTestFile(t, dir, "zones.txt", inputContent) + + bindConfPath := filepath.Join(dir, "domains.conf") + + cmd = exec.Command(binary, + "--config", filepath.Join(dir, "catz.yaml"), + "--output-dir", dir, + "--bind-conf", bindConfPath, + filepath.Join(dir, "zones.txt")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("catalog-zone-gen failed: %v\n%s", err, out) + } + + // Verify bind conf was written + content := readTestFile(t, bindConfPath) + assertContains(t, content, "# THIS FILE IS GENERATED BY catalog-zone-gen") + + // zone.example.com should come before zone.example.org (sorted) + comIdx := strings.Index(content, "zone.example.com") + orgIdx := strings.Index(content, "zone.example.org") + if comIdx >= orgIdx { + t.Error("expected zone.example.com before zone.example.org (alphabetical)") + } + + // DNSSEC zone + assertContains(t, content, "dnssec-policy standard; inline-signing yes;") + // Non-DNSSEC zone should not have it + lines := splitLines(content) + for _, line := range lines { + if strings.Contains(line, "example.com") && strings.Contains(line, "file") { + if strings.Contains(line, "dnssec-policy") { + t.Error("example.com should not have dnssec-policy") + } + } + } +} + +func TestIntegrationCLIBindConfMissingFile(t *testing.T) { + binary := filepath.Join(t.TempDir(), "catalog-zone-gen") + cmd := exec.Command("go", "build", "-o", binary, ".") + cmd.Dir = projectDir(t) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go build failed: %v\n%s", err, out) + } + + dir := t.TempDir() + + configContent := `catalogs: + catalog1: + zone: catalog1.example.com. + +soa: + mname: ns1.example.com. + rname: hostmaster.example.com. +` + writeTestFile(t, dir, "catz.yaml", configContent) + + // Zone without file= property + inputContent := "zone.example.org catalog1\n" + writeTestFile(t, dir, "zones.txt", inputContent) + + bindConfPath := filepath.Join(dir, "domains.conf") + + cmd = exec.Command(binary, + "--config", filepath.Join(dir, "catz.yaml"), + "--output-dir", dir, + "--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") + } + assertContains(t, string(out), "missing file=") +}