Files
2026-08-16 21:18:45 -05:00

355 lines
8.5 KiB
Go

// 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))]
}