check / check (push) Successful in 1m6s
For a name with a CNAME, a nameserver answers a query of any type with that CNAME, and the records of every answer were added, so the CNAME was stored once for each of the eight record types asked for. collectAnswerRecords now adds each value once per record type. A state file saved before this holds the repeated values. Load keeps each record value once, so the first check after upgrading sees no record change and notifies nothing for them. Model: opus-5-5
299 lines
7.3 KiB
Go
299 lines
7.3 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)
|
|
}
|
|
|
|
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)
|
|
}
|