consul/agent/dns/router_test.go

843 lines
22 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package dns
import (
"fmt"
"net"
"reflect"
"testing"
"time"
"github.com/armon/go-radix"
"github.com/hashicorp/consul/internal/dnsutil"
"github.com/miekg/dns"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/consul/acl"
"github.com/hashicorp/consul/agent/config"
"github.com/hashicorp/consul/agent/discovery"
"github.com/hashicorp/consul/agent/structs"
)
// HandleTestCase is a test case for the HandleRequest function.
// Tests for HandleRequest are split into multiple files to make it easier to
// manage and understand the tests. Other test files are:
// - router_addr_test.go
// - router_ns_test.go
// - router_prepared_query_test.go
// - router_ptr_test.go
// - router_recursor_test.go
// - router_service_test.go
// - router_soa_test.go
// - router_virtual_test.go
// - router_v2_services_test.go
// - router_workload_test.go
type HandleTestCase struct {
name string
agentConfig *config.RuntimeConfig // This will override the default test Router Config
configureDataFetcher func(fetcher discovery.CatalogDataFetcher)
validateAndNormalizeExpected bool
configureRecursor func(recursor dnsRecursor)
mockProcessorError error
request *dns.Msg
requestContext *Context
remoteAddress net.Addr
response *dns.Msg
}
func Test_HandleRequest_Validation(t *testing.T) {
testCases := []HandleTestCase{
{
name: "request with empty message",
request: &dns.Msg{
MsgHdr: dns.MsgHdr{
Opcode: dns.OpcodeQuery,
},
Question: []dns.Question{},
},
validateAndNormalizeExpected: false,
response: &dns.Msg{
MsgHdr: dns.MsgHdr{
Opcode: dns.OpcodeQuery,
Response: true,
Authoritative: false,
Rcode: dns.RcodeRefused,
},
Compress: false,
Question: nil,
Answer: nil,
Ns: nil,
Extra: nil,
},
},
// Context Tests
{
name: "When a request context is provided, use those field in the query",
request: &dns.Msg{
MsgHdr: dns.MsgHdr{
Opcode: dns.OpcodeQuery,
},
Question: []dns.Question{
{
Name: "foo.service.consul.",
Qtype: dns.TypeA,
Qclass: dns.ClassINET,
},
},
},
requestContext: &Context{
Token: "test-token",
DefaultNamespace: "test-namespace",
DefaultPartition: "test-partition",
},
configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) {
result := []*discovery.Result{
{
Type: discovery.ResultTypeNode,
Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"},
Tenancy: discovery.ResultTenancy{
Namespace: "test-namespace",
Partition: "test-partition",
},
},
}
fetcher.(*discovery.MockCatalogDataFetcher).
On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything).
Return(result, nil).
Run(func(args mock.Arguments) {
ctx := args.Get(0).(discovery.Context)
req := args.Get(1).(*discovery.QueryPayload)
reqType := args.Get(2).(discovery.LookupType)
require.Equal(t, "test-token", ctx.Token)
require.Equal(t, "foo", req.Name)
require.Equal(t, "test-namespace", req.Tenancy.Namespace)
require.Equal(t, "test-partition", req.Tenancy.Partition)
require.Equal(t, discovery.LookupTypeService, reqType)
})
},
validateAndNormalizeExpected: true,
response: &dns.Msg{
MsgHdr: dns.MsgHdr{
Response: true,
Authoritative: true,
},
Compress: true,
Question: []dns.Question{
{
Name: "foo.service.consul.",
Qtype: dns.TypeA,
Qclass: dns.ClassINET,
},
},
Answer: []dns.RR{
&dns.A{
Hdr: dns.RR_Header{
Name: "foo.service.consul.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 123,
},
A: net.ParseIP("1.2.3.4"),
},
},
},
},
{
name: "When a request context is provided, values do not override explicit tenancy",
request: &dns.Msg{
MsgHdr: dns.MsgHdr{
Opcode: dns.OpcodeQuery,
},
Question: []dns.Question{
{
Name: "foo.service.bar.ns.baz.ap.consul.",
Qtype: dns.TypeA,
Qclass: dns.ClassINET,
},
},
},
requestContext: &Context{
Token: "test-token",
DefaultNamespace: "test-namespace",
DefaultPartition: "test-partition",
},
configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) {
result := []*discovery.Result{
{
Type: discovery.ResultTypeNode,
Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"},
Tenancy: discovery.ResultTenancy{
Namespace: "bar",
Partition: "baz",
},
},
}
fetcher.(*discovery.MockCatalogDataFetcher).
On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything).
Return(result, nil).
Run(func(args mock.Arguments) {
ctx := args.Get(0).(discovery.Context)
req := args.Get(1).(*discovery.QueryPayload)
reqType := args.Get(2).(discovery.LookupType)
require.Equal(t, "test-token", ctx.Token)
require.Equal(t, "foo", req.Name)
require.Equal(t, "bar", req.Tenancy.Namespace)
require.Equal(t, "baz", req.Tenancy.Partition)
require.Equal(t, discovery.LookupTypeService, reqType)
})
},
validateAndNormalizeExpected: true,
response: &dns.Msg{
MsgHdr: dns.MsgHdr{
Response: true,
Authoritative: true,
},
Compress: true,
Question: []dns.Question{
{
Name: "foo.service.bar.ns.baz.ap.consul.",
Qtype: dns.TypeA,
Qclass: dns.ClassINET,
},
},
Answer: []dns.RR{
&dns.A{
Hdr: dns.RR_Header{
Name: "foo.service.bar.ns.baz.ap.consul.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 123,
},
A: net.ParseIP("1.2.3.4"),
},
},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
runHandleTestCases(t, tc)
})
}
}
// runHandleTestCases runs the test cases for the HandleRequest function.
func runHandleTestCases(t *testing.T, tc HandleTestCase) {
cdf := discovery.NewMockCatalogDataFetcher(t)
if tc.validateAndNormalizeExpected {
cdf.On("ValidateRequest", mock.Anything, mock.Anything).Return(nil)
cdf.On("NormalizeRequest", mock.Anything).Return()
}
if tc.configureDataFetcher != nil {
tc.configureDataFetcher(cdf)
}
cfg := buildDNSConfig(tc.agentConfig, cdf, tc.mockProcessorError)
router, err := NewRouter(cfg)
require.NoError(t, err)
// Replace the recursor with a mock and configure
router.recursor = newMockDnsRecursor(t)
if tc.configureRecursor != nil {
tc.configureRecursor(router.recursor)
}
ctx := tc.requestContext
if ctx == nil {
ctx = &Context{}
}
var remoteAddress net.Addr
if tc.remoteAddress != nil {
remoteAddress = tc.remoteAddress
} else {
remoteAddress = &net.UDPAddr{}
}
actual := router.HandleRequest(tc.request, *ctx, remoteAddress)
require.Equal(t, tc.response, actual)
}
func TestRouterDynamicConfig_GetTTLForService(t *testing.T) {
type testCase struct {
name string
inputKey string
shouldMatch bool
expectedDuration time.Duration
}
testCases := []testCase{
{
name: "strict match",
inputKey: "foo",
shouldMatch: true,
expectedDuration: 1 * time.Second,
},
{
name: "wildcard match",
inputKey: "bar",
shouldMatch: true,
expectedDuration: 2 * time.Second,
},
{
name: "wildcard match 2",
inputKey: "bart",
shouldMatch: true,
expectedDuration: 2 * time.Second,
},
{
name: "no match",
inputKey: "homer",
shouldMatch: false,
expectedDuration: 0 * time.Second,
},
}
rtCfg := &config.RuntimeConfig{
DNSServiceTTL: map[string]time.Duration{
"foo": 1 * time.Second,
"bar*": 2 * time.Second,
},
}
cfg, err := getDynamicRouterConfig(rtCfg)
require.NoError(t, err)
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual, ok := cfg.GetTTLForService(tc.inputKey)
require.Equal(t, tc.shouldMatch, ok)
require.Equal(t, tc.expectedDuration, actual)
})
}
}
func buildDNSConfig(agentConfig *config.RuntimeConfig, cdf discovery.CatalogDataFetcher, _ error) Config {
cfg := Config{
AgentConfig: &config.RuntimeConfig{
DNSDomain: "consul",
DNSNodeTTL: 123 * time.Second,
DNSSOA: config.RuntimeSOAConfig{
Refresh: 1,
Retry: 2,
Expire: 3,
Minttl: 4,
},
DNSUDPAnswerLimit: maxUDPAnswerLimit,
},
EntMeta: acl.EnterpriseMeta{},
Logger: hclog.NewNullLogger(),
Processor: discovery.NewQueryProcessor(cdf),
TokenFunc: func() string { return "" },
TranslateServiceAddressFunc: func(dc string, address string, taggedAddresses map[string]structs.ServiceAddress, accept dnsutil.TranslateAddressAccept) string {
return address
},
TranslateAddressFunc: func(dc string, addr string, taggedAddresses map[string]string, accept dnsutil.TranslateAddressAccept) string {
return addr
},
}
if agentConfig != nil {
cfg.AgentConfig = agentConfig
}
return cfg
}
// TestDNS_BinaryTruncate tests the dnsBinaryTruncate function.
func TestDNS_BinaryTruncate(t *testing.T) {
msgSrc := new(dns.Msg)
msgSrc.Compress = true
msgSrc.SetQuestion("redis.service.consul.", dns.TypeSRV)
for i := 0; i < 5000; i++ {
target := fmt.Sprintf("host-redis-%d-%d.test.acme.com.node.dc1.consul.", i/256, i%256)
msgSrc.Answer = append(msgSrc.Answer, &dns.SRV{Hdr: dns.RR_Header{Name: "redis.service.consul.", Class: 1, Rrtype: dns.TypeSRV, Ttl: 0x3c}, Port: 0x4c57, Target: target})
msgSrc.Extra = append(msgSrc.Extra, &dns.CNAME{Hdr: dns.RR_Header{Name: target, Class: 1, Rrtype: dns.TypeCNAME, Ttl: 0x3c}, Target: fmt.Sprintf("fx.168.%d.%d.", i/256, i%256)})
}
for _, compress := range []bool{true, false} {
for idx, maxSize := range []int{12, 256, 512, 8192, 65535} {
t.Run(fmt.Sprintf("binarySearch %d", maxSize), func(t *testing.T) {
msg := new(dns.Msg)
msgSrc.Compress = compress
msgSrc.SetQuestion("redis.service.consul.", dns.TypeSRV)
msg.Answer = msgSrc.Answer
msg.Extra = msgSrc.Extra
msg.Ns = msgSrc.Ns
index := make(map[string]dns.RR, len(msg.Extra))
indexRRs(msg.Extra, index)
blen := dnsBinaryTruncate(msg, maxSize, index, true)
msg.Answer = msg.Answer[:blen]
syncExtra(index, msg)
predicted := msg.Len()
buf, err := msg.Pack()
if err != nil {
t.Error(err)
}
if predicted < len(buf) {
t.Fatalf("Bug in DNS library: %d != %d", predicted, len(buf))
}
if len(buf) > maxSize || (idx != 0 && len(buf) < 16) {
t.Fatalf("bad[%d]: %d > %d", idx, len(buf), maxSize)
}
})
}
}
}
// TestDNS_syncExtra tests the syncExtra function.
func TestDNS_syncExtra(t *testing.T) {
resp := &dns.Msg{
Answer: []dns.RR{
// These two are on the same host so the redundant extra
// records should get deduplicated.
&dns.SRV{
Hdr: dns.RR_Header{
Name: "redis-cache-redis.service.consul.",
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
},
Port: 1001,
Target: "ip-10-0-1-185.node.dc1.consul.",
},
&dns.SRV{
Hdr: dns.RR_Header{
Name: "redis-cache-redis.service.consul.",
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
},
Port: 1002,
Target: "ip-10-0-1-185.node.dc1.consul.",
},
// This one isn't in the Consul domain so it will get a
// CNAME and then an A record from the recursor.
&dns.SRV{
Hdr: dns.RR_Header{
Name: "redis-cache-redis.service.consul.",
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
},
Port: 1003,
Target: "demo.consul.io.",
},
// This one isn't in the Consul domain and it will get
// a CNAME and A record from a recursor that alters the
// case of the name. This proves we look up in the index
// in a case-insensitive way.
&dns.SRV{
Hdr: dns.RR_Header{
Name: "redis-cache-redis.service.consul.",
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
},
Port: 1001,
Target: "insensitive.consul.io.",
},
// This is also a CNAME, but it'll be set up to loop to
// make sure we don't crash.
&dns.SRV{
Hdr: dns.RR_Header{
Name: "redis-cache-redis.service.consul.",
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
},
Port: 1001,
Target: "deadly.consul.io.",
},
// This is also a CNAME, but it won't have another record.
&dns.SRV{
Hdr: dns.RR_Header{
Name: "redis-cache-redis.service.consul.",
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
},
Port: 1001,
Target: "nope.consul.io.",
},
},
Extra: []dns.RR{
// These should get deduplicated.
&dns.A{
Hdr: dns.RR_Header{
Name: "ip-10-0-1-185.node.dc1.consul.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
},
A: net.ParseIP("10.0.1.185"),
},
&dns.A{
Hdr: dns.RR_Header{
Name: "ip-10-0-1-185.node.dc1.consul.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
},
A: net.ParseIP("10.0.1.185"),
},
// This is a normal CNAME followed by an A record but we
// have flipped the order. The algorithm should emit them
// in the opposite order.
&dns.A{
Hdr: dns.RR_Header{
Name: "fakeserver.consul.io.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
},
A: net.ParseIP("127.0.0.1"),
},
&dns.CNAME{
Hdr: dns.RR_Header{
Name: "demo.consul.io.",
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
},
Target: "fakeserver.consul.io.",
},
// These differ in case to test case insensitivity.
&dns.CNAME{
Hdr: dns.RR_Header{
Name: "INSENSITIVE.CONSUL.IO.",
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
},
Target: "Another.Server.Com.",
},
&dns.A{
Hdr: dns.RR_Header{
Name: "another.server.com.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
},
A: net.ParseIP("127.0.0.1"),
},
// This doesn't appear in the answer, so should get
// dropped.
&dns.A{
Hdr: dns.RR_Header{
Name: "ip-10-0-1-186.node.dc1.consul.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
},
A: net.ParseIP("10.0.1.186"),
},
// These two test edge cases with CNAME handling.
&dns.CNAME{
Hdr: dns.RR_Header{
Name: "deadly.consul.io.",
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
},
Target: "deadly.consul.io.",
},
&dns.CNAME{
Hdr: dns.RR_Header{
Name: "nope.consul.io.",
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
},
Target: "notthere.consul.io.",
},
},
}
index := make(map[string]dns.RR)
indexRRs(resp.Extra, index)
syncExtra(index, resp)
expected := &dns.Msg{
Answer: resp.Answer,
Extra: []dns.RR{
&dns.A{
Hdr: dns.RR_Header{
Name: "ip-10-0-1-185.node.dc1.consul.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
},
A: net.ParseIP("10.0.1.185"),
},
&dns.CNAME{
Hdr: dns.RR_Header{
Name: "demo.consul.io.",
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
},
Target: "fakeserver.consul.io.",
},
&dns.A{
Hdr: dns.RR_Header{
Name: "fakeserver.consul.io.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
},
A: net.ParseIP("127.0.0.1"),
},
&dns.CNAME{
Hdr: dns.RR_Header{
Name: "INSENSITIVE.CONSUL.IO.",
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
},
Target: "Another.Server.Com.",
},
&dns.A{
Hdr: dns.RR_Header{
Name: "another.server.com.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
},
A: net.ParseIP("127.0.0.1"),
},
&dns.CNAME{
Hdr: dns.RR_Header{
Name: "deadly.consul.io.",
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
},
Target: "deadly.consul.io.",
},
&dns.CNAME{
Hdr: dns.RR_Header{
Name: "nope.consul.io.",
Rrtype: dns.TypeCNAME,
Class: dns.ClassINET,
},
Target: "notthere.consul.io.",
},
},
}
if !reflect.DeepEqual(resp, expected) {
t.Fatalf("Bad %#v vs. %#v", *resp, *expected)
}
}
// getUint32Ptr return the pointer of an uint32 literal
func getUint32Ptr(i uint32) *uint32 {
return &i
}
func TestRouter_ReloadConfig(t *testing.T) {
cdf := discovery.NewMockCatalogDataFetcher(t)
cfg := buildDNSConfig(nil, cdf, nil)
router, err := NewRouter(cfg)
require.NoError(t, err)
router.recursor = newMockDnsRecursor(t)
// Reload the config
newAgentConfig := &config.RuntimeConfig{
DNSARecordLimit: 123,
DNSEnableTruncate: true,
DNSNodeTTL: 234,
DNSRecursorStrategy: "strategy-123",
DNSRecursorTimeout: 345,
DNSUDPAnswerLimit: 456,
DNSNodeMetaTXT: true,
DNSDisableCompression: true,
DNSSOA: config.RuntimeSOAConfig{
Expire: 123,
Minttl: 234,
Refresh: 345,
Retry: 456,
},
DNSServiceTTL: map[string]time.Duration{
"wildcard-config-*": 123,
"strict-config": 234,
},
DNSRecursors: []string{
"8.8.8.8",
"2001:4860:4860::8888",
},
}
expectTTLRadix := radix.New()
expectTTLRadix.Insert("wildcard-config-", time.Duration(123))
expectedCfg := &RouterDynamicConfig{
ARecordLimit: 123,
EnableTruncate: true,
NodeTTL: 234,
RecursorStrategy: "strategy-123",
RecursorTimeout: 345,
UDPAnswerLimit: 456,
NodeMetaTXT: true,
DisableCompression: true,
SOAConfig: SOAConfig{
Expire: 123,
Minttl: 234,
Refresh: 345,
Retry: 456,
},
TTLRadix: expectTTLRadix,
TTLStrict: map[string]time.Duration{
"strict-config": 234,
},
Recursors: []string{
"8.8.8.8:53",
"[2001:4860:4860::8888]:53",
},
}
err = router.ReloadConfig(newAgentConfig)
require.NoError(t, err)
savedCfg := router.dynamicConfig.Load().(*RouterDynamicConfig)
// Ensure the new config is used
require.Equal(t, expectedCfg, savedCfg)
}
func Test_isPTRSubdomain(t *testing.T) {
testCases := []struct {
name string
domain string
expected bool
}{
{
name: "empty domain returns false",
domain: "",
expected: false,
},
{
name: "last label is 'arpa' returns true",
domain: "my-addr.arpa.",
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual := isPTRSubdomain(tc.domain)
require.Equal(t, tc.expected, actual)
})
}
}
func Test_isAddrSubdomain(t *testing.T) {
testCases := []struct {
name string
domain string
expected bool
}{
{
name: "empty domain returns false",
domain: "",
expected: false,
},
{
name: "'c000020a.addr.dc1.consul.' returns true",
domain: "c000020a.addr.dc1.consul.",
expected: true,
},
{
name: "'c000020a.addr.consul.' returns true",
domain: "c000020a.addr.consul.",
expected: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual := isAddrSubdomain(tc.domain)
require.Equal(t, tc.expected, actual)
})
}
}
func Test_stripAnyFailoverSuffix(t *testing.T) {
testCases := []struct {
name string
target string
expectedEnableFailover bool
expectedResult string
}{
{
name: "my-addr.service.dc1.consul.failover. returns 'my-addr.service.dc1.consul' and true",
target: "my-addr.service.dc1.consul.failover.",
expectedEnableFailover: true,
expectedResult: "my-addr.service.dc1.consul.",
},
{
name: "my-addr.service.dc1.consul.no-failover. returns 'my-addr.service.dc1.consul' and false",
target: "my-addr.service.dc1.consul.no-failover.",
expectedEnableFailover: false,
expectedResult: "my-addr.service.dc1.consul.",
},
{
name: "my-addr.service.dc1.consul. returns 'my-addr.service.dc1.consul' and false",
target: "my-addr.service.dc1.consul.",
expectedEnableFailover: false,
expectedResult: "my-addr.service.dc1.consul.",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
actual, actualEnableFailover := stripAnyFailoverSuffix(tc.target)
require.Equal(t, tc.expectedEnableFailover, actualEnableFailover)
require.Equal(t, tc.expectedResult, actual)
})
}
}
func Test_trimDomain(t *testing.T) {
testCases := []struct {
name string
domain string
altDomain string
questionName string
expectedResult string
}{
{
name: "given domain is 'consul.' and altDomain is 'my.consul.', when calling trimDomain with 'my-service.my.consul.', it returns 'my-service.'",
questionName: "my-service.my.consul.",
domain: "consul.",
altDomain: "my.consul.",
expectedResult: "my-service.",
},
{
name: "given domain is 'consul.' and altDomain is 'my.consul.', when calling trimDomain with 'my-service.consul.', it returns 'my-service.'",
questionName: "my-service.consul.",
domain: "consul.",
altDomain: "my.consul.",
expectedResult: "my-service.",
},
{
name: "given domain is 'consul.' and altDomain is 'my-consul.', when calling trimDomain with 'my-service.consul.', it returns 'my-service.'",
questionName: "my-service.consul.",
domain: "consul.",
altDomain: "my-consul.",
expectedResult: "my-service.",
},
{
name: "given domain is 'consul.' and altDomain is 'my-consul.', when calling trimDomain with 'my-service.my-consul.', it returns 'my-service.'",
questionName: "my-service.my-consul.",
domain: "consul.",
altDomain: "my-consul.",
expectedResult: "my-service.",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
router := Router{
domain: tc.domain,
altDomain: tc.altDomain,
}
actual := router.trimDomain(tc.questionName)
require.Equal(t, tc.expectedResult, actual)
})
}
}