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") }