From 1f2f39f40cd7f888539b7a8ba4f03f389fae8aa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ask=20Bj=C3=B8rn=20Hansen?= Date: Sat, 28 Feb 2026 16:13:58 -0800 Subject: [PATCH] Add catalog-zone-gen tool Generate RFC 9432 DNS catalog zone files from a declarative input file. Parses zone-to-catalog assignments with optional group and coo properties, produces deterministic BIND-format output with automatic SOA serial management and change detection. --- README.md | 166 ++++++++++++++++++ catalog.go | 249 ++++++++++++++++++++++++++ catalog_test.go | 332 +++++++++++++++++++++++++++++++++++ config.go | 78 +++++++++ config_test.go | 141 +++++++++++++++ go.mod | 14 ++ go.sum | 12 ++ helpers_test.go | 30 ++++ input.go | 130 ++++++++++++++ input_test.go | 277 +++++++++++++++++++++++++++++ main.go | 76 ++++++++ main_test.go | 425 +++++++++++++++++++++++++++++++++++++++++++++ testdata/catz.yaml | 9 + testdata/zones.txt | 5 + 14 files changed, 1944 insertions(+) create mode 100644 README.md create mode 100644 catalog.go create mode 100644 catalog_test.go create mode 100644 config.go create mode 100644 config_test.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 helpers_test.go create mode 100644 input.go create mode 100644 input_test.go create mode 100644 main.go create mode 100644 main_test.go create mode 100644 testdata/catz.yaml create mode 100644 testdata/zones.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..05afc45 --- /dev/null +++ b/README.md @@ -0,0 +1,166 @@ +# catalog-zone-gen + +Generate RFC 9432 DNS catalog zone files from a declarative input file. + +Given a list of zones with their catalog assignments and optional properties, +this tool produces one BIND-format zone file per catalog. Output is +deterministic: re-running with unchanged input produces unchanged output (no +serial bump, no file write). + +## Installation + +``` +go install catalog-zone-gen@latest +``` + +Or build from source: + +``` +go build -o catalog-zone-gen . +``` + +## Usage + +``` +catalog-zone-gen [--config path] [--output-dir 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) + +## Configuration File + +The config file (default `catz.yaml`) defines catalog zone names and SOA parameters. + +```yaml +catalogs: + catalog1: + zone: catalog1.example.com. + catalog2: + zone: catalog2.example.com. + +soa: + mname: ns1.example.com. + rname: hostmaster.example.com. +``` + +### Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `catalogs` | yes | Map of catalog names to their zone FQDNs. Names are used as references in the input file. | +| `catalogs..zone` | yes | The FQDN of the catalog zone (trailing dot optional, will be normalized). | +| `soa.mname` | yes | Primary nameserver for the SOA record. | +| `soa.rname` | yes | Responsible person email (in DNS format: `hostmaster.example.com.`). | + +SOA timing values are hardcoded: refresh=900, retry=600, expire=2147483646, minimum=0. +The NS record is hardcoded to `invalid.` per RFC 9432. + +## Input File Format + +Whitespace and comma delimited. Lines starting with `#` are comments. +Blank lines are ignored. + +``` + [, ...] [, group=] [, coo=] +``` + +### Fields + +| Position | Format | Description | +|----------|--------|-------------| +| First token | FQDN | Zone name (trailing dot optional, normalized internally). | +| 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. | + +A zone can appear in multiple catalogs (for distributing to different server groups). + +### Example + +``` +# 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. +``` + +Whitespace and comma placement is flexible. These are all equivalent: + +``` +zone.example.org catalog1,catalog2 +zone.example.org catalog1 , catalog2 +zone.example.org catalog1, catalog2 +``` + +## Output + +One BIND-format zone file per catalog, written to the output directory. + +**Filename:** `.zone` (e.g., `catalog1.example.com.zone`) + +**Example output:** + +``` +catalog1.example.com. 0 IN SOA ns1.example.com. hostmaster.example.com. 2026030201 900 600 2147483646 0 +catalog1.example.com. 0 IN NS invalid. +version.catalog1.example.com. 0 IN TXT "2" +grfen8g.zones.catalog1.example.com. 0 IN PTR app.example.org. +group.grfen8g.zones.catalog1.example.com. 0 IN TXT "external" +coo.grfen8g.zones.catalog1.example.com. 0 IN PTR migrated.example.com. +2qvgcfg.zones.catalog1.example.com. 0 IN PTR test.example.net. +group.2qvgcfg.zones.catalog1.example.com. 0 IN TXT "internal" +1860l9o.zones.catalog1.example.com. 0 IN PTR zone.example.org. +``` + +### Record order + +1. SOA record +2. NS record (`invalid.`) +3. Version TXT record (`"2"`) +4. Member zones sorted alphabetically by zone name, each with: + - PTR record (member zone) + - Group TXT record (if `group=` set) + - COO PTR record (if `coo=` set) + +All records use TTL 0 and class IN. Fully-qualified owner names; no `$ORIGIN` or `$TTL` directives. + +### Member zone hashing + +Member zone labels are generated by FNV-1a 32-bit hashing the normalized zone +name, then encoding as lowercase base32hex without padding. This produces +compact labels like `grfen8g`. + +Changing tools will likely produce different hash labels, which is intentional +per RFC 9432 Section 5.4 — it triggers a reconfig event on consumers. + +### SOA serial + +Format: `YYYYMMDDNN` where NN is a sequence number (01-99). + +- New zone files start at `YYYYMMDD01`. +- On subsequent runs, the tool generates output with the existing serial and + compares bytes. If unchanged, no write occurs. +- If content differs, the serial is incremented (same date bumps NN; new date + resets to `YYYYMMDD01`). +- If NN reaches 99 on the same date: the tool errors. + +## Validation + +The tool validates input and reports errors with file location: + +``` +error: zones.txt:3: unknown catalog "bogus" +error: zones.txt:5: zone example.com. already assigned to catalog "catalog1" at line 2 +``` + +**Checked conditions:** + +- Unknown catalog name (not in config) — error +- 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 diff --git a/catalog.go b/catalog.go new file mode 100644 index 0000000..90c2c95 --- /dev/null +++ b/catalog.go @@ -0,0 +1,249 @@ +package main + +import ( + "encoding/base32" + "fmt" + "hash/fnv" + "os" + "sort" + "strings" + "time" + + "codeberg.org/miekg/dns" +) + +// base32hex encoding, lowercase, no padding +var b32 = base32.NewEncoding("0123456789abcdefghijklmnopqrstuv").WithPadding(base32.NoPadding) + +// Hardcoded SOA timing values +const ( + soaRefresh = 900 + soaRetry = 600 + soaExpire = 2147483646 + soaMinimum = 0 +) + +// hashZoneName computes the FNV-1a 32-bit hash of a normalized zone name +// and returns it as a lowercase base32hex string (no padding). +func hashZoneName(zone string) string { + h := fnv.New32a() + h.Write([]byte(zone)) + sum := h.Sum32() + b := make([]byte, 4) + b[0] = byte(sum >> 24) + b[1] = byte(sum >> 16) + b[2] = byte(sum >> 8) + b[3] = byte(sum) + return b32.EncodeToString(b) +} + +// generateCatalogZone builds the zone file content for a single catalog. +func generateCatalogZone(catName string, cfg *Config, members []ZoneEntry, serial uint32) (string, error) { + catCfg := cfg.Catalogs[catName] + origin := catCfg.Zone + + var records []string + + // SOA + soaStr := fmt.Sprintf("%s 0 IN SOA %s %s %d %d %d %d %d", + origin, cfg.SOA.Mname, cfg.SOA.Rname, serial, soaRefresh, soaRetry, soaExpire, soaMinimum) + soaRR, err := dns.New(soaStr) + if err != nil { + return "", fmt.Errorf("building SOA: %w", err) + } + records = append(records, soaRR.String()) + + // NS + nsStr := fmt.Sprintf("%s 0 IN NS invalid.", origin) + nsRR, err := dns.New(nsStr) + if err != nil { + return "", fmt.Errorf("building NS: %w", err) + } + records = append(records, nsRR.String()) + + // Version TXT + verStr := fmt.Sprintf("version.%s 0 IN TXT \"2\"", origin) + verRR, err := dns.New(verStr) + if err != nil { + return "", fmt.Errorf("building version TXT: %w", err) + } + records = append(records, verRR.String()) + + // Sort members alphabetically by normalized zone name + sorted := make([]ZoneEntry, len(members)) + copy(sorted, members) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Zone < sorted[j].Zone + }) + + // Check for hash collisions + hashToZone := make(map[string]string) + for _, entry := range sorted { + h := hashZoneName(entry.Zone) + if existing, ok := hashToZone[h]; ok && existing != entry.Zone { + return "", fmt.Errorf("error: %s:%d: hash collision between %s and %s in catalog %q", + entry.File, entry.Line, existing, entry.Zone, catName) + } + hashToZone[h] = entry.Zone + } + + // Member records + for _, entry := range sorted { + h := hashZoneName(entry.Zone) + + // PTR record + ptrOwner := fmt.Sprintf("%s.zones.%s", h, origin) + ptrStr := fmt.Sprintf("%s 0 IN PTR %s", ptrOwner, entry.Zone) + ptrRR, err := dns.New(ptrStr) + if err != nil { + return "", fmt.Errorf("building PTR for %s: %w", entry.Zone, err) + } + records = append(records, ptrRR.String()) + + // Group TXT (optional) + if entry.Group != "" { + groupOwner := fmt.Sprintf("group.%s.zones.%s", h, origin) + groupStr := fmt.Sprintf("%s 0 IN TXT \"%s\"", groupOwner, entry.Group) + groupRR, err := dns.New(groupStr) + if err != nil { + return "", fmt.Errorf("building group TXT for %s: %w", entry.Zone, err) + } + records = append(records, groupRR.String()) + } + + // COO PTR (optional) + if entry.COO != "" { + cooOwner := fmt.Sprintf("coo.%s.zones.%s", h, origin) + cooStr := fmt.Sprintf("%s 0 IN PTR %s", cooOwner, entry.COO) + cooRR, err := dns.New(cooStr) + if err != nil { + return "", fmt.Errorf("building coo PTR for %s: %w", entry.Zone, err) + } + records = append(records, cooRR.String()) + } + } + + return strings.Join(records, "\n") + "\n", nil +} + +// readExistingSerial reads an existing zone file and extracts the SOA serial. +// Returns 0, nil if the file doesn't exist. +func readExistingSerial(path string) (uint32, error) { + f, err := os.Open(path) + if os.IsNotExist(err) { + return 0, nil + } + if err != nil { + return 0, fmt.Errorf("reading existing zone %s: %w", path, err) + } + defer f.Close() + + zp := dns.NewZoneParser(f, "", path) + for rr, ok := zp.Next(); ok; rr, ok = zp.Next() { + if soa, ok := rr.(*dns.SOA); ok { + return soa.Serial, nil + } + } + if err := zp.Err(); err != nil { + return 0, fmt.Errorf("parsing existing zone %s: %w", path, err) + } + + return 0, nil +} + +// readExistingContent reads the full content of an existing zone file. +// Returns empty string if file doesn't exist. +func readExistingContent(path string) (string, error) { + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return "", nil + } + if err != nil { + return "", err + } + return string(data), nil +} + +// defaultSerial returns a serial for today with sequence 01: YYYYMMDD01. +func defaultSerial(now time.Time) uint32 { + return uint32(now.Year())*1000000 + + uint32(now.Month())*10000 + + uint32(now.Day())*100 + 1 +} + +// bumpSerial increments a serial. If same date, bumps the sequence number. +// If different date, starts at YYYYMMDD01. +// Returns error if sequence reaches 99 and needs another bump. +func bumpSerial(old uint32, now time.Time) (uint32, error) { + todayBase := uint32(now.Year())*1000000 + + uint32(now.Month())*10000 + + uint32(now.Day())*100 + + if old >= todayBase && old < todayBase+100 { + // Same date, bump sequence + seq := old - todayBase + if seq >= 99 { + return 0, fmt.Errorf("serial overflow: already at %d, cannot bump further on the same date", old) + } + return old + 1, nil + } + + // Different date or old serial doesn't follow our format + return todayBase + 1, nil +} + +// processCatalog handles generating and writing a single catalog zone file. +// Returns true if the file was written (changed), false if unchanged. +func processCatalog(catName string, cfg *Config, members []ZoneEntry, outputDir string, now time.Time) (bool, error) { + catCfg := cfg.Catalogs[catName] + outputPath := outputDir + "/" + catCfg.Zone + "zone" + + // Read existing serial + oldSerial, err := readExistingSerial(outputPath) + if err != nil { + return false, err + } + + // Use old serial or default + serial := oldSerial + if serial == 0 { + serial = defaultSerial(now) + } + + // Generate with current serial + content, err := generateCatalogZone(catName, cfg, members, serial) + if err != nil { + return false, err + } + + // Compare with existing file + existing, err := readExistingContent(outputPath) + if err != nil { + return false, err + } + + if content == existing { + return false, nil + } + + // Content changed — bump serial only if we had an existing file. + // For new files, the default serial is already correct. + if oldSerial != 0 { + newSerial, err := bumpSerial(serial, now) + if err != nil { + return false, err + } + if newSerial != serial { + content, err = generateCatalogZone(catName, cfg, members, newSerial) + if err != nil { + return false, err + } + } + } + + if err := os.WriteFile(outputPath, []byte(content), 0o644); err != nil { + return false, fmt.Errorf("writing zone file %s: %w", outputPath, err) + } + + return true, nil +} diff --git a/catalog_test.go b/catalog_test.go new file mode 100644 index 0000000..72414e9 --- /dev/null +++ b/catalog_test.go @@ -0,0 +1,332 @@ +package main + +import ( + "testing" +) + +func TestHashZoneName(t *testing.T) { + tests := []struct { + zone string + want string + }{ + {"example.com.", "lq2vr60"}, + {"example.org.", "an5oaj8"}, + // Same input should always produce same output + {"example.com.", "lq2vr60"}, + } + + for _, tt := range tests { + got := hashZoneName(tt.zone) + if got != tt.want { + t.Errorf("hashZoneName(%q) = %q, want %q", tt.zone, got, tt.want) + } + } +} + +func TestHashZoneNameUniqueness(t *testing.T) { + zones := []string{ + "example.com.", + "example.org.", + "example.net.", + "test.example.com.", + "app.example.org.", + "zone.example.org.", + "zone.example.com.", + "test.example.net.", + } + + hashes := make(map[string]string) + for _, zone := range zones { + h := hashZoneName(zone) + if existing, ok := hashes[h]; ok { + t.Errorf("hash collision: %q and %q both hash to %q", existing, zone, h) + } + hashes[h] = zone + } +} + +func TestBumpSerial(t *testing.T) { + tests := []struct { + name string + old uint32 + year int + month int + day int + want uint32 + wantErr bool + }{ + { + name: "new serial from zero", + old: 0, + year: 2026, month: 3, day: 2, + want: 2026030201, + }, + { + name: "bump same date", + old: 2026030201, + year: 2026, month: 3, day: 2, + want: 2026030202, + }, + { + name: "new date", + old: 2026030205, + year: 2026, month: 3, day: 3, + want: 2026030301, + }, + { + name: "old serial different format", + old: 12345, + year: 2026, month: 3, day: 2, + want: 2026030201, + }, + { + name: "overflow at 99", + old: 2026030299, + year: 2026, month: 3, day: 2, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + now := fixedTime(tt.year, tt.month, tt.day) + got, err := bumpSerial(tt.old, now) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("bumpSerial(%d) = %d, want %d", tt.old, got, tt.want) + } + }) + } +} + +func TestDefaultSerial(t *testing.T) { + now := fixedTime(2026, 3, 2) + got := defaultSerial(now) + want := uint32(2026030201) + if got != want { + t.Errorf("defaultSerial() = %d, want %d", got, want) + } +} + +func TestGenerateCatalogZone(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{ + "cat1": {Zone: "catalog.example.com."}, + }, + SOA: SOAConfig{ + Mname: "ns1.example.com.", + Rname: "hostmaster.example.com.", + }, + } + + 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}, + } + + content, err := generateCatalogZone("cat1", cfg, members, 2026030201) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify SOA is first line + lines := splitLines(content) + if len(lines) < 4 { + t.Fatalf("expected at least 4 lines, got %d", len(lines)) + } + + // SOA should contain the serial + assertContains(t, lines[0], "2026030201") + assertContains(t, lines[0], "SOA") + + // NS should be second + assertContains(t, lines[1], "NS") + assertContains(t, lines[1], "invalid.") + + // Version TXT should be third + assertContains(t, lines[2], "TXT") + assertContains(t, lines[2], "\"2\"") + + // a.example.com should come before b.example.com (sorted) + aIdx := -1 + bIdx := -1 + for i, line := range lines { + if containsStr(line, "a.example.com.") { + if aIdx == -1 { + aIdx = i + } + } + if containsStr(line, "b.example.com.") { + if bIdx == -1 { + bIdx = i + } + } + } + if aIdx == -1 || bIdx == -1 { + t.Fatal("expected both a.example.com and b.example.com in output") + } + if aIdx >= bIdx { + t.Errorf("a.example.com (line %d) should come before b.example.com (line %d)", aIdx, bIdx) + } + + // a.example.com should have a group TXT + foundGroup := false + for _, line := range lines { + if containsStr(line, "group.") && containsStr(line, "\"mygroup\"") { + foundGroup = true + } + } + if !foundGroup { + t.Error("expected group TXT record for a.example.com") + } +} + +func TestGenerateCatalogZoneCOO(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{ + "cat1": {Zone: "catalog.example.com."}, + }, + SOA: SOAConfig{ + Mname: "ns1.example.com.", + Rname: "hostmaster.example.com.", + }, + } + + members := []ZoneEntry{ + {Zone: "a.example.com.", Catalogs: []string{"cat1"}, COO: "old.example.com.", File: "test", Line: 1}, + } + + content, err := generateCatalogZone("cat1", cfg, members, 2026030201) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + foundCOO := false + for _, line := range splitLines(content) { + if containsStr(line, "coo.") && containsStr(line, "old.example.com.") { + foundCOO = true + } + } + if !foundCOO { + t.Error("expected coo PTR record") + } +} + +func TestReadExistingSerial(t *testing.T) { + t.Run("file does not exist", func(t *testing.T) { + serial, err := readExistingSerial("/nonexistent/path.zone") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if serial != 0 { + t.Errorf("serial = %d, want 0", serial) + } + }) + + t.Run("valid zone file", func(t *testing.T) { + dir := t.TempDir() + path := dir + "/test.zone" + content := "example.com. 0 IN SOA ns1.example.com. hostmaster.example.com. 2026030205 900 600 2147483646 0\nexample.com. 0 IN NS invalid.\n" + writeTestFile(t, dir, "test.zone", content) + + serial, err := readExistingSerial(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if serial != 2026030205 { + t.Errorf("serial = %d, want 2026030205", serial) + } + }) + + t.Run("zone file with no SOA", func(t *testing.T) { + dir := t.TempDir() + path := dir + "/test.zone" + content := "example.com. 0 IN NS invalid.\n" + writeTestFile(t, dir, "test.zone", content) + + serial, err := readExistingSerial(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if serial != 0 { + t.Errorf("serial = %d, want 0 (no SOA found)", serial) + } + }) +} + +func TestReadExistingContent(t *testing.T) { + t.Run("file does not exist", func(t *testing.T) { + content, err := readExistingContent("/nonexistent/path.zone") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if content != "" { + t.Errorf("content = %q, want empty", content) + } + }) + + t.Run("file exists", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "test.zone", "hello\n") + + content, err := readExistingContent(dir + "/test.zone") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if content != "hello\n" { + t.Errorf("content = %q, want %q", content, "hello\n") + } + }) +} + +func TestGenerateCatalogZoneEmpty(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{ + "cat1": {Zone: "catalog.example.com."}, + }, + SOA: SOAConfig{ + Mname: "ns1.example.com.", + Rname: "hostmaster.example.com.", + }, + } + + content, err := generateCatalogZone("cat1", cfg, nil, 2026030201) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + lines := splitLines(content) + if len(lines) != 3 { + t.Fatalf("expected 3 lines (SOA, NS, version TXT), got %d", len(lines)) + } + assertContains(t, lines[0], "SOA") + assertContains(t, lines[1], "NS") + assertContains(t, lines[2], "TXT") +} + +func TestNormalizeFQDN(t *testing.T) { + tests := []struct { + in, want string + }{ + {"example.com.", "example.com."}, + {"example.com", "example.com."}, + {"Example.COM.", "example.com."}, + {" example.com ", "example.com."}, + {"", ""}, + } + + for _, tt := range tests { + got := normalizeFQDN(tt.in) + if got != tt.want { + t.Errorf("normalizeFQDN(%q) = %q, want %q", tt.in, got, tt.want) + } + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..c78ce28 --- /dev/null +++ b/config.go @@ -0,0 +1,78 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// CatalogConfig holds the configuration for a single catalog zone. +type CatalogConfig struct { + Zone string `yaml:"zone"` +} + +// SOAConfig holds the configurable SOA fields. +type SOAConfig struct { + Mname string `yaml:"mname"` + Rname string `yaml:"rname"` +} + +// Config holds the parsed catz.yaml configuration. +type Config struct { + Catalogs map[string]CatalogConfig `yaml:"catalogs"` + SOA SOAConfig `yaml:"soa"` +} + +func loadConfig(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + if err := validateConfig(&cfg); err != nil { + return nil, err + } + + // Normalize zone FQDNs + for name, cat := range cfg.Catalogs { + cat.Zone = normalizeFQDN(cat.Zone) + cfg.Catalogs[name] = cat + } + cfg.SOA.Mname = normalizeFQDN(cfg.SOA.Mname) + cfg.SOA.Rname = normalizeFQDN(cfg.SOA.Rname) + + return &cfg, nil +} + +func validateConfig(cfg *Config) error { + if len(cfg.Catalogs) == 0 { + return fmt.Errorf("config: no catalogs defined") + } + for name, cat := range cfg.Catalogs { + if cat.Zone == "" { + return fmt.Errorf("config: catalog %q has no zone defined", name) + } + } + if cfg.SOA.Mname == "" { + return fmt.Errorf("config: soa.mname is required") + } + if cfg.SOA.Rname == "" { + return fmt.Errorf("config: soa.rname is required") + } + return nil +} + +func normalizeFQDN(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + if name != "" && !strings.HasSuffix(name, ".") { + name += "." + } + return name +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..55b35a1 --- /dev/null +++ b/config_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadConfig(t *testing.T) { + t.Run("valid config", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "catz.yaml", `catalogs: + cat1: + zone: catalog1.example.com. +soa: + mname: ns1.example.com. + rname: hostmaster.example.com. +`) + cfg, err := loadConfig(filepath.Join(dir, "catz.yaml")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Catalogs["cat1"].Zone != "catalog1.example.com." { + t.Errorf("zone = %q, want %q", cfg.Catalogs["cat1"].Zone, "catalog1.example.com.") + } + }) + + t.Run("normalizes FQDNs", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "catz.yaml", `catalogs: + cat1: + zone: Catalog1.Example.COM +soa: + mname: NS1.Example.COM + rname: Hostmaster.Example.COM +`) + cfg, err := loadConfig(filepath.Join(dir, "catz.yaml")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Catalogs["cat1"].Zone != "catalog1.example.com." { + t.Errorf("zone = %q, want normalized FQDN", cfg.Catalogs["cat1"].Zone) + } + if cfg.SOA.Mname != "ns1.example.com." { + t.Errorf("mname = %q, want normalized FQDN", cfg.SOA.Mname) + } + }) + + t.Run("missing file", func(t *testing.T) { + _, err := loadConfig("/nonexistent/catz.yaml") + if err == nil { + t.Fatal("expected error for missing file") + } + }) + + t.Run("invalid YAML", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "catz.yaml", "{{invalid yaml") + _, err := loadConfig(filepath.Join(dir, "catz.yaml")) + if err == nil { + t.Fatal("expected error for invalid YAML") + } + }) +} + +func TestValidateConfig(t *testing.T) { + t.Run("no catalogs", func(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{}, + SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."}, + } + if err := validateConfig(cfg); err == nil { + t.Fatal("expected error for no catalogs") + } + }) + + t.Run("nil catalogs", func(t *testing.T) { + cfg := &Config{ + SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."}, + } + if err := validateConfig(cfg); err == nil { + t.Fatal("expected error for nil catalogs") + } + }) + + t.Run("empty zone", func(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{"cat1": {Zone: ""}}, + SOA: SOAConfig{Mname: "ns1.example.com.", Rname: "hostmaster.example.com."}, + } + if err := validateConfig(cfg); err == nil { + t.Fatal("expected error for empty zone") + } + }) + + t.Run("missing mname", func(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{"cat1": {Zone: "cat.example.com."}}, + SOA: SOAConfig{Rname: "hostmaster.example.com."}, + } + if err := validateConfig(cfg); err == nil { + t.Fatal("expected error for missing mname") + } + }) + + t.Run("missing rname", func(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{"cat1": {Zone: "cat.example.com."}}, + SOA: SOAConfig{Mname: "ns1.example.com."}, + } + if err := validateConfig(cfg); err == nil { + t.Fatal("expected error for missing rname") + } + }) + + t.Run("valid config", 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."}, + } + if err := validateConfig(cfg); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) +} + +func TestLoadConfigWritePermission(t *testing.T) { + // Test that loadConfig returns error for unreadable file + dir := t.TempDir() + path := filepath.Join(dir, "catz.yaml") + writeTestFile(t, dir, "catz.yaml", "catalogs: {}") + if err := os.Chmod(path, 0o000); err != nil { + t.Skip("cannot change file permissions") + } + defer os.Chmod(path, 0o644) + + _, err := loadConfig(path) + if err == nil { + t.Fatal("expected error for unreadable file") + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ebef72b --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module catalog-zone-gen + +go 1.25.5 + +require ( + codeberg.org/miekg/dns v0.6.61 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.40.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..771a6d8 --- /dev/null +++ b/go.sum @@ -0,0 +1,12 @@ +codeberg.org/miekg/dns v0.6.61 h1:EZZG2MWW3IeUQvX+L4Qg/2LrU96yH045p/M8PB1P6AE= +codeberg.org/miekg/dns v0.6.61/go.mod h1:fIxAzBMDPnXWSw0fp8+pfZMRiAqYY4+HHYLzUo/S6Dg= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/helpers_test.go b/helpers_test.go new file mode 100644 index 0000000..a5bcc6a --- /dev/null +++ b/helpers_test.go @@ -0,0 +1,30 @@ +package main + +import ( + "strings" + "testing" + "time" +) + +func fixedTime(year, month, day int) time.Time { + return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC) +} + +func splitLines(s string) []string { + s = strings.TrimRight(s, "\n") + if s == "" { + return nil + } + return strings.Split(s, "\n") +} + +func assertContains(t *testing.T, s, substr string) { + t.Helper() + if !strings.Contains(s, substr) { + t.Errorf("expected %q to contain %q", s, substr) + } +} + +func containsStr(s, substr string) bool { + return strings.Contains(s, substr) +} diff --git a/input.go b/input.go new file mode 100644 index 0000000..8e5d7a4 --- /dev/null +++ b/input.go @@ -0,0 +1,130 @@ +package main + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// 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 +} + +// CatalogMembers groups zone entries by catalog name. +type CatalogMembers map[string][]ZoneEntry + +func parseInput(path string, cfg *Config) (CatalogMembers, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("opening input: %w", err) + } + defer f.Close() + + var entries []ZoneEntry + scanner := bufio.NewScanner(f) + lineNum := 0 + for scanner.Scan() { + lineNum++ + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + entry, err := parseLine(line, path, lineNum) + if err != nil { + return nil, err + } + entries = append(entries, entry) + } + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("reading %s: %w", path, err) + } + + return buildCatalogMembers(entries, cfg) +} + +func parseLine(line, file string, lineNum int) (ZoneEntry, error) { + // Split on whitespace first, then handle comma separation within tokens + tokens := tokenize(line) + if len(tokens) < 2 { + return ZoneEntry{}, fmt.Errorf("error: %s:%d: expected zone name followed by at least one catalog name", file, lineNum) + } + + entry := ZoneEntry{ + Zone: normalizeFQDN(tokens[0]), + File: file, + Line: lineNum, + } + + for _, tok := range tokens[1:] { + if strings.Contains(tok, "=") { + key, value, _ := strings.Cut(tok, "=") + switch strings.ToLower(key) { + case "group": + if value == "" { + return ZoneEntry{}, fmt.Errorf("error: %s:%d: empty group value", file, lineNum) + } + entry.Group = value + case "coo": + if value == "" { + return ZoneEntry{}, fmt.Errorf("error: %s:%d: empty coo value", file, lineNum) + } + entry.COO = normalizeFQDN(value) + default: + return ZoneEntry{}, fmt.Errorf("error: %s:%d: unknown property %q", file, lineNum, key) + } + } else { + // Bare name = catalog assignment + entry.Catalogs = append(entry.Catalogs, tok) + } + } + + if len(entry.Catalogs) == 0 { + return ZoneEntry{}, fmt.Errorf("error: %s:%d: no catalog assignment for zone %s", file, lineNum, entry.Zone) + } + + return entry, nil +} + +// tokenize splits a line on whitespace and commas, stripping empty tokens. +func tokenize(line string) []string { + // Replace commas with spaces, then split on whitespace + line = strings.ReplaceAll(line, ",", " ") + fields := strings.Fields(line) + return fields +} + +func buildCatalogMembers(entries []ZoneEntry, cfg *Config) (CatalogMembers, error) { + members := make(CatalogMembers) + + // Track duplicates: catalog -> zone -> line + seen := make(map[string]map[string]int) + + for _, entry := range entries { + for _, catName := range entry.Catalogs { + if _, ok := cfg.Catalogs[catName]; !ok { + return nil, fmt.Errorf("error: %s:%d: unknown catalog %q", entry.File, entry.Line, catName) + } + + if seen[catName] == nil { + seen[catName] = make(map[string]int) + } + if prevLine, dup := seen[catName][entry.Zone]; dup { + return nil, fmt.Errorf("error: %s:%d: zone %s already assigned to catalog %q at line %d", + entry.File, entry.Line, entry.Zone, catName, prevLine) + } + seen[catName][entry.Zone] = entry.Line + + members[catName] = append(members[catName], entry) + } + } + + return members, nil +} diff --git a/input_test.go b/input_test.go new file mode 100644 index 0000000..5a46e3e --- /dev/null +++ b/input_test.go @@ -0,0 +1,277 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseLine(t *testing.T) { + tests := []struct { + name string + line string + wantZone string + wantCats []string + wantGrp string + wantCOO string + wantErr bool + }{ + { + name: "simple single catalog", + line: "zone.example.org catalog1", + wantZone: "zone.example.org.", + wantCats: []string{"catalog1"}, + }, + { + name: "multiple catalogs comma separated", + line: "zone.example.org catalog1, catalog2", + wantZone: "zone.example.org.", + wantCats: []string{"catalog1", "catalog2"}, + }, + { + name: "with group", + line: "test.example.net catalog1, group=internal", + wantZone: "test.example.net.", + wantCats: []string{"catalog1"}, + wantGrp: "internal", + }, + { + name: "with coo", + line: "zone.example.com catalog2, coo=old-catalog.example.com.", + wantZone: "zone.example.com.", + wantCats: []string{"catalog2"}, + wantCOO: "old-catalog.example.com.", + }, + { + name: "with group and coo", + line: "app.example.org catalog1, group=external, coo=migrated.example.com.", + wantZone: "app.example.org.", + wantCats: []string{"catalog1"}, + wantGrp: "external", + wantCOO: "migrated.example.com.", + }, + { + name: "trailing dot on zone", + line: "zone.example.org. catalog1", + wantZone: "zone.example.org.", + wantCats: []string{"catalog1"}, + }, + { + name: "no catalog", + line: "zone.example.org", + wantErr: true, + }, + { + name: "only properties no catalog", + line: "zone.example.org group=foo", + wantErr: true, + }, + { + name: "unknown property", + line: "zone.example.org catalog1, foo=bar", + wantErr: true, + }, + { + name: "empty group value", + line: "zone.example.org catalog1, group=", + wantErr: true, + }, + { + name: "empty coo value", + line: "zone.example.org catalog1, coo=", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + entry, err := parseLine(tt.line, "test.txt", 1) + if tt.wantErr { + if err == nil { + t.Fatal("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if entry.Zone != tt.wantZone { + t.Errorf("zone = %q, want %q", entry.Zone, tt.wantZone) + } + if len(entry.Catalogs) != len(tt.wantCats) { + t.Fatalf("catalogs = %v, want %v", entry.Catalogs, tt.wantCats) + } + for i, cat := range entry.Catalogs { + if cat != tt.wantCats[i] { + t.Errorf("catalog[%d] = %q, want %q", i, cat, tt.wantCats[i]) + } + } + if entry.Group != tt.wantGrp { + t.Errorf("group = %q, want %q", entry.Group, tt.wantGrp) + } + if entry.COO != tt.wantCOO { + t.Errorf("coo = %q, want %q", entry.COO, tt.wantCOO) + } + }) + } +} + +func TestTokenize(t *testing.T) { + tests := []struct { + line string + want []string + }{ + {"zone.example.org catalog1, catalog2", []string{"zone.example.org", "catalog1", "catalog2"}}, + {"zone.example.org catalog1,catalog2", []string{"zone.example.org", "catalog1", "catalog2"}}, + {"zone.example.org catalog1 , catalog2", []string{"zone.example.org", "catalog1", "catalog2"}}, + {"zone.example.org\tcatalog1", []string{"zone.example.org", "catalog1"}}, + } + + for _, tt := range tests { + got := tokenize(tt.line) + if len(got) != len(tt.want) { + t.Errorf("tokenize(%q) = %v, want %v", tt.line, got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("tokenize(%q)[%d] = %q, want %q", tt.line, i, got[i], tt.want[i]) + } + } + } +} + +func TestBuildCatalogMembers(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{ + "catalog1": {Zone: "catalog1.example.com."}, + "catalog2": {Zone: "catalog2.example.com."}, + }, + } + + 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}, + } + + members, err := buildCatalogMembers(entries, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(members["catalog1"]) != 2 { + t.Errorf("catalog1 members = %d, want 2", len(members["catalog1"])) + } + if len(members["catalog2"]) != 1 { + t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"])) + } + }) + + t.Run("unknown catalog", func(t *testing.T) { + entries := []ZoneEntry{ + {Zone: "a.example.com.", Catalogs: []string{"unknown"}, File: "test", Line: 1}, + } + _, err := buildCatalogMembers(entries, cfg) + if err == nil { + t.Fatal("expected error for unknown catalog") + } + }) + + 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}, + } + _, err := buildCatalogMembers(entries, cfg) + if err == nil { + t.Fatal("expected error for duplicate zone") + } + }) +} + +func TestParseInput(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{ + "catalog1": {Zone: "catalog1.example.com."}, + "catalog2": {Zone: "catalog2.example.com."}, + }, + } + + dir := t.TempDir() + inputPath := filepath.Join(dir, "zones.txt") + + content := `# Comment +zone.example.org catalog1, catalog2 + +test.example.net catalog1, group=internal +` + if err := os.WriteFile(inputPath, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + members, err := parseInput(inputPath, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(members["catalog1"]) != 2 { + t.Errorf("catalog1 members = %d, want 2", len(members["catalog1"])) + } + if len(members["catalog2"]) != 1 { + t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"])) + } +} + +func TestParseInputErrors(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{ + "catalog1": {Zone: "catalog1.example.com."}, + }, + } + + t.Run("missing file", func(t *testing.T) { + _, err := parseInput("/nonexistent/zones.txt", cfg) + if err == nil { + t.Fatal("expected error for missing file") + } + }) + + t.Run("invalid line in input", func(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "zones.txt") + if err := os.WriteFile(path, []byte("zone-with-no-catalog\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := parseInput(path, cfg) + if err == nil { + t.Fatal("expected error for invalid line") + } + }) +} + +func TestBuildCatalogMembersSameZoneDifferentCatalogs(t *testing.T) { + cfg := &Config{ + Catalogs: map[string]CatalogConfig{ + "catalog1": {Zone: "catalog1.example.com."}, + "catalog2": {Zone: "catalog2.example.com."}, + }, + } + + // Same zone in different catalogs is OK + entries := []ZoneEntry{ + {Zone: "a.example.com.", Catalogs: []string{"catalog1", "catalog2"}, File: "test", Line: 1}, + } + + members, err := buildCatalogMembers(entries, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(members["catalog1"]) != 1 { + t.Errorf("catalog1 members = %d, want 1", len(members["catalog1"])) + } + if len(members["catalog2"]) != 1 { + t.Errorf("catalog2 members = %d, want 1", len(members["catalog2"])) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..44376be --- /dev/null +++ b/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "flag" + "fmt" + "os" + "path/filepath" + "sort" + "time" +) + +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)") + flag.Usage = func() { + fmt.Fprintf(os.Stderr, "Usage: catalog-zone-gen [--config path] [--output-dir path] \n") + flag.PrintDefaults() + } + flag.Parse() + + if flag.NArg() != 1 { + flag.Usage() + os.Exit(1) + } + + inputFile := flag.Arg(0) + inputDir := filepath.Dir(inputFile) + + if *configPath == "" { + *configPath = filepath.Join(inputDir, "catz.yaml") + } + if *outputDir == "" { + *outputDir = inputDir + } + + cfg, err := loadConfig(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %s\n", err) + os.Exit(1) + } + + members, err := parseInput(inputFile, cfg) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } + + now := time.Now().UTC() + + // Process catalogs in sorted order for deterministic output + catNames := make([]string, 0, len(members)) + for name := range members { + catNames = append(catNames, name) + } + sort.Strings(catNames) + + hasErrors := false + for _, catName := range catNames { + changed, err := processCatalog(catName, cfg, members[catName], *outputDir, now) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + hasErrors = true + continue + } + catZone := cfg.Catalogs[catName].Zone + if changed { + fmt.Fprintf(os.Stderr, "%s%s: updated\n", catZone, "zone") + } else { + fmt.Fprintf(os.Stderr, "%s%s: unchanged\n", catZone, "zone") + } + } + + if hasErrors { + os.Exit(1) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..074741c --- /dev/null +++ b/main_test.go @@ -0,0 +1,425 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestIntegrationEndToEnd(t *testing.T) { + dir := t.TempDir() + + // Write config + configContent := `catalogs: + catalog1: + zone: catalog1.example.com. + catalog2: + zone: catalog2.example.com. + +soa: + mname: ns1.example.com. + rname: hostmaster.example.com. +` + writeTestFile(t, dir, "catz.yaml", configContent) + + // Write input + inputContent := `# Test 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. +` + writeTestFile(t, dir, "zones.txt", inputContent) + + cfg, err := loadConfig(filepath.Join(dir, "catz.yaml")) + if err != nil { + t.Fatalf("loadConfig: %v", err) + } + + members, err := parseInput(filepath.Join(dir, "zones.txt"), cfg) + if err != nil { + t.Fatalf("parseInput: %v", err) + } + + now := fixedTime(2026, 1, 15) + + // First run: should create files + for catName, catMembers := range members { + changed, err := processCatalog(catName, cfg, catMembers, dir, now) + if err != nil { + t.Fatalf("processCatalog(%s): %v", catName, err) + } + if !changed { + t.Errorf("expected %s to be changed on first run", catName) + } + } + + // Verify files exist + cat1Path := filepath.Join(dir, "catalog1.example.com.zone") + cat2Path := filepath.Join(dir, "catalog2.example.com.zone") + + cat1Content := readTestFile(t, cat1Path) + cat2Content := readTestFile(t, cat2Path) + + // Verify catalog1 content + assertContains(t, cat1Content, "catalog1.example.com.\t0\tIN\tSOA") + assertContains(t, cat1Content, "catalog1.example.com.\t0\tIN\tNS\tinvalid.") + assertContains(t, cat1Content, "version.catalog1.example.com.\t0\tIN\tTXT\t\"2\"") + assertContains(t, cat1Content, "IN\tPTR\tapp.example.org.") + assertContains(t, cat1Content, "IN\tPTR\ttest.example.net.") + assertContains(t, cat1Content, "IN\tPTR\tzone.example.org.") + assertContains(t, cat1Content, "IN\tTXT\t\"internal\"") + assertContains(t, cat1Content, "IN\tTXT\t\"external\"") + assertContains(t, cat1Content, "IN\tPTR\tmigrated.example.com.") + + // Verify catalog2 content + assertContains(t, cat2Content, "catalog2.example.com.\t0\tIN\tSOA") + assertContains(t, cat2Content, "IN\tPTR\tzone.example.com.") + assertContains(t, cat2Content, "IN\tPTR\tzone.example.org.") + assertContains(t, cat2Content, "IN\tPTR\told-catalog.example.com.") + + // Verify serial format + assertContains(t, cat1Content, "2026011501") + + // Second run (same time): should be unchanged + for catName, catMembers := range members { + changed, err := processCatalog(catName, cfg, catMembers, dir, now) + if err != nil { + t.Fatalf("processCatalog(%s) second run: %v", catName, err) + } + if changed { + t.Errorf("expected %s to be unchanged on second run", catName) + } + } + + // Verify content hasn't changed + cat1After := readTestFile(t, cat1Path) + if cat1After != cat1Content { + t.Error("catalog1 content changed on idempotent run") + } +} + +func TestIntegrationSerialBump(t *testing.T) { + dir := t.TempDir() + + configContent := `catalogs: + cat1: + zone: cat1.example.com. + +soa: + mname: ns1.example.com. + rname: hostmaster.example.com. +` + writeTestFile(t, dir, "catz.yaml", configContent) + + cfg, err := loadConfig(filepath.Join(dir, "catz.yaml")) + if err != nil { + t.Fatal(err) + } + + members1 := []ZoneEntry{ + {Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1}, + } + + now := fixedTime(2026, 1, 15) + + // First run + changed, err := processCatalog("cat1", cfg, members1, dir, now) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Error("expected changed on first run") + } + + content1 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone")) + assertContains(t, content1, "2026011501") + + // 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}, + } + + changed, err = processCatalog("cat1", cfg, members2, dir, now) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Error("expected changed when input differs") + } + + content2 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone")) + assertContains(t, content2, "2026011502") + + // Third run with same input: unchanged + changed, err = processCatalog("cat1", cfg, members2, dir, now) + if err != nil { + t.Fatal(err) + } + if changed { + t.Error("expected unchanged on third run") + } + + // Fourth run next day with same input: new serial + nextDay := fixedTime(2026, 1, 16) + changed, err = processCatalog("cat1", cfg, members2, dir, nextDay) + if err != nil { + t.Fatal(err) + } + // Content hasn't changed, so this should be unchanged even though date differs + if changed { + t.Error("expected unchanged when content hasn't changed") + } +} + +func TestIntegrationSortOrder(t *testing.T) { + dir := t.TempDir() + + configContent := `catalogs: + cat1: + zone: cat1.example.com. + +soa: + mname: ns1.example.com. + rname: hostmaster.example.com. +` + writeTestFile(t, dir, "catz.yaml", configContent) + + cfg, err := loadConfig(filepath.Join(dir, "catz.yaml")) + if err != nil { + t.Fatal(err) + } + + // 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}, + } + + now := fixedTime(2026, 1, 15) + _, err = processCatalog("cat1", cfg, members, dir, now) + if err != nil { + t.Fatal(err) + } + + content := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone")) + lines := splitLines(content) + + // Find PTR lines and verify order + var ptrZones []string + for _, line := range lines { + if containsStr(line, "\tPTR\t") && !containsStr(line, "coo.") { + // Extract the PTR target + parts := strings.Split(line, "\t") + ptrZones = append(ptrZones, parts[len(parts)-1]) + } + } + + if len(ptrZones) != 3 { + t.Fatalf("expected 3 PTR records, got %d", len(ptrZones)) + } + if ptrZones[0] != "a.example.com." || ptrZones[1] != "m.example.com." || ptrZones[2] != "z.example.com." { + t.Errorf("PTR records not sorted: %v", ptrZones) + } +} + +func TestIntegrationCLI(t *testing.T) { + // Build the binary + 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 := "a.example.com. catalog1\n" + writeTestFile(t, dir, "zones.txt", inputContent) + + // Run the binary + cmd = exec.Command(binary, "--config", filepath.Join(dir, "catz.yaml"), "--output-dir", dir, filepath.Join(dir, "zones.txt")) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("catalog-zone-gen failed: %v\n%s", err, out) + } + + // Verify output file exists + zonePath := filepath.Join(dir, "catalog1.example.com.zone") + if _, err := os.Stat(zonePath); err != nil { + t.Fatalf("output file not created: %v", err) + } + + content := readTestFile(t, zonePath) + assertContains(t, content, "catalog1.example.com.") + assertContains(t, content, "a.example.com.") +} + +func TestIntegrationCLIErrors(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) + } + + t.Run("no arguments", func(t *testing.T) { + cmd := exec.Command(binary) + err := cmd.Run() + if err == nil { + t.Error("expected error with no arguments") + } + }) + + t.Run("missing input file", func(t *testing.T) { + cmd := exec.Command(binary, "/nonexistent/zones.txt") + err := cmd.Run() + if err == nil { + t.Error("expected error with missing input file") + } + }) + + t.Run("unknown catalog in input", func(t *testing.T) { + dir := t.TempDir() + writeTestFile(t, dir, "catz.yaml", `catalogs: + cat1: + zone: cat1.example.com. +soa: + mname: ns1.example.com. + rname: hostmaster.example.com. +`) + writeTestFile(t, dir, "zones.txt", "a.example.com. unknown_catalog\n") + + cmd := exec.Command(binary, + "--config", filepath.Join(dir, "catz.yaml"), + "--output-dir", dir, + filepath.Join(dir, "zones.txt")) + out, err := cmd.CombinedOutput() + if err == nil { + t.Error("expected error for unknown catalog") + } + if !containsStr(string(out), "unknown catalog") { + t.Errorf("expected 'unknown catalog' in error output, got: %s", out) + } + }) +} + +func writeTestFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func projectDir(t *testing.T) string { + t.Helper() + // Find the project root by looking for go.mod + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + t.Fatal("could not find project root (go.mod)") + } + dir = parent + } +} + +// Verify the serial stays the same when the same time is used but content changes on a new day. +func TestIntegrationSerialNewDay(t *testing.T) { + dir := t.TempDir() + + configContent := `catalogs: + cat1: + zone: cat1.example.com. + +soa: + mname: ns1.example.com. + rname: hostmaster.example.com. +` + writeTestFile(t, dir, "catz.yaml", configContent) + + cfg, err := loadConfig(filepath.Join(dir, "catz.yaml")) + if err != nil { + t.Fatal(err) + } + + members := []ZoneEntry{ + {Zone: "a.example.com.", Catalogs: []string{"cat1"}, File: "test", Line: 1}, + } + + day1 := fixedTime(2026, 1, 15) + _, err = processCatalog("cat1", cfg, members, dir, day1) + if err != nil { + t.Fatal(err) + } + + content1 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone")) + assertContains(t, content1, "2026011501") + + // 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}, + } + + day2 := fixedTime(2026, 1, 16) + changed, err := processCatalog("cat1", cfg, members2, dir, day2) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Error("expected changed with new zone") + } + + content2 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone")) + assertContains(t, content2, "2026011601") + + // Simulate not using the tool for a while, running on a much later date + day3 := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC) + 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}, + } + + changed, err = processCatalog("cat1", cfg, members3, dir, day3) + if err != nil { + t.Fatal(err) + } + if !changed { + t.Error("expected changed with new zone") + } + + content3 := readTestFile(t, filepath.Join(dir, "cat1.example.com.zone")) + assertContains(t, content3, "2026061501") +} diff --git a/testdata/catz.yaml b/testdata/catz.yaml new file mode 100644 index 0000000..a67f0f0 --- /dev/null +++ b/testdata/catz.yaml @@ -0,0 +1,9 @@ +catalogs: + catalog1: + zone: catalog1.example.com. + catalog2: + zone: catalog2.example.com. + +soa: + mname: ns1.example.com. + rname: hostmaster.example.com. diff --git a/testdata/zones.txt b/testdata/zones.txt new file mode 100644 index 0000000..8433f38 --- /dev/null +++ b/testdata/zones.txt @@ -0,0 +1,5 @@ +# 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.