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.
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.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
|
|
}
|