Files
catz/catalog.go
Ask Bjørn Hansen 1f2f39f40c 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.
2026-02-28 16:13:58 -08:00

250 lines
6.6 KiB
Go

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
}