// Package authoritative builds an immutable in-memory index of the configured // zones and answers queries from it. // // The index is rebuilt from SQLite whenever configuration changes and then // swapped in atomically, so the DNS data path never touches the database and // never takes a lock that a writer could hold. package authoritative import ( "fmt" "sort" "strings" "github.com/miekg/dns" "github.com/owen/vibedns/internal/models" "github.com/owen/vibedns/internal/validate" ) // nameNode holds every RRset owned by one name. type nameNode struct { types map[uint16][]dns.RR } func (n *nameNode) add(rr dns.RR) { t := rr.Header().Rrtype n.types[t] = append(n.types[t], rr) } func (n *nameNode) typeList() []uint16 { out := make([]uint16, 0, len(n.types)) for t := range n.types { out = append(out, t) } sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) return out } // Zone is one compiled authoritative zone. type Zone struct { ID int64 Name string // normalised FQDN, e.g. "example.com." Kind models.ZoneKind DefaultTTL uint32 soa *dns.SOA ns []dns.RR names map[string]*nameNode // owner name -> RRsets wildcards map[string]*nameNode // "*.parent." -> RRsets // ents contains every name that exists in the zone, including empty // non-terminals. It is what separates NXDOMAIN from NODATA. ents map[string]struct{} // delegations lists non-apex names that carry NS records. delegations map[string]*nameNode // maxDelegationDepth caps the ancestor walk when looking for a referral. hasDelegations bool } // SOA returns the zone's start-of-authority record. func (z *Zone) SOA() *dns.SOA { return z.soa } // RecordCount reports how many RRs the compiled zone holds. func (z *Zone) RecordCount() int { n := 0 for _, node := range z.names { for _, rrs := range node.types { n += len(rrs) } } for _, node := range z.wildcards { for _, rrs := range node.types { n += len(rrs) } } return n } // Index maps names to the zone that is authoritative for them. type Index struct { zones map[string]*Zone // maxLabels bounds the suffix walk in Lookup. maxLabels int } // BuildError describes a record that could not be compiled into the index. // These are reported to the operator but never prevent the server from // starting: one bad record must not take the whole zone offline. type BuildError struct { ZoneID int64 ZoneName string RecordID int64 Name string Type string Err error } func (e BuildError) Error() string { return fmt.Sprintf("zone %s record %s %s: %v", e.ZoneName, e.Name, e.Type, e.Err) } // Build compiles zones and their records into a queryable index. Disabled // zones are skipped entirely. func Build(zones []models.Zone, records map[int64][]models.Record) (*Index, []BuildError) { idx := &Index{zones: make(map[string]*Zone, len(zones))} var problems []BuildError for _, mz := range zones { if !mz.Enabled { continue } z, errs := buildZone(mz, records[mz.ID]) problems = append(problems, errs...) idx.zones[z.Name] = z if n := dns.CountLabel(z.Name); n > idx.maxLabels { idx.maxLabels = n } } return idx, problems } func buildZone(mz models.Zone, recs []models.Record) (*Zone, []BuildError) { z := &Zone{ ID: mz.ID, Name: mz.Name, Kind: mz.Kind, DefaultTTL: mz.DefaultTTL, names: make(map[string]*nameNode, len(recs)+1), wildcards: map[string]*nameNode{}, ents: make(map[string]struct{}, len(recs)+1), delegations: map[string]*nameNode{}, } var problems []BuildError for _, r := range recs { owner := validate.AbsoluteName(r.Name, mz.Name) ttl := r.EffectiveTTL(mz.DefaultTTL) rr, err := validate.BuildRR(mz.Name, r.Name, r.Type, r.Data, ttl) if err != nil { problems = append(problems, BuildError{ ZoneID: mz.ID, ZoneName: mz.Name, RecordID: r.ID, Name: r.Name, Type: r.Type, Err: err, }) continue } // The parser resolves the owner against the origin; normalise anyway so // map keys are always lowercase. rr.Header().Name = strings.ToLower(rr.Header().Name) owner = rr.Header().Name if strings.HasPrefix(owner, "*.") { node := z.wildcards[owner] if node == nil { node = &nameNode{types: map[uint16][]dns.RR{}} z.wildcards[owner] = node } node.add(rr) // A wildcard's parent exists as a name for ENT purposes. z.addENT(strings.TrimPrefix(owner, "*.")) continue } node := z.names[owner] if node == nil { node = &nameNode{types: map[uint16][]dns.RR{}} z.names[owner] = node } node.add(rr) z.addENT(owner) if rr.Header().Rrtype == dns.TypeSOA { if soa, ok := rr.(*dns.SOA); ok && owner == mz.Name { z.soa = soa } } if rr.Header().Rrtype == dns.TypeNS { if owner == mz.Name { z.ns = append(z.ns, rr) } else { dn := z.delegations[owner] if dn == nil { dn = &nameNode{types: map[uint16][]dns.RR{}} z.delegations[owner] = dn } dn.add(rr) z.hasDelegations = true } } } // Automatic SOA management: a zone always answers with a SOA, whether or // not one was explicitly stored. if z.soa == nil { z.soa = synthesiseSOA(mz) apex := z.node(mz.Name) apex.types[dns.TypeSOA] = []dns.RR{z.soa} z.addENT(mz.Name) } // Likewise a zone should always have at least one apex NS record. if len(z.ns) == 0 { ns := synthesiseNS(mz) apex := z.node(mz.Name) apex.types[dns.TypeNS] = append(apex.types[dns.TypeNS], ns) z.ns = append(z.ns, ns) } return z, problems } func (z *Zone) node(name string) *nameNode { n := z.names[name] if n == nil { n = &nameNode{types: map[uint16][]dns.RR{}} z.names[name] = n } return n } // addENT records a name and every ancestor of it up to the apex, so that a // query for an intermediate name returns NODATA rather than NXDOMAIN. func (z *Zone) addENT(name string) { for n := name; n != "" && dns.IsSubDomain(z.Name, n); { if _, ok := z.ents[n]; ok { break // ancestors already recorded } z.ents[n] = struct{}{} if n == z.Name { break } i, end := dns.NextLabel(n, 0) if end { break } n = n[i:] } } func synthesiseSOA(mz models.Zone) *dns.SOA { ns := mz.PrimaryNS if ns == "" { ns = "ns1." + mz.Name } if !strings.HasSuffix(ns, ".") { ns += "." } mbox := mailboxName(mz.AdminEmail, mz.Name) return &dns.SOA{ Hdr: dns.RR_Header{ Name: mz.Name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: mz.DefaultTTL, }, Ns: strings.ToLower(ns), Mbox: strings.ToLower(mbox), Serial: mz.Serial, Refresh: nonZero(mz.Refresh, 7200), Retry: nonZero(mz.Retry, 3600), Expire: nonZero(mz.Expire, 1209600), Minttl: nonZero(mz.Minimum, 3600), } } func synthesiseNS(mz models.Zone) dns.RR { ns := mz.PrimaryNS if ns == "" { ns = "ns1." + mz.Name } if !strings.HasSuffix(ns, ".") { ns += "." } return &dns.NS{ Hdr: dns.RR_Header{Name: mz.Name, Rrtype: dns.TypeNS, Class: dns.ClassINET, Ttl: mz.DefaultTTL}, Ns: strings.ToLower(ns), } } // mailboxName converts an email address into SOA RNAME form. func mailboxName(email, zone string) string { e := strings.TrimSpace(strings.ToLower(email)) if e == "" { return "hostmaster." + zone } if strings.HasSuffix(e, ".") && !strings.Contains(e, "@") { return e // already in RNAME form } at := strings.LastIndex(e, "@") if at < 0 { if !strings.HasSuffix(e, ".") { e += "." } return e } local := strings.ReplaceAll(e[:at], ".", `\.`) domain := e[at+1:] if !strings.HasSuffix(domain, ".") { domain += "." } return local + "." + domain } func nonZero(v, def uint32) uint32 { if v == 0 { return def } return v } // Lookup finds the most specific zone authoritative for qname, or nil. func (idx *Index) Lookup(qname string) *Zone { if idx == nil || len(idx.zones) == 0 { return nil } name := strings.ToLower(dns.Fqdn(qname)) for { if z, ok := idx.zones[name]; ok { return z } if name == "." || name == "" { return nil } i, end := dns.NextLabel(name, 0) if end { return nil } name = name[i:] } } // Zones returns the compiled zones, ordered by name. func (idx *Index) Zones() []*Zone { if idx == nil { return nil } out := make([]*Zone, 0, len(idx.zones)) for _, z := range idx.zones { out = append(out, z) } sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) return out } // Len reports the number of compiled zones. func (idx *Index) Len() int { if idx == nil { return 0 } return len(idx.zones) } // Zone returns a compiled zone by exact apex name. func (idx *Index) Zone(name string) *Zone { if idx == nil { return nil } return idx.zones[strings.ToLower(dns.Fqdn(name))] }