check / check (push) Successful in 1m27s
The resolver lists in FailedTypes each record type whose query to a nameserver got no usable reply (no reply, a code other than NOERROR or NXDOMAIN, a referral, or a truncated reply whose TCP retry failed) and logs it unless shutdown cut it short. A nameserver that answered no type has failed. The watcher saves such a type in failedTypes with the previous check's records, leaves it out of the comparison with other nameservers on that check, and compares it with the next answer. When the previous check did not know its records either, it is also in unknownTypes and not compared until it answers. A nameserver whose A, AAAA or CNAME query failed is no answer when following a CNAME or resolving addresses. Model: opus-5-5
318 lines
7.9 KiB
Go
318 lines
7.9 KiB
Go
package resolver_test
|
|
|
|
import (
|
|
"math/rand/v2"
|
|
"slices"
|
|
"testing"
|
|
|
|
"github.com/miekg/dns"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"sneak.berlin/go/dnswatcher/internal/resolver"
|
|
)
|
|
|
|
// TestCollectIPs_OneAnswerIsEnough checks that one nameserver answering
|
|
// NXDOMAIN says the name has no addresses, though the other timed out.
|
|
func TestCollectIPs_OneAnswerIsEnough(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ips, _, err := resolver.CollectIPs(
|
|
map[string]*resolver.NameserverResponse{
|
|
"ns1.example.": {Status: resolver.StatusTimeout},
|
|
"ns2.example.": {Status: resolver.StatusNXDomain},
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, ips)
|
|
}
|
|
|
|
// TestCollectIPs_FailedIsNoAnswer checks that nameservers that all have
|
|
// status error, from a refusal, a server failure, a network error or a
|
|
// referral, are no answer rather than a name with no addresses.
|
|
func TestCollectIPs_FailedIsNoAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ips, _, err := resolver.CollectIPs(
|
|
map[string]*resolver.NameserverResponse{
|
|
"ns1.example.": {Status: resolver.StatusError},
|
|
"ns2.example.": {Status: resolver.StatusError},
|
|
},
|
|
)
|
|
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
|
|
assert.Empty(t, ips)
|
|
}
|
|
|
|
// TestCollectIPs_FailedTypeIsNoAnswer checks that a nameserver whose
|
|
// query for one of the types failed is no answer: its addresses are
|
|
// only part of them.
|
|
func TestCollectIPs_FailedTypeIsNoAnswer(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ips, _, err := resolver.CollectIPs(
|
|
map[string]*resolver.NameserverResponse{
|
|
nsExample1: {
|
|
Records: map[string][]string{"A": {"192.0.2.1"}},
|
|
FailedTypes: []string{"AAAA"},
|
|
Status: resolver.StatusOK,
|
|
},
|
|
},
|
|
)
|
|
require.ErrorIs(t, err, resolver.ErrNoNameserverAnswered)
|
|
assert.Empty(t, ips)
|
|
}
|
|
|
|
const (
|
|
// exampleCom is the zone most cases of TestUsableReply and
|
|
// TestNSSetFrom are about, and wwwExampleCom a name in it.
|
|
exampleCom = "example.com."
|
|
wwwExampleCom = "www.example.com."
|
|
|
|
// exampleNS is the server the NS records nsRecord builds name.
|
|
exampleNS = "ns1.example.net."
|
|
)
|
|
|
|
// nsRecord builds an NS record that names a server of zone.
|
|
func nsRecord(zone string) *dns.NS {
|
|
return &dns.NS{
|
|
Hdr: dns.RR_Header{
|
|
Name: zone, Rrtype: dns.TypeNS, Class: dns.ClassINET,
|
|
},
|
|
Ns: exampleNS,
|
|
}
|
|
}
|
|
|
|
// referralTo builds a reply that refers the query to the servers of
|
|
// zone.
|
|
func referralTo(zone string) *dns.Msg {
|
|
msg := new(dns.Msg)
|
|
msg.Ns = []dns.RR{nsRecord(zone)}
|
|
|
|
return msg
|
|
}
|
|
|
|
// TestUsableReply checks which replies from one of a zone's servers are
|
|
// used. A reply that is not usable moves the query on to the zone's
|
|
// next server.
|
|
func TestUsableReply(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
servfail := new(dns.Msg)
|
|
servfail.Rcode = dns.RcodeServerFailure
|
|
|
|
answer := new(dns.Msg)
|
|
answer.Authoritative = true
|
|
answer.Answer = []dns.RR{nsRecord(exampleCom)}
|
|
|
|
nxdomain := new(dns.Msg)
|
|
nxdomain.Authoritative = true
|
|
nxdomain.Rcode = dns.RcodeNameError
|
|
|
|
tests := []struct {
|
|
name string
|
|
resp *dns.Msg
|
|
zone string
|
|
query string
|
|
want bool
|
|
}{
|
|
{
|
|
name: "SERVFAIL", resp: servfail,
|
|
zone: exampleCom, query: exampleCom, want: false,
|
|
},
|
|
{
|
|
name: "answer", resp: answer,
|
|
zone: exampleCom, query: exampleCom, want: true,
|
|
},
|
|
{
|
|
name: "NXDOMAIN", resp: nxdomain,
|
|
zone: ".", query: exampleCom, want: true,
|
|
},
|
|
{
|
|
name: "root refers to com", resp: referralTo("com."),
|
|
zone: ".", query: exampleCom, want: true,
|
|
},
|
|
{
|
|
name: "com refers to example.com", resp: referralTo(exampleCom),
|
|
zone: "com.", query: wwwExampleCom, want: true,
|
|
},
|
|
{
|
|
name: "referral back to the zone", resp: referralTo(exampleCom),
|
|
zone: exampleCom, query: exampleCom, want: false,
|
|
},
|
|
{
|
|
name: "referral up to the root", resp: referralTo("."),
|
|
zone: exampleCom, query: exampleCom, want: false,
|
|
},
|
|
{
|
|
name: "referral sideways", resp: referralTo("net."),
|
|
zone: ".", query: exampleCom, want: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t, tt.want,
|
|
resolver.UsableReply(tt.resp, tt.zone, tt.query),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNSSetFrom checks which NS set a reply gives for a domain; a set
|
|
// that is not empty ends the walk. The referral to example.com that
|
|
// com's servers all send alike gives its delegation, so the set is the
|
|
// same whichever of them answered, and example.com's own servers, which
|
|
// can disagree, are not asked.
|
|
func TestNSSetFrom(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
answer := new(dns.Msg)
|
|
answer.Authoritative = true
|
|
answer.Answer = []dns.RR{nsRecord(exampleCom)}
|
|
|
|
tests := []struct {
|
|
name string
|
|
resp *dns.Msg
|
|
domain string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "com refers to example.com", resp: referralTo(exampleCom),
|
|
domain: exampleCom, want: []string{exampleNS},
|
|
},
|
|
{
|
|
name: "com refers on, for www.example.com",
|
|
resp: referralTo(exampleCom), domain: wwwExampleCom,
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "answer from a server that holds example.com",
|
|
resp: answer, domain: exampleCom,
|
|
want: []string{exampleNS},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.ElementsMatch(t, tt.want,
|
|
resolver.NSSetFrom(tt.resp, tt.domain),
|
|
)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExtractRecordValue_LetterCase(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
rr dns.RR
|
|
want string
|
|
}{
|
|
{
|
|
name: "MX target lower-cased",
|
|
rr: &dns.MX{Preference: 1, Mx: "ASPMX.L.GOOGLE.COM."},
|
|
want: "1 aspmx.l.google.com.",
|
|
},
|
|
{
|
|
name: "NS target lower-cased",
|
|
rr: &dns.NS{Ns: "x.ns.joker.COM."},
|
|
want: "x.ns.joker.com.",
|
|
},
|
|
{
|
|
name: "CNAME target lower-cased",
|
|
rr: &dns.CNAME{Target: "WWW.Example.Com."},
|
|
want: "www.example.com.",
|
|
},
|
|
{
|
|
name: "SRV target lower-cased",
|
|
rr: &dns.SRV{
|
|
Priority: 10, Weight: 5, Port: 443,
|
|
Target: "SIP.Example.Com.",
|
|
},
|
|
want: "10 5 443 sip.example.com.",
|
|
},
|
|
{
|
|
name: "TXT value keeps its case",
|
|
rr: &dns.TXT{Txt: []string{"Verify=AbC123"}},
|
|
want: "Verify=AbC123",
|
|
},
|
|
{
|
|
name: "CAA value keeps its case",
|
|
rr: &dns.CAA{Flag: 0, Tag: "issue", Value: "LetsEncrypt.org"},
|
|
want: `0 issue "LetsEncrypt.org"`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
assert.Equal(t, tt.want, resolver.ExtractRecordValue(tt.rr))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestCollectAnswerRecords_CNAMEOnce collects the answers a nameserver
|
|
// gives for a name with a CNAME, one for each record type a check asks
|
|
// for. Each answer holds the CNAME, which must be stored once.
|
|
func TestCollectAnswerRecords_CNAMEOnce(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
cname := &dns.CNAME{
|
|
Hdr: dns.RR_Header{
|
|
Name: "git.eeqj.de.", Rrtype: dns.TypeCNAME, Class: dns.ClassINET,
|
|
},
|
|
Target: "fsn1app1.datavi.be.",
|
|
}
|
|
|
|
resp := &resolver.NameserverResponse{Records: map[string][]string{}}
|
|
|
|
for _, qtype := range []uint16{
|
|
dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, dns.TypeMX,
|
|
dns.TypeTXT, dns.TypeSRV, dns.TypeCAA, dns.TypeNS,
|
|
} {
|
|
msg := new(dns.Msg)
|
|
msg.SetQuestion("git.eeqj.de.", qtype)
|
|
msg.Answer = []dns.RR{cname}
|
|
|
|
resolver.CollectAnswerRecords(msg, resp)
|
|
}
|
|
|
|
assert.Equal(t,
|
|
map[string][]string{"CNAME": {"fsn1app1.datavi.be."}},
|
|
resp.Records,
|
|
)
|
|
}
|
|
|
|
// TestShuffled shuffles the root servers with many seeds. Every order
|
|
// must hold each root server once, so each is tried before a
|
|
// resolution fails; each root server must come first for some seed, so
|
|
// no one root server gets every first query; and the list passed in
|
|
// must be left as it was.
|
|
func TestShuffled(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const seeds = 1000
|
|
|
|
roots := resolver.RootServerList()
|
|
before := slices.Clone(roots)
|
|
first := make(map[string]bool)
|
|
|
|
for seed := range uint64(seeds) {
|
|
rng := rand.New(rand.NewPCG(seed, 0)) //nolint:gosec // seeded on purpose
|
|
order := resolver.Shuffled(roots, rng.Shuffle)
|
|
|
|
assert.ElementsMatch(t, roots, order)
|
|
|
|
first[order[0]] = true
|
|
}
|
|
|
|
assert.Len(t, first, len(roots))
|
|
assert.Equal(t, before, roots)
|
|
}
|