package localzone import ( "errors" "fmt" "slices" "sort" "strconv" "strings" "time" "github.com/miekg/dns" "git.k8s-git-k6n.net/mats/fancy-dns/internal/zonefile" ) // DefaultZoneTTL is the $TTL a rendered zone gets when its spec does not name // one. It is also the SOA minimum, so a record written without a // TTL of its own inherits it. const DefaultZoneTTL uint32 = 300 // editableTypes are the RR types the API accepts. It is a subset of what the // master-file assembler recognises, and of the types a zone browser can put // in a form: SOA is zone metadata the writer owns, and AXFR/IXFR/ANY are not // things a console should be able to add to a zone it serves. var editableTypes = []string{ "A", "AAAA", "CAA", "CNAME", "DHCID", "HINFO", "HTTPS", "LOC", "MX", "NAPTR", "NS", "PTR", "SRV", "SPF", "SRV", "SSHFP", "SVCB", "TXT", "URI", } // EditableTypes is the set of record types the API will write, sorted and // deduplicated. The console builds its type picker from this, so the two // cannot drift apart. func EditableTypes() []string { seen := make(map[string]bool, len(editableTypes)) out := make([]string, 0, len(editableTypes)) for _, t := range editableTypes { if seen[t] { continue } seen[t] = true out = append(out, t) } sort.Strings(out) return out } func isEditableType(rrtype string) bool { for _, t := range EditableTypes() { if t == rrtype { return true } } return false } // ZoneDefaults seeds a managed file for an apex that has none yet: the SOA and // apex NS set of a zone that already exists elsewhere, or the NS targets the // request itself supplies. type ZoneDefaults struct { // TTL is the $TTL of a new file; 0 means DefaultZoneTTL. TTL uint32 // Soa is an existing zone's SOA rdata (absolute names), or "" to // synthesise one from the first NS target. Soa string // NS are the apex NS targets of a new file. NS []string } // fileState is one managed file's content in presentation form. type fileState struct { origin string ttl uint32 soa *dns.SOA recs []Record } // newFileState builds a file for an apex that has none: an existing zone's // SOA and NS set when the caller supplied them, else a synthesised SOA whose // primary is the first NS target. func newFileState(origin string, def ZoneDefaults) (*fileState, error) { ttl := def.TTL if ttl == 0 { ttl = DefaultZoneTTL } f := &fileState{origin: origin, ttl: ttl} if def.Soa != "" { rr, err := dns.NewRR(origin + " " + zonefile.FormatTTL(ttl) + " IN SOA " + def.Soa) if err != nil { return nil, fmt.Errorf("seed SOA: %w", err) } soa, ok := rr.(*dns.SOA) if !ok { return nil, fmt.Errorf("seed SOA is not an SOA record") } cp := *soa f.soa = &cp } for _, target := range def.NS { rec, err := newRecord(origin, origin, "NS", ttl, target) if err != nil { return nil, err } f.recs = append(f.recs, rec) } return f, nil } // finish is the invariant check that runs before anything touches the disk: a // zone with no SOA cannot answer NXDOMAIN or NODATA (the engine sends a NODATA // with no authority section instead), and a zone with no apex NS set delegates // nothing and has no server to name. func (f *fileState) finish() error { apex := make([]string, 0, 4) for _, rec := range f.recs { if rec.Name == f.origin && rec.Type == "NS" { apex = append(apex, rec.Value) } } if err := f.checkCuts(); err != nil { return err } if f.soa == nil { if len(apex) == 0 { return errors.New("a new zone needs an NS record at the apex, or a delegation to start from") } soa, err := synthSOA(f.origin, apex[0], serialFor(time.Now()), f.ttl) if err != nil { return err } f.soa = soa } // The SOA's primary follows the apex NS set. A zone whose SOA names a // server that is not in its own NS set is a zone that hands out a // referral no resolver can follow, and the console is what changes that // set, so the console has to keep the two together. sort.Strings(apex) if !slices.Contains(apex, f.soa.Ns) && len(apex) > 0 { f.soa.Ns = apex[0] } f.soa.Serial = nextSerial(f.soa.Serial) sortRecords(f.recs) return nil } // checkCuts refuses data that a delegation has taken away. A cut means the // names under it belong to another server: the store answers them as // delegated and never looks at a record that lives there, so a record written // below a cut is dead data that looks alive. The cut's own NS RRset is the // one thing the parent does serve (RFC 1034 §4.3.3). func (f *fileState) checkCuts() error { cuts := make([]string, 0, 4) for _, rec := range f.recs { if rec.Type == "NS" && rec.Name != f.origin { cuts = append(cuts, rec.Name) } } for _, cut := range cuts { for _, rec := range f.recs { if rec.Name == cut { continue } if strings.HasSuffix(rec.Name, "."+cut) { return fmt.Errorf("%s is delegated to another server; delete the delegation to put data back", rec.Name) } } } return nil } // render writes the file in the form an operator would have typed: a header, a // $ORIGIN/$TTL pair, the SOA, then one owner line per name with its records // indented under it. func (f *fileState) render() string { var sb strings.Builder sb.WriteString(ZoneSourceHeader) fmt.Fprintf(&sb, "$ORIGIN %s\n", f.origin) fmt.Fprintf(&sb, "$TTL %s\n", zonefile.FormatTTL(f.ttl)) soa := f.soa fmt.Fprintf(&sb, "@\t%s IN SOA\t%s %s %d %d %d %d %d\n", zonefile.FormatTTL(soa.Hdr.Ttl), soa.Ns, soa.Mbox, soa.Serial, soa.Refresh, soa.Retry, soa.Expire, soa.Minttl) byName := make(map[string][]Record) for _, rec := range f.recs { byName[rec.Name] = append(byName[rec.Name], rec) } // One record per line, not the owner-then-indented-records form: this // project's master-file parser takes a bare owner line as a record with // no type, and a zone file only loads if its own parser accepts it. for _, name := range sortedNames(byName) { for _, rec := range byName[name] { ttl := rec.TTL if ttl == 0 { ttl = f.ttl } fmt.Fprintf(&sb, "%-28s %s IN %-5s %s\n", relativeOwner(name, f.origin), zonefile.FormatTTL(ttl), rec.Type, rec.Value) } } return sb.String() } func sortedNames(byName map[string][]Record) []string { out := make([]string, 0, len(byName)) for name := range byName { out = append(out, name) } sort.Strings(out) return out } // fileStateFromZone converts a parsed master file into presentation form. func fileStateFromZone(z *zonefile.Zone, origin string) *fileState { ttl := z.DefaultTTL if ttl == 0 { ttl = DefaultZoneTTL } f := &fileState{origin: origin, ttl: ttl} if z.SOA != nil { cp := *z.SOA f.soa = &cp } for _, rec := range z.Records { if _, isSOA := rec.RR.(*dns.SOA); isSOA { continue } rrttl := rec.TTL if rrttl == 0 { rrttl = ttl } f.recs = append(f.recs, Record{ Name: rec.Name, Type: dns.TypeToString[rec.RR.Header().Rrtype], TTL: rrttl, Value: rdata(rec.RR), Wildcard: strings.HasPrefix(rec.Name, "*."), }) } return f } // parseFileState is the reader half of the round-trip check: it parses text // the writer produced and returns it in the same form the writer held it. func parseFileState(origin, text string) (*fileState, error) { z, err := zonefile.Parse(strings.NewReader(text), zonefile.Options{Origin: origin, File: ""}) if err != nil { return nil, err } return fileStateFromZone(z, origin), nil } // sameRecords compares the records a rendered file parses back to with the // ones the writer meant to write. A round-trip that loses a record, changes a // TTL or mangles a name is a writer bug, and it is caught here rather than by // an operator noticing a zone that stopped answering. func sameRecords(got, want []Record) error { if len(got) != len(want) { return fmt.Errorf("rendered %d records, expected %d", len(got), len(want)) } for i := range want { if got[i] != want[i] { return fmt.Errorf("rendered %+v, expected %+v", got[i], want[i]) } } return nil } // newRecord validates one API record and returns it in canonical form: the // owner fully qualified, the type uppercased, the RDATA as dns.NewRR parses // it. Canonicalising at the edge means the store snapshot, the console and // the file always agree on spelling. func newRecord(origin, name, rrtype string, ttl uint32, value string) (Record, error) { owner, err := ownerInZone(origin, name) if err != nil { return Record{}, err } up := strings.ToUpper(strings.TrimSpace(rrtype)) if up == "" { return Record{}, errors.New("record type is required") } if !isEditableType(up) { return Record{}, fmt.Errorf("type %s cannot be written through the API", up) } value = strings.TrimSpace(value) if value == "" { return Record{}, fmt.Errorf("the %s record needs a value", up) } line := owner + " " + zonefile.FormatTTL(ttl) + " IN " + up + " " + value // The project's own master-file parser is the authority on what a record // line means: it qualifies an RDATA name written without a trailing dot // against the origin, which is what an operator typing "ns1" for a CNAME // in a zone file gets. Parsing here rather than with dns.NewRR is what // keeps the API's idea of a value and the store's the same value. z, err := zonefile.Parse(strings.NewReader(line), zonefile.Options{Origin: origin, File: ""}) if err != nil { return Record{}, fmt.Errorf("%s %s: %w", owner, up, err) } if len(z.Records) != 1 { return Record{}, fmt.Errorf("%s %s: the line parsed into %d records", owner, up, len(z.Records)) } parsed := z.Records[0] if _, isSOA := parsed.RR.(*dns.SOA); isSOA { return Record{}, errors.New("SOA is zone metadata, not a record the API writes") } if !inZone(parsed.Name, origin) { return Record{}, fmt.Errorf("%s is outside the zone %s", parsed.Name, origin) } return Record{ Name: parsed.Name, Type: up, TTL: parsed.TTL, Value: rdata(parsed.RR), Wildcard: strings.HasPrefix(parsed.Name, "*."), }, nil } // ttlOr picks the record's own TTL, falling back to the zone's. func ttlOr(rec, zone uint32) uint32 { if rec != 0 { return rec } return zone } // ownerInZone canonicalises an owner name the way a zone file would: a name // written without a trailing dot belongs to the zone it is in, so "www" in // home.example.com is www.home.example.com. — the console asks for a short // name and a relative one is the common case, not an error. func ownerInZone(origin, name string) (string, error) { trimmed := strings.TrimSpace(name) owner, err := NormalizeName(trimmed) if err != nil { return "", fmt.Errorf("owner name: %w", err) } if !strings.HasSuffix(trimmed, ".") { // A relative name: qualify it, unless it already spells the apex or // something under it (which is what an operator pasting a name out of // the table above does). bare := strings.TrimSuffix(strings.TrimPrefix(owner, "*."), ".") base := strings.TrimSuffix(origin, ".") if bare != base && !strings.HasSuffix(bare, "."+base) { owner = dns.Fqdn(bare + "." + base) if strings.HasPrefix(strings.TrimSpace(name), "*.") { owner = "*." + owner } } } if !inZone(owner, origin) { return "", fmt.Errorf("%s is outside the zone %s", owner, origin) } return owner, nil } // NormalizeOrigin canonicalises a zone apex and refuses the ones the writer // cannot round-trip. func NormalizeOrigin(in string) (string, error) { out := strings.ToLower(strings.TrimSpace(dns.Fqdn(strings.TrimSpace(in)))) if out == "." { return "", errors.New("the root zone is not editable") } if out == "" || !strings.Contains(strings.TrimSuffix(out, "."), ".") { return "", fmt.Errorf("%q is not a zone apex", in) } for _, label := range strings.Split(strings.TrimSuffix(out, "."), ".") { if label == "" { return "", fmt.Errorf("%q is not a zone apex", in) } } return out, nil } // NormalizeName canonicalises an owner name, allowing a leftmost "*." wildcard. func NormalizeName(in string) (string, error) { s := strings.ToLower(strings.TrimSpace(in)) if s == "" || s == "." { return "", errors.New("owner name is required") } wild := strings.HasPrefix(s, "*.") s = strings.TrimPrefix(s, "*.") full := strings.ToLower(dns.Fqdn(s)) for _, label := range strings.Split(strings.TrimSuffix(full, "."), ".") { if label == "" { return "", fmt.Errorf("%q is not a domain name", in) } } if wild { full = "*." + full } return full, nil } // relativeOwner renders an owner relative to the origin, the way a zone file // is written. func relativeOwner(name, origin string) string { if name == origin { return "@" } suffixed := strings.TrimSuffix(name, ".") originSuffix := strings.TrimSuffix(origin, ".") if suffixed == originSuffix { return "@" } if len(suffixed) > len(originSuffix) && strings.HasSuffix(suffixed, "."+originSuffix) { return suffixed[:len(suffixed)-len(originSuffix)-1] } return name } // synthSOA builds a zone's start of authority. func synthSOA(origin, primary string, serial, ttl uint32) (*dns.SOA, error) { if primary == "" { return nil, errors.New("an SOA needs a primary nameserver") } if !strings.HasSuffix(primary, ".") { primary = dns.Fqdn(primary) } rr, err := dns.NewRR(fmt.Sprintf("%s %d IN SOA %s hostmaster.%s %d 3600 600 604800 %d", origin, ttl, primary, origin, serial, ttl)) if err != nil { return nil, err } soa, ok := rr.(*dns.SOA) if !ok { return nil, errors.New("built SOA did not parse") } return soa, nil } // nextSerial advances a serial by one. A zone with no serial yet gets a // date-based one, which is what an operator's first edit expects to see. func nextSerial(current uint32) uint32 { if current == 0 { return serialFor(time.Now()) } if next, err := zonefile.SerialNext(current, "+1"); err == nil { return next } return current } // serialFor is the YYYYMMDDnn form RFC 1912 §2.2.2 recommends. func serialFor(t time.Time) uint32 { day, err := strconv.ParseUint(t.UTC().Format("20060102"), 10, 32) if err != nil { return 1 } return uint32(day) }