Merge readExistingSerial and readExistingContent into a single readExisting function to eliminate duplicate file I/O. Extract dateBase helper to deduplicate serial formula between defaultSerial and bumpSerial. Cache hash results during collision check to avoid recomputing per member. Normalize error prefixes (remove "error:" from fmt.Errorf, add uniformly at print sites). Use filepath.Join instead of manual "/" concatenation. Replace trivial containsStr wrapper with strings.Contains. Simplify tokenize to a single return. Use writeTestFile and fixedTime helpers consistently in tests.
239 lines
6.5 KiB
Go
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.File, 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
|
|
}
|