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.
This commit is contained in:
249
catalog.go
Normal file
249
catalog.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user