initial commit
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
// 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))]
|
||||
}
|
||||
Reference in New Issue
Block a user