initial commit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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