package localzone import ( "fmt" "strings" "git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile" ) // RenderZone renders a complete zone from declared records, for sources that // own a zone outright (a Kubernetes DNSZone) rather than editing a file in // place. It runs the same checks the console writer does: every record goes // through newRecord, a delegation may not shadow data, and the rendering is // re-parsed and compared with the records before it is returned, so a renderer // bug surfaces as an error here and not as a zone that silently stopped // answering. // // serial is the SOA serial; 0 means the writer's default. A declarative source // should pass something that changes exactly when the spec does, so two // replicas rendering the same object produce the same bytes. func RenderZone(origin string, def ZoneDefaults, recs []Record, serial uint32) (string, error) { origin, err := NormalizeOrigin(origin) if err != nil { return "", fmt.Errorf("zone name: %w", err) } f, err := newFileState(origin, def) if err != nil { return "", err } for _, in := range recs { rec, err := newRecord(origin, in.Name, in.Type, ttlOr(in.TTL, f.ttl), in.Value) if err != nil { return "", err } for _, have := range f.recs { if have.Key() == rec.Key() && have.Value == rec.Value { return "", fmt.Errorf("%s %s is declared twice", rec.Name, rec.Type) } } f.recs = append(f.recs, rec) } if err := f.finish(); err != nil { return "", err } if serial != 0 { f.soa.Serial = serial } text := f.render() check, err := parseFileState(origin, text) if err != nil { return "", fmt.Errorf("the zone file for %s did not render correctly: %w", origin, err) } if err := sameRecords(check.recs, f.recs); err != nil { return "", fmt.Errorf("the zone file for %s did not render correctly: %w", origin, err) } return strings.Replace(text, FileHeader, ZoneSourceHeader, 1), nil } // ZoneSourceHeader tops a rendered zone that an external object owns. const ZoneSourceHeader = `; fancy-dns zone rendered from a Kubernetes DNSZone object. ; ; Regenerated whenever the object changes; edit the DNSZone, not this file. ` // CanonicalRecord returns a record in the form the zone store holds it: the // owner fully qualified, the type uppercased, the RDATA as the master-file // parser reads it. An editor compares records in this form, so "www" and // "www.example.com." are the same record. func CanonicalRecord(origin string, in Record) (Record, error) { origin, err := NormalizeOrigin(origin) if err != nil { return Record{}, fmt.Errorf("zone name: %w", err) } rec, err := newRecord(origin, in.Name, in.Type, ttlOr(in.TTL, DefaultZoneTTL), in.Value) if err != nil { return Record{}, err } rec.TTL = in.TTL // 0 still means "the zone's default" return rec, nil } // ZoneFile is a master file read back into the form a DNSZone holds it in. type ZoneFile struct { // TTL is the file's $TTL, or the default when it has none. TTL uint32 // Soa is the SOA rdata ("ns mbox serial refresh retry expire minimum"), or // "" when the file has none. Soa string Records []Record } // ParseZoneFile reads an RFC 1035 master file for the zone origin. Records come // back canonical (absolute owner, uppercase type, rdata as the parser prints // it) and without the SOA, which is returned separately. It is the reading half // of a migration from master files to DNSZone objects. func ParseZoneFile(origin, text string) (ZoneFile, error) { origin, err := NormalizeOrigin(origin) if err != nil { return ZoneFile{}, fmt.Errorf("zone name: %w", err) } z, err := zonefile.Parse(strings.NewReader(text), zonefile.Options{Origin: origin, File: origin + "zone"}) if err != nil { return ZoneFile{}, err } f := fileStateFromZone(z, origin) out := ZoneFile{TTL: f.ttl, Records: f.recs} if f.soa != nil { out.Soa = fmt.Sprintf("%s %s %d %d %d %d %d", f.soa.Ns, f.soa.Mbox, f.soa.Serial, f.soa.Refresh, f.soa.Retry, f.soa.Expire, f.soa.Minttl) } return out, nil }