initial commit

This commit is contained in:
2026-08-16 21:18:45 -05:00
commit 1e05a01bcf
122 changed files with 29178 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
package authoritative
import (
"strings"
"github.com/miekg/dns"
)
// maxCNAMEChain bounds in-zone CNAME following so a loop cannot hang a query.
const maxCNAMEChain = 12
// Answer builds an authoritative reply for the question in req.
//
// It returns nil when no configured zone covers the question, which tells the
// caller to fall through to the cache and the recursive resolver.
func (idx *Index) Answer(req *dns.Msg, do bool) *dns.Msg {
if idx == nil || len(req.Question) == 0 {
return nil
}
q := req.Question[0]
if q.Qclass != dns.ClassINET && q.Qclass != dns.ClassANY {
return nil
}
z := idx.Lookup(q.Name)
if z == nil {
return nil
}
return z.Answer(req, do)
}
// Answer builds an authoritative reply for req from this zone.
func (z *Zone) Answer(req *dns.Msg, do bool) *dns.Msg {
q := req.Question[0]
qname := strings.ToLower(dns.Fqdn(q.Name))
qtype := q.Qtype
m := new(dns.Msg)
m.SetReply(req)
m.Authoritative = true
m.Compress = true
// A delegation between the apex and the queried name means the answer
// belongs to a child zone: return a referral rather than our own data.
if z.hasDelegations {
if dp := z.delegationFor(qname, qtype); dp != "" {
z.writeReferral(m, dp, do)
return m
}
}
name := qname
for depth := 0; depth < maxCNAMEChain; depth++ {
node, synthesised := z.resolveNode(name)
if node == nil {
// The name has no data. Distinguish "exists but no records of this
// type" (NODATA) from "does not exist at all" (NXDOMAIN).
if _, exists := z.ents[name]; exists || depth > 0 {
z.writeNoData(m, do)
} else {
m.Rcode = dns.RcodeNameError
z.writeNoData(m, do)
}
return m
}
// A CNAME is followed unless the client asked for the CNAME itself.
if cnames := node.types[dns.TypeCNAME]; len(cnames) > 0 && qtype != dns.TypeCNAME && qtype != dns.TypeANY {
rr := materialise(cnames[0], name, synthesised)
m.Answer = append(m.Answer, rr)
z.appendSignatures(m, node, dns.TypeCNAME, name, synthesised, do)
target := strings.ToLower(rr.(*dns.CNAME).Target)
if !dns.IsSubDomain(z.Name, target) {
// The chain leaves our zone; the client (or the recursor in
// front of it) has to continue from here.
m.Authoritative = true
return m
}
name = target
continue
}
if qtype == dns.TypeANY {
for _, t := range node.typeList() {
for _, rr := range node.types[t] {
m.Answer = append(m.Answer, materialise(rr, name, synthesised))
}
}
if len(m.Answer) == 0 {
z.writeNoData(m, do)
} else {
z.addAuthorityNS(m, do)
}
return m
}
rrs := node.types[qtype]
if len(rrs) == 0 {
z.writeNoData(m, do)
return m
}
for _, rr := range rrs {
m.Answer = append(m.Answer, materialise(rr, name, synthesised))
}
z.appendSignatures(m, node, qtype, name, synthesised, do)
z.addAuthorityNS(m, do)
z.addAdditional(m, do)
return m
}
// Chain too long: return what we have rather than looping.
return m
}
// resolveNode finds the RRsets for a name, falling back to wildcard synthesis
// using the RFC 4592 closest-encloser rule.
func (z *Zone) resolveNode(name string) (node *nameNode, synthesised bool) {
if n, ok := z.names[name]; ok {
return n, false
}
if _, exists := z.ents[name]; exists {
return nil, false // empty non-terminal: exists, but holds no data
}
if len(z.wildcards) == 0 {
return nil, false
}
ce := z.closestEncloser(name)
if wn, ok := z.wildcards["*."+ce]; ok {
return wn, true
}
return nil, false
}
// closestEncloser returns the deepest ancestor of name that exists in the zone.
func (z *Zone) closestEncloser(name string) string {
n := name
for {
if n == z.Name {
return z.Name
}
i, end := dns.NextLabel(n, 0)
if end {
return z.Name
}
n = n[i:]
if !dns.IsSubDomain(z.Name, n) {
return z.Name
}
if _, ok := z.ents[n]; ok {
return n
}
}
}
// delegationFor returns the deepest delegation point at or above qname, or "".
// A DS query at the delegation point itself is answered from the parent side,
// so it is not treated as a referral.
func (z *Zone) delegationFor(qname string, qtype uint16) string {
n := qname
for {
if n == z.Name || !dns.IsSubDomain(z.Name, n) {
return ""
}
if _, ok := z.delegations[n]; ok {
if n == qname && qtype == dns.TypeDS {
return ""
}
return n
}
i, end := dns.NextLabel(n, 0)
if end {
return ""
}
n = n[i:]
}
}
// writeReferral fills the authority section with the child zone's NS records
// and the additional section with any in-zone glue.
func (z *Zone) writeReferral(m *dns.Msg, delegation string, do bool) {
m.Authoritative = false
node := z.delegations[delegation]
if node == nil {
return
}
for _, rr := range node.types[dns.TypeNS] {
m.Ns = append(m.Ns, dns.Copy(rr))
}
// A signed delegation carries a DS RRset (or a proof of its absence).
if dsNode, ok := z.names[delegation]; ok && do {
for _, rr := range dsNode.types[dns.TypeDS] {
m.Ns = append(m.Ns, dns.Copy(rr))
}
for _, rr := range dsNode.types[dns.TypeRRSIG] {
if sig, ok := rr.(*dns.RRSIG); ok && sig.TypeCovered == dns.TypeDS {
m.Ns = append(m.Ns, dns.Copy(rr))
}
}
}
z.addGlueFor(m, m.Ns)
}
// writeNoData puts the SOA in the authority section, which is what tells a
// resolver how long to cache the negative answer.
func (z *Zone) writeNoData(m *dns.Msg, do bool) {
if z.soa == nil {
return
}
soa := dns.Copy(z.soa).(*dns.SOA)
// RFC 2308: the negative caching TTL is the lesser of the SOA TTL and the
// SOA MINIMUM field.
if soa.Minttl < soa.Hdr.Ttl {
soa.Hdr.Ttl = soa.Minttl
}
m.Ns = append(m.Ns, soa)
if do {
if apex, ok := z.names[z.Name]; ok {
for _, rr := range apex.types[dns.TypeRRSIG] {
if sig, ok := rr.(*dns.RRSIG); ok && sig.TypeCovered == dns.TypeSOA {
m.Ns = append(m.Ns, dns.Copy(rr))
}
}
}
}
}
// addAuthorityNS adds the zone's NS RRset to a positive answer, except when the
// answer already is that RRset.
func (z *Zone) addAuthorityNS(m *dns.Msg, do bool) {
if len(m.Answer) == 0 || len(z.ns) == 0 {
return
}
if h := m.Answer[0].Header(); h.Rrtype == dns.TypeNS && h.Name == z.Name {
return
}
if h := m.Answer[0].Header(); h.Rrtype == dns.TypeSOA {
return
}
for _, rr := range z.ns {
m.Ns = append(m.Ns, dns.Copy(rr))
}
if do {
if apex, ok := z.names[z.Name]; ok {
for _, rr := range apex.types[dns.TypeRRSIG] {
if sig, ok := rr.(*dns.RRSIG); ok && sig.TypeCovered == dns.TypeNS {
m.Ns = append(m.Ns, dns.Copy(rr))
}
}
}
}
}
// addAdditional supplies address records for names referenced by the answer,
// saving the client a follow-up query.
func (z *Zone) addAdditional(m *dns.Msg, do bool) {
z.addGlueFor(m, m.Answer)
z.addGlueFor(m, m.Ns)
}
func (z *Zone) addGlueFor(m *dns.Msg, section []dns.RR) {
seen := map[string]bool{}
for _, rr := range m.Extra {
seen[strings.ToLower(rr.Header().Name)] = true
}
for _, rr := range section {
var target string
switch v := rr.(type) {
case *dns.MX:
target = v.Mx
case *dns.SRV:
target = v.Target
case *dns.NS:
target = v.Ns
default:
continue
}
target = strings.ToLower(dns.Fqdn(target))
if target == "" || seen[target] || !dns.IsSubDomain(z.Name, target) {
continue
}
node, ok := z.names[target]
if !ok {
continue
}
seen[target] = true
for _, t := range []uint16{dns.TypeA, dns.TypeAAAA} {
for _, arr := range node.types[t] {
m.Extra = append(m.Extra, dns.Copy(arr))
}
}
}
}
// appendSignatures adds the RRSIGs covering an RRset when the client set DO.
//
// Zones served here are not signed by this application; signatures are only
// present when a pre-signed zone file was imported. Serving them unchanged
// keeps such zones verifiable, and leaves room for an in-process signer later.
func (z *Zone) appendSignatures(m *dns.Msg, node *nameNode, covered uint16, name string, synthesised, do bool) {
if !do {
return
}
for _, rr := range node.types[dns.TypeRRSIG] {
sig, ok := rr.(*dns.RRSIG)
if !ok || sig.TypeCovered != covered {
continue
}
m.Answer = append(m.Answer, materialise(rr, name, synthesised))
}
}
// materialise copies an RR, rewriting the owner name when the record came from
// a wildcard node.
func materialise(rr dns.RR, owner string, synthesised bool) dns.RR {
c := dns.Copy(rr)
if synthesised {
c.Header().Name = owner
}
return c
}
+266
View File
@@ -0,0 +1,266 @@
package authoritative
import (
"testing"
"github.com/miekg/dns"
"github.com/owen/vibedns/internal/models"
)
func ttlPtr(v uint32) *uint32 { return &v }
// testZone builds a small but representative zone: an apex, a delegation, a
// wildcard, a CNAME chain and an empty non-terminal.
func testIndex(t *testing.T) *Index {
t.Helper()
zone := models.Zone{
ID: 1, Name: "example.com.", Kind: models.ZoneForward, Enabled: true,
DefaultTTL: 3600, PrimaryNS: "ns1.example.com.", AdminEmail: "hostmaster@example.com",
Serial: 7, Refresh: 7200, Retry: 3600, Expire: 1209600, Minimum: 300,
}
recs := []models.Record{
{ZoneID: 1, Name: "@", Type: "NS", Data: "ns1.example.com.", Enabled: true},
{ZoneID: 1, Name: "@", Type: "A", Data: "192.0.2.1", Enabled: true},
{ZoneID: 1, Name: "ns1", Type: "A", Data: "192.0.2.53", Enabled: true},
{ZoneID: 1, Name: "www", Type: "CNAME", Data: "example.com.", Enabled: true},
{ZoneID: 1, Name: "mail", Type: "A", Data: "192.0.2.20", Enabled: true},
{ZoneID: 1, Name: "mail", Type: "AAAA", Data: "2001:db8::20", Enabled: true},
{ZoneID: 1, Name: "@", Type: "MX", Data: "10 mail.example.com.", Enabled: true},
{ZoneID: 1, Name: "*.wild", Type: "A", Data: "192.0.2.99", Enabled: true},
{ZoneID: 1, Name: "deep.ent.chain", Type: "TXT", Data: `"hello"`, Enabled: true},
{ZoneID: 1, Name: "sub", Type: "NS", Data: "ns1.sub.example.com.", Enabled: true},
{ZoneID: 1, Name: "ns1.sub", Type: "A", Data: "192.0.2.60", Enabled: true},
{ZoneID: 1, Name: "short", Type: "A", Data: "192.0.2.7", TTL: ttlPtr(60), Enabled: true},
}
idx, problems := Build([]models.Zone{zone}, map[int64][]models.Record{1: recs})
for _, p := range problems {
t.Fatalf("unexpected build problem: %v", p)
}
return idx
}
func query(t *testing.T, idx *Index, name string, qtype uint16) *dns.Msg {
t.Helper()
req := new(dns.Msg)
req.SetQuestion(dns.Fqdn(name), qtype)
return idx.Answer(req, false)
}
func TestAnswerBasicLookups(t *testing.T) {
idx := testIndex(t)
tests := []struct {
name string
qname string
qtype uint16
rcode int
wantAns int
wantFirst string
aa bool
}{
{"apex A", "example.com.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.1", true},
{"host A", "mail.example.com.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.20", true},
{"host AAAA", "mail.example.com.", dns.TypeAAAA, dns.RcodeSuccess, 1, "2001:db8::20", true},
{"case insensitive", "MAIL.Example.COM.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.20", true},
{"nodata", "mail.example.com.", dns.TypeTXT, dns.RcodeSuccess, 0, "", true},
{"nxdomain", "nope.example.com.", dns.TypeA, dns.RcodeNameError, 0, "", true},
{"wildcard", "anything.wild.example.com.", dns.TypeA, dns.RcodeSuccess, 1, "192.0.2.99", true},
{"wildcard nodata", "anything.wild.example.com.", dns.TypeTXT, dns.RcodeSuccess, 0, "", true},
{"explicit MX", "example.com.", dns.TypeMX, dns.RcodeSuccess, 1, "mail.example.com.", true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m := query(t, idx, tc.qname, tc.qtype)
if m == nil {
t.Fatal("expected an authoritative answer, got none")
}
if m.Rcode != tc.rcode {
t.Errorf("rcode = %s, want %s", dns.RcodeToString[m.Rcode], dns.RcodeToString[tc.rcode])
}
if len(m.Answer) != tc.wantAns {
t.Fatalf("answer count = %d, want %d (%v)", len(m.Answer), tc.wantAns, m.Answer)
}
if m.Authoritative != tc.aa {
t.Errorf("AA = %v, want %v", m.Authoritative, tc.aa)
}
if tc.wantAns == 0 {
if len(m.Ns) == 0 {
t.Error("negative answer should carry a SOA in the authority section")
} else if _, ok := m.Ns[0].(*dns.SOA); !ok {
t.Errorf("authority section = %T, want *dns.SOA", m.Ns[0])
}
return
}
switch rr := m.Answer[0].(type) {
case *dns.A:
if rr.A.String() != tc.wantFirst {
t.Errorf("A = %s, want %s", rr.A, tc.wantFirst)
}
case *dns.AAAA:
if rr.AAAA.String() != tc.wantFirst {
t.Errorf("AAAA = %s, want %s", rr.AAAA, tc.wantFirst)
}
case *dns.MX:
if rr.Mx != tc.wantFirst {
t.Errorf("MX = %s, want %s", rr.Mx, tc.wantFirst)
}
}
})
}
}
func TestWildcardOwnerNameIsRewritten(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "host.wild.example.com.", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("answer count = %d, want 1", len(m.Answer))
}
if got := m.Answer[0].Header().Name; got != "host.wild.example.com." {
t.Errorf("owner name = %q, want the queried name, not the wildcard", got)
}
}
func TestEmptyNonTerminalIsNoDataNotNXDOMAIN(t *testing.T) {
idx := testIndex(t)
// "chain.example.com." and "ent.chain.example.com." hold no records but
// exist because a name below them does.
for _, name := range []string{"chain.example.com.", "ent.chain.example.com."} {
m := query(t, idx, name, dns.TypeA)
if m.Rcode != dns.RcodeSuccess {
t.Errorf("%s: rcode = %s, want NOERROR (empty non-terminal)",
name, dns.RcodeToString[m.Rcode])
}
}
m := query(t, idx, "missing.chain.example.com.", dns.TypeA)
if m.Rcode != dns.RcodeNameError {
t.Errorf("truly missing name: rcode = %s, want NXDOMAIN", dns.RcodeToString[m.Rcode])
}
}
func TestCNAMEIsFollowedInZone(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "www.example.com.", dns.TypeA)
if len(m.Answer) != 2 {
t.Fatalf("answer count = %d, want CNAME plus target A: %v", len(m.Answer), m.Answer)
}
if _, ok := m.Answer[0].(*dns.CNAME); !ok {
t.Errorf("first answer = %T, want *dns.CNAME", m.Answer[0])
}
if a, ok := m.Answer[1].(*dns.A); !ok || a.A.String() != "192.0.2.1" {
t.Errorf("second answer = %v, want the apex A record", m.Answer[1])
}
// Asking for the CNAME itself must not follow the chain.
m = query(t, idx, "www.example.com.", dns.TypeCNAME)
if len(m.Answer) != 1 {
t.Fatalf("CNAME query answer count = %d, want 1", len(m.Answer))
}
}
func TestDelegationReturnsReferral(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "host.sub.example.com.", dns.TypeA)
if m.Authoritative {
t.Error("a referral must not set the AA bit")
}
if len(m.Answer) != 0 {
t.Errorf("referral answer section = %v, want empty", m.Answer)
}
if len(m.Ns) == 0 {
t.Fatal("referral must carry NS records in the authority section")
}
if _, ok := m.Ns[0].(*dns.NS); !ok {
t.Errorf("authority = %T, want *dns.NS", m.Ns[0])
}
var glued bool
for _, rr := range m.Extra {
if a, ok := rr.(*dns.A); ok && a.A.String() == "192.0.2.60" {
glued = true
}
}
if !glued {
t.Error("referral should include in-zone glue for the child name server")
}
}
func TestAdditionalSectionCarriesMXAddresses(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "example.com.", dns.TypeMX)
var haveA, haveAAAA bool
for _, rr := range m.Extra {
switch v := rr.(type) {
case *dns.A:
haveA = haveA || v.A.String() == "192.0.2.20"
case *dns.AAAA:
haveAAAA = haveAAAA || v.AAAA.String() == "2001:db8::20"
}
}
if !haveA || !haveAAAA {
t.Errorf("MX answer should glue the exchange addresses; extra = %v", m.Extra)
}
}
func TestSOAIsSynthesisedAndSerialUsed(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "example.com.", dns.TypeSOA)
if len(m.Answer) != 1 {
t.Fatalf("SOA answer count = %d, want 1", len(m.Answer))
}
soa, ok := m.Answer[0].(*dns.SOA)
if !ok {
t.Fatalf("answer = %T, want *dns.SOA", m.Answer[0])
}
if soa.Serial != 7 {
t.Errorf("serial = %d, want the zone serial 7", soa.Serial)
}
if soa.Mbox != `hostmaster.example.com.` {
t.Errorf("mbox = %q, want the email address in RNAME form", soa.Mbox)
}
}
func TestPerRecordTTLOverridesZoneDefault(t *testing.T) {
idx := testIndex(t)
m := query(t, idx, "short.example.com.", dns.TypeA)
if len(m.Answer) != 1 {
t.Fatalf("answer count = %d, want 1", len(m.Answer))
}
if got := m.Answer[0].Header().Ttl; got != 60 {
t.Errorf("TTL = %d, want the per-record value 60", got)
}
m = query(t, idx, "mail.example.com.", dns.TypeA)
if got := m.Answer[0].Header().Ttl; got != 3600 {
t.Errorf("TTL = %d, want the zone default 3600", got)
}
}
func TestOutOfZoneQueryIsNotAnswered(t *testing.T) {
idx := testIndex(t)
if m := query(t, idx, "example.org.", dns.TypeA); m != nil {
t.Errorf("expected no authoritative answer for an unconfigured zone, got %v", m)
}
}
func TestDisabledZoneIsNotServed(t *testing.T) {
zone := models.Zone{ID: 1, Name: "off.example.", Enabled: false, DefaultTTL: 300}
idx, _ := Build([]models.Zone{zone}, nil)
if idx.Lookup("off.example.") != nil {
t.Error("a disabled zone must not be indexed")
}
}
func TestBuildReportsInvalidRecordsWithoutFailingTheZone(t *testing.T) {
zone := models.Zone{ID: 1, Name: "example.com.", Enabled: true, DefaultTTL: 300}
recs := []models.Record{
{ZoneID: 1, Name: "good", Type: "A", Data: "192.0.2.1", Enabled: true},
{ZoneID: 1, Name: "bad", Type: "A", Data: "not-an-address", Enabled: true},
}
idx, problems := Build([]models.Zone{zone}, map[int64][]models.Record{1: recs})
if len(problems) != 1 {
t.Fatalf("problems = %d, want 1", len(problems))
}
if m := query(t, idx, "good.example.com.", dns.TypeA); len(m.Answer) != 1 {
t.Error("a single bad record must not take the rest of the zone offline")
}
}
+354
View File
@@ -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))]
}