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 }