Files
catz/catalog.go
Ask Bjørn Hansen 0eddb9fcfe Add --bind-conf flag for BIND domains.conf generation
Generate a BIND-format domains.conf file alongside catalog zones.
New input properties: file= (zone data path) and dnssec (bare flag).
When --bind-conf is set, every zone must have file= or it errors.

Renames ZoneEntry.File to ZonesFile (input path for error messages)
and adds ZoneFile (BIND file path) and DNSSEC (bool) fields.
2026-03-28 11:15:06 -07:00

239 lines
6.5 KiB
Go

package main
import (
"encoding/base32"
"fmt"
"hash/fnv"
"os"
"path/filepath"
"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 := [4]byte{
byte(sum >> 24),
byte(sum >> 16),
byte(sum >> 8),
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 and cache results
hashToZone := make(map[string]string, len(sorted))
zoneHash := make(map[string]string, len(sorted))
for _, entry := range sorted {
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.ZonesFile, entry.Line, existing, entry.Zone, catName)
}
hashToZone[h] = entry.Zone
zoneHash[entry.Zone] = h
}
// Member records
for _, entry := range sorted {
h := zoneHash[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
}
// readExisting reads an existing zone file and returns its content and SOA serial.
// Returns ("", 0, nil) if the file doesn't exist.
func readExisting(path string) (string, uint32, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return "", 0, nil
}
if err != nil {
return "", 0, fmt.Errorf("reading existing zone %s: %w", path, err)
}
content := string(data)
zp := dns.NewZoneParser(strings.NewReader(content), "", path)
for rr, ok := zp.Next(); ok; rr, ok = zp.Next() {
if soa, ok := rr.(*dns.SOA); ok {
return content, soa.Serial, nil
}
}
if err := zp.Err(); err != nil {
return "", 0, fmt.Errorf("parsing existing zone %s: %w", path, err)
}
return content, 0, nil
}
// dateBase returns the YYYYMMDD00 serial base for the given time.
func dateBase(now time.Time) uint32 {
return uint32(now.Year())*1000000 +
uint32(now.Month())*10000 +
uint32(now.Day())*100
}
// defaultSerial returns a serial for today with sequence 01: YYYYMMDD01.
func defaultSerial(now time.Time) uint32 {
return dateBase(now) + 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 := dateBase(now)
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 := filepath.Join(outputDir, catCfg.Zone+"zone")
// Read existing file (content + serial in one pass)
existing, oldSerial, err := readExisting(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
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
}