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 }