Files
traefik-dns-watcher/dns.go
2026-03-15 19:34:38 +03:00

136 lines
3.7 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
// ZoneFile is the in-memory representation of a dynamic OctoDNS zone YAML file.
// Keys are subdomain labels (empty string = apex record).
// Values are either a single record map or a list of record maps (for multi-type).
type ZoneFile map[string]interface{}
// RecordStats counts the changes between two zone states.
type RecordStats struct {
Added int
Removed int
Changed int
}
// ReadZoneFile parses an OctoDNS YAML zone file.
// If the file does not exist, returns an empty ZoneFile (not an error).
func ReadZoneFile(path string) (ZoneFile, error) {
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return ZoneFile{}, nil
}
if err != nil {
return nil, fmt.Errorf("read zone file %q: %w", path, err)
}
var zf ZoneFile
if err := yaml.Unmarshal(data, &zf); err != nil {
return nil, fmt.Errorf("parse zone file %q: %w", path, err)
}
if zf == nil {
zf = ZoneFile{}
}
return zf, nil
}
// WriteZoneFile writes an OctoDNS YAML zone file, creating parent directories as needed.
// yaml.v3 sorts map keys alphabetically, ensuring deterministic output.
func WriteZoneFile(path string, zf ZoneFile) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create directory for zone file: %w", err)
}
data, err := yaml.Marshal(map[string]interface{}(zf))
if err != nil {
return fmt.Errorf("marshal zone file: %w", err)
}
if err := os.WriteFile(path, data, 0o644); err != nil {
return fmt.Errorf("write zone file %q: %w", path, err)
}
return nil
}
// BuildZoneFile constructs the desired zone file from the set of active subdomains.
func BuildZoneFile(subdomains map[string]struct{}, cfg *Config) ZoneFile {
zf := make(ZoneFile, len(subdomains))
for sub := range subdomains {
zf[sub] = buildRecord(cfg)
}
return zf
}
// buildRecord creates the YAML-serialisable record value for a subdomain.
// Returns a single-record map (OctoDNS simple syntax) when only IPv4 is configured,
// or a two-element list (OctoDNS list syntax) when both A and AAAA are required.
func buildRecord(cfg *Config) interface{} {
aRec := map[string]interface{}{
"ttl": cfg.RecordTTL,
"type": "A",
"values": []string{cfg.PublicIP},
}
if cfg.CloudflareAutoTTL {
aRec["octodns"] = map[string]interface{}{
"cloudflare": map[string]interface{}{
"auto-ttl": true,
},
}
}
if cfg.PublicIPv6 == "" {
return aRec
}
aaaaRec := map[string]interface{}{
"ttl": cfg.RecordTTL,
"type": "AAAA",
"values": []string{cfg.PublicIPv6},
}
if cfg.CloudflareAutoTTL {
aaaaRec["octodns"] = map[string]interface{}{
"cloudflare": map[string]interface{}{
"auto-ttl": true,
},
}
}
return []interface{}{aRec, aaaaRec}
}
// DiffZoneFile computes the difference between the current (on-disk) and desired zone files.
// It reports how many records were added, removed, or changed.
func DiffZoneFile(current, desired ZoneFile) RecordStats {
var stats RecordStats
for k := range desired {
if _, exists := current[k]; !exists {
stats.Added++
}
}
for k := range current {
if _, exists := desired[k]; !exists {
stats.Removed++
}
}
// Detect value changes (e.g. IP or TTL updated in config).
for k, dv := range desired {
cv, exists := current[k]
if !exists {
continue
}
// Marshal both sides to compare: handles nested maps and slices uniformly.
cs, _ := yaml.Marshal(cv)
ds, _ := yaml.Marshal(dv)
if string(cs) != string(ds) {
stats.Changed++
}
}
return stats
}
// ZoneFilePath returns the absolute path to the dynamic zone YAML file for a given zone.
func ZoneFilePath(cfg *Config, zone string) string {
return filepath.Join(cfg.RepoPath, cfg.DynamicDir, zone+".yaml")
}