From a1c6181677afd3d222eeaa6dfc64ff7d52a2efd2 Mon Sep 17 00:00:00 2001 From: John Murret Date: Fri, 1 Mar 2024 08:36:37 -0700 Subject: [PATCH] DNS v2 - split up router into multiple responsibilities & break up router tests into multiple files. (#20688) * Update agent/dns.go Co-authored-by: Michael Zalimeni * PR feedback * split tests out into multiple files. * Extract responsibilities from router into discoveryResultsFetcher, messageSerializer, responseGenerator. * adding recordmaker tests * add response generator test coverage. * changing tests case name based on PR feedback --------- Co-authored-by: Michael Zalimeni --- agent/discovery/discovery.go | 1 - ..._query.go => discovery_results_fetcher.go} | 109 +- ...t.go => discovery_results_fetcher_test.go} | 0 agent/dns/dns_record_maker.go | 151 + agent/dns/dns_record_maker_test.go | 228 ++ agent/dns/message_serializer.go | 656 ++++ ...uter_response.go => response_generator.go} | 174 +- agent/dns/response_generator_test.go | 739 ++++ agent/dns/router.go | 1209 +------ agent/dns/router_addr_test.go | 405 +++ agent/dns/router_ns_test.go | 244 ++ agent/dns/router_prepared_query_test.go | 187 + agent/dns/router_ptr_test.go | 563 +++ agent/dns/router_recursor_test.go | 296 ++ agent/dns/router_soa_test.go | 277 ++ agent/dns/router_test.go | 3169 +---------------- agent/dns/router_v2_services_test.go | 628 ++++ agent/dns/router_virtual_test.go | 122 + agent/dns/router_workload_test.go | 515 +++ 19 files changed, 5617 insertions(+), 4056 deletions(-) rename agent/dns/{router_query.go => discovery_results_fetcher.go} (66%) rename agent/dns/{router_query_test.go => discovery_results_fetcher_test.go} (100%) create mode 100644 agent/dns/dns_record_maker.go create mode 100644 agent/dns/dns_record_maker_test.go create mode 100644 agent/dns/message_serializer.go rename agent/dns/{router_response.go => response_generator.go} (57%) create mode 100644 agent/dns/response_generator_test.go create mode 100644 agent/dns/router_addr_test.go create mode 100644 agent/dns/router_ns_test.go create mode 100644 agent/dns/router_prepared_query_test.go create mode 100644 agent/dns/router_ptr_test.go create mode 100644 agent/dns/router_recursor_test.go create mode 100644 agent/dns/router_soa_test.go create mode 100644 agent/dns/router_v2_services_test.go create mode 100644 agent/dns/router_virtual_test.go create mode 100644 agent/dns/router_workload_test.go diff --git a/agent/discovery/discovery.go b/agent/discovery/discovery.go index b8c6cb6ab8..ee2d742fe7 100644 --- a/agent/discovery/discovery.go +++ b/agent/discovery/discovery.go @@ -106,7 +106,6 @@ const ( type Result struct { Service *Location // The name and address of the service. Node *Location // The name and address of the node. - Weight uint32 // SRV queries Metadata map[string]string // Used to collect metadata into TXT Records Type ResultType // Used to reconstruct the fqdn name of the resource DNS DNSConfig // Used for DNS-specific configuration for this result diff --git a/agent/dns/router_query.go b/agent/dns/discovery_results_fetcher.go similarity index 66% rename from agent/dns/router_query.go rename to agent/dns/discovery_results_fetcher.go index bbcbca6698..f68f24865c 100644 --- a/agent/dns/router_query.go +++ b/agent/dns/discovery_results_fetcher.go @@ -4,14 +4,95 @@ package dns import ( + "encoding/hex" "net" "strings" "github.com/miekg/dns" + "github.com/hashicorp/go-hclog" + + "github.com/hashicorp/consul/acl" "github.com/hashicorp/consul/agent/discovery" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/internal/dnsutil" ) +// discoveryResultsFetcher is a facade for the DNS router to formulate +// and execute discovery queries. +type discoveryResultsFetcher struct{} + +// getQueryOptions is a struct to hold the options for getQueryResults method. +type getQueryOptions struct { + req *dns.Msg + reqCtx Context + qName string + remoteAddress net.Addr + processor DiscoveryQueryProcessor + logger hclog.Logger + domain string + altDomain string +} + +// getQueryResults returns a discovery.Result from a DNS message. +func (d discoveryResultsFetcher) getQueryResults(opts *getQueryOptions) ([]*discovery.Result, *discovery.Query, error) { + reqType := parseRequestType(opts.req) + + switch reqType { + case requestTypeConsul: + // This is a special case of discovery.QueryByName where we know that we need to query the consul service + // regardless of the question name. + query := &discovery.Query{ + QueryType: discovery.QueryTypeService, + QueryPayload: discovery.QueryPayload{ + Name: structs.ConsulServiceName, + Tenancy: discovery.QueryTenancy{ + // We specify the partition here so that in the case we are a client agent in a non-default partition. + // We don't want the query processors default partition to be used. + // This is a small hack because for V1 CE, this is not the correct default partition name, but we + // need to add something to disambiguate the empty field. + Partition: acl.DefaultPartitionName, //NOTE: note this won't work if we ever have V2 client agents + }, + Limit: 3, + }, + } + + results, err := opts.processor.QueryByName(query, discovery.Context{Token: opts.reqCtx.Token}) + return results, query, err + case requestTypeName: + query, err := buildQueryFromDNSMessage(opts.req, opts.reqCtx, opts.domain, opts.altDomain, opts.remoteAddress) + if err != nil { + opts.logger.Error("error building discovery query from DNS request", "error", err) + return nil, query, err + } + results, err := opts.processor.QueryByName(query, discovery.Context{Token: opts.reqCtx.Token}) + + if getErrorFromECSNotGlobalError(err) != nil { + opts.logger.Error("error processing discovery query", "error", err) + return nil, query, err + } + return results, query, err + case requestTypeIP: + ip := dnsutil.IPFromARPA(opts.qName) + if ip == nil { + opts.logger.Error("error building IP from DNS request", "name", opts.qName) + return nil, nil, errNameNotFound + } + results, err := opts.processor.QueryByIP(ip, discovery.Context{Token: opts.reqCtx.Token}) + return results, nil, err + case requestTypeAddress: + results, err := buildAddressResults(opts.req) + if err != nil { + opts.logger.Error("error processing discovery query", "error", err) + return nil, nil, err + } + return results, nil, nil + } + + opts.logger.Error("error parsing discovery query type", "requestType", reqType) + return nil, nil, errInvalidQuestion +} + // buildQueryFromDNSMessage returns a discovery.Query from a DNS message. func buildQueryFromDNSMessage(req *dns.Msg, reqCtx Context, domain, altDomain string, remoteAddress net.Addr) (*discovery.Query, error) { @@ -46,6 +127,32 @@ func buildQueryFromDNSMessage(req *dns.Msg, reqCtx Context, domain, altDomain st }, nil } +// buildAddressResults returns a discovery.Result from a DNS request for addr. records. +func buildAddressResults(req *dns.Msg) ([]*discovery.Result, error) { + domain := dns.CanonicalName(req.Question[0].Name) + labels := dns.SplitDomainName(domain) + hexadecimal := labels[0] + + if len(hexadecimal)/2 != 4 && len(hexadecimal)/2 != 16 { + return nil, errNameNotFound + } + + var ip net.IP + ip, err := hex.DecodeString(hexadecimal) + if err != nil { + return nil, errNameNotFound + } + + return []*discovery.Result{ + { + Node: &discovery.Location{ + Address: ip.String(), + }, + Type: discovery.ResultTypeNode, // We choose node by convention since we do not know the origin of the IP + }, + }, nil +} + // getQueryNameAndTagFromParts returns the query name and tag from the query parts that are taken from the original dns question. func getQueryNameAndTagFromParts(queryType discovery.QueryType, queryParts []string) (string, string) { n := len(queryParts) @@ -146,7 +253,7 @@ func getEffectiveDatacenter(labels *parsedLabels, defaultDC string) string { func getQueryTypePartsAndSuffixesFromDNSMessage(req *dns.Msg, domain, altDomain string) (queryType discovery.QueryType, parts []string, suffixes []string) { // Get the QName without the domain suffix // TODO (v2-dns): we will also need to handle the "failover" and "no-failover" suffixes here. - // They come AFTER the domain. See `stripSuffix` in router.go + // They come AFTER the domain. See `stripAnyFailoverSuffix` in router.go qName := trimDomainFromQuestionName(req.Question[0].Name, domain, altDomain) // Split into the label parts diff --git a/agent/dns/router_query_test.go b/agent/dns/discovery_results_fetcher_test.go similarity index 100% rename from agent/dns/router_query_test.go rename to agent/dns/discovery_results_fetcher_test.go diff --git a/agent/dns/dns_record_maker.go b/agent/dns/dns_record_maker.go new file mode 100644 index 0000000000..c63d9d500b --- /dev/null +++ b/agent/dns/dns_record_maker.go @@ -0,0 +1,151 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "regexp" + "strings" + "time" + + "github.com/miekg/dns" + + "github.com/hashicorp/consul/agent/discovery" +) + +// dnsRecordMaker creates DNS records to be used when generating +// responses to dns requests. +type dnsRecordMaker struct{} + +// makeSOA returns an SOA record for the given domain and config. +func (dnsRecordMaker) makeSOA(domain string, cfg *RouterDynamicConfig) dns.RR { + return &dns.SOA{ + Hdr: dns.RR_Header{ + Name: domain, + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + // Has to be consistent with MinTTL to avoid invalidation + Ttl: cfg.SOAConfig.Minttl, + }, + Ns: "ns." + domain, + Serial: uint32(time.Now().Unix()), + Mbox: "hostmaster." + domain, + Refresh: cfg.SOAConfig.Refresh, + Retry: cfg.SOAConfig.Retry, + Expire: cfg.SOAConfig.Expire, + Minttl: cfg.SOAConfig.Minttl, + } +} + +// makeNS returns an NS record for the given domain and fqdn. +func (dnsRecordMaker) makeNS(domain, fqdn string, ttl uint32) dns.RR { + return &dns.NS{ + Hdr: dns.RR_Header{ + Name: domain, + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: ttl, + }, + Ns: fqdn, + } +} + +// makeIPBasedRecord returns an A or AAAA record for the given name and IP. +// Note: we might want to pass in the Query Name here, which is used in addr. and virtual. queries +// since there is only ever one result. Right now choosing to leave it off for simplification. +func (dnsRecordMaker) makeIPBasedRecord(name string, addr *dnsAddress, ttl uint32) dns.RR { + + if addr.IsIPV4() { + // check if the query type is A for IPv4 or ANY + return &dns.A{ + Hdr: dns.RR_Header{ + Name: name, + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: ttl, + }, + A: addr.IP(), + } + } + + return &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: name, + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: ttl, + }, + AAAA: addr.IP(), + } +} + +// makeCNAME returns a CNAME record for the given name and target. +func (dnsRecordMaker) makeCNAME(name string, target string, ttl uint32) *dns.CNAME { + return &dns.CNAME{ + Hdr: dns.RR_Header{ + Name: name, + Rrtype: dns.TypeCNAME, + Class: dns.ClassINET, + Ttl: ttl, + }, + Target: dns.Fqdn(target), + } +} + +// makeSRV returns an SRV record for the given name and target. +func (dnsRecordMaker) makeSRV(name, target string, weight uint16, ttl uint32, port *discovery.Port) *dns.SRV { + return &dns.SRV{ + Hdr: dns.RR_Header{ + Name: name, + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: ttl, + }, + Priority: 1, + Weight: weight, + Port: uint16(port.Number), + Target: target, + } +} + +// makeTXT returns a TXT record for the given name and result metadata. +func (dnsRecordMaker) makeTXT(name string, metadata map[string]string, ttl uint32) []dns.RR { + extra := make([]dns.RR, 0, len(metadata)) + for key, value := range metadata { + txt := value + if !strings.HasPrefix(strings.ToLower(key), "rfc1035-") { + txt = encodeKVasRFC1464(key, value) + } + + extra = append(extra, &dns.TXT{ + Hdr: dns.RR_Header{ + Name: name, + Rrtype: dns.TypeTXT, + Class: dns.ClassINET, + Ttl: ttl, + }, + Txt: []string{txt}, + }) + } + return extra +} + +// encodeKVasRFC1464 encodes a key-value pair according to RFC1464 +func encodeKVasRFC1464(key, value string) (txt string) { + // For details on these replacements c.f. https://www.ietf.org/rfc/rfc1464.txt + key = strings.Replace(key, "`", "``", -1) + key = strings.Replace(key, "=", "`=", -1) + + // Backquote the leading spaces + leadingSpacesRE := regexp.MustCompile("^ +") + numLeadingSpaces := len(leadingSpacesRE.FindString(key)) + key = leadingSpacesRE.ReplaceAllString(key, strings.Repeat("` ", numLeadingSpaces)) + + // Backquote the trailing spaces + numTrailingSpaces := len(trailingSpacesRE.FindString(key)) + key = trailingSpacesRE.ReplaceAllString(key, strings.Repeat("` ", numTrailingSpaces)) + + value = strings.Replace(value, "`", "``", -1) + + return key + "=" + value +} diff --git a/agent/dns/dns_record_maker_test.go b/agent/dns/dns_record_maker_test.go new file mode 100644 index 0000000000..3235ab3ef0 --- /dev/null +++ b/agent/dns/dns_record_maker_test.go @@ -0,0 +1,228 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent/discovery" +) + +func TestDNSRecordMaker_makeSOA(t *testing.T) { + cfg := &RouterDynamicConfig{ + SOAConfig: SOAConfig{ + Refresh: 1, + Retry: 2, + Expire: 3, + Minttl: 4, + }, + } + domain := "testdomain." + expected := &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 4, + }, + Ns: "ns.testdomain.", + Serial: uint32(time.Now().Unix()), + Mbox: "hostmaster.testdomain.", + Refresh: 1, + Retry: 2, + Expire: 3, + Minttl: 4, + } + actual := dnsRecordMaker{}.makeSOA(domain, cfg) + require.Equal(t, expected, actual) +} + +func TestDNSRecordMaker_makeNS(t *testing.T) { + domain := "testdomain." + fqdn := "ns.testdomain." + ttl := uint32(123) + expected := &dns.NS{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: 123, + }, + Ns: "ns.testdomain.", + } + actual := dnsRecordMaker{}.makeNS(domain, fqdn, ttl) + require.Equal(t, expected, actual) +} + +func TestDNSRecordMaker_makeIPBasedRecord(t *testing.T) { + ipv4Addr := newDNSAddress("1.2.3.4") + ipv6Addr := newDNSAddress("2001:db8:1:2:cafe::1337") + testCases := []struct { + name string + recordHeaderName string + addr *dnsAddress + ttl uint32 + expected dns.RR + }{ + { + name: "IPv4", + recordHeaderName: "my.service.dc1.consul.", + addr: ipv4Addr, + ttl: 123, + expected: &dns.A{ + Hdr: dns.RR_Header{ + Name: "my.service.dc1.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: ipv4Addr.IP(), + }, + }, + { + name: "IPv6", + recordHeaderName: "my.service.dc1.consul.", + addr: ipv6Addr, + ttl: 123, + expected: &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: "my.service.dc1.consul.", + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: 123, + }, + AAAA: ipv6Addr.IP(), + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual := dnsRecordMaker{}.makeIPBasedRecord(tc.recordHeaderName, tc.addr, tc.ttl) + require.Equal(t, tc.expected, actual) + }) + } +} + +func TestDNSRecordMaker_makeCNAME(t *testing.T) { + name := "my.service.consul." + target := "foo" + ttl := uint32(123) + expected := &dns.CNAME{ + Hdr: dns.RR_Header{ + Name: "my.service.consul.", + Rrtype: dns.TypeCNAME, + Class: dns.ClassINET, + Ttl: 123, + }, + Target: "foo.", + } + actual := dnsRecordMaker{}.makeCNAME(name, target, ttl) + require.Equal(t, expected, actual) +} + +func TestDNSRecordMaker_makeSRV(t *testing.T) { + name := "my.service.consul." + target := "foo" + ttl := uint32(123) + expected := &dns.SRV{ + Hdr: dns.RR_Header{ + Name: "my.service.consul.", + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: 123, + }, + Priority: 1, + Weight: uint16(345), + Port: uint16(234), + Target: "foo", + } + actual := dnsRecordMaker{}.makeSRV(name, target, uint16(345), ttl, &discovery.Port{Number: 234}) + require.Equal(t, expected, actual) +} + +func TestDNSRecordMaker_makeTXT(t *testing.T) { + testCases := []struct { + name string + metadata map[string]string + ttl uint32 + expected []dns.RR + }{ + { + name: "single metadata", + metadata: map[string]string{ + "key": "value", + }, + ttl: 123, + expected: []dns.RR{ + &dns.TXT{ + Hdr: dns.RR_Header{ + Name: "my.service.consul.", + Rrtype: dns.TypeTXT, + Class: dns.ClassINET, + Ttl: 123, + }, + Txt: []string{"key=value"}, + }, + }, + }, + { + name: "multiple metadata entries", + metadata: map[string]string{ + "key1": "value1", + "key2": "value2", + }, + ttl: 123, + expected: []dns.RR{ + &dns.TXT{ + Hdr: dns.RR_Header{ + Name: "my.service.consul.", + Rrtype: dns.TypeTXT, + Class: dns.ClassINET, + Ttl: 123, + }, + Txt: []string{"key1=value1"}, + }, + &dns.TXT{ + Hdr: dns.RR_Header{ + Name: "my.service.consul.", + Rrtype: dns.TypeTXT, + Class: dns.ClassINET, + Ttl: 123, + }, + Txt: []string{"key2=value2"}, + }, + }, + }, + { + name: "'rfc1035-' prefixed- metadata entry", + metadata: map[string]string{ + "rfc1035-key": "value", + }, + ttl: 123, + expected: []dns.RR{ + &dns.TXT{ + Hdr: dns.RR_Header{ + Name: "my.service.consul.", + Rrtype: dns.TypeTXT, + Class: dns.ClassINET, + Ttl: 123, + }, + Txt: []string{"value"}, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + actual := dnsRecordMaker{}.makeTXT("my.service.consul.", tc.metadata, tc.ttl) + require.ElementsMatchf(t, tc.expected, actual, "expected: %v, actual: %v", tc.expected, actual) + }) + } +} diff --git a/agent/dns/message_serializer.go b/agent/dns/message_serializer.go new file mode 100644 index 0000000000..2369b28319 --- /dev/null +++ b/agent/dns/message_serializer.go @@ -0,0 +1,656 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "encoding/hex" + "fmt" + "net" + "strings" + "time" + + "github.com/miekg/dns" + + "github.com/hashicorp/consul/agent/discovery" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/internal/dnsutil" +) + +// messageSerializer is the high level orchestrator for generating the Answer, +// Extra, and Ns records for a DNS response. +type messageSerializer struct{} + +// serializeOptions are the options for serializing a discovery.Result into a DNS message. +type serializeOptions struct { + req *dns.Msg + reqCtx Context + query *discovery.Query + results []*discovery.Result + resp *dns.Msg + cfg *RouterDynamicConfig + responseDomain string + remoteAddress net.Addr + maxRecursionLevel int + dnsRecordMaker dnsRecordMaker + translateAddressFunc func(dc string, addr string, taggedAddresses map[string]string, accept dnsutil.TranslateAddressAccept) string + translateServiceAddressFunc func(dc string, address string, taggedAddresses map[string]structs.ServiceAddress, accept dnsutil.TranslateAddressAccept) string + resolveCnameFunc func(cfgContext *RouterDynamicConfig, name string, reqCtx Context, remoteAddress net.Addr, maxRecursionLevel int) []dns.RR +} + +// serializeQueryResults converts a discovery.Result into a DNS message. +func (d messageSerializer) serialize(opts *serializeOptions) (*dns.Msg, error) { + resp := new(dns.Msg) + resp.SetReply(opts.req) + resp.Compress = !opts.cfg.DisableCompression + resp.Authoritative = true + resp.RecursionAvailable = canRecurse(opts.cfg) + opts.resp = resp + + qType := opts.req.Question[0].Qtype + reqType := parseRequestType(opts.req) + + // Always add the SOA record if requested. + if qType == dns.TypeSOA { + resp.Answer = append(resp.Answer, opts.dnsRecordMaker.makeSOA(opts.responseDomain, opts.cfg)) + } + + switch { + case qType == dns.TypeSOA, reqType == requestTypeAddress: + for _, result := range opts.results { + for _, port := range getPortsFromResult(result) { + ans, ex, ns := d.getAnswerExtraAndNs(serializeToGetAnswerExtraAndNsOptions(opts, result, port)) + resp.Answer = append(resp.Answer, ans...) + resp.Extra = append(resp.Extra, ex...) + resp.Ns = append(resp.Ns, ns...) + } + } + case qType == dns.TypeSRV: + handled := make(map[string]struct{}) + for _, result := range opts.results { + for _, port := range getPortsFromResult(result) { + + // Avoid duplicate entries, possible if a node has + // the same service the same port, etc. + + // The datacenter should be empty during translation if it is a peering lookup. + // This should be fine because we should always prefer the WAN address. + + address := "" + if result.Service != nil { + address = result.Service.Address + } else { + address = result.Node.Address + } + tuple := fmt.Sprintf("%s:%s:%d", result.Node.Name, address, port.Number) + if _, ok := handled[tuple]; ok { + continue + } + handled[tuple] = struct{}{} + + ans, ex, ns := d.getAnswerExtraAndNs(serializeToGetAnswerExtraAndNsOptions(opts, result, port)) + resp.Answer = append(resp.Answer, ans...) + resp.Extra = append(resp.Extra, ex...) + resp.Ns = append(resp.Ns, ns...) + } + } + default: + // default will send it to where it does some de-duping while it calls getAnswerExtraAndNs and recurses. + d.appendResultsToDNSResponse(opts) + } + + if opts.query != nil && opts.query.QueryType != discovery.QueryTypeVirtual && + len(resp.Answer) == 0 && len(resp.Extra) == 0 { + return nil, discovery.ErrNoData + } + + return resp, nil +} + +// appendResultsToDNSResponse builds dns message from the discovery results and +// appends them to the dns response. +func (d messageSerializer) appendResultsToDNSResponse(opts *serializeOptions) { + + // Always add the SOA record if requested. + if opts.req.Question[0].Qtype == dns.TypeSOA { + opts.resp.Answer = append(opts.resp.Answer, opts.dnsRecordMaker.makeSOA(opts.responseDomain, opts.cfg)) + } + + handled := make(map[string]struct{}) + var answerCNAME []dns.RR = nil + + count := 0 + for _, result := range opts.results { + for _, port := range getPortsFromResult(result) { + + // Add the node record + had_answer := false + ans, extra, _ := d.getAnswerExtraAndNs(serializeToGetAnswerExtraAndNsOptions(opts, result, port)) + opts.resp.Extra = append(opts.resp.Extra, extra...) + + if len(ans) == 0 { + continue + } + + // Avoid duplicate entries, possible if a node has + // the same service on multiple ports, etc. + if _, ok := handled[ans[0].String()]; ok { + continue + } + handled[ans[0].String()] = struct{}{} + + switch ans[0].(type) { + case *dns.CNAME: + // keep track of the first CNAME + associated RRs but don't add to the resp.Answer yet + // this will only be added if no non-CNAME RRs are found + if len(answerCNAME) == 0 { + answerCNAME = ans + } + default: + opts.resp.Answer = append(opts.resp.Answer, ans...) + had_answer = true + } + + if had_answer { + count++ + if count == opts.cfg.ARecordLimit { + // We stop only if greater than 0 or we reached the limit + return + } + } + } + } + if len(opts.resp.Answer) == 0 && len(answerCNAME) > 0 { + opts.resp.Answer = answerCNAME + } +} + +// getAnswerExtraAndNsOptions are the options for getting the Answer, Extra, and Ns records for a DNS response. +type getAnswerExtraAndNsOptions struct { + port discovery.Port + result *discovery.Result + req *dns.Msg + reqCtx Context + query *discovery.Query + results []*discovery.Result + resp *dns.Msg + cfg *RouterDynamicConfig + responseDomain string + remoteAddress net.Addr + maxRecursionLevel int + ttl uint32 + dnsRecordMaker dnsRecordMaker + translateAddressFunc func(dc string, addr string, taggedAddresses map[string]string, accept dnsutil.TranslateAddressAccept) string + translateServiceAddressFunc func(dc string, address string, taggedAddresses map[string]structs.ServiceAddress, accept dnsutil.TranslateAddressAccept) string + resolveCnameFunc func(cfgContext *RouterDynamicConfig, name string, reqCtx Context, remoteAddress net.Addr, maxRecursionLevel int) []dns.RR +} + +// getAnswerAndExtra creates the dns answer and extra from discovery results. +func (d messageSerializer) getAnswerExtraAndNs(opts *getAnswerExtraAndNsOptions) (answer []dns.RR, extra []dns.RR, ns []dns.RR) { + serviceAddress, nodeAddress := d.getServiceAndNodeAddresses(opts) + qName := opts.req.Question[0].Name + ttlLookupName := qName + if opts.query != nil { + ttlLookupName = opts.query.QueryPayload.Name + } + + opts.ttl = getTTLForResult(ttlLookupName, opts.result.DNS.TTL, opts.query, opts.cfg) + + qType := opts.req.Question[0].Qtype + + // TODO (v2-dns): skip records that refer to a workload/node that don't have a valid DNS name. + + // Special case responses + switch { + // PTR requests are first since they are a special case of domain overriding question type + case parseRequestType(opts.req) == requestTypeIP: + ptrTarget := "" + if opts.result.Type == discovery.ResultTypeNode { + ptrTarget = opts.result.Node.Name + } else if opts.result.Type == discovery.ResultTypeService { + ptrTarget = opts.result.Service.Name + } + + ptr := &dns.PTR{ + Hdr: dns.RR_Header{Name: qName, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: 0}, + Ptr: canonicalNameForResult(opts.result.Type, ptrTarget, opts.responseDomain, opts.result.Tenancy, opts.port.Name), + } + answer = append(answer, ptr) + case qType == dns.TypeNS: + resultType := opts.result.Type + target := opts.result.Node.Name + if parseRequestType(opts.req) == requestTypeConsul && resultType == discovery.ResultTypeService { + resultType = discovery.ResultTypeNode + } + fqdn := canonicalNameForResult(resultType, target, opts.responseDomain, opts.result.Tenancy, opts.port.Name) + extraRecord := opts.dnsRecordMaker.makeIPBasedRecord(fqdn, nodeAddress, opts.ttl) + + answer = append(answer, opts.dnsRecordMaker.makeNS(opts.responseDomain, fqdn, opts.ttl)) + extra = append(extra, extraRecord) + case qType == dns.TypeSOA: + // to be returned in the result. + fqdn := canonicalNameForResult(opts.result.Type, opts.result.Node.Name, opts.responseDomain, opts.result.Tenancy, opts.port.Name) + extraRecord := opts.dnsRecordMaker.makeIPBasedRecord(fqdn, nodeAddress, opts.ttl) + + ns = append(ns, opts.dnsRecordMaker.makeNS(opts.responseDomain, fqdn, opts.ttl)) + extra = append(extra, extraRecord) + case qType == dns.TypeSRV: + // We put A/AAAA/CNAME records in the additional section for SRV requests + a, e := d.getAnswerExtrasForAddressAndTarget(nodeAddress, serviceAddress, opts) + answer = append(answer, a...) + extra = append(extra, e...) + + default: + a, e := d.getAnswerExtrasForAddressAndTarget(nodeAddress, serviceAddress, opts) + answer = append(answer, a...) + extra = append(extra, e...) + } + + a, e := getAnswerAndExtraTXT(opts.req, opts.cfg, qName, opts.result, opts.ttl, + opts.responseDomain, opts.query, &opts.port, opts.dnsRecordMaker) + answer = append(answer, a...) + extra = append(extra, e...) + return +} + +// getServiceAndNodeAddresses returns the service and node addresses from a discovery result. +func (d messageSerializer) getServiceAndNodeAddresses(opts *getAnswerExtraAndNsOptions) (*dnsAddress, *dnsAddress) { + addrTranslate := dnsutil.TranslateAddressAcceptDomain + if opts.req.Question[0].Qtype == dns.TypeA { + addrTranslate |= dnsutil.TranslateAddressAcceptIPv4 + } else if opts.req.Question[0].Qtype == dns.TypeAAAA { + addrTranslate |= dnsutil.TranslateAddressAcceptIPv6 + } else { + addrTranslate |= dnsutil.TranslateAddressAcceptAny + } + + // The datacenter should be empty during translation if it is a peering lookup. + // This should be fine because we should always prefer the WAN address. + serviceAddress := newDNSAddress("") + if opts.result.Service != nil { + sa := opts.translateServiceAddressFunc(opts.result.Tenancy.Datacenter, + opts.result.Service.Address, getServiceAddressMapFromLocationMap(opts.result.Service.TaggedAddresses), + addrTranslate) + serviceAddress = newDNSAddress(sa) + } + nodeAddress := newDNSAddress("") + if opts.result.Node != nil { + na := opts.translateAddressFunc(opts.result.Tenancy.Datacenter, opts.result.Node.Address, + getStringAddressMapFromTaggedAddressMap(opts.result.Node.TaggedAddresses), addrTranslate) + nodeAddress = newDNSAddress(na) + } + return serviceAddress, nodeAddress +} + +// getAnswerExtrasForAddressAndTarget creates the dns answer and extra from nodeAddress and serviceAddress dnsAddress pairs. +func (d messageSerializer) getAnswerExtrasForAddressAndTarget(nodeAddress *dnsAddress, + serviceAddress *dnsAddress, opts *getAnswerExtraAndNsOptions) (answer []dns.RR, extra []dns.RR) { + qName := opts.req.Question[0].Name + reqType := parseRequestType(opts.req) + + switch { + case (reqType == requestTypeAddress || opts.result.Type == discovery.ResultTypeVirtual) && + serviceAddress.IsEmptyString() && nodeAddress.IsIP(): + a, e := getAnswerExtrasForIP(qName, nodeAddress, opts.req.Question[0], + reqType, opts.result, opts.ttl, opts.responseDomain, &opts.port, opts.dnsRecordMaker) + answer = append(answer, a...) + extra = append(extra, e...) + + case opts.result.Type == discovery.ResultTypeNode && nodeAddress.IsIP(): + canonicalNodeName := canonicalNameForResult(opts.result.Type, + opts.result.Node.Name, opts.responseDomain, opts.result.Tenancy, opts.port.Name) + a, e := getAnswerExtrasForIP(canonicalNodeName, nodeAddress, opts.req.Question[0], reqType, + opts.result, opts.ttl, opts.responseDomain, &opts.port, opts.dnsRecordMaker) + answer = append(answer, a...) + extra = append(extra, e...) + + case opts.result.Type == discovery.ResultTypeNode && !nodeAddress.IsIP(): + a, e := d.makeRecordFromFQDN(serviceAddress.FQDN(), opts) + answer = append(answer, a...) + extra = append(extra, e...) + + case serviceAddress.IsEmptyString() && nodeAddress.IsEmptyString(): + return nil, nil + + // There is no service address and the node address is an IP + case serviceAddress.IsEmptyString() && nodeAddress.IsIP(): + resultType := discovery.ResultTypeNode + if opts.result.Type == discovery.ResultTypeWorkload { + resultType = discovery.ResultTypeWorkload + } + canonicalNodeName := canonicalNameForResult(resultType, opts.result.Node.Name, + opts.responseDomain, opts.result.Tenancy, opts.port.Name) + a, e := getAnswerExtrasForIP(canonicalNodeName, nodeAddress, opts.req.Question[0], + reqType, opts.result, opts.ttl, opts.responseDomain, &opts.port, opts.dnsRecordMaker) + answer = append(answer, a...) + extra = append(extra, e...) + + // There is no service address and the node address is a FQDN (external service) + case serviceAddress.IsEmptyString(): + a, e := d.makeRecordFromFQDN(nodeAddress.FQDN(), opts) + answer = append(answer, a...) + extra = append(extra, e...) + + // The service address is an IP + case serviceAddress.IsIP(): + canonicalServiceName := canonicalNameForResult(discovery.ResultTypeService, + opts.result.Service.Name, opts.responseDomain, opts.result.Tenancy, opts.port.Name) + a, e := getAnswerExtrasForIP(canonicalServiceName, serviceAddress, + opts.req.Question[0], reqType, opts.result, opts.ttl, opts.responseDomain, &opts.port, opts.dnsRecordMaker) + answer = append(answer, a...) + extra = append(extra, e...) + + // If the service address is a CNAME for the service we are looking + // for then use the node address. + case serviceAddress.FQDN() == opts.req.Question[0].Name && nodeAddress.IsIP(): + canonicalNodeName := canonicalNameForResult(discovery.ResultTypeNode, + opts.result.Node.Name, opts.responseDomain, opts.result.Tenancy, opts.port.Name) + a, e := getAnswerExtrasForIP(canonicalNodeName, nodeAddress, opts.req.Question[0], + reqType, opts.result, opts.ttl, opts.responseDomain, &opts.port, opts.dnsRecordMaker) + answer = append(answer, a...) + extra = append(extra, e...) + + // The service address is a FQDN (internal or external service name) + default: + a, e := d.makeRecordFromFQDN(serviceAddress.FQDN(), opts) + answer = append(answer, a...) + extra = append(extra, e...) + } + + return +} + +// makeRecordFromFQDN creates a DNS record from a FQDN. +func (d messageSerializer) makeRecordFromFQDN(fqdn string, opts *getAnswerExtraAndNsOptions) ([]dns.RR, []dns.RR) { + edns := opts.req.IsEdns0() != nil + q := opts.req.Question[0] + + more := opts.resolveCnameFunc(opts.cfg, dns.Fqdn(fqdn), opts.reqCtx, opts.remoteAddress, opts.maxRecursionLevel) + var additional []dns.RR + extra := 0 +MORE_REC: + for _, rr := range more { + switch rr.Header().Rrtype { + case dns.TypeCNAME, dns.TypeA, dns.TypeAAAA, dns.TypeTXT: + // set the TTL manually + rr.Header().Ttl = opts.ttl + additional = append(additional, rr) + + extra++ + if extra == maxRecurseRecords && !edns { + break MORE_REC + } + } + } + + if q.Qtype == dns.TypeSRV { + answer := opts.dnsRecordMaker.makeSRV(q.Name, fqdn, uint16(opts.result.DNS.Weight), opts.ttl, &opts.port) + return []dns.RR{answer}, additional + } + + address := "" + if opts.result.Service != nil && opts.result.Service.Address != "" { + address = opts.result.Service.Address + } else if opts.result.Node != nil { + address = opts.result.Node.Address + } + + answers := []dns.RR{ + opts.dnsRecordMaker.makeCNAME(q.Name, address, opts.ttl), + } + answers = append(answers, additional...) + + return answers, nil +} + +// getAnswerAndExtraTXT determines whether a TXT needs to be create and then +// returns the TXT record in the answer or extra depending on the question type. +func getAnswerAndExtraTXT(req *dns.Msg, cfg *RouterDynamicConfig, qName string, + result *discovery.Result, ttl uint32, domain string, query *discovery.Query, + port *discovery.Port, maker dnsRecordMaker) (answer []dns.RR, extra []dns.RR) { + if !shouldAppendTXTRecord(query, cfg, req) { + return + } + recordHeaderName := qName + serviceAddress := newDNSAddress("") + if result.Service != nil { + serviceAddress = newDNSAddress(result.Service.Address) + } + if result.Type != discovery.ResultTypeNode && + result.Type != discovery.ResultTypeVirtual && + !serviceAddress.IsInternalFQDN(domain) && + !serviceAddress.IsExternalFQDN(domain) { + recordHeaderName = canonicalNameForResult(discovery.ResultTypeNode, result.Node.Name, + domain, result.Tenancy, port.Name) + } + qType := req.Question[0].Qtype + generateMeta := false + metaInAnswer := false + if qType == dns.TypeANY || qType == dns.TypeTXT { + generateMeta = true + metaInAnswer = true + } else if cfg.NodeMetaTXT { + generateMeta = true + } + + // Do not generate txt records if we don't have to: https://github.com/hashicorp/consul/pull/5272 + if generateMeta { + meta := maker.makeTXT(recordHeaderName, result.Metadata, ttl) + if metaInAnswer { + answer = append(answer, meta...) + } else { + extra = append(extra, meta...) + } + } + return answer, extra +} + +// shouldAppendTXTRecord determines whether a TXT record should be appended to the response. +func shouldAppendTXTRecord(query *discovery.Query, cfg *RouterDynamicConfig, req *dns.Msg) bool { + qType := req.Question[0].Qtype + switch { + // Node records + case query != nil && query.QueryType == discovery.QueryTypeNode && (cfg.NodeMetaTXT || qType == dns.TypeANY || qType == dns.TypeTXT): + return true + // Service records + case query != nil && query.QueryType == discovery.QueryTypeService && cfg.NodeMetaTXT && qType == dns.TypeSRV: + return true + // Prepared query records + case query != nil && query.QueryType == discovery.QueryTypePreparedQuery && cfg.NodeMetaTXT && qType == dns.TypeSRV: + return true + } + return false +} + +// getAnswerExtrasForIP creates the dns answer and extra from IP dnsAddress pairs. +func getAnswerExtrasForIP(name string, addr *dnsAddress, question dns.Question, + reqType requestType, result *discovery.Result, ttl uint32, domain string, + port *discovery.Port, maker dnsRecordMaker) (answer []dns.RR, extra []dns.RR) { + qType := question.Qtype + canReturnARecord := qType == dns.TypeSRV || qType == dns.TypeA || qType == dns.TypeANY || qType == dns.TypeNS || qType == dns.TypeTXT + canReturnAAAARecord := qType == dns.TypeSRV || qType == dns.TypeAAAA || qType == dns.TypeANY || qType == dns.TypeNS || qType == dns.TypeTXT + if reqType != requestTypeAddress && result.Type != discovery.ResultTypeVirtual { + switch { + // check IPV4 + case addr.IsIP() && addr.IsIPV4() && !canReturnARecord, + // check IPV6 + addr.IsIP() && !addr.IsIPV4() && !canReturnAAAARecord: + return + } + } + + // Have to pass original question name here even if the system has recursed + // and stripped off the domain suffix. + recHdrName := question.Name + if qType == dns.TypeSRV { + nameSplit := strings.Split(name, ".") + if len(nameSplit) > 1 && nameSplit[1] == addrLabel { + recHdrName = name + } else { + recHdrName = name + } + name = question.Name + } + + if reqType != requestTypeAddress && qType == dns.TypeSRV { + if result.Type == discovery.ResultTypeService && addr.IsIP() && result.Node.Address != addr.String() { + // encode the ip to be used in the header of the A/AAAA record + // as well as the target of the SRV record. + recHdrName = encodeIPAsFqdn(result, addr.IP(), domain) + } + if result.Type == discovery.ResultTypeWorkload { + recHdrName = canonicalNameForResult(result.Type, result.Node.Name, domain, result.Tenancy, port.Name) + } + srv := maker.makeSRV(name, recHdrName, uint16(result.DNS.Weight), ttl, port) + answer = append(answer, srv) + } + + record := maker.makeIPBasedRecord(recHdrName, addr, ttl) + + isARecordWhenNotExplicitlyQueried := record.Header().Rrtype == dns.TypeA && qType != dns.TypeA && qType != dns.TypeANY + isAAAARecordWhenNotExplicitlyQueried := record.Header().Rrtype == dns.TypeAAAA && qType != dns.TypeAAAA && qType != dns.TypeANY + + // For explicit A/AAAA queries, we must only return those records in the answer section. + if isARecordWhenNotExplicitlyQueried || + isAAAARecordWhenNotExplicitlyQueried { + extra = append(extra, record) + } else { + answer = append(answer, record) + } + + return +} + +// getPortsFromResult returns the ports from a discovery result. +func getPortsFromResult(result *discovery.Result) []discovery.Port { + if len(result.Ports) > 0 { + return result.Ports + } + // return one record. + return []discovery.Port{{}} +} + +// encodeIPAsFqdn encodes an IP address as a FQDN. +func encodeIPAsFqdn(result *discovery.Result, ip net.IP, responseDomain string) string { + ipv4 := ip.To4() + ipStr := hex.EncodeToString(ip) + if ipv4 != nil { + ipStr = ipStr[len(ipStr)-(net.IPv4len*2):] + } + if result.Tenancy.PeerName != "" { + // Exclude the datacenter from the FQDN on the addr for peers. + // This technically makes no difference, since the addr endpoint ignores the DC + // component of the request, but do it anyway for a less confusing experience. + return fmt.Sprintf("%s.addr.%s", ipStr, responseDomain) + } + return fmt.Sprintf("%s.addr.%s.%s", ipStr, result.Tenancy.Datacenter, responseDomain) +} + +// canonicalNameForResult returns the canonical name for a discovery result. +func canonicalNameForResult(resultType discovery.ResultType, target, domain string, + tenancy discovery.ResultTenancy, portName string) string { + switch resultType { + case discovery.ResultTypeService: + if tenancy.Namespace != "" { + return fmt.Sprintf("%s.%s.%s.%s.%s", target, "service", tenancy.Namespace, tenancy.Datacenter, domain) + } + return fmt.Sprintf("%s.%s.%s.%s", target, "service", tenancy.Datacenter, domain) + case discovery.ResultTypeNode: + if tenancy.PeerName != "" && tenancy.Partition != "" { + // We must return a more-specific DNS name for peering so + // that there is no ambiguity with lookups. + // Nodes are always registered in the default namespace, so + // the `.ns` qualifier is not required. + return fmt.Sprintf("%s.node.%s.peer.%s.ap.%s", + target, + tenancy.PeerName, + tenancy.Partition, + domain) + } + if tenancy.PeerName != "" { + // We must return a more-specific DNS name for peering so + // that there is no ambiguity with lookups. + return fmt.Sprintf("%s.node.%s.peer.%s", + target, + tenancy.PeerName, + domain) + } + // Return a simpler format for non-peering nodes. + return fmt.Sprintf("%s.node.%s.%s", target, tenancy.Datacenter, domain) + case discovery.ResultTypeWorkload: + // TODO (v2-dns): it doesn't appear this is being used to return a result. Need to investigate and refactor + if portName != "" { + return fmt.Sprintf("%s.port.%s.workload.%s.ns.%s.ap.%s", portName, target, tenancy.Namespace, tenancy.Partition, domain) + } + return fmt.Sprintf("%s.workload.%s.ns.%s.ap.%s", target, tenancy.Namespace, tenancy.Partition, domain) + } + return "" +} + +// getServiceAddressMapFromLocationMap converts a map of Location to a map of ServiceAddress. +func getServiceAddressMapFromLocationMap(taggedAddresses map[string]*discovery.TaggedAddress) map[string]structs.ServiceAddress { + taggedServiceAddresses := make(map[string]structs.ServiceAddress, len(taggedAddresses)) + for k, v := range taggedAddresses { + taggedServiceAddresses[k] = structs.ServiceAddress{ + Address: v.Address, + Port: int(v.Port.Number), + } + } + return taggedServiceAddresses +} + +// getStringAddressMapFromTaggedAddressMap converts a map of Location to a map of string. +func getStringAddressMapFromTaggedAddressMap(taggedAddresses map[string]*discovery.TaggedAddress) map[string]string { + taggedServiceAddresses := make(map[string]string, len(taggedAddresses)) + for k, v := range taggedAddresses { + taggedServiceAddresses[k] = v.Address + } + return taggedServiceAddresses +} + +// getTTLForResult returns the TTL for a given result. +func getTTLForResult(name string, overrideTTL *uint32, query *discovery.Query, cfg *RouterDynamicConfig) uint32 { + // In the case we are not making a discovery query, such as addr. or arpa. lookups, + // use the node TTL by convention + if query == nil { + return uint32(cfg.NodeTTL / time.Second) + } + + if overrideTTL != nil { + // If a result was provided with an override, use that. This is the case for some prepared queries. + return *overrideTTL + } + + switch query.QueryType { + case discovery.QueryTypeService, discovery.QueryTypePreparedQuery: + ttl, ok := cfg.GetTTLForService(name) + if ok { + return uint32(ttl / time.Second) + } + fallthrough + default: + return uint32(cfg.NodeTTL / time.Second) + } +} + +// serializeToGetAnswerExtraAndNsOptions converts serializeOptions to getAnswerExtraAndNsOptions. +func serializeToGetAnswerExtraAndNsOptions(opts *serializeOptions, + result *discovery.Result, port discovery.Port) *getAnswerExtraAndNsOptions { + return &getAnswerExtraAndNsOptions{ + port: port, + result: result, + req: opts.req, + reqCtx: opts.reqCtx, + query: opts.query, + results: opts.results, + resp: opts.resp, + cfg: opts.cfg, + responseDomain: opts.responseDomain, + remoteAddress: opts.remoteAddress, + maxRecursionLevel: opts.maxRecursionLevel, + translateAddressFunc: opts.translateAddressFunc, + translateServiceAddressFunc: opts.translateServiceAddressFunc, + resolveCnameFunc: opts.resolveCnameFunc, + dnsRecordMaker: opts.dnsRecordMaker, + } +} diff --git a/agent/dns/router_response.go b/agent/dns/response_generator.go similarity index 57% rename from agent/dns/router_response.go rename to agent/dns/response_generator.go index d2000745c8..ad7b14270d 100644 --- a/agent/dns/router_response.go +++ b/agent/dns/response_generator.go @@ -1,14 +1,20 @@ // Copyright (c) HashiCorp, Inc. // SPDX-License-Identifier: BUSL-1.1 + package dns import ( + "errors" "fmt" + "math" + "net" + "strings" + + "github.com/miekg/dns" + + "github.com/hashicorp/consul/agent/discovery" "github.com/hashicorp/consul/lib" "github.com/hashicorp/go-hclog" - "github.com/miekg/dns" - "math" - "strings" ) const ( @@ -28,8 +34,116 @@ const ( maxUDPDatagramSize = math.MaxUint16 - 68 ) +// dnsResponseGenerator is used to: +// - generate DNS responses for errors +// - trim and truncate DNS responses +// - EDNS to the response +type dnsResponseGenerator struct{} + +// createRefusedResponse returns a REFUSED message. This is the default behavior for unmatched queries in +// upstream miekg/dns. +func (d dnsResponseGenerator) createRefusedResponse(req *dns.Msg) *dns.Msg { + // Return a REFUSED message + m := &dns.Msg{} + m.SetRcode(req, dns.RcodeRefused) + return m +} + +// createServerFailureResponse returns a SERVFAIL message. +func (d dnsResponseGenerator) createServerFailureResponse(req *dns.Msg, cfg *RouterDynamicConfig, recursionAvailable bool) *dns.Msg { + // Return a SERVFAIL message + m := &dns.Msg{} + m.SetReply(req) + m.Compress = !cfg.DisableCompression + m.SetRcode(req, dns.RcodeServerFailure) + m.RecursionAvailable = recursionAvailable + if edns := req.IsEdns0(); edns != nil { + d.setEDNS(req, m, true) + } + + return m +} + +// createAuthoritativeResponse returns an authoritative message that contains the SOA in the event that data is +// not return for a query. There can be multiple reasons for not returning data, hence the rcode argument. +func (d dnsResponseGenerator) createAuthoritativeResponse(req *dns.Msg, cfg *RouterDynamicConfig, domain string, rcode int, ecsGlobal bool) *dns.Msg { + m := &dns.Msg{} + m.SetRcode(req, rcode) + m.Compress = !cfg.DisableCompression + m.Authoritative = true + m.RecursionAvailable = canRecurse(cfg) + if edns := req.IsEdns0(); edns != nil { + d.setEDNS(req, m, ecsGlobal) + } + + // We add the SOA on NameErrors + maker := &dnsRecordMaker{} + soa := maker.makeSOA(domain, cfg) + m.Ns = append(m.Ns, soa) + + return m +} + +// generateResponseFromErrorOpts is used to pass options to generateResponseFromError. +type generateResponseFromErrorOpts struct { + req *dns.Msg + err error + qName string + configCtx *RouterDynamicConfig + responseDomain string + isECSGlobal bool + query *discovery.Query + canRecurse bool + logger hclog.Logger +} + +// generateResponseFromError generates a response from an error. +func (d dnsResponseGenerator) generateResponseFromError(opts *generateResponseFromErrorOpts) *dns.Msg { + switch { + case errors.Is(opts.err, errInvalidQuestion): + opts.logger.Error("invalid question", "name", opts.qName) + + return d.createAuthoritativeResponse(opts.req, opts.configCtx, opts.responseDomain, dns.RcodeNameError, opts.isECSGlobal) + case errors.Is(opts.err, errNameNotFound): + opts.logger.Error("name not found", "name", opts.qName) + + return d.createAuthoritativeResponse(opts.req, opts.configCtx, opts.responseDomain, dns.RcodeNameError, opts.isECSGlobal) + case errors.Is(opts.err, errNotImplemented): + opts.logger.Error("query not implemented", "name", opts.qName, "type", dns.Type(opts.req.Question[0].Qtype).String()) + + return d.createAuthoritativeResponse(opts.req, opts.configCtx, opts.responseDomain, dns.RcodeNotImplemented, opts.isECSGlobal) + case errors.Is(opts.err, discovery.ErrNotSupported): + opts.logger.Debug("query name syntax not supported", "name", opts.req.Question[0].Name) + + return d.createAuthoritativeResponse(opts.req, opts.configCtx, opts.responseDomain, dns.RcodeNameError, opts.isECSGlobal) + case errors.Is(opts.err, discovery.ErrNotFound): + opts.logger.Debug("query name not found", "name", opts.req.Question[0].Name) + + return d.createAuthoritativeResponse(opts.req, opts.configCtx, opts.responseDomain, dns.RcodeNameError, opts.isECSGlobal) + case errors.Is(opts.err, discovery.ErrNoData): + opts.logger.Debug("no data available", "name", opts.qName) + + return d.createAuthoritativeResponse(opts.req, opts.configCtx, opts.responseDomain, dns.RcodeSuccess, opts.isECSGlobal) + case errors.Is(opts.err, discovery.ErrNoPathToDatacenter): + dc := "" + if opts.query != nil { + dc = opts.query.QueryPayload.Tenancy.Datacenter + } + opts.logger.Debug("no path to datacenter", "datacenter", dc) + return d.createAuthoritativeResponse(opts.req, opts.configCtx, opts.responseDomain, dns.RcodeNameError, opts.isECSGlobal) + } + opts.logger.Error("error processing discovery query", "error", opts.err) + return d.createServerFailureResponse(opts.req, opts.configCtx, opts.canRecurse) +} + // trimDNSResponse will trim the response for UDP and TCP -func trimDNSResponse(cfg *RouterDynamicConfig, network string, req, resp *dns.Msg, logger hclog.Logger) { +func (d dnsResponseGenerator) trimDNSResponse(cfg *RouterDynamicConfig, remoteAddress net.Addr, req, resp *dns.Msg, logger hclog.Logger) { + // Switch to TCP if the client is + network := "udp" + if _, ok := remoteAddress.(*net.TCPAddr); ok { + network = "tcp" + } + var trimmed bool originalSize := resp.Len() originalNumRecords := len(resp.Answer) @@ -52,6 +166,58 @@ func trimDNSResponse(cfg *RouterDynamicConfig, network string, req, resp *dns.Ms } } +// setEDNS is used to set the responses EDNS size headers and +// possibly the ECS headers as well if they were present in the +// original request +func (d dnsResponseGenerator) setEDNS(request *dns.Msg, response *dns.Msg, ecsGlobal bool) { + edns := request.IsEdns0() + if edns == nil { + return + } + + // cannot just use the SetEdns0 function as we need to embed + // the ECS option as well + ednsResp := new(dns.OPT) + ednsResp.Hdr.Name = "." + ednsResp.Hdr.Rrtype = dns.TypeOPT + ednsResp.SetUDPSize(edns.UDPSize()) + + // Set up the ECS option if present + if subnet := ednsSubnetForRequest(request); subnet != nil { + subOp := new(dns.EDNS0_SUBNET) + subOp.Code = dns.EDNS0SUBNET + subOp.Family = subnet.Family + subOp.Address = subnet.Address + subOp.SourceNetmask = subnet.SourceNetmask + if c := response.Rcode; ecsGlobal || c == dns.RcodeNameError || c == dns.RcodeServerFailure || c == dns.RcodeRefused || c == dns.RcodeNotImplemented { + // reply is globally valid and should be cached accordingly + subOp.SourceScope = 0 + } else { + // reply is only valid for the subnet it was queried with + subOp.SourceScope = subnet.SourceNetmask + } + ednsResp.Option = append(ednsResp.Option, subOp) + } + + response.Extra = append(response.Extra, ednsResp) +} + +// ednsSubnetForRequest looks through the request to find any EDS subnet options +func ednsSubnetForRequest(req *dns.Msg) *dns.EDNS0_SUBNET { + // IsEdns0 returns the EDNS RR if present or nil otherwise + edns := req.IsEdns0() + if edns == nil { + return nil + } + + for _, o := range edns.Option { + if subnet, ok := o.(*dns.EDNS0_SUBNET); ok { + return subnet + } + } + return nil +} + // trimTCPResponse limit the MaximumSize of messages to 64k as it is the limit // of DNS responses func trimTCPResponse(req, resp *dns.Msg) (trimmed bool) { diff --git a/agent/dns/response_generator_test.go b/agent/dns/response_generator_test.go new file mode 100644 index 0000000000..ec9849307e --- /dev/null +++ b/agent/dns/response_generator_test.go @@ -0,0 +1,739 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "errors" + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent/discovery" + "github.com/hashicorp/consul/sdk/testutil" +) + +func TestDNSResponseGenerator_generateResponseFromError(t *testing.T) { + testCases := []struct { + name string + opts *generateResponseFromErrorOpts + expectedResponse *dns.Msg + }{ + { + name: "error is nil returns server failure", + opts: &generateResponseFromErrorOpts{ + req: &dns.Msg{}, + logger: testutil.Logger(t), + configCtx: &RouterDynamicConfig{ + DisableCompression: true, + }, + err: nil, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: false, + Rcode: dns.RcodeServerFailure, + }, + }, + }, + { + name: "error is invalid question returns name error", + opts: &generateResponseFromErrorOpts{ + req: &dns.Msg{ + Question: []dns.Question{ + { + Name: "invalid-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + }, + qName: "invalid-question", + responseDomain: "testdomain.", + logger: testutil.Logger(t), + configCtx: &RouterDynamicConfig{ + DisableCompression: true, + }, + err: errInvalidQuestion, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeNameError, + }, + Question: []dns.Question{ + { + Name: "invalid-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + Ns: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 0, + }, + Ns: "ns.testdomain.", + Mbox: "hostmaster.testdomain.", + Serial: uint32(time.Now().Unix()), + }, + }, + }, + }, + { + name: "error is name not found returns name error", + opts: &generateResponseFromErrorOpts{ + req: &dns.Msg{ + Question: []dns.Question{ + { + Name: "invalid-name", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + }, + qName: "invalid-name", + responseDomain: "testdomain.", + logger: testutil.Logger(t), + configCtx: &RouterDynamicConfig{ + DisableCompression: true, + }, + err: errNameNotFound, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeNameError, + }, + Question: []dns.Question{ + { + Name: "invalid-name", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + Ns: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 0, + }, + Ns: "ns.testdomain.", + Mbox: "hostmaster.testdomain.", + Serial: uint32(time.Now().Unix()), + }, + }, + }, + }, + { + name: "error is not implemented returns not implemented error", + opts: &generateResponseFromErrorOpts{ + req: &dns.Msg{ + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + }, + qName: "some-question", + responseDomain: "testdomain.", + logger: testutil.Logger(t), + configCtx: &RouterDynamicConfig{ + DisableCompression: true, + }, + err: errNotImplemented, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeNotImplemented, + }, + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + Ns: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 0, + }, + Ns: "ns.testdomain.", + Mbox: "hostmaster.testdomain.", + Serial: uint32(time.Now().Unix()), + }, + }, + }, + }, + { + name: "error is not supported returns name error", + opts: &generateResponseFromErrorOpts{ + req: &dns.Msg{ + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + }, + qName: "some-question", + responseDomain: "testdomain.", + logger: testutil.Logger(t), + configCtx: &RouterDynamicConfig{ + DisableCompression: true, + }, + err: discovery.ErrNotSupported, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeNameError, + }, + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + Ns: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 0, + }, + Ns: "ns.testdomain.", + Mbox: "hostmaster.testdomain.", + Serial: uint32(time.Now().Unix()), + }, + }, + }, + }, + { + name: "error is not found returns name error", + opts: &generateResponseFromErrorOpts{ + req: &dns.Msg{ + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + }, + qName: "some-question", + responseDomain: "testdomain.", + logger: testutil.Logger(t), + configCtx: &RouterDynamicConfig{ + DisableCompression: true, + }, + err: discovery.ErrNotFound, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeNameError, + }, + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + Ns: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 0, + }, + Ns: "ns.testdomain.", + Mbox: "hostmaster.testdomain.", + Serial: uint32(time.Now().Unix()), + }, + }, + }, + }, + { + name: "error is no data returns success with soa", + opts: &generateResponseFromErrorOpts{ + req: &dns.Msg{ + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + }, + qName: "some-question", + responseDomain: "testdomain.", + logger: testutil.Logger(t), + configCtx: &RouterDynamicConfig{ + DisableCompression: true, + }, + err: discovery.ErrNoData, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + Ns: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 0, + }, + Ns: "ns.testdomain.", + Mbox: "hostmaster.testdomain.", + Serial: uint32(time.Now().Unix()), + }, + }, + }, + }, + { + name: "error is no path to datacenter returns name error", + opts: &generateResponseFromErrorOpts{ + req: &dns.Msg{ + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + }, + qName: "some-question", + responseDomain: "testdomain.", + logger: testutil.Logger(t), + configCtx: &RouterDynamicConfig{ + DisableCompression: true, + }, + err: discovery.ErrNoPathToDatacenter, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeNameError, + }, + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + Ns: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 0, + }, + Ns: "ns.testdomain.", + Mbox: "hostmaster.testdomain.", + Serial: uint32(time.Now().Unix()), + }, + }, + }, + }, + { + name: "error is something else returns server failure error", + opts: &generateResponseFromErrorOpts{ + req: &dns.Msg{ + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + }, + qName: "some-question", + responseDomain: "testdomain.", + logger: testutil.Logger(t), + configCtx: &RouterDynamicConfig{ + DisableCompression: true, + }, + err: errors.New("KABOOM"), + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: false, + Rcode: dns.RcodeServerFailure, + }, + Question: []dns.Question{ + { + Name: "some-question", + Qtype: dns.TypeSRV, + Qclass: dns.ClassANY, + }, + }, + Ns: nil, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tc.opts.req.IsEdns0() + actualResponse := dnsResponseGenerator{}.generateResponseFromError(tc.opts) + require.Equal(t, tc.expectedResponse, actualResponse) + }) + } +} + +func TestDNSResponseGenerator_setEDNS(t *testing.T) { + testCases := []struct { + name string + req *dns.Msg + response *dns.Msg + ecsGlobal bool + expectedResponse *dns.Msg + }{ + { + name: "request is not edns0, response is not edns0", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Extra: []dns.RR{ + &dns.OPT{ + Hdr: dns.RR_Header{ + Name: ".", + Rrtype: dns.TypeOPT, + Class: 4096, + Ttl: 0, + }, + Option: []dns.EDNS0{ + &dns.EDNS0_SUBNET{ + Code: 1, + Family: 2, + SourceNetmask: 3, + SourceScope: 4, + Address: net.ParseIP("255.255.255.255"), + }, + }, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Extra: []dns.RR{ + &dns.OPT{ + Hdr: dns.RR_Header{ + Name: ".", + Rrtype: dns.TypeOPT, + Class: 4096, + Ttl: 0, + }, + Option: []dns.EDNS0{ + &dns.EDNS0_SUBNET{ + Code: 8, + Family: 2, + SourceNetmask: 3, + SourceScope: 3, + Address: net.ParseIP("255.255.255.255"), + }, + }, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + dnsResponseGenerator{}.setEDNS(tc.req, tc.response, tc.ecsGlobal) + require.Equal(t, tc.expectedResponse, tc.response) + }) + } +} + +func TestDNSResponseGenerator_trimDNSResponse(t *testing.T) { + testCases := []struct { + name string + req *dns.Msg + response *dns.Msg + cfg *RouterDynamicConfig + remoteAddress net.Addr + expectedResponse *dns.Msg + }{ + { + name: "network is udp, enable truncate is true, answer count of 1 is less/equal than configured max f 1, response is not trimmed", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "foo.query.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + cfg: &RouterDynamicConfig{ + UDPAnswerLimit: 1, + }, + remoteAddress: &net.UDPAddr{ + IP: net.ParseIP("127.0.0.1"), + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "foo.query.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.query.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "foo.query.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.query.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + }, + { + name: "network is udp, enable truncate is true, answer count of 2 is greater than configure UDP max f 2, response is trimmed", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "foo.query.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + cfg: &RouterDynamicConfig{ + UDPAnswerLimit: 1, + }, + remoteAddress: &net.UDPAddr{ + IP: net.ParseIP("127.0.0.1"), + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "foo.query.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo1.query.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo2.query.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("2.2.3.4"), + }, + }, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "foo.query.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo1.query.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + }, + { + name: "network is tcp, enable truncate is true, answer is less than 64k limit, response is not trimmed", + req: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "foo.query.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + cfg: &RouterDynamicConfig{}, + remoteAddress: &net.TCPAddr{ + IP: net.ParseIP("127.0.0.1"), + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "foo.query.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.query.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + expectedResponse: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "foo.query.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.query.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) { + logger := testutil.Logger(t) + dnsResponseGenerator{}.trimDNSResponse(tc.cfg, tc.remoteAddress, tc.req, tc.response, logger) + require.Equal(t, tc.expectedResponse, tc.response) + }) + + } +} diff --git a/agent/dns/router.go b/agent/dns/router.go index 40d366cff7..d03beffdfb 100644 --- a/agent/dns/router.go +++ b/agent/dns/router.go @@ -4,7 +4,6 @@ package dns import ( - "encoding/hex" "errors" "fmt" "net" @@ -19,7 +18,6 @@ import ( "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" @@ -73,6 +71,24 @@ type RouterDynamicConfig struct { UDPAnswerLimit int } +// GetTTLForService Find the TTL for a given service. +// return ttl, true if found, 0, false otherwise +func (cfg *RouterDynamicConfig) GetTTLForService(service string) (time.Duration, bool) { + if cfg.TTLStrict != nil { + ttl, ok := cfg.TTLStrict[service] + if ok { + return ttl, true + } + } + if cfg.TTLRadix != nil { + _, ttlRaw, ok := cfg.TTLRadix.LongestPrefix(service) + if ok { + return ttlRaw.(time.Duration), true + } + } + return 0, false +} + type SOAConfig struct { Refresh uint32 // 3600 by default Retry uint32 // 600 @@ -148,17 +164,19 @@ func NewRouter(cfg Config) (*Router, error) { func (r *Router) HandleRequest(req *dns.Msg, reqCtx Context, remoteAddress net.Addr) *dns.Msg { configCtx := r.dynamicConfig.Load().(*RouterDynamicConfig) - r.logger.Trace("received request", "question", req.Question[0].Name, "type", dns.Type(req.Question[0].Qtype).String()) + respGenerator := dnsResponseGenerator{} err := validateAndNormalizeRequest(req) if err != nil { r.logger.Error("error parsing DNS query", "error", err) if errors.Is(err, errInvalidQuestion) { - return createRefusedResponse(req) + return respGenerator.createRefusedResponse(req) } - return createServerFailureResponse(req, configCtx, false) + return respGenerator.createServerFailureResponse(req, configCtx, false) } + r.logger.Trace("received request", "question", req.Question[0].Name, "type", dns.Type(req.Question[0].Qtype).String()) + defer func(s time.Time, q dns.Question) { metrics.MeasureSinceWithLabels([]string{"dns", "query"}, s, []metrics.Label{ @@ -179,18 +197,11 @@ func (r *Router) HandleRequest(req *dns.Msg, reqCtx Context, remoteAddress net.A return r.handleRequestRecursively(req, reqCtx, configCtx, remoteAddress, maxRecursionLevelDefault) } -// getErrorFromECSNotGlobalError returns the underlying error from an ECSNotGlobalError, if it exists. -func getErrorFromECSNotGlobalError(err error) error { - if errors.Is(err, discovery.ErrECSNotGlobal) { - return err.(discovery.ECSNotGlobalError).Unwrap() - } - return err -} - // handleRequestRecursively is used to process an individual DNS request. It will recurse as needed // a maximum number of times and returns a message in success or fail cases. func (r *Router) handleRequestRecursively(req *dns.Msg, reqCtx Context, configCtx *RouterDynamicConfig, remoteAddress net.Addr, maxRecursionLevel int) *dns.Msg { + respGenerator := dnsResponseGenerator{} r.logger.Trace( "received request", @@ -201,7 +212,7 @@ func (r *Router) handleRequestRecursively(req *dns.Msg, reqCtx Context, configCt responseDomain, needRecurse := r.parseDomain(req.Question[0].Name) if needRecurse && !canRecurse(configCtx) { // This is the same error as an unmatched domain - return createRefusedResponse(req) + return respGenerator.createRefusedResponse(req) } if needRecurse { @@ -213,7 +224,7 @@ func (r *Router) handleRequestRecursively(req *dns.Msg, reqCtx Context, configCt r.logger.Error("unhandled error recursing DNS query", "error", err) } if err != nil { - return createServerFailureResponse(req, configCtx, true) + return respGenerator.createServerFailureResponse(req, configCtx, true) } return resp } @@ -226,81 +237,72 @@ func (r *Router) handleRequestRecursively(req *dns.Msg, reqCtx Context, configCt qName = r.trimDomain(qName) } - reqType := parseRequestType(req) - results, query, err := r.getQueryResults(req, reqCtx, reqType, qName, remoteAddress) + results, query, err := discoveryResultsFetcher{}.getQueryResults(&getQueryOptions{ + req: req, + reqCtx: reqCtx, + qName: qName, + remoteAddress: remoteAddress, + processor: r.processor, + logger: r.logger, + domain: r.domain, + altDomain: r.altDomain, + }) // in case of the wrapped ECSNotGlobalError, extract the error from it. isECSGlobal := !errors.Is(err, discovery.ErrECSNotGlobal) err = getErrorFromECSNotGlobalError(err) if err != nil { - return r.generateResponseFromError(req, err, qName, configCtx, responseDomain, - isECSGlobal, query, canRecurse(configCtx)) + return respGenerator.generateResponseFromError(&generateResponseFromErrorOpts{ + req: req, + err: err, + qName: qName, + configCtx: configCtx, + responseDomain: responseDomain, + isECSGlobal: isECSGlobal, + query: query, + canRecurse: canRecurse(configCtx), + logger: r.logger, + }) } r.logger.Trace("serializing results", "question", req.Question[0].Name, "results-found", len(results)) // This needs the question information because it affects the serialization format. // e.g., the Consul service has the same "results" for both NS and A/AAAA queries, but the serialization differs. - resp, err := r.serializeQueryResults(req, reqCtx, query, results, configCtx, responseDomain, remoteAddress, maxRecursionLevel) + serializedOpts := &serializeOptions{ + req: req, + reqCtx: reqCtx, + query: query, + results: results, + cfg: configCtx, + responseDomain: responseDomain, + remoteAddress: remoteAddress, + maxRecursionLevel: maxRecursionLevel, + translateAddressFunc: r.translateAddressFunc, + translateServiceAddressFunc: r.translateServiceAddressFunc, + resolveCnameFunc: r.resolveCNAME, + } + resp, err := messageSerializer{}.serialize(serializedOpts) if err != nil { r.logger.Error("error serializing DNS results", "error", err) - return r.generateResponseFromError(req, err, qName, configCtx, responseDomain, - false, query, false) + return respGenerator.generateResponseFromError(&generateResponseFromErrorOpts{ + req: req, + err: err, + qName: qName, + configCtx: configCtx, + responseDomain: responseDomain, + isECSGlobal: isECSGlobal, + query: query, + canRecurse: false, + logger: r.logger, + }) } - // Switch to TCP if the client is - network := "udp" - if _, ok := remoteAddress.(*net.TCPAddr); ok { - network = "tcp" - } - - trimDNSResponse(configCtx, network, req, resp, r.logger) - - setEDNS(req, resp, isECSGlobal) + respGenerator.trimDNSResponse(configCtx, remoteAddress, req, resp, r.logger) + respGenerator.setEDNS(req, resp, isECSGlobal) return resp } -// generateResponseFromError generates a response from an error. -func (r *Router) generateResponseFromError(req *dns.Msg, err error, qName string, - configCtx *RouterDynamicConfig, responseDomain string, isECSGlobal bool, - query *discovery.Query, canRecurse bool) *dns.Msg { - switch { - case errors.Is(err, errInvalidQuestion): - r.logger.Error("invalid question", "name", qName) - - return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeNameError, isECSGlobal) - case errors.Is(err, errNameNotFound): - r.logger.Error("name not found", "name", qName) - - return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeNameError, isECSGlobal) - case errors.Is(err, errNotImplemented): - r.logger.Error("query not implemented", "name", qName, "type", dns.Type(req.Question[0].Qtype).String()) - - return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeNotImplemented, isECSGlobal) - case errors.Is(err, discovery.ErrNotSupported): - r.logger.Debug("query name syntax not supported", "name", req.Question[0].Name) - - return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeNameError, isECSGlobal) - case errors.Is(err, discovery.ErrNotFound): - r.logger.Debug("query name not found", "name", req.Question[0].Name) - - return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeNameError, isECSGlobal) - case errors.Is(err, discovery.ErrNoData): - r.logger.Debug("no data available", "name", qName) - - return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeSuccess, isECSGlobal) - case errors.Is(err, discovery.ErrNoPathToDatacenter): - dc := "" - if query != nil { - dc = query.QueryPayload.Tenancy.Datacenter - } - r.logger.Debug("no path to datacenter", "datacenter", dc) - return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeNameError, isECSGlobal) - } - r.logger.Error("error processing discovery query", "error", err) - return createServerFailureResponse(req, configCtx, canRecurse) -} - // trimDomain trims the domain from the question name. func (r *Router) trimDomain(questionName string) string { longer := r.domain @@ -316,89 +318,6 @@ func (r *Router) trimDomain(questionName string) string { return strings.TrimSuffix(questionName, shorter) } -// getTTLForResult returns the TTL for a given result. -func getTTLForResult(name string, overrideTTL *uint32, query *discovery.Query, cfg *RouterDynamicConfig) uint32 { - // In the case we are not making a discovery query, such as addr. or arpa. lookups, - // use the node TTL by convention - if query == nil { - return uint32(cfg.NodeTTL / time.Second) - } - - if overrideTTL != nil { - // If a result was provided with an override, use that. This is the case for some prepared queries. - return *overrideTTL - } - - switch query.QueryType { - case discovery.QueryTypeService, discovery.QueryTypePreparedQuery: - ttl, ok := cfg.GetTTLForService(name) - if ok { - return uint32(ttl / time.Second) - } - fallthrough - default: - return uint32(cfg.NodeTTL / time.Second) - } -} - -// getQueryResults returns a discovery.Result from a DNS message. -func (r *Router) getQueryResults(req *dns.Msg, reqCtx Context, reqType requestType, - qName string, remoteAddress net.Addr) ([]*discovery.Result, *discovery.Query, error) { - switch reqType { - case requestTypeConsul: - // This is a special case of discovery.QueryByName where we know that we need to query the consul service - // regardless of the question name. - query := &discovery.Query{ - QueryType: discovery.QueryTypeService, - QueryPayload: discovery.QueryPayload{ - Name: structs.ConsulServiceName, - Tenancy: discovery.QueryTenancy{ - // We specify the partition here so that in the case we are a client agent in a non-default partition. - // We don't want the query processors default partition to be used. - // This is a small hack because for V1 CE, this is not the correct default partition name, but we - // need to add something to disambiguate the empty field. - Partition: acl.DefaultPartitionName, //NOTE: note this won't work if we ever have V2 client agents - }, - Limit: 3, - }, - } - - results, err := r.processor.QueryByName(query, discovery.Context{Token: reqCtx.Token}) - return results, query, err - case requestTypeName: - query, err := buildQueryFromDNSMessage(req, reqCtx, r.domain, r.altDomain, remoteAddress) - if err != nil { - r.logger.Error("error building discovery query from DNS request", "error", err) - return nil, query, err - } - results, err := r.processor.QueryByName(query, discovery.Context{Token: reqCtx.Token}) - - if getErrorFromECSNotGlobalError(err) != nil { - r.logger.Error("error processing discovery query", "error", err) - return nil, query, err - } - return results, query, err - case requestTypeIP: - ip := dnsutil.IPFromARPA(qName) - if ip == nil { - r.logger.Error("error building IP from DNS request", "name", qName) - return nil, nil, errNameNotFound - } - results, err := r.processor.QueryByIP(ip, discovery.Context{Token: reqCtx.Token}) - return results, nil, err - case requestTypeAddress: - results, err := buildAddressResults(req) - if err != nil { - r.logger.Error("error processing discovery query", "error", err) - return nil, nil, err - } - return results, nil, nil - } - - r.logger.Error("error parsing discovery query type", "requestType", reqType) - return nil, nil, errInvalidQuestion -} - // ServeDNS implements the miekg/dns.Handler interface. // This is a standard DNS listener, so we inject a default request context based on the agent's config. func (r *Router) ServeDNS(w dns.ResponseWriter, req *dns.Msg) { @@ -417,262 +336,6 @@ func (r *Router) ReloadConfig(newCfg *config.RuntimeConfig) error { return nil } -// GetConfig returns the current router config -func (r *Router) GetConfig() *RouterDynamicConfig { - return r.dynamicConfig.Load().(*RouterDynamicConfig) -} - -// GetTTLForService Find the TTL for a given service. -// return ttl, true if found, 0, false otherwise -func (cfg *RouterDynamicConfig) GetTTLForService(service string) (time.Duration, bool) { - if cfg.TTLStrict != nil { - ttl, ok := cfg.TTLStrict[service] - if ok { - return ttl, true - } - } - if cfg.TTLRadix != nil { - _, ttlRaw, ok := cfg.TTLRadix.LongestPrefix(service) - if ok { - return ttlRaw.(time.Duration), true - } - } - return 0, false -} - -// Request type is similar to miekg/dns.Type, but correlates to the different query processors we might need to invoke. -type requestType string - -const ( - requestTypeName requestType = "NAME" // A/AAAA/CNAME/SRV - requestTypeIP requestType = "IP" // PTR - requestTypeAddress requestType = "ADDR" // Custom addr. A/AAAA lookups - requestTypeConsul requestType = "CONSUL" // SOA/NS -) - -// parseDomain converts a DNS message into a generic discovery request. -// If the request domain does not match "consul." or the alternative domain, -// it will return true for needRecurse. The logic is based on miekg/dns.ServeDNS matcher. -// The implementation assumes that the only valid domains are "consul." and the alternative domain, and -// that DS query types are not supported. -func (r *Router) parseDomain(questionName string) (string, bool) { - target := dns.CanonicalName(questionName) - target, _ = stripSuffix(target) - - for offset, overflow := 0, false; !overflow; offset, overflow = dns.NextLabel(target, offset) { - subdomain := target[offset:] - switch subdomain { - case ".": - // We don't support consul having a domain or altdomain attached to the root. - return "", true - case r.domain: - return r.domain, false - case r.altDomain: - return r.altDomain, false - case arpaDomain: - // PTR queries always respond with the primary domain. - return r.domain, false - // Default: fallthrough - } - } - // No match found; recurse if possible - return "", true -} - -// parseRequestType inspects the DNS message type and question name to determine the requestType of request. -// We assume by the time this is called, we are responding to a question with a domain we serve. -// This is used internally to determine which query processor method (if any) to invoke. -func parseRequestType(req *dns.Msg) requestType { - switch { - case req.Question[0].Qtype == dns.TypeSOA || req.Question[0].Qtype == dns.TypeNS: - // SOA and NS type supersede the domain - // NOTE!: In V1 of the DNS server it was possible to serve a PTR lookup using the arpa domain but a SOA question type. - // This also included the SOA record. This seemed inconsistent and unnecessary - it was removed for simplicity. - return requestTypeConsul - case isPTRSubdomain(req.Question[0].Name): - return requestTypeIP - case isAddrSubdomain(req.Question[0].Name): - return requestTypeAddress - default: - return requestTypeName - } -} - -func getPortsFromResult(result *discovery.Result) []discovery.Port { - if len(result.Ports) > 0 { - return result.Ports - } - // return one record. - return []discovery.Port{{}} -} - -// serializeQueryResults converts a discovery.Result into a DNS message. -func (r *Router) serializeQueryResults(req *dns.Msg, reqCtx Context, - query *discovery.Query, results []*discovery.Result, cfg *RouterDynamicConfig, - responseDomain string, remoteAddress net.Addr, maxRecursionLevel int) (*dns.Msg, error) { - resp := new(dns.Msg) - resp.SetReply(req) - resp.Compress = !cfg.DisableCompression - resp.Authoritative = true - resp.RecursionAvailable = canRecurse(cfg) - - qType := req.Question[0].Qtype - reqType := parseRequestType(req) - - // Always add the SOA record if requested. - switch { - case qType == dns.TypeSOA: - resp.Answer = append(resp.Answer, makeSOARecord(responseDomain, cfg)) - for _, result := range results { - for _, port := range getPortsFromResult(result) { - ans, ex, ns := r.getAnswerExtraAndNs(result, port, req, reqCtx, query, cfg, responseDomain, remoteAddress, maxRecursionLevel) - resp.Answer = append(resp.Answer, ans...) - resp.Extra = append(resp.Extra, ex...) - resp.Ns = append(resp.Ns, ns...) - } - } - case reqType == requestTypeAddress: - for _, result := range results { - for _, port := range getPortsFromResult(result) { - ans, ex, ns := r.getAnswerExtraAndNs(result, port, req, reqCtx, query, cfg, responseDomain, remoteAddress, maxRecursionLevel) - resp.Answer = append(resp.Answer, ans...) - resp.Extra = append(resp.Extra, ex...) - resp.Ns = append(resp.Ns, ns...) - } - } - case qType == dns.TypeSRV: - handled := make(map[string]struct{}) - for _, result := range results { - for _, port := range getPortsFromResult(result) { - - // Avoid duplicate entries, possible if a node has - // the same service the same port, etc. - - // The datacenter should be empty during translation if it is a peering lookup. - // This should be fine because we should always prefer the WAN address. - - address := "" - if result.Service != nil { - address = result.Service.Address - } else { - address = result.Node.Address - } - tuple := fmt.Sprintf("%s:%s:%d", result.Node.Name, address, port.Number) - if _, ok := handled[tuple]; ok { - continue - } - handled[tuple] = struct{}{} - - ans, ex, ns := r.getAnswerExtraAndNs(result, port, req, reqCtx, query, cfg, responseDomain, remoteAddress, maxRecursionLevel) - resp.Answer = append(resp.Answer, ans...) - resp.Extra = append(resp.Extra, ex...) - resp.Ns = append(resp.Ns, ns...) - } - } - default: - // default will send it to where it does some de-duping while it calls getAnswerExtraAndNs and recurses. - r.appendResultsToDNSResponse(req, reqCtx, query, resp, results, cfg, responseDomain, remoteAddress, maxRecursionLevel) - } - - if query != nil && query.QueryType != discovery.QueryTypeVirtual && - len(resp.Answer) == 0 && len(resp.Extra) == 0 { - return nil, discovery.ErrNoData - } - - return resp, nil -} - -// getServiceAddressMapFromLocationMap converts a map of Location to a map of ServiceAddress. -func getServiceAddressMapFromLocationMap(taggedAddresses map[string]*discovery.TaggedAddress) map[string]structs.ServiceAddress { - taggedServiceAddresses := make(map[string]structs.ServiceAddress, len(taggedAddresses)) - for k, v := range taggedAddresses { - taggedServiceAddresses[k] = structs.ServiceAddress{ - Address: v.Address, - Port: int(v.Port.Number), - } - } - return taggedServiceAddresses -} - -// getStringAddressMapFromTaggedAddressMap converts a map of Location to a map of string. -func getStringAddressMapFromTaggedAddressMap(taggedAddresses map[string]*discovery.TaggedAddress) map[string]string { - taggedServiceAddresses := make(map[string]string, len(taggedAddresses)) - for k, v := range taggedAddresses { - taggedServiceAddresses[k] = v.Address - } - return taggedServiceAddresses -} - -// appendResultsToDNSResponse builds dns message from the discovery results and -// appends them to the dns response. -func (r *Router) appendResultsToDNSResponse(req *dns.Msg, reqCtx Context, - query *discovery.Query, resp *dns.Msg, results []*discovery.Result, cfg *RouterDynamicConfig, - responseDomain string, remoteAddress net.Addr, maxRecursionLevel int) { - - // Always add the SOA record if requested. - if req.Question[0].Qtype == dns.TypeSOA { - resp.Answer = append(resp.Answer, makeSOARecord(responseDomain, cfg)) - } - - handled := make(map[string]struct{}) - var answerCNAME []dns.RR = nil - - count := 0 - for _, result := range results { - for _, port := range getPortsFromResult(result) { - - // Add the node record - had_answer := false - ans, extra, _ := r.getAnswerExtraAndNs(result, port, req, reqCtx, query, cfg, responseDomain, remoteAddress, maxRecursionLevel) - resp.Extra = append(resp.Extra, extra...) - - if len(ans) == 0 { - continue - } - - // Avoid duplicate entries, possible if a node has - // the same service on multiple ports, etc. - if _, ok := handled[ans[0].String()]; ok { - continue - } - handled[ans[0].String()] = struct{}{} - - switch ans[0].(type) { - case *dns.CNAME: - // keep track of the first CNAME + associated RRs but don't add to the resp.Answer yet - // this will only be added if no non-CNAME RRs are found - if len(answerCNAME) == 0 { - answerCNAME = ans - } - default: - resp.Answer = append(resp.Answer, ans...) - had_answer = true - } - - if had_answer { - count++ - if count == cfg.ARecordLimit { - // We stop only if greater than 0 or we reached the limit - return - } - } - } - } - if len(resp.Answer) == 0 && len(answerCNAME) > 0 { - resp.Answer = answerCNAME - } -} - -// defaultAgentDNSRequestContext returns a default request context based on the agent's config. -func (r *Router) defaultAgentDNSRequestContext() Context { - return Context{ - Token: r.tokenFunc(), - DefaultDatacenter: r.datacenter, - // We don't need to specify the agent's partition here because that will be handled further down the stack - // in the query processor. - } -} - // resolveCNAME is used to recursively resolve CNAME records func (r *Router) resolveCNAME(cfgContext *RouterDynamicConfig, name string, reqCtx Context, remoteAddress net.Addr, maxRecursionLevel int) []dns.RR { @@ -713,6 +376,87 @@ func (r *Router) resolveCNAME(cfgContext *RouterDynamicConfig, name string, reqC return nil } +// Request type is similar to miekg/dns.Type, but correlates to the different query processors we might need to invoke. +type requestType string + +const ( + requestTypeName requestType = "NAME" // A/AAAA/CNAME/SRV + requestTypeIP requestType = "IP" // PTR + requestTypeAddress requestType = "ADDR" // Custom addr. A/AAAA lookups + requestTypeConsul requestType = "CONSUL" // SOA/NS +) + +// parseDomain converts a DNS message into a generic discovery request. +// If the request domain does not match "consul." or the alternative domain, +// it will return true for needRecurse. The logic is based on miekg/dns.ServeDNS matcher. +// The implementation assumes that the only valid domains are "consul." and the alternative domain, and +// that DS query types are not supported. +func (r *Router) parseDomain(questionName string) (string, bool) { + target := dns.CanonicalName(questionName) + target, _ = stripAnyFailoverSuffix(target) + + for offset, overflow := 0, false; !overflow; offset, overflow = dns.NextLabel(target, offset) { + subdomain := target[offset:] + switch subdomain { + case ".": + // We don't support consul having a domain or altdomain attached to the root. + return "", true + case r.domain: + return r.domain, false + case r.altDomain: + return r.altDomain, false + case arpaDomain: + // PTR queries always respond with the primary domain. + return r.domain, false + // Default: fallthrough + } + } + // No match found; recurse if possible + return "", true +} + +// GetConfig returns the current router config +func (r *Router) GetConfig() *RouterDynamicConfig { + return r.dynamicConfig.Load().(*RouterDynamicConfig) +} + +// defaultAgentDNSRequestContext returns a default request context based on the agent's config. +func (r *Router) defaultAgentDNSRequestContext() Context { + return Context{ + Token: r.tokenFunc(), + DefaultDatacenter: r.datacenter, + // We don't need to specify the agent's partition here because that will be handled further down the stack + // in the query processor. + } +} + +// getErrorFromECSNotGlobalError returns the underlying error from an ECSNotGlobalError, if it exists. +func getErrorFromECSNotGlobalError(err error) error { + if errors.Is(err, discovery.ErrECSNotGlobal) { + return err.(discovery.ECSNotGlobalError).Unwrap() + } + return err +} + +// parseRequestType inspects the DNS message type and question name to determine the requestType of request. +// We assume by the time this is called, we are responding to a question with a domain we serve. +// This is used internally to determine which query processor method (if any) to invoke. +func parseRequestType(req *dns.Msg) requestType { + switch { + case req.Question[0].Qtype == dns.TypeSOA || req.Question[0].Qtype == dns.TypeNS: + // SOA and NS type supersede the domain + // NOTE!: In V1 of the DNS server it was possible to serve a PTR lookup using the arpa domain but a SOA question type. + // This also included the SOA record. This seemed inconsistent and unnecessary - it was removed for simplicity. + return requestTypeConsul + case isPTRSubdomain(req.Question[0].Name): + return requestTypeIP + case isAddrSubdomain(req.Question[0].Name): + return requestTypeAddress + default: + return requestTypeName + } +} + // validateAndNormalizeRequest validates the DNS request and normalizes the request name. func validateAndNormalizeRequest(req *dns.Msg) error { // like upstream miekg/dns, we require at least one question, @@ -727,8 +471,8 @@ func validateAndNormalizeRequest(req *dns.Msg) error { return nil } -// stripSuffix strips off the suffixes that may have been added to the request name. -func stripSuffix(target string) (string, bool) { +// stripAnyFailoverSuffix strips off the suffixes that may have been added to the request name. +func stripAnyFailoverSuffix(target string) (string, bool) { enableFailover := false // Strip off any suffixes that may have been added. @@ -822,642 +566,3 @@ func getDynamicRouterConfig(conf *config.RuntimeConfig) (*RouterDynamicConfig, e func canRecurse(cfg *RouterDynamicConfig) bool { return len(cfg.Recursors) > 0 } - -// createServerFailureResponse returns a SERVFAIL message. -func createServerFailureResponse(req *dns.Msg, cfg *RouterDynamicConfig, recursionAvailable bool) *dns.Msg { - // Return a SERVFAIL message - m := &dns.Msg{} - m.SetReply(req) - m.Compress = !cfg.DisableCompression - m.SetRcode(req, dns.RcodeServerFailure) - m.RecursionAvailable = recursionAvailable - if edns := req.IsEdns0(); edns != nil { - setEDNS(req, m, true) - } - - return m -} - -// setEDNS is used to set the responses EDNS size headers and -// possibly the ECS headers as well if they were present in the -// original request -func setEDNS(request *dns.Msg, response *dns.Msg, ecsGlobal bool) { - edns := request.IsEdns0() - if edns == nil { - return - } - - // cannot just use the SetEdns0 function as we need to embed - // the ECS option as well - ednsResp := new(dns.OPT) - ednsResp.Hdr.Name = "." - ednsResp.Hdr.Rrtype = dns.TypeOPT - ednsResp.SetUDPSize(edns.UDPSize()) - - // Set up the ECS option if present - if subnet := ednsSubnetForRequest(request); subnet != nil { - subOp := new(dns.EDNS0_SUBNET) - subOp.Code = dns.EDNS0SUBNET - subOp.Family = subnet.Family - subOp.Address = subnet.Address - subOp.SourceNetmask = subnet.SourceNetmask - if c := response.Rcode; ecsGlobal || c == dns.RcodeNameError || c == dns.RcodeServerFailure || c == dns.RcodeRefused || c == dns.RcodeNotImplemented { - // reply is globally valid and should be cached accordingly - subOp.SourceScope = 0 - } else { - // reply is only valid for the subnet it was queried with - subOp.SourceScope = subnet.SourceNetmask - } - ednsResp.Option = append(ednsResp.Option, subOp) - } - - response.Extra = append(response.Extra, ednsResp) -} - -// ednsSubnetForRequest looks through the request to find any EDS subnet options -func ednsSubnetForRequest(req *dns.Msg) *dns.EDNS0_SUBNET { - // IsEdns0 returns the EDNS RR if present or nil otherwise - edns := req.IsEdns0() - if edns == nil { - return nil - } - - for _, o := range edns.Option { - if subnet, ok := o.(*dns.EDNS0_SUBNET); ok { - return subnet - } - } - return nil -} - -// createRefusedResponse returns a REFUSED message. This is the default behavior for unmatched queries in -// upstream miekg/dns. -func createRefusedResponse(req *dns.Msg) *dns.Msg { - // Return a REFUSED message - m := &dns.Msg{} - m.SetRcode(req, dns.RcodeRefused) - return m -} - -// createAuthoritativeResponse returns an authoritative message that contains the SOA in the event that data is -// not return for a query. There can be multiple reasons for not returning data, hence the rcode argument. -func createAuthoritativeResponse(req *dns.Msg, cfg *RouterDynamicConfig, domain string, rcode int, ecsGlobal bool) *dns.Msg { - m := &dns.Msg{} - m.SetRcode(req, rcode) - m.Compress = !cfg.DisableCompression - m.Authoritative = true - m.RecursionAvailable = canRecurse(cfg) - if edns := req.IsEdns0(); edns != nil { - setEDNS(req, m, ecsGlobal) - } - - // We add the SOA on NameErrors - soa := makeSOARecord(domain, cfg) - m.Ns = append(m.Ns, soa) - - return m -} - -// buildAddressResults returns a discovery.Result from a DNS request for addr. records. -func buildAddressResults(req *dns.Msg) ([]*discovery.Result, error) { - domain := dns.CanonicalName(req.Question[0].Name) - labels := dns.SplitDomainName(domain) - hexadecimal := labels[0] - - if len(hexadecimal)/2 != 4 && len(hexadecimal)/2 != 16 { - return nil, errNameNotFound - } - - var ip net.IP - ip, err := hex.DecodeString(hexadecimal) - if err != nil { - return nil, errNameNotFound - } - - return []*discovery.Result{ - { - Node: &discovery.Location{ - Address: ip.String(), - }, - Type: discovery.ResultTypeNode, // We choose node by convention since we do not know the origin of the IP - }, - }, nil -} - -// getAnswerAndExtra creates the dns answer and extra from discovery results. -func (r *Router) getAnswerExtraAndNs(result *discovery.Result, port discovery.Port, req *dns.Msg, reqCtx Context, - query *discovery.Query, cfg *RouterDynamicConfig, domain string, remoteAddress net.Addr, - maxRecursionLevel int) (answer []dns.RR, extra []dns.RR, ns []dns.RR) { - serviceAddress, nodeAddress := r.getServiceAndNodeAddresses(result, req) - qName := req.Question[0].Name - ttlLookupName := qName - if query != nil { - ttlLookupName = query.QueryPayload.Name - } - - ttl := getTTLForResult(ttlLookupName, result.DNS.TTL, query, cfg) - - qType := req.Question[0].Qtype - - // TODO (v2-dns): skip records that refer to a workload/node that don't have a valid DNS name. - - // Special case responses - switch { - // PTR requests are first since they are a special case of domain overriding question type - case parseRequestType(req) == requestTypeIP: - ptrTarget := "" - if result.Type == discovery.ResultTypeNode { - ptrTarget = result.Node.Name - } else if result.Type == discovery.ResultTypeService { - ptrTarget = result.Service.Name - } - - ptr := &dns.PTR{ - Hdr: dns.RR_Header{Name: qName, Rrtype: dns.TypePTR, Class: dns.ClassINET, Ttl: 0}, - Ptr: canonicalNameForResult(result.Type, ptrTarget, domain, result.Tenancy, port.Name), - } - answer = append(answer, ptr) - case qType == dns.TypeNS: - resultType := result.Type - target := result.Node.Name - if parseRequestType(req) == requestTypeConsul && resultType == discovery.ResultTypeService { - resultType = discovery.ResultTypeNode - } - fqdn := canonicalNameForResult(resultType, target, domain, result.Tenancy, port.Name) - extraRecord := makeIPBasedRecord(fqdn, nodeAddress, ttl) - - answer = append(answer, makeNSRecord(domain, fqdn, ttl)) - extra = append(extra, extraRecord) - case qType == dns.TypeSOA: - // to be returned in the result. - fqdn := canonicalNameForResult(result.Type, result.Node.Name, domain, result.Tenancy, port.Name) - extraRecord := makeIPBasedRecord(fqdn, nodeAddress, ttl) - - ns = append(ns, makeNSRecord(domain, fqdn, ttl)) - extra = append(extra, extraRecord) - case qType == dns.TypeSRV: - // We put A/AAAA/CNAME records in the additional section for SRV requests - a, e := r.getAnswerExtrasForAddressAndTarget(nodeAddress, serviceAddress, req, reqCtx, - result, port, ttl, remoteAddress, cfg, domain, maxRecursionLevel) - answer = append(answer, a...) - extra = append(extra, e...) - - default: - a, e := r.getAnswerExtrasForAddressAndTarget(nodeAddress, serviceAddress, req, reqCtx, - result, port, ttl, remoteAddress, cfg, domain, maxRecursionLevel) - answer = append(answer, a...) - extra = append(extra, e...) - } - - a, e := getAnswerAndExtraTXT(req, cfg, qName, result, ttl, domain, query, &port) - answer = append(answer, a...) - extra = append(extra, e...) - return -} - -// getServiceAndNodeAddresses returns the service and node addresses from a discovery result. -func (r *Router) getServiceAndNodeAddresses(result *discovery.Result, req *dns.Msg) (*dnsAddress, *dnsAddress) { - addrTranslate := dnsutil.TranslateAddressAcceptDomain - if req.Question[0].Qtype == dns.TypeA { - addrTranslate |= dnsutil.TranslateAddressAcceptIPv4 - } else if req.Question[0].Qtype == dns.TypeAAAA { - addrTranslate |= dnsutil.TranslateAddressAcceptIPv6 - } else { - addrTranslate |= dnsutil.TranslateAddressAcceptAny - } - - // The datacenter should be empty during translation if it is a peering lookup. - // This should be fine because we should always prefer the WAN address. - serviceAddress := newDNSAddress("") - if result.Service != nil { - sa := r.translateServiceAddressFunc(result.Tenancy.Datacenter, - result.Service.Address, getServiceAddressMapFromLocationMap(result.Service.TaggedAddresses), - addrTranslate) - serviceAddress = newDNSAddress(sa) - } - nodeAddress := newDNSAddress("") - if result.Node != nil { - na := r.translateAddressFunc(result.Tenancy.Datacenter, result.Node.Address, - getStringAddressMapFromTaggedAddressMap(result.Node.TaggedAddresses), addrTranslate) - nodeAddress = newDNSAddress(na) - } - return serviceAddress, nodeAddress -} - -// getAnswerExtrasForAddressAndTarget creates the dns answer and extra from nodeAddress and serviceAddress dnsAddress pairs. -func (r *Router) getAnswerExtrasForAddressAndTarget(nodeAddress *dnsAddress, serviceAddress *dnsAddress, req *dns.Msg, - reqCtx Context, result *discovery.Result, port discovery.Port, ttl uint32, remoteAddress net.Addr, - cfg *RouterDynamicConfig, domain string, maxRecursionLevel int) (answer []dns.RR, extra []dns.RR) { - qName := req.Question[0].Name - reqType := parseRequestType(req) - - switch { - case (reqType == requestTypeAddress || result.Type == discovery.ResultTypeVirtual) && - serviceAddress.IsEmptyString() && nodeAddress.IsIP(): - a, e := getAnswerExtrasForIP(qName, nodeAddress, req.Question[0], reqType, result, ttl, domain, &port) - answer = append(answer, a...) - extra = append(extra, e...) - - case result.Type == discovery.ResultTypeNode && nodeAddress.IsIP(): - canonicalNodeName := canonicalNameForResult(result.Type, result.Node.Name, domain, result.Tenancy, port.Name) - a, e := getAnswerExtrasForIP(canonicalNodeName, nodeAddress, req.Question[0], reqType, - result, ttl, domain, &port) - answer = append(answer, a...) - extra = append(extra, e...) - - case result.Type == discovery.ResultTypeNode && !nodeAddress.IsIP(): - a, e := r.makeRecordFromFQDN(result, req, reqCtx, cfg, - ttl, remoteAddress, maxRecursionLevel, serviceAddress.FQDN(), &port) - answer = append(answer, a...) - extra = append(extra, e...) - - case serviceAddress.IsEmptyString() && nodeAddress.IsEmptyString(): - return nil, nil - - // There is no service address and the node address is an IP - case serviceAddress.IsEmptyString() && nodeAddress.IsIP(): - resultType := discovery.ResultTypeNode - if result.Type == discovery.ResultTypeWorkload { - resultType = discovery.ResultTypeWorkload - } - canonicalNodeName := canonicalNameForResult(resultType, result.Node.Name, domain, result.Tenancy, port.Name) - a, e := getAnswerExtrasForIP(canonicalNodeName, nodeAddress, req.Question[0], reqType, result, ttl, domain, &port) - answer = append(answer, a...) - extra = append(extra, e...) - - // There is no service address and the node address is a FQDN (external service) - case serviceAddress.IsEmptyString(): - a, e := r.makeRecordFromFQDN(result, req, reqCtx, cfg, ttl, remoteAddress, maxRecursionLevel, nodeAddress.FQDN(), &port) - answer = append(answer, a...) - extra = append(extra, e...) - - // The service address is an IP - case serviceAddress.IsIP(): - canonicalServiceName := canonicalNameForResult(discovery.ResultTypeService, result.Service.Name, domain, result.Tenancy, port.Name) - a, e := getAnswerExtrasForIP(canonicalServiceName, serviceAddress, req.Question[0], reqType, result, ttl, domain, &port) - answer = append(answer, a...) - extra = append(extra, e...) - - // If the service address is a CNAME for the service we are looking - // for then use the node address. - case serviceAddress.FQDN() == req.Question[0].Name && nodeAddress.IsIP(): - canonicalNodeName := canonicalNameForResult(discovery.ResultTypeNode, result.Node.Name, domain, result.Tenancy, port.Name) - a, e := getAnswerExtrasForIP(canonicalNodeName, nodeAddress, req.Question[0], reqType, result, ttl, domain, &port) - answer = append(answer, a...) - extra = append(extra, e...) - - // The service address is a FQDN (internal or external service name) - default: - a, e := r.makeRecordFromFQDN(result, req, reqCtx, cfg, ttl, remoteAddress, maxRecursionLevel, serviceAddress.FQDN(), &port) - answer = append(answer, a...) - extra = append(extra, e...) - } - - return -} - -// getAnswerAndExtraTXT determines whether a TXT needs to be create and then -// returns the TXT record in the answer or extra depending on the question type. -func getAnswerAndExtraTXT(req *dns.Msg, cfg *RouterDynamicConfig, qName string, - result *discovery.Result, ttl uint32, domain string, query *discovery.Query, port *discovery.Port) (answer []dns.RR, extra []dns.RR) { - if !shouldAppendTXTRecord(query, cfg, req) { - return - } - recordHeaderName := qName - serviceAddress := newDNSAddress("") - if result.Service != nil { - serviceAddress = newDNSAddress(result.Service.Address) - } - if result.Type != discovery.ResultTypeNode && - result.Type != discovery.ResultTypeVirtual && - !serviceAddress.IsInternalFQDN(domain) && - !serviceAddress.IsExternalFQDN(domain) { - recordHeaderName = canonicalNameForResult(discovery.ResultTypeNode, result.Node.Name, - domain, result.Tenancy, port.Name) - } - qType := req.Question[0].Qtype - generateMeta := false - metaInAnswer := false - if qType == dns.TypeANY || qType == dns.TypeTXT { - generateMeta = true - metaInAnswer = true - } else if cfg.NodeMetaTXT { - generateMeta = true - } - - // Do not generate txt records if we don't have to: https://github.com/hashicorp/consul/pull/5272 - if generateMeta { - meta := makeTXTRecord(recordHeaderName, result, ttl) - if metaInAnswer { - answer = append(answer, meta...) - } else { - extra = append(extra, meta...) - } - } - return answer, extra -} - -// shouldAppendTXTRecord determines whether a TXT record should be appended to the response. -func shouldAppendTXTRecord(query *discovery.Query, cfg *RouterDynamicConfig, req *dns.Msg) bool { - qType := req.Question[0].Qtype - switch { - // Node records - case query != nil && query.QueryType == discovery.QueryTypeNode && (cfg.NodeMetaTXT || qType == dns.TypeANY || qType == dns.TypeTXT): - return true - // Service records - case query != nil && query.QueryType == discovery.QueryTypeService && cfg.NodeMetaTXT && qType == dns.TypeSRV: - return true - // Prepared query records - case query != nil && query.QueryType == discovery.QueryTypePreparedQuery && cfg.NodeMetaTXT && qType == dns.TypeSRV: - return true - } - return false -} - -// getAnswerExtrasForIP creates the dns answer and extra from IP dnsAddress pairs. -func getAnswerExtrasForIP(name string, addr *dnsAddress, question dns.Question, - reqType requestType, result *discovery.Result, ttl uint32, domain string, port *discovery.Port) (answer []dns.RR, extra []dns.RR) { - qType := question.Qtype - canReturnARecord := qType == dns.TypeSRV || qType == dns.TypeA || qType == dns.TypeANY || qType == dns.TypeNS || qType == dns.TypeTXT - canReturnAAAARecord := qType == dns.TypeSRV || qType == dns.TypeAAAA || qType == dns.TypeANY || qType == dns.TypeNS || qType == dns.TypeTXT - if reqType != requestTypeAddress && result.Type != discovery.ResultTypeVirtual { - switch { - // check IPV4 - case addr.IsIP() && addr.IsIPV4() && !canReturnARecord, - // check IPV6 - addr.IsIP() && !addr.IsIPV4() && !canReturnAAAARecord: - return - } - } - - // Have to pass original question name here even if the system has recursed - // and stripped off the domain suffix. - recHdrName := question.Name - if qType == dns.TypeSRV { - nameSplit := strings.Split(name, ".") - if len(nameSplit) > 1 && nameSplit[1] == addrLabel { - recHdrName = name - } else { - recHdrName = name - } - name = question.Name - } - - if reqType != requestTypeAddress && qType == dns.TypeSRV { - if result.Type == discovery.ResultTypeService && addr.IsIP() && result.Node.Address != addr.String() { - // encode the ip to be used in the header of the A/AAAA record - // as well as the target of the SRV record. - recHdrName = encodeIPAsFqdn(result, addr.IP(), domain) - } - if result.Type == discovery.ResultTypeWorkload { - recHdrName = canonicalNameForResult(result.Type, result.Node.Name, domain, result.Tenancy, port.Name) - } - srv := makeSRVRecord(name, recHdrName, result, ttl, port) - answer = append(answer, srv) - } - - record := makeIPBasedRecord(recHdrName, addr, ttl) - - isARecordWhenNotExplicitlyQueried := record.Header().Rrtype == dns.TypeA && qType != dns.TypeA && qType != dns.TypeANY - isAAAARecordWhenNotExplicitlyQueried := record.Header().Rrtype == dns.TypeAAAA && qType != dns.TypeAAAA && qType != dns.TypeANY - - // For explicit A/AAAA queries, we must only return those records in the answer section. - if isARecordWhenNotExplicitlyQueried || - isAAAARecordWhenNotExplicitlyQueried { - extra = append(extra, record) - } else { - answer = append(answer, record) - } - - return -} - -// encodeIPAsFqdn encodes an IP address as a FQDN. -func encodeIPAsFqdn(result *discovery.Result, ip net.IP, responseDomain string) string { - ipv4 := ip.To4() - ipStr := hex.EncodeToString(ip) - if ipv4 != nil { - ipStr = ipStr[len(ipStr)-(net.IPv4len*2):] - } - if result.Tenancy.PeerName != "" { - // Exclude the datacenter from the FQDN on the addr for peers. - // This technically makes no difference, since the addr endpoint ignores the DC - // component of the request, but do it anyway for a less confusing experience. - return fmt.Sprintf("%s.addr.%s", ipStr, responseDomain) - } - return fmt.Sprintf("%s.addr.%s.%s", ipStr, result.Tenancy.Datacenter, responseDomain) -} - -func makeSOARecord(domain string, cfg *RouterDynamicConfig) dns.RR { - return &dns.SOA{ - Hdr: dns.RR_Header{ - Name: domain, - Rrtype: dns.TypeSOA, - Class: dns.ClassINET, - // Has to be consistent with MinTTL to avoid invalidation - Ttl: cfg.SOAConfig.Minttl, - }, - Ns: "ns." + domain, - Serial: uint32(time.Now().Unix()), - Mbox: "hostmaster." + domain, - Refresh: cfg.SOAConfig.Refresh, - Retry: cfg.SOAConfig.Retry, - Expire: cfg.SOAConfig.Expire, - Minttl: cfg.SOAConfig.Minttl, - } -} - -func makeNSRecord(domain, fqdn string, ttl uint32) dns.RR { - return &dns.NS{ - Hdr: dns.RR_Header{ - Name: domain, - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - Ttl: ttl, - }, - Ns: fqdn, - } -} - -// makeIPBasedRecord an A or AAAA record for the given name and IP. -// Note: we might want to pass in the Query Name here, which is used in addr. and virtual. queries -// since there is only ever one result. Right now choosing to leave it off for simplification. -func makeIPBasedRecord(name string, addr *dnsAddress, ttl uint32) dns.RR { - - if addr.IsIPV4() { - // check if the query type is A for IPv4 or ANY - return &dns.A{ - Hdr: dns.RR_Header{ - Name: name, - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: ttl, - }, - A: addr.IP(), - } - } - - return &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: name, - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - Ttl: ttl, - }, - AAAA: addr.IP(), - } -} - -func (r *Router) makeRecordFromFQDN(result *discovery.Result, req *dns.Msg, reqCtx Context, cfg *RouterDynamicConfig, ttl uint32, remoteAddress net.Addr, maxRecursionLevel int, fqdn string, port *discovery.Port) ([]dns.RR, []dns.RR) { - edns := req.IsEdns0() != nil - q := req.Question[0] - - more := r.resolveCNAME(cfg, dns.Fqdn(fqdn), reqCtx, remoteAddress, maxRecursionLevel) - var additional []dns.RR - extra := 0 -MORE_REC: - for _, rr := range more { - switch rr.Header().Rrtype { - case dns.TypeCNAME, dns.TypeA, dns.TypeAAAA, dns.TypeTXT: - // set the TTL manually - rr.Header().Ttl = ttl - additional = append(additional, rr) - - extra++ - if extra == maxRecurseRecords && !edns { - break MORE_REC - } - } - } - - if q.Qtype == dns.TypeSRV { - answer := makeSRVRecord(q.Name, fqdn, result, ttl, port) - return []dns.RR{answer}, additional - } - - address := "" - if result.Service != nil && result.Service.Address != "" { - address = result.Service.Address - } else if result.Node != nil { - address = result.Node.Address - } - - answers := []dns.RR{ - makeCNAMERecord(q.Name, address, ttl), - } - answers = append(answers, additional...) - - return answers, nil -} - -// makeCNAMERecord returns a CNAME record for the given name and target. -func makeCNAMERecord(name string, target string, ttl uint32) *dns.CNAME { - return &dns.CNAME{ - Hdr: dns.RR_Header{ - Name: name, - Rrtype: dns.TypeCNAME, - Class: dns.ClassINET, - Ttl: ttl, - }, - Target: dns.Fqdn(target), - } -} - -// func makeSRVRecord returns an SRV record for the given name and target. -func makeSRVRecord(name, target string, result *discovery.Result, ttl uint32, port *discovery.Port) *dns.SRV { - return &dns.SRV{ - Hdr: dns.RR_Header{ - Name: name, - Rrtype: dns.TypeSRV, - Class: dns.ClassINET, - Ttl: ttl, - }, - Priority: 1, - Weight: uint16(result.DNS.Weight), - Port: uint16(port.Number), - Target: target, - } -} - -// encodeKVasRFC1464 encodes a key-value pair according to RFC1464 -func encodeKVasRFC1464(key, value string) (txt string) { - // For details on these replacements c.f. https://www.ietf.org/rfc/rfc1464.txt - key = strings.Replace(key, "`", "``", -1) - key = strings.Replace(key, "=", "`=", -1) - - // Backquote the leading spaces - leadingSpacesRE := regexp.MustCompile("^ +") - numLeadingSpaces := len(leadingSpacesRE.FindString(key)) - key = leadingSpacesRE.ReplaceAllString(key, strings.Repeat("` ", numLeadingSpaces)) - - // Backquote the trailing spaces - numTrailingSpaces := len(trailingSpacesRE.FindString(key)) - key = trailingSpacesRE.ReplaceAllString(key, strings.Repeat("` ", numTrailingSpaces)) - - value = strings.Replace(value, "`", "``", -1) - - return key + "=" + value -} - -// makeTXTRecord returns a TXT record for the given name and result metadata. -func makeTXTRecord(name string, result *discovery.Result, ttl uint32) []dns.RR { - extra := make([]dns.RR, 0, len(result.Metadata)) - for key, value := range result.Metadata { - txt := value - if !strings.HasPrefix(strings.ToLower(key), "rfc1035-") { - txt = encodeKVasRFC1464(key, value) - } - - extra = append(extra, &dns.TXT{ - Hdr: dns.RR_Header{ - Name: name, - Rrtype: dns.TypeTXT, - Class: dns.ClassINET, - Ttl: ttl, - }, - Txt: []string{txt}, - }) - } - return extra -} - -// canonicalNameForResult returns the canonical name for a discovery result. -func canonicalNameForResult(resultType discovery.ResultType, target, domain string, - tenancy discovery.ResultTenancy, portName string) string { - switch resultType { - case discovery.ResultTypeService: - if tenancy.Namespace != "" { - return fmt.Sprintf("%s.%s.%s.%s.%s", target, "service", tenancy.Namespace, tenancy.Datacenter, domain) - } - return fmt.Sprintf("%s.%s.%s.%s", target, "service", tenancy.Datacenter, domain) - case discovery.ResultTypeNode: - if tenancy.PeerName != "" && tenancy.Partition != "" { - // We must return a more-specific DNS name for peering so - // that there is no ambiguity with lookups. - // Nodes are always registered in the default namespace, so - // the `.ns` qualifier is not required. - return fmt.Sprintf("%s.node.%s.peer.%s.ap.%s", - target, - tenancy.PeerName, - tenancy.Partition, - domain) - } - if tenancy.PeerName != "" { - // We must return a more-specific DNS name for peering so - // that there is no ambiguity with lookups. - return fmt.Sprintf("%s.node.%s.peer.%s", - target, - tenancy.PeerName, - domain) - } - // Return a simpler format for non-peering nodes. - return fmt.Sprintf("%s.node.%s.%s", target, tenancy.Datacenter, domain) - case discovery.ResultTypeWorkload: - // TODO (v2-dns): it doesn't appear this is being used to return a result. Need to investigate and refactor - if portName != "" { - return fmt.Sprintf("%s.port.%s.workload.%s.ns.%s.ap.%s", portName, target, tenancy.Namespace, tenancy.Partition, domain) - } - return fmt.Sprintf("%s.workload.%s.ns.%s.ap.%s", target, tenancy.Namespace, tenancy.Partition, domain) - } - return "" -} diff --git a/agent/dns/router_addr_test.go b/agent/dns/router_addr_test.go new file mode 100644 index 0000000000..2c493bd13e --- /dev/null +++ b/agent/dns/router_addr_test.go @@ -0,0 +1,405 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "net" + "testing" + "time" + + "github.com/miekg/dns" +) + +func Test_HandleRequest_ADDR(t *testing.T) { + testCases := []HandleTestCase{ + { + name: "test A 'addr.' query, ipv4 response", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "c000020a.addr.dc1.consul", // "intentionally missing the trailing dot" + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "c000020a.addr.dc1.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "c000020a.addr.dc1.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("192.0.2.10"), + }, + }, + }, + }, + { + name: "test AAAA 'addr.' query, ipv4 response", + // Since we asked for an AAAA record, the A record that resolves from the address is attached as an extra + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "c000020a.addr.dc1.consul", + Qtype: dns.TypeAAAA, + Qclass: dns.ClassINET, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "c000020a.addr.dc1.consul.", + Qtype: dns.TypeAAAA, + Qclass: dns.ClassINET, + }, + }, + Extra: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "c000020a.addr.dc1.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("192.0.2.10"), + }, + }, + }, + }, + { + name: "test SRV 'addr.' query, ipv4 response", + // Since we asked for a SRV record, the A record that resolves from the address is attached as an extra + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "c000020a.addr.dc1.consul", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "c000020a.addr.dc1.consul.", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + Extra: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "c000020a.addr.dc1.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("192.0.2.10"), + }, + }, + }, + }, + { + name: "test ANY 'addr.' query, ipv4 response", + // The response to ANY should look the same as the A response + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "c000020a.addr.dc1.consul", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "c000020a.addr.dc1.consul.", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "c000020a.addr.dc1.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("192.0.2.10"), + }, + }, + }, + }, + { + name: "test AAAA 'addr.' query, ipv6 response", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "20010db800010002cafe000000001337.addr.dc1.consul", + Qtype: dns.TypeAAAA, + Qclass: dns.ClassINET, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "20010db800010002cafe000000001337.addr.dc1.consul.", + Qtype: dns.TypeAAAA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: "20010db800010002cafe000000001337.addr.dc1.consul.", + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: 123, + }, + AAAA: net.ParseIP("2001:db8:1:2:cafe::1337"), + }, + }, + }, + }, + { + name: "test A 'addr.' query, ipv6 response", + // Since we asked for an A record, the AAAA record that resolves from the address is attached as an extra + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "20010db800010002cafe000000001337.addr.dc1.consul", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "20010db800010002cafe000000001337.addr.dc1.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Extra: []dns.RR{ + &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: "20010db800010002cafe000000001337.addr.dc1.consul.", + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: 123, + }, + AAAA: net.ParseIP("2001:db8:1:2:cafe::1337"), + }, + }, + }, + }, + { + name: "test SRV 'addr.' query, ipv6 response", + // Since we asked for an SRV record, the AAAA record that resolves from the address is attached as an extra + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "20010db800010002cafe000000001337.addr.dc1.consul", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "20010db800010002cafe000000001337.addr.dc1.consul.", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + Extra: []dns.RR{ + &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: "20010db800010002cafe000000001337.addr.dc1.consul.", + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: 123, + }, + AAAA: net.ParseIP("2001:db8:1:2:cafe::1337"), + }, + }, + }, + }, + { + name: "test ANY 'addr.' query, ipv6 response", + // The response to ANY should look the same as the AAAA response + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "20010db800010002cafe000000001337.addr.dc1.consul", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "20010db800010002cafe000000001337.addr.dc1.consul.", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: "20010db800010002cafe000000001337.addr.dc1.consul.", + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: 123, + }, + AAAA: net.ParseIP("2001:db8:1:2:cafe::1337"), + }, + }, + }, + }, + { + name: "test malformed 'addr.' query", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "c000.addr.dc1.consul", // too short + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Rcode: dns.RcodeNameError, // NXDOMAIN + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "c000.addr.dc1.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Ns: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "consul.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 4, + }, + Ns: "ns.consul.", + Serial: uint32(time.Now().Unix()), + Mbox: "hostmaster.consul.", + Refresh: 1, + Expire: 3, + Retry: 2, + Minttl: 4, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runHandleTestCases(t, tc) + }) + } +} diff --git a/agent/dns/router_ns_test.go b/agent/dns/router_ns_test.go new file mode 100644 index 0000000000..0011ac8626 --- /dev/null +++ b/agent/dns/router_ns_test.go @@ -0,0 +1,244 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent/config" + "github.com/hashicorp/consul/agent/discovery" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/internal/resource" +) + +func Test_HandleRequest_NS(t *testing.T) { + testCases := []HandleTestCase{ + { + name: "vanilla NS query", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "consul.", + Qtype: dns.TypeNS, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). + Return([]*discovery.Result{ + { + Node: &discovery.Location{Name: "server-one", Address: "1.2.3.4"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + }, + { + Node: &discovery.Location{Name: "server-two", Address: "4.5.6.7"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + }, + }, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + reqType := args.Get(2).(discovery.LookupType) + + require.Equal(t, discovery.LookupTypeService, reqType) + require.Equal(t, structs.ConsulServiceName, req.Name) + require.Equal(t, 3, req.Limit) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "consul.", + Qtype: dns.TypeNS, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.NS{ + Hdr: dns.RR_Header{ + Name: "consul.", + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: 123, + }, + Ns: "server-one.workload.default.ns.default.ap.consul.", + }, + &dns.NS{ + Hdr: dns.RR_Header{ + Name: "consul.", + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: 123, + }, + Ns: "server-two.workload.default.ns.default.ap.consul.", + }, + }, + Extra: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "server-one.workload.default.ns.default.ap.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + &dns.A{ + Hdr: dns.RR_Header{ + Name: "server-two.workload.default.ns.default.ap.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("4.5.6.7"), + }, + }, + }, + }, + { + name: "NS query against alternate domain", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "testdomain.", + Qtype: dns.TypeNS, + Qclass: dns.ClassINET, + }, + }, + }, + agentConfig: &config.RuntimeConfig{ + DNSDomain: "consul", + DNSAltDomain: "testdomain", + DNSNodeTTL: 123 * time.Second, + DNSSOA: config.RuntimeSOAConfig{ + Refresh: 1, + Retry: 2, + Expire: 3, + Minttl: 4, + }, + DNSUDPAnswerLimit: maxUDPAnswerLimit, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). + Return([]*discovery.Result{ + { + Node: &discovery.Location{Name: "server-one", Address: "1.2.3.4"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + }, + { + Node: &discovery.Location{Name: "server-two", Address: "4.5.6.7"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + }, + }, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + reqType := args.Get(2).(discovery.LookupType) + + require.Equal(t, discovery.LookupTypeService, reqType) + require.Equal(t, structs.ConsulServiceName, req.Name) + require.Equal(t, 3, req.Limit) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "testdomain.", + Qtype: dns.TypeNS, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.NS{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: 123, + }, + Ns: "server-one.workload.default.ns.default.ap.testdomain.", + }, + &dns.NS{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: 123, + }, + Ns: "server-two.workload.default.ns.default.ap.testdomain.", + }, + }, + Extra: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "server-one.workload.default.ns.default.ap.testdomain.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + &dns.A{ + Hdr: dns.RR_Header{ + Name: "server-two.workload.default.ns.default.ap.testdomain.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("4.5.6.7"), + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runHandleTestCases(t, tc) + }) + } +} diff --git a/agent/dns/router_prepared_query_test.go b/agent/dns/router_prepared_query_test.go new file mode 100644 index 0000000000..ff33395a5e --- /dev/null +++ b/agent/dns/router_prepared_query_test.go @@ -0,0 +1,187 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent/config" + "github.com/hashicorp/consul/agent/discovery" +) + +func Test_HandleRequest_PreparedQuery(t *testing.T) { + testCases := []HandleTestCase{ + { + name: "v1 prepared query w/ TTL override, ANY query, returns A record", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "foo.query.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + agentConfig: &config.RuntimeConfig{ + DNSDomain: "consul", + DNSNodeTTL: 123 * time.Second, + DNSSOA: config.RuntimeSOAConfig{ + Refresh: 1, + Retry: 2, + Expire: 3, + Minttl: 4, + }, + DNSUDPAnswerLimit: maxUDPAnswerLimit, + // We shouldn't use this if we have the override defined + DNSServiceTTL: map[string]time.Duration{ + "foo": 1 * time.Second, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchPreparedQuery", mock.Anything, mock.Anything). + Return([]*discovery.Result{ + { + Service: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, + Node: &discovery.Location{Name: "bar", Address: "1.2.3.4"}, + Type: discovery.ResultTypeService, + Tenancy: discovery.ResultTenancy{ + Datacenter: "dc1", + }, + DNS: discovery.DNSConfig{ + TTL: getUint32Ptr(3), + Weight: 1, + }, + }, + }, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + require.Equal(t, "foo", req.Name) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "foo.query.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.query.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 3, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + }, + { + name: "v1 prepared query w/ matching service TTL, ANY query, returns A record", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "foo.query.dc1.cluster.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + agentConfig: &config.RuntimeConfig{ + DNSDomain: "consul", + DNSNodeTTL: 123 * time.Second, + DNSSOA: config.RuntimeSOAConfig{ + Refresh: 1, + Retry: 2, + Expire: 3, + Minttl: 4, + }, + DNSUDPAnswerLimit: maxUDPAnswerLimit, + // Results should use this as the TTL + DNSServiceTTL: map[string]time.Duration{ + "foo": 1 * time.Second, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchPreparedQuery", mock.Anything, mock.Anything). + Return([]*discovery.Result{ + { + Service: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, + Node: &discovery.Location{Name: "bar", Address: "1.2.3.4"}, + Type: discovery.ResultTypeService, + Tenancy: discovery.ResultTenancy{ + Datacenter: "dc1", + }, + DNS: discovery.DNSConfig{ + // Intentionally no TTL here. + Weight: 1, + }, + }, + }, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + require.Equal(t, "foo", req.Name) + require.Equal(t, "dc1", req.Tenancy.Datacenter) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "foo.query.dc1.cluster.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.query.dc1.cluster.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 1, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runHandleTestCases(t, tc) + }) + } +} diff --git a/agent/dns/router_ptr_test.go b/agent/dns/router_ptr_test.go new file mode 100644 index 0000000000..7704e350d1 --- /dev/null +++ b/agent/dns/router_ptr_test.go @@ -0,0 +1,563 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent/discovery" +) + +func Test_HandleRequest_PTR(t *testing.T) { + testCases := []HandleTestCase{ + { + name: "PTR lookup for node, query type is ANY", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, + Service: &discovery.Location{Name: "bar", Address: "foo"}, + Type: discovery.ResultTypeNode, + Tenancy: discovery.ResultTenancy{ + Datacenter: "dc2", + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchRecordsByIp", mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(net.IP) + + require.NotNil(t, req) + require.Equal(t, "1.2.3.4", req.String()) + }) + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa.", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.PTR{ + Hdr: dns.RR_Header{ + Name: "4.3.2.1.in-addr.arpa.", + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + }, + Ptr: "foo.node.dc2.consul.", + }, + }, + }, + }, + { + name: "PTR lookup for IPV6 node", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", + Qtype: dns.TypePTR, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo", Address: "2001:db8::567:89ab"}, + Service: &discovery.Location{Name: "web", Address: "foo"}, + Type: discovery.ResultTypeNode, + Tenancy: discovery.ResultTenancy{ + Datacenter: "dc2", + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchRecordsByIp", mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(net.IP) + + require.NotNil(t, req) + require.Equal(t, "2001:db8::567:89ab", req.String()) + }) + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", + Qtype: dns.TypePTR, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.PTR{ + Hdr: dns.RR_Header{ + Name: "b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + }, + Ptr: "foo.node.dc2.consul.", + }, + }, + }, + }, + { + name: "PTR lookup for invalid IP address", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "257.3.2.1.in-addr.arpa", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeNameError, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "257.3.2.1.in-addr.arpa.", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + Ns: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "consul.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 4, + }, + Ns: "ns.consul.", + Serial: uint32(time.Now().Unix()), + Mbox: "hostmaster.consul.", + Refresh: 1, + Expire: 3, + Retry: 2, + Minttl: 4, + }, + }, + }, + }, + { + name: "PTR lookup for invalid subdomain", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "4.3.2.1.blah.arpa", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeNameError, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "4.3.2.1.blah.arpa.", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + Ns: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "consul.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 4, + }, + Ns: "ns.consul.", + Serial: uint32(time.Now().Unix()), + Mbox: "hostmaster.consul.", + Refresh: 1, + Expire: 3, + Retry: 2, + Minttl: 4, + }, + }, + }, + }, + { + name: "[ENT] PTR Lookup for node w/ peer name in default partition, query type is ANY", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, + Type: discovery.ResultTypeNode, + Service: &discovery.Location{Name: "foo-web", Address: "foo"}, + Tenancy: discovery.ResultTenancy{ + Datacenter: "dc2", + PeerName: "peer1", + Partition: "default", + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchRecordsByIp", mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(net.IP) + + require.NotNil(t, req) + require.Equal(t, "1.2.3.4", req.String()) + }) + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa.", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.PTR{ + Hdr: dns.RR_Header{ + Name: "4.3.2.1.in-addr.arpa.", + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + }, + Ptr: "foo.node.peer1.peer.default.ap.consul.", + }, + }, + }, + }, + { + name: "[ENT] PTR Lookup for service in default namespace, query type is PTR", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa", + Qtype: dns.TypePTR, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, + Type: discovery.ResultTypeService, + Service: &discovery.Location{Name: "foo", Address: "foo"}, + Tenancy: discovery.ResultTenancy{ + Datacenter: "dc2", + Namespace: "default", + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchRecordsByIp", mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(net.IP) + + require.NotNil(t, req) + require.Equal(t, "1.2.3.4", req.String()) + }) + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa.", + Qtype: dns.TypePTR, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.PTR{ + Hdr: dns.RR_Header{ + Name: "4.3.2.1.in-addr.arpa.", + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + }, + Ptr: "foo.service.default.dc2.consul.", + }, + }, + }, + }, + { + name: "[ENT] PTR Lookup for service in a non-default namespace, query type is PTR", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa", + Qtype: dns.TypePTR, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo-node", Address: "1.2.3.4"}, + Type: discovery.ResultTypeService, + Service: &discovery.Location{Name: "foo", Address: "foo"}, + Tenancy: discovery.ResultTenancy{ + Datacenter: "dc2", + Namespace: "bar", + Partition: "baz", + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchRecordsByIp", mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(net.IP) + + require.NotNil(t, req) + require.Equal(t, "1.2.3.4", req.String()) + }) + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa.", + Qtype: dns.TypePTR, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.PTR{ + Hdr: dns.RR_Header{ + Name: "4.3.2.1.in-addr.arpa.", + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + }, + Ptr: "foo.service.bar.dc2.consul.", + }, + }, + }, + }, + { + name: "[CE] PTR Lookup for node w/ peer name, query type is ANY", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, + Type: discovery.ResultTypeNode, + Service: &discovery.Location{Name: "foo", Address: "foo"}, + Tenancy: discovery.ResultTenancy{ + Datacenter: "dc2", + PeerName: "peer1", + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchRecordsByIp", mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(net.IP) + + require.NotNil(t, req) + require.Equal(t, "1.2.3.4", req.String()) + }) + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa.", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.PTR{ + Hdr: dns.RR_Header{ + Name: "4.3.2.1.in-addr.arpa.", + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + }, + Ptr: "foo.node.peer1.peer.consul.", + }, + }, + }, + }, + { + name: "[CE] PTR Lookup for service, query type is PTR", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa", + Qtype: dns.TypePTR, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, + Service: &discovery.Location{Name: "foo", Address: "foo"}, + Type: discovery.ResultTypeService, + Tenancy: discovery.ResultTenancy{ + Datacenter: "dc2", + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchRecordsByIp", mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(net.IP) + + require.NotNil(t, req) + require.Equal(t, "1.2.3.4", req.String()) + }) + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "4.3.2.1.in-addr.arpa.", + Qtype: dns.TypePTR, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.PTR{ + Hdr: dns.RR_Header{ + Name: "4.3.2.1.in-addr.arpa.", + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + }, + Ptr: "foo.service.dc2.consul.", + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runHandleTestCases(t, tc) + }) + } +} diff --git a/agent/dns/router_recursor_test.go b/agent/dns/router_recursor_test.go new file mode 100644 index 0000000000..b5e4c029c0 --- /dev/null +++ b/agent/dns/router_recursor_test.go @@ -0,0 +1,296 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "errors" + "github.com/hashicorp/consul/agent/config" + "github.com/miekg/dns" + "github.com/stretchr/testify/mock" + "net" + "testing" +) + +func Test_HandleRequest_recursor(t *testing.T) { + testCases := []HandleTestCase{ + { + name: "recursors not configured, non-matching domain", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "google.com", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + // configureRecursor: call not expected. + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Rcode: dns.RcodeRefused, + }, + Question: []dns.Question{ + { + Name: "google.com.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + }, + { + name: "recursors configured, matching domain", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "google.com", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + agentConfig: &config.RuntimeConfig{ + DNSRecursors: []string{"8.8.8.8"}, + DNSUDPAnswerLimit: maxUDPAnswerLimit, + }, + configureRecursor: func(recursor dnsRecursor) { + resp := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "google.com.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "google.com.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + } + recursor.(*mockDnsRecursor).On("handle", + mock.Anything, mock.Anything, mock.Anything).Return(resp, nil) + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "google.com.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "google.com.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + }, + { + name: "recursors configured, no matching domain", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "google.com", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + agentConfig: &config.RuntimeConfig{ + DNSRecursors: []string{"8.8.8.8"}, + DNSUDPAnswerLimit: maxUDPAnswerLimit, + }, + configureRecursor: func(recursor dnsRecursor) { + recursor.(*mockDnsRecursor).On("handle", mock.Anything, mock.Anything, mock.Anything). + Return(nil, errRecursionFailed) + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: false, + Rcode: dns.RcodeServerFailure, + RecursionAvailable: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "google.com.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + }, + { + name: "recursors configured, unhandled error calling recursors", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "google.com", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + agentConfig: &config.RuntimeConfig{ + DNSRecursors: []string{"8.8.8.8"}, + DNSUDPAnswerLimit: maxUDPAnswerLimit, + }, + configureRecursor: func(recursor dnsRecursor) { + err := errors.New("ahhhhh!!!!") + recursor.(*mockDnsRecursor).On("handle", mock.Anything, mock.Anything, mock.Anything). + Return(nil, err) + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: false, + Rcode: dns.RcodeServerFailure, + RecursionAvailable: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "google.com.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + }, + { + name: "recursors configured, the root domain is handled by the recursor", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: ".", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + agentConfig: &config.RuntimeConfig{ + DNSRecursors: []string{"8.8.8.8"}, + DNSUDPAnswerLimit: maxUDPAnswerLimit, + }, + configureRecursor: func(recursor dnsRecursor) { + // this response is modeled after `dig .` + resp := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: ".", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: ".", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 86391, + }, + Ns: "a.root-servers.net.", + Serial: 2024012200, + Mbox: "nstld.verisign-grs.com.", + Refresh: 1800, + Retry: 900, + Expire: 604800, + Minttl: 86400, + }, + }, + } + recursor.(*mockDnsRecursor).On("handle", + mock.Anything, mock.Anything, mock.Anything).Return(resp, nil) + }, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: ".", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: ".", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 86391, + }, + Ns: "a.root-servers.net.", + Serial: 2024012200, + Mbox: "nstld.verisign-grs.com.", + Refresh: 1800, + Retry: 900, + Expire: 604800, + Minttl: 86400, + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runHandleTestCases(t, tc) + }) + } +} diff --git a/agent/dns/router_soa_test.go b/agent/dns/router_soa_test.go new file mode 100644 index 0000000000..f578a4abe3 --- /dev/null +++ b/agent/dns/router_soa_test.go @@ -0,0 +1,277 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent/config" + "github.com/hashicorp/consul/agent/discovery" + "github.com/hashicorp/consul/agent/structs" + "github.com/hashicorp/consul/internal/resource" +) + +func Test_HandleRequest_SOA(t *testing.T) { + testCases := []HandleTestCase{ + { + name: "vanilla SOA query", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "consul.", + Qtype: dns.TypeSOA, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). + Return([]*discovery.Result{ + { + Node: &discovery.Location{Name: "server-one", Address: "1.2.3.4"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + }, + { + Node: &discovery.Location{Name: "server-two", Address: "4.5.6.7"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + }, + }, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + reqType := args.Get(2).(discovery.LookupType) + + require.Equal(t, discovery.LookupTypeService, reqType) + require.Equal(t, structs.ConsulServiceName, req.Name) + require.Equal(t, 3, req.Limit) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "consul.", + Qtype: dns.TypeSOA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "consul.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 4, + }, + Ns: "ns.consul.", + Serial: uint32(time.Now().Unix()), + Mbox: "hostmaster.consul.", + Refresh: 1, + Expire: 3, + Retry: 2, + Minttl: 4, + }, + }, + Ns: []dns.RR{ + &dns.NS{ + Hdr: dns.RR_Header{ + Name: "consul.", + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: 123, + }, + Ns: "server-one.workload.default.ns.default.ap.consul.", + }, + &dns.NS{ + Hdr: dns.RR_Header{ + Name: "consul.", + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: 123, + }, + Ns: "server-two.workload.default.ns.default.ap.consul.", + }, + }, + Extra: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "server-one.workload.default.ns.default.ap.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + &dns.A{ + Hdr: dns.RR_Header{ + Name: "server-two.workload.default.ns.default.ap.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("4.5.6.7"), + }, + }, + }, + }, + { + name: "SOA query against alternate domain", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "testdomain.", + Qtype: dns.TypeSOA, + Qclass: dns.ClassINET, + }, + }, + }, + agentConfig: &config.RuntimeConfig{ + DNSDomain: "consul", + DNSAltDomain: "testdomain", + DNSNodeTTL: 123 * time.Second, + DNSSOA: config.RuntimeSOAConfig{ + Refresh: 1, + Retry: 2, + Expire: 3, + Minttl: 4, + }, + DNSUDPAnswerLimit: maxUDPAnswerLimit, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). + Return([]*discovery.Result{ + { + Node: &discovery.Location{Name: "server-one", Address: "1.2.3.4"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + }, + { + Node: &discovery.Location{Name: "server-two", Address: "4.5.6.7"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }}, + }, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + reqType := args.Get(2).(discovery.LookupType) + + require.Equal(t, discovery.LookupTypeService, reqType) + require.Equal(t, structs.ConsulServiceName, req.Name) + require.Equal(t, 3, req.Limit) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "testdomain.", + Qtype: dns.TypeSOA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.SOA{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeSOA, + Class: dns.ClassINET, + Ttl: 4, + }, + Ns: "ns.testdomain.", + Serial: uint32(time.Now().Unix()), + Mbox: "hostmaster.testdomain.", + Refresh: 1, + Expire: 3, + Retry: 2, + Minttl: 4, + }, + }, + Ns: []dns.RR{ + &dns.NS{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: 123, + }, + Ns: "server-one.workload.default.ns.default.ap.testdomain.", + }, + &dns.NS{ + Hdr: dns.RR_Header{ + Name: "testdomain.", + Rrtype: dns.TypeNS, + Class: dns.ClassINET, + Ttl: 123, + }, + Ns: "server-two.workload.default.ns.default.ap.testdomain.", + }, + }, + Extra: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "server-one.workload.default.ns.default.ap.testdomain.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + &dns.A{ + Hdr: dns.RR_Header{ + Name: "server-two.workload.default.ns.default.ap.testdomain.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("4.5.6.7"), + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runHandleTestCases(t, tc) + }) + } +} diff --git a/agent/dns/router_test.go b/agent/dns/router_test.go index 5cc1050b17..fe1a034bf8 100644 --- a/agent/dns/router_test.go +++ b/agent/dns/router_test.go @@ -4,14 +4,14 @@ package dns import ( - "errors" "fmt" - "github.com/armon/go-radix" "net" "reflect" "testing" "time" + "github.com/armon/go-radix" + "github.com/hashicorp/consul/internal/dnsutil" "github.com/miekg/dns" @@ -24,9 +24,21 @@ import ( "github.com/hashicorp/consul/agent/config" "github.com/hashicorp/consul/agent/discovery" "github.com/hashicorp/consul/agent/structs" - "github.com/hashicorp/consul/internal/resource" ) +// 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 @@ -56,3012 +68,29 @@ var testSOA = &dns.SOA{ Minttl: 4, } -func Test_HandleRequest(t *testing.T) { +func Test_HandleRequest_Validation(t *testing.T) { testCases := []HandleTestCase{ - // recursor queries { - name: "recursors not configured, non-matching domain", + name: "request with empty message", request: &dns.Msg{ MsgHdr: dns.MsgHdr{ Opcode: dns.OpcodeQuery, }, - Question: []dns.Question{ - { - Name: "google.com", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - // configureRecursor: call not expected. - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Rcode: dns.RcodeRefused, - }, - Question: []dns.Question{ - { - Name: "google.com.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - }, - { - name: "recursors configured, matching domain", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "google.com", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - agentConfig: &config.RuntimeConfig{ - DNSRecursors: []string{"8.8.8.8"}, - DNSUDPAnswerLimit: maxUDPAnswerLimit, - }, - configureRecursor: func(recursor dnsRecursor) { - resp := &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - Rcode: dns.RcodeSuccess, - }, - Question: []dns.Question{ - { - Name: "google.com.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "google.com.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - } - recursor.(*mockDnsRecursor).On("handle", - mock.Anything, mock.Anything, mock.Anything).Return(resp, nil) + Question: []dns.Question{}, }, + validateAndNormalizeExpected: false, response: &dns.Msg{ MsgHdr: dns.MsgHdr{ Opcode: dns.OpcodeQuery, Response: true, - Authoritative: true, - Rcode: dns.RcodeSuccess, - }, - Question: []dns.Question{ - { - Name: "google.com.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "google.com.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - }, - }, - { - name: "recursors configured, no matching domain", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "google.com", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - agentConfig: &config.RuntimeConfig{ - DNSRecursors: []string{"8.8.8.8"}, - DNSUDPAnswerLimit: maxUDPAnswerLimit, - }, - configureRecursor: func(recursor dnsRecursor) { - recursor.(*mockDnsRecursor).On("handle", mock.Anything, mock.Anything, mock.Anything). - Return(nil, errRecursionFailed) - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: false, - Rcode: dns.RcodeServerFailure, - RecursionAvailable: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "google.com.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - }, - { - name: "recursors configured, unhandled error calling recursors", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "google.com", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - agentConfig: &config.RuntimeConfig{ - DNSRecursors: []string{"8.8.8.8"}, - DNSUDPAnswerLimit: maxUDPAnswerLimit, - }, - configureRecursor: func(recursor dnsRecursor) { - err := errors.New("ahhhhh!!!!") - recursor.(*mockDnsRecursor).On("handle", mock.Anything, mock.Anything, mock.Anything). - Return(nil, err) - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: false, - Rcode: dns.RcodeServerFailure, - RecursionAvailable: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "google.com.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - }, - { - name: "recursors configured, the root domain is handled by the recursor", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: ".", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - agentConfig: &config.RuntimeConfig{ - DNSRecursors: []string{"8.8.8.8"}, - DNSUDPAnswerLimit: maxUDPAnswerLimit, - }, - configureRecursor: func(recursor dnsRecursor) { - // this response is modeled after `dig .` - resp := &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - Rcode: dns.RcodeSuccess, - }, - Question: []dns.Question{ - { - Name: ".", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.SOA{ - Hdr: dns.RR_Header{ - Name: ".", - Rrtype: dns.TypeSOA, - Class: dns.ClassINET, - Ttl: 86391, - }, - Ns: "a.root-servers.net.", - Serial: 2024012200, - Mbox: "nstld.verisign-grs.com.", - Refresh: 1800, - Retry: 900, - Expire: 604800, - Minttl: 86400, - }, - }, - } - recursor.(*mockDnsRecursor).On("handle", - mock.Anything, mock.Anything, mock.Anything).Return(resp, nil) - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - Rcode: dns.RcodeSuccess, - }, - Question: []dns.Question{ - { - Name: ".", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.SOA{ - Hdr: dns.RR_Header{ - Name: ".", - Rrtype: dns.TypeSOA, - Class: dns.ClassINET, - Ttl: 86391, - }, - Ns: "a.root-servers.net.", - Serial: 2024012200, - Mbox: "nstld.verisign-grs.com.", - Refresh: 1800, - Retry: 900, - Expire: 604800, - Minttl: 86400, - }, - }, - }, - }, - // addr queries - { - name: "test A 'addr.' query, ipv4 response", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "c000020a.addr.dc1.consul", // "intentionally missing the trailing dot" - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "c000020a.addr.dc1.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "c000020a.addr.dc1.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("192.0.2.10"), - }, - }, - }, - }, - { - name: "test AAAA 'addr.' query, ipv4 response", - // Since we asked for an AAAA record, the A record that resolves from the address is attached as an extra - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "c000020a.addr.dc1.consul", - Qtype: dns.TypeAAAA, - Qclass: dns.ClassINET, - }, - }, - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "c000020a.addr.dc1.consul.", - Qtype: dns.TypeAAAA, - Qclass: dns.ClassINET, - }, - }, - Extra: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "c000020a.addr.dc1.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("192.0.2.10"), - }, - }, - }, - }, - { - name: "test SRV 'addr.' query, ipv4 response", - // Since we asked for a SRV record, the A record that resolves from the address is attached as an extra - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "c000020a.addr.dc1.consul", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "c000020a.addr.dc1.consul.", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - Extra: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "c000020a.addr.dc1.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("192.0.2.10"), - }, - }, - }, - }, - { - name: "test ANY 'addr.' query, ipv4 response", - // The response to ANY should look the same as the A response - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "c000020a.addr.dc1.consul", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "c000020a.addr.dc1.consul.", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "c000020a.addr.dc1.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("192.0.2.10"), - }, - }, - }, - }, - { - name: "test AAAA 'addr.' query, ipv6 response", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "20010db800010002cafe000000001337.addr.dc1.consul", - Qtype: dns.TypeAAAA, - Qclass: dns.ClassINET, - }, - }, - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "20010db800010002cafe000000001337.addr.dc1.consul.", - Qtype: dns.TypeAAAA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: "20010db800010002cafe000000001337.addr.dc1.consul.", - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - Ttl: 123, - }, - AAAA: net.ParseIP("2001:db8:1:2:cafe::1337"), - }, - }, - }, - }, - { - name: "test A 'addr.' query, ipv6 response", - // Since we asked for an A record, the AAAA record that resolves from the address is attached as an extra - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "20010db800010002cafe000000001337.addr.dc1.consul", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "20010db800010002cafe000000001337.addr.dc1.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Extra: []dns.RR{ - &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: "20010db800010002cafe000000001337.addr.dc1.consul.", - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - Ttl: 123, - }, - AAAA: net.ParseIP("2001:db8:1:2:cafe::1337"), - }, - }, - }, - }, - { - name: "test SRV 'addr.' query, ipv6 response", - // Since we asked for an SRV record, the AAAA record that resolves from the address is attached as an extra - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "20010db800010002cafe000000001337.addr.dc1.consul", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "20010db800010002cafe000000001337.addr.dc1.consul.", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - Extra: []dns.RR{ - &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: "20010db800010002cafe000000001337.addr.dc1.consul.", - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - Ttl: 123, - }, - AAAA: net.ParseIP("2001:db8:1:2:cafe::1337"), - }, - }, - }, - }, - { - name: "test ANY 'addr.' query, ipv6 response", - // The response to ANY should look the same as the AAAA response - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "20010db800010002cafe000000001337.addr.dc1.consul", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "20010db800010002cafe000000001337.addr.dc1.consul.", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: "20010db800010002cafe000000001337.addr.dc1.consul.", - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - Ttl: 123, - }, - AAAA: net.ParseIP("2001:db8:1:2:cafe::1337"), - }, - }, - }, - }, - { - name: "test malformed 'addr.' query", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "c000.addr.dc1.consul", // too short - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Rcode: dns.RcodeNameError, // NXDOMAIN - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "c000.addr.dc1.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Ns: []dns.RR{ - &dns.SOA{ - Hdr: dns.RR_Header{ - Name: "consul.", - Rrtype: dns.TypeSOA, - Class: dns.ClassINET, - Ttl: 4, - }, - Ns: "ns.consul.", - Serial: uint32(time.Now().Unix()), - Mbox: "hostmaster.consul.", - Refresh: 1, - Expire: 3, - Retry: 2, - Minttl: 4, - }, - }, - }, - }, - // virtual ip queries - we will test just the A record, since the - // AAAA and SRV records are handled the same way and the complete - // set of addr tests above cover the rest of the cases. - { - name: "test A 'virtual.' query, ipv4 response", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "c000020a.virtual.dc1.consul", // "intentionally missing the trailing dot" - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - fetcher.(*discovery.MockCatalogDataFetcher).On("FetchVirtualIP", - mock.Anything, mock.Anything).Return(&discovery.Result{ - Node: &discovery.Location{Address: "240.0.0.2"}, - Type: discovery.ResultTypeVirtual, - }, nil) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "c000020a.virtual.dc1.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "c000020a.virtual.dc1.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("240.0.0.2"), - }, - }, - }, - }, - { - name: "test A 'virtual.' query, ipv6 response", - // Since we asked for an A record, the AAAA record that resolves from the address is attached as an extra - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "20010db800010002cafe000000001337.virtual.dc1.consul", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - fetcher.(*discovery.MockCatalogDataFetcher).On("FetchVirtualIP", - mock.Anything, mock.Anything).Return(&discovery.Result{ - Node: &discovery.Location{Address: "2001:db8:1:2:cafe::1337"}, - Type: discovery.ResultTypeVirtual, - }, nil) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "20010db800010002cafe000000001337.virtual.dc1.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Extra: []dns.RR{ - &dns.AAAA{ - Hdr: dns.RR_Header{ - Name: "20010db800010002cafe000000001337.virtual.dc1.consul.", - Rrtype: dns.TypeAAAA, - Class: dns.ClassINET, - Ttl: 123, - }, - AAAA: net.ParseIP("2001:db8:1:2:cafe::1337"), - }, - }, - }, - }, - // SOA Queries - { - name: "vanilla SOA query", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "consul.", - Qtype: dns.TypeSOA, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). - Return([]*discovery.Result{ - { - Node: &discovery.Location{Name: "server-one", Address: "1.2.3.4"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - }, - { - Node: &discovery.Location{Name: "server-two", Address: "4.5.6.7"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - }, - }, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - reqType := args.Get(2).(discovery.LookupType) - - require.Equal(t, discovery.LookupTypeService, reqType) - require.Equal(t, structs.ConsulServiceName, req.Name) - require.Equal(t, 3, req.Limit) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "consul.", - Qtype: dns.TypeSOA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.SOA{ - Hdr: dns.RR_Header{ - Name: "consul.", - Rrtype: dns.TypeSOA, - Class: dns.ClassINET, - Ttl: 4, - }, - Ns: "ns.consul.", - Serial: uint32(time.Now().Unix()), - Mbox: "hostmaster.consul.", - Refresh: 1, - Expire: 3, - Retry: 2, - Minttl: 4, - }, - }, - Ns: []dns.RR{ - &dns.NS{ - Hdr: dns.RR_Header{ - Name: "consul.", - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - Ttl: 123, - }, - Ns: "server-one.workload.default.ns.default.ap.consul.", - }, - &dns.NS{ - Hdr: dns.RR_Header{ - Name: "consul.", - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - Ttl: 123, - }, - Ns: "server-two.workload.default.ns.default.ap.consul.", - }, - }, - Extra: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "server-one.workload.default.ns.default.ap.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("1.2.3.4"), - }, - &dns.A{ - Hdr: dns.RR_Header{ - Name: "server-two.workload.default.ns.default.ap.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("4.5.6.7"), - }, - }, - }, - }, - { - name: "SOA query against alternate domain", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "testdomain.", - Qtype: dns.TypeSOA, - Qclass: dns.ClassINET, - }, - }, - }, - agentConfig: &config.RuntimeConfig{ - DNSDomain: "consul", - DNSAltDomain: "testdomain", - DNSNodeTTL: 123 * time.Second, - DNSSOA: config.RuntimeSOAConfig{ - Refresh: 1, - Retry: 2, - Expire: 3, - Minttl: 4, - }, - DNSUDPAnswerLimit: maxUDPAnswerLimit, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). - Return([]*discovery.Result{ - { - Node: &discovery.Location{Name: "server-one", Address: "1.2.3.4"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - }, - { - Node: &discovery.Location{Name: "server-two", Address: "4.5.6.7"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }}, - }, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - reqType := args.Get(2).(discovery.LookupType) - - require.Equal(t, discovery.LookupTypeService, reqType) - require.Equal(t, structs.ConsulServiceName, req.Name) - require.Equal(t, 3, req.Limit) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "testdomain.", - Qtype: dns.TypeSOA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.SOA{ - Hdr: dns.RR_Header{ - Name: "testdomain.", - Rrtype: dns.TypeSOA, - Class: dns.ClassINET, - Ttl: 4, - }, - Ns: "ns.testdomain.", - Serial: uint32(time.Now().Unix()), - Mbox: "hostmaster.testdomain.", - Refresh: 1, - Expire: 3, - Retry: 2, - Minttl: 4, - }, - }, - Ns: []dns.RR{ - &dns.NS{ - Hdr: dns.RR_Header{ - Name: "testdomain.", - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - Ttl: 123, - }, - Ns: "server-one.workload.default.ns.default.ap.testdomain.", - }, - &dns.NS{ - Hdr: dns.RR_Header{ - Name: "testdomain.", - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - Ttl: 123, - }, - Ns: "server-two.workload.default.ns.default.ap.testdomain.", - }, - }, - Extra: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "server-one.workload.default.ns.default.ap.testdomain.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("1.2.3.4"), - }, - &dns.A{ - Hdr: dns.RR_Header{ - Name: "server-two.workload.default.ns.default.ap.testdomain.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("4.5.6.7"), - }, - }, - }, - }, - // NS Queries - { - name: "vanilla NS query", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "consul.", - Qtype: dns.TypeNS, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). - Return([]*discovery.Result{ - { - Node: &discovery.Location{Name: "server-one", Address: "1.2.3.4"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - }, - { - Node: &discovery.Location{Name: "server-two", Address: "4.5.6.7"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - }, - }, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - reqType := args.Get(2).(discovery.LookupType) - - require.Equal(t, discovery.LookupTypeService, reqType) - require.Equal(t, structs.ConsulServiceName, req.Name) - require.Equal(t, 3, req.Limit) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "consul.", - Qtype: dns.TypeNS, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.NS{ - Hdr: dns.RR_Header{ - Name: "consul.", - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - Ttl: 123, - }, - Ns: "server-one.workload.default.ns.default.ap.consul.", - }, - &dns.NS{ - Hdr: dns.RR_Header{ - Name: "consul.", - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - Ttl: 123, - }, - Ns: "server-two.workload.default.ns.default.ap.consul.", - }, - }, - Extra: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "server-one.workload.default.ns.default.ap.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("1.2.3.4"), - }, - &dns.A{ - Hdr: dns.RR_Header{ - Name: "server-two.workload.default.ns.default.ap.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("4.5.6.7"), - }, - }, - }, - }, - { - name: "NS query against alternate domain", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "testdomain.", - Qtype: dns.TypeNS, - Qclass: dns.ClassINET, - }, - }, - }, - agentConfig: &config.RuntimeConfig{ - DNSDomain: "consul", - DNSAltDomain: "testdomain", - DNSNodeTTL: 123 * time.Second, - DNSSOA: config.RuntimeSOAConfig{ - Refresh: 1, - Retry: 2, - Expire: 3, - Minttl: 4, - }, - DNSUDPAnswerLimit: maxUDPAnswerLimit, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). - Return([]*discovery.Result{ - { - Node: &discovery.Location{Name: "server-one", Address: "1.2.3.4"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - }, - { - Node: &discovery.Location{Name: "server-two", Address: "4.5.6.7"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - }, - }, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - reqType := args.Get(2).(discovery.LookupType) - - require.Equal(t, discovery.LookupTypeService, reqType) - require.Equal(t, structs.ConsulServiceName, req.Name) - require.Equal(t, 3, req.Limit) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "testdomain.", - Qtype: dns.TypeNS, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.NS{ - Hdr: dns.RR_Header{ - Name: "testdomain.", - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - Ttl: 123, - }, - Ns: "server-one.workload.default.ns.default.ap.testdomain.", - }, - &dns.NS{ - Hdr: dns.RR_Header{ - Name: "testdomain.", - Rrtype: dns.TypeNS, - Class: dns.ClassINET, - Ttl: 123, - }, - Ns: "server-two.workload.default.ns.default.ap.testdomain.", - }, - }, - Extra: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "server-one.workload.default.ns.default.ap.testdomain.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("1.2.3.4"), - }, - &dns.A{ - Hdr: dns.RR_Header{ - Name: "server-two.workload.default.ns.default.ap.testdomain.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("4.5.6.7"), - }, - }, - }, - }, - // PTR Lookups - { - name: "PTR lookup for node, query type is ANY", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, - Service: &discovery.Location{Name: "bar", Address: "foo"}, - Type: discovery.ResultTypeNode, - Tenancy: discovery.ResultTenancy{ - Datacenter: "dc2", - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchRecordsByIp", mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(net.IP) - - require.NotNil(t, req) - require.Equal(t, "1.2.3.4", req.String()) - }) - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa.", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.PTR{ - Hdr: dns.RR_Header{ - Name: "4.3.2.1.in-addr.arpa.", - Rrtype: dns.TypePTR, - Class: dns.ClassINET, - }, - Ptr: "foo.node.dc2.consul.", - }, - }, - }, - }, - { - name: "PTR lookup for IPV6 node", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa", - Qtype: dns.TypePTR, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo", Address: "2001:db8::567:89ab"}, - Service: &discovery.Location{Name: "web", Address: "foo"}, - Type: discovery.ResultTypeNode, - Tenancy: discovery.ResultTenancy{ - Datacenter: "dc2", - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchRecordsByIp", mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(net.IP) - - require.NotNil(t, req) - require.Equal(t, "2001:db8::567:89ab", req.String()) - }) - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", - Qtype: dns.TypePTR, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.PTR{ - Hdr: dns.RR_Header{ - Name: "b.a.9.8.7.6.5.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.", - Rrtype: dns.TypePTR, - Class: dns.ClassINET, - }, - Ptr: "foo.node.dc2.consul.", - }, - }, - }, - }, - { - name: "PTR lookup for invalid IP address", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "257.3.2.1.in-addr.arpa", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - Rcode: dns.RcodeNameError, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "257.3.2.1.in-addr.arpa.", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - Ns: []dns.RR{ - &dns.SOA{ - Hdr: dns.RR_Header{ - Name: "consul.", - Rrtype: dns.TypeSOA, - Class: dns.ClassINET, - Ttl: 4, - }, - Ns: "ns.consul.", - Serial: uint32(time.Now().Unix()), - Mbox: "hostmaster.consul.", - Refresh: 1, - Expire: 3, - Retry: 2, - Minttl: 4, - }, - }, - }, - }, - { - name: "PTR lookup for invalid subdomain", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "4.3.2.1.blah.arpa", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - Rcode: dns.RcodeNameError, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "4.3.2.1.blah.arpa.", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - Ns: []dns.RR{ - &dns.SOA{ - Hdr: dns.RR_Header{ - Name: "consul.", - Rrtype: dns.TypeSOA, - Class: dns.ClassINET, - Ttl: 4, - }, - Ns: "ns.consul.", - Serial: uint32(time.Now().Unix()), - Mbox: "hostmaster.consul.", - Refresh: 1, - Expire: 3, - Retry: 2, - Minttl: 4, - }, - }, - }, - }, - { - name: "[ENT] PTR Lookup for node w/ peer name in default partition, query type is ANY", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, - Type: discovery.ResultTypeNode, - Service: &discovery.Location{Name: "foo-web", Address: "foo"}, - Tenancy: discovery.ResultTenancy{ - Datacenter: "dc2", - PeerName: "peer1", - Partition: "default", - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchRecordsByIp", mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(net.IP) - - require.NotNil(t, req) - require.Equal(t, "1.2.3.4", req.String()) - }) - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa.", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.PTR{ - Hdr: dns.RR_Header{ - Name: "4.3.2.1.in-addr.arpa.", - Rrtype: dns.TypePTR, - Class: dns.ClassINET, - }, - Ptr: "foo.node.peer1.peer.default.ap.consul.", - }, - }, - }, - }, - { - name: "[ENT] PTR Lookup for service in default namespace, query type is PTR", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa", - Qtype: dns.TypePTR, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, - Type: discovery.ResultTypeService, - Service: &discovery.Location{Name: "foo", Address: "foo"}, - Tenancy: discovery.ResultTenancy{ - Datacenter: "dc2", - Namespace: "default", - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchRecordsByIp", mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(net.IP) - - require.NotNil(t, req) - require.Equal(t, "1.2.3.4", req.String()) - }) - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa.", - Qtype: dns.TypePTR, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.PTR{ - Hdr: dns.RR_Header{ - Name: "4.3.2.1.in-addr.arpa.", - Rrtype: dns.TypePTR, - Class: dns.ClassINET, - }, - Ptr: "foo.service.default.dc2.consul.", - }, - }, - }, - }, - { - name: "[ENT] PTR Lookup for service in a non-default namespace, query type is PTR", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa", - Qtype: dns.TypePTR, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo-node", Address: "1.2.3.4"}, - Type: discovery.ResultTypeService, - Service: &discovery.Location{Name: "foo", Address: "foo"}, - Tenancy: discovery.ResultTenancy{ - Datacenter: "dc2", - Namespace: "bar", - Partition: "baz", - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchRecordsByIp", mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(net.IP) - - require.NotNil(t, req) - require.Equal(t, "1.2.3.4", req.String()) - }) - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa.", - Qtype: dns.TypePTR, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.PTR{ - Hdr: dns.RR_Header{ - Name: "4.3.2.1.in-addr.arpa.", - Rrtype: dns.TypePTR, - Class: dns.ClassINET, - }, - Ptr: "foo.service.bar.dc2.consul.", - }, - }, - }, - }, - { - name: "[CE] PTR Lookup for node w/ peer name, query type is ANY", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, - Type: discovery.ResultTypeNode, - Service: &discovery.Location{Name: "foo", Address: "foo"}, - Tenancy: discovery.ResultTenancy{ - Datacenter: "dc2", - PeerName: "peer1", - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchRecordsByIp", mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(net.IP) - - require.NotNil(t, req) - require.Equal(t, "1.2.3.4", req.String()) - }) - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa.", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.PTR{ - Hdr: dns.RR_Header{ - Name: "4.3.2.1.in-addr.arpa.", - Rrtype: dns.TypePTR, - Class: dns.ClassINET, - }, - Ptr: "foo.node.peer1.peer.consul.", - }, - }, - }, - }, - { - name: "[CE] PTR Lookup for service, query type is PTR", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa", - Qtype: dns.TypePTR, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, - Service: &discovery.Location{Name: "foo", Address: "foo"}, - Type: discovery.ResultTypeService, - Tenancy: discovery.ResultTenancy{ - Datacenter: "dc2", - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchRecordsByIp", mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(net.IP) - - require.NotNil(t, req) - require.Equal(t, "1.2.3.4", req.String()) - }) - }, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "4.3.2.1.in-addr.arpa.", - Qtype: dns.TypePTR, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.PTR{ - Hdr: dns.RR_Header{ - Name: "4.3.2.1.in-addr.arpa.", - Rrtype: dns.TypePTR, - Class: dns.ClassINET, - }, - Ptr: "foo.service.dc2.consul.", - }, - }, - }, - }, - // V2 Workload Lookup - { - name: "workload A query w/ port, returns A record", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "api.port.foo.workload.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - result := &discovery.Result{ - Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{}, - Ports: []discovery.Port{ - { - Name: "api", - Number: 5678, - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchWorkload", mock.Anything, mock.Anything). - Return(result, nil). //TODO - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - - require.Equal(t, "foo", req.Name) - require.Equal(t, "api", req.PortName) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "api.port.foo.workload.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "api.port.foo.workload.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - }, - }, - { - name: "workload ANY query w/o port, returns A record", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "foo.workload.consul.", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - result := &discovery.Result{ - Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{}, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchWorkload", mock.Anything, mock.Anything). - Return(result, nil). //TODO - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - - require.Equal(t, "foo", req.Name) - require.Empty(t, req.PortName) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "foo.workload.consul.", - Qtype: dns.TypeANY, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo.workload.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - }, - }, - { - name: "workload A query with namespace, partition, and cluster id; IPV4 address; returns A record", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "foo.workload.bar.ns.baz.ap.dc3.dc.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - result := &discovery.Result{ - Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: "bar", - Partition: "baz", - // We currently don't set the datacenter in any of the V2 results. - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchWorkload", mock.Anything, mock.Anything). - Return(result, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - - require.Equal(t, "foo", req.Name) - require.Empty(t, req.PortName) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "foo.workload.bar.ns.baz.ap.dc3.dc.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo.workload.bar.ns.baz.ap.dc3.dc.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - }, - }, - { - name: "workload w/hostname address, ANY query (no recursor)", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "api.port.foo.workload.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - result := &discovery.Result{ - Node: &discovery.Location{Name: "foo", Address: "foo.example.com"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{}, - Ports: []discovery.Port{ - { - Name: "api", - Number: 5678, - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchWorkload", mock.Anything, mock.Anything). - Return(result, nil). //TODO - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - - require.Equal(t, "foo", req.Name) - require.Equal(t, "api", req.PortName) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "api.port.foo.workload.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.CNAME{ - Hdr: dns.RR_Header{ - Name: "api.port.foo.workload.consul.", - Rrtype: dns.TypeCNAME, - Class: dns.ClassINET, - Ttl: 123, - }, - Target: "foo.example.com.", - }, - }, - }, - }, - { - name: "workload w/hostname address, ANY query (w/ recursor)", - // https://datatracker.ietf.org/doc/html/rfc1034#section-3.6.2 both the CNAME and the A record should be in the answer - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "api.port.foo.workload.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - result := &discovery.Result{ - Node: &discovery.Location{Name: "foo", Address: "foo.example.com"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{}, - Ports: []discovery.Port{ - { - Name: "api", - Number: 5678, - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchWorkload", mock.Anything, mock.Anything). - Return(result, nil). //TODO - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - - require.Equal(t, "foo", req.Name) - require.Equal(t, "api", req.PortName) - }) - }, - agentConfig: &config.RuntimeConfig{ - DNSRecursors: []string{"8.8.8.8"}, - DNSDomain: "consul", - DNSNodeTTL: 123 * time.Second, - DNSSOA: config.RuntimeSOAConfig{ - Refresh: 1, - Retry: 2, - Expire: 3, - Minttl: 4, - }, - DNSUDPAnswerLimit: maxUDPAnswerLimit, - }, - configureRecursor: func(recursor dnsRecursor) { - resp := &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - Rcode: dns.RcodeSuccess, - }, - Question: []dns.Question{ - { - Name: "foo.example.com.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo.example.com.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - } - recursor.(*mockDnsRecursor).On("handle", - mock.Anything, mock.Anything, mock.Anything).Return(resp, nil) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - RecursionAvailable: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "api.port.foo.workload.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.CNAME{ - Hdr: dns.RR_Header{ - Name: "api.port.foo.workload.consul.", - Rrtype: dns.TypeCNAME, - Class: dns.ClassINET, - Ttl: 123, - }, - Target: "foo.example.com.", - }, - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo.example.com.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - }, - }, - { - name: "workload w/hostname address, CNAME query (w/ recursor)", - // https://datatracker.ietf.org/doc/html/rfc1034#section-3.6.2 only the CNAME should be in the answer - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "api.port.foo.workload.consul.", - Qtype: dns.TypeCNAME, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - result := &discovery.Result{ - Node: &discovery.Location{Name: "foo", Address: "foo.example.com"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{}, - Ports: []discovery.Port{ - { - Name: "api", - Number: 5678, - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchWorkload", mock.Anything, mock.Anything). - Return(result, nil). //TODO - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - - require.Equal(t, "foo", req.Name) - require.Equal(t, "api", req.PortName) - }) - }, - agentConfig: &config.RuntimeConfig{ - DNSRecursors: []string{"8.8.8.8"}, - DNSDomain: "consul", - DNSNodeTTL: 123 * time.Second, - DNSSOA: config.RuntimeSOAConfig{ - Refresh: 1, - Retry: 2, - Expire: 3, - Minttl: 4, - }, - DNSUDPAnswerLimit: maxUDPAnswerLimit, - }, - configureRecursor: func(recursor dnsRecursor) { - resp := &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - Rcode: dns.RcodeSuccess, - }, - Question: []dns.Question{ - { - Name: "foo.example.com.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo.example.com.", - Rrtype: dns.TypeCNAME, - Class: dns.ClassINET, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - } - recursor.(*mockDnsRecursor).On("handle", - mock.Anything, mock.Anything, mock.Anything).Return(resp, nil) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - RecursionAvailable: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "api.port.foo.workload.consul.", - Qtype: dns.TypeCNAME, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.CNAME{ - Hdr: dns.RR_Header{ - Name: "api.port.foo.workload.consul.", - Rrtype: dns.TypeCNAME, - Class: dns.ClassINET, - Ttl: 123, - }, - Target: "foo.example.com.", - }, - // TODO (v2-dns): this next record is wrong per the RFC-1034 mentioned in the comment above (NET-8060) - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo.example.com.", - Rrtype: dns.TypeCNAME, - Class: dns.ClassINET, - Ttl: 123, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - }, - }, - // V2 Services - { - name: "A/AAAA Query a service and return multiple A records", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "foo.service.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo-1", Address: "10.0.0.1"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - Ports: []discovery.Port{ - { - Name: "api", - Number: 5678, - }, - // Intentionally not in the mesh - }, - DNS: discovery.DNSConfig{ - Weight: 2, - }, - }, - { - Node: &discovery.Location{Name: "foo-2", Address: "10.0.0.2"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - Ports: []discovery.Port{ - { - Name: "api", - Number: 5678, - }, - { - Name: "mesh", - Number: 21000, - }, - }, - DNS: discovery.DNSConfig{ - Weight: 3, - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - reqType := args.Get(2).(discovery.LookupType) - - require.Equal(t, "foo", req.Name) - require.Empty(t, req.PortName) - require.Equal(t, discovery.LookupTypeService, reqType) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - 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: uint32(123), - }, - A: net.ParseIP("10.0.0.1"), - }, - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo.service.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - A: net.ParseIP("10.0.0.2"), - }, - }, - }, - }, - { - name: "SRV Query with a multi-port service return multiple SRV records", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "foo.service.consul.", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo-1", Address: "10.0.0.1"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - Ports: []discovery.Port{ - { - Name: "api", - Number: 5678, - }, - // Intentionally not in the mesh - }, - DNS: discovery.DNSConfig{ - Weight: 2, - }, - }, - { - Node: &discovery.Location{Name: "foo-2", Address: "10.0.0.2"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - Ports: []discovery.Port{ - { - Name: "api", - Number: 5678, - }, - { - Name: "mesh", - Number: 21000, - }, - }, - DNS: discovery.DNSConfig{ - Weight: 3, - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - reqType := args.Get(2).(discovery.LookupType) - - require.Equal(t, "foo", req.Name) - require.Empty(t, req.PortName) - require.Equal(t, discovery.LookupTypeService, reqType) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "foo.service.consul.", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.SRV{ - Hdr: dns.RR_Header{ - Name: "foo.service.consul.", - Rrtype: dns.TypeSRV, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - Weight: 2, - Priority: 1, - Port: 5678, - Target: "api.port.foo-1.workload.default.ns.default.ap.consul.", - }, - &dns.SRV{ - Hdr: dns.RR_Header{ - Name: "foo.service.consul.", - Rrtype: dns.TypeSRV, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - Weight: 3, - Priority: 1, - Port: 5678, - Target: "api.port.foo-2.workload.default.ns.default.ap.consul.", - }, - &dns.SRV{ - Hdr: dns.RR_Header{ - Name: "foo.service.consul.", - Rrtype: dns.TypeSRV, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - Weight: 3, - Priority: 1, - Port: 21000, - Target: "mesh.port.foo-2.workload.default.ns.default.ap.consul.", - }, - }, - Extra: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "api.port.foo-1.workload.default.ns.default.ap.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - A: net.ParseIP("10.0.0.1"), - }, - &dns.A{ - Hdr: dns.RR_Header{ - Name: "api.port.foo-2.workload.default.ns.default.ap.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - A: net.ParseIP("10.0.0.2"), - }, - &dns.A{ - Hdr: dns.RR_Header{ - Name: "mesh.port.foo-2.workload.default.ns.default.ap.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - A: net.ParseIP("10.0.0.2"), - }, - }, - }, - }, - { - name: "SRV Query with a multi-port service where the client requests a specific port, returns SRV and A records", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "mesh.port.foo.service.consul.", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo-2", Address: "10.0.0.2"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - Ports: []discovery.Port{ - { - Name: "mesh", - Number: 21000, - }, - }, - DNS: discovery.DNSConfig{ - Weight: 3, - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - reqType := args.Get(2).(discovery.LookupType) - - require.Equal(t, "foo", req.Name) - require.Equal(t, "mesh", req.PortName) - require.Equal(t, discovery.LookupTypeService, reqType) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "mesh.port.foo.service.consul.", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.SRV{ - Hdr: dns.RR_Header{ - Name: "mesh.port.foo.service.consul.", - Rrtype: dns.TypeSRV, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - Weight: 3, - Priority: 1, - Port: 21000, - Target: "mesh.port.foo-2.workload.default.ns.default.ap.consul.", - }, - }, - Extra: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "mesh.port.foo-2.workload.default.ns.default.ap.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - A: net.ParseIP("10.0.0.2"), - }, - }, - }, - }, - { - name: "SRV Query with a multi-port service that has workloads w/ hostnames (no recursors)", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "foo.service.consul.", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo-1", Address: "foo-1.example.com"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - Ports: []discovery.Port{ - { - Name: "api", - Number: 5678, - }, - { - Name: "web", - Number: 8080, - }, - }, - DNS: discovery.DNSConfig{ - Weight: 2, - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - reqType := args.Get(2).(discovery.LookupType) - - require.Equal(t, "foo", req.Name) - require.Empty(t, req.PortName) - require.Equal(t, discovery.LookupTypeService, reqType) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "foo.service.consul.", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.SRV{ - Hdr: dns.RR_Header{ - Name: "foo.service.consul.", - Rrtype: dns.TypeSRV, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - Weight: 2, - Priority: 1, - Port: 5678, - Target: "foo-1.example.com.", - }, - &dns.SRV{ - Hdr: dns.RR_Header{ - Name: "foo.service.consul.", - Rrtype: dns.TypeSRV, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - Weight: 2, - Priority: 1, - Port: 8080, - Target: "foo-1.example.com.", - }, - }, - }, - }, - { - name: "SRV Query with a multi-port service that has workloads w/ hostnames (no recursor)", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "foo.service.consul.", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - results := []*discovery.Result{ - { - Node: &discovery.Location{Name: "foo-1", Address: "foo-1.example.com"}, - Type: discovery.ResultTypeWorkload, - Tenancy: discovery.ResultTenancy{ - Namespace: resource.DefaultNamespaceName, - Partition: resource.DefaultPartitionName, - }, - Ports: []discovery.Port{ - { - Name: "api", - Number: 5678, - }, - { - Name: "web", - Number: 8080, - }, - }, - DNS: discovery.DNSConfig{ - Weight: 2, - }, - }, - } - - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). - Return(results, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - reqType := args.Get(2).(discovery.LookupType) - - require.Equal(t, "foo", req.Name) - require.Empty(t, req.PortName) - require.Equal(t, discovery.LookupTypeService, reqType) - }) - }, - agentConfig: &config.RuntimeConfig{ - DNSRecursors: []string{"8.8.8.8"}, - DNSDomain: "consul", - DNSNodeTTL: 123 * time.Second, - DNSSOA: config.RuntimeSOAConfig{ - Refresh: 1, - Retry: 2, - Expire: 3, - Minttl: 4, - }, - DNSUDPAnswerLimit: maxUDPAnswerLimit, - }, - configureRecursor: func(recursor dnsRecursor) { - resp := &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - Rcode: dns.RcodeSuccess, - }, - Question: []dns.Question{ - { - Name: "foo-1.example.com.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo-1.example.com.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - } - recursor.(*mockDnsRecursor).On("handle", - mock.Anything, mock.Anything, mock.Anything).Return(resp, nil) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - RecursionAvailable: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "foo.service.consul.", - Qtype: dns.TypeSRV, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.SRV{ - Hdr: dns.RR_Header{ - Name: "foo.service.consul.", - Rrtype: dns.TypeSRV, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - Weight: 2, - Priority: 1, - Port: 5678, - Target: "foo-1.example.com.", - }, - &dns.SRV{ - Hdr: dns.RR_Header{ - Name: "foo.service.consul.", - Rrtype: dns.TypeSRV, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - Weight: 2, - Priority: 1, - Port: 8080, - Target: "foo-1.example.com.", - }, - }, - Extra: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo-1.example.com.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - A: net.ParseIP("1.2.3.4"), - }, - // TODO (v2-dns): This needs to be de-duplicated (NET-8064) - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo-1.example.com.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: uint32(123), - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - }, - }, - // V1 Prepared Queries - { - name: "v1 prepared query w/ TTL override, ANY query, returns A record", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "foo.query.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - agentConfig: &config.RuntimeConfig{ - DNSDomain: "consul", - DNSNodeTTL: 123 * time.Second, - DNSSOA: config.RuntimeSOAConfig{ - Refresh: 1, - Retry: 2, - Expire: 3, - Minttl: 4, - }, - DNSUDPAnswerLimit: maxUDPAnswerLimit, - // We shouldn't use this if we have the override defined - DNSServiceTTL: map[string]time.Duration{ - "foo": 1 * time.Second, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchPreparedQuery", mock.Anything, mock.Anything). - Return([]*discovery.Result{ - { - Service: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, - Node: &discovery.Location{Name: "bar", Address: "1.2.3.4"}, - Type: discovery.ResultTypeService, - Tenancy: discovery.ResultTenancy{ - Datacenter: "dc1", - }, - DNS: discovery.DNSConfig{ - TTL: getUint32Ptr(3), - Weight: 1, - }, - }, - }, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - require.Equal(t, "foo", req.Name) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "foo.query.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo.query.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 3, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, - }, - }, - { - name: "v1 prepared query w/ matching service TTL, ANY query, returns A record", - request: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - }, - Question: []dns.Question{ - { - Name: "foo.query.dc1.cluster.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - }, - agentConfig: &config.RuntimeConfig{ - DNSDomain: "consul", - DNSNodeTTL: 123 * time.Second, - DNSSOA: config.RuntimeSOAConfig{ - Refresh: 1, - Retry: 2, - Expire: 3, - Minttl: 4, - }, - DNSUDPAnswerLimit: maxUDPAnswerLimit, - // Results should use this as the TTL - DNSServiceTTL: map[string]time.Duration{ - "foo": 1 * time.Second, - }, - }, - configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { - fetcher.(*discovery.MockCatalogDataFetcher). - On("FetchPreparedQuery", mock.Anything, mock.Anything). - Return([]*discovery.Result{ - { - Service: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, - Node: &discovery.Location{Name: "bar", Address: "1.2.3.4"}, - Type: discovery.ResultTypeService, - Tenancy: discovery.ResultTenancy{ - Datacenter: "dc1", - }, - DNS: discovery.DNSConfig{ - // Intentionally no TTL here. - Weight: 1, - }, - }, - }, nil). - Run(func(args mock.Arguments) { - req := args.Get(1).(*discovery.QueryPayload) - require.Equal(t, "foo", req.Name) - require.Equal(t, "dc1", req.Tenancy.Datacenter) - }) - }, - validateAndNormalizeExpected: true, - response: &dns.Msg{ - MsgHdr: dns.MsgHdr{ - Opcode: dns.OpcodeQuery, - Response: true, - Authoritative: true, - }, - Compress: true, - Question: []dns.Question{ - { - Name: "foo.query.dc1.cluster.consul.", - Qtype: dns.TypeA, - Qclass: dns.ClassINET, - }, - }, - Answer: []dns.RR{ - &dns.A{ - Hdr: dns.RR_Header{ - Name: "foo.query.dc1.cluster.consul.", - Rrtype: dns.TypeA, - Class: dns.ClassINET, - Ttl: 1, - }, - A: net.ParseIP("1.2.3.4"), - }, - }, + Authoritative: false, + Rcode: dns.RcodeRefused, + }, + Compress: false, + Question: nil, + Answer: nil, + Ns: nil, + Extra: nil, }, }, } @@ -3073,6 +102,7 @@ func Test_HandleRequest(t *testing.T) { } } +// runHandleTestCases runs the test cases for the HandleRequest function. func runHandleTestCases(t *testing.T, tc HandleTestCase) { cdf := discovery.NewMockCatalogDataFetcher(t) if tc.validateAndNormalizeExpected { @@ -3532,3 +562,146 @@ func TestRouter_ReloadConfig(t *testing.T) { // 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) + }) + } +} diff --git a/agent/dns/router_v2_services_test.go b/agent/dns/router_v2_services_test.go new file mode 100644 index 0000000000..45cb98b615 --- /dev/null +++ b/agent/dns/router_v2_services_test.go @@ -0,0 +1,628 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent/config" + "github.com/hashicorp/consul/agent/discovery" + "github.com/hashicorp/consul/internal/resource" +) + +func Test_HandleRequest_V2Services(t *testing.T) { + testCases := []HandleTestCase{ + { + name: "A/AAAA Query a service and return multiple A records", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "foo.service.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo-1", Address: "10.0.0.1"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + Ports: []discovery.Port{ + { + Name: "api", + Number: 5678, + }, + // Intentionally not in the mesh + }, + DNS: discovery.DNSConfig{ + Weight: 2, + }, + }, + { + Node: &discovery.Location{Name: "foo-2", Address: "10.0.0.2"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + Ports: []discovery.Port{ + { + Name: "api", + Number: 5678, + }, + { + Name: "mesh", + Number: 21000, + }, + }, + DNS: discovery.DNSConfig{ + Weight: 3, + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + reqType := args.Get(2).(discovery.LookupType) + + require.Equal(t, "foo", req.Name) + require.Empty(t, req.PortName) + require.Equal(t, discovery.LookupTypeService, reqType) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + 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: uint32(123), + }, + A: net.ParseIP("10.0.0.1"), + }, + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.service.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + A: net.ParseIP("10.0.0.2"), + }, + }, + }, + }, + { + name: "SRV Query with a multi-port service return multiple SRV records", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "foo.service.consul.", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo-1", Address: "10.0.0.1"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + Ports: []discovery.Port{ + { + Name: "api", + Number: 5678, + }, + // Intentionally not in the mesh + }, + DNS: discovery.DNSConfig{ + Weight: 2, + }, + }, + { + Node: &discovery.Location{Name: "foo-2", Address: "10.0.0.2"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + Ports: []discovery.Port{ + { + Name: "api", + Number: 5678, + }, + { + Name: "mesh", + Number: 21000, + }, + }, + DNS: discovery.DNSConfig{ + Weight: 3, + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + reqType := args.Get(2).(discovery.LookupType) + + require.Equal(t, "foo", req.Name) + require.Empty(t, req.PortName) + require.Equal(t, discovery.LookupTypeService, reqType) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "foo.service.consul.", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.SRV{ + Hdr: dns.RR_Header{ + Name: "foo.service.consul.", + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + Weight: 2, + Priority: 1, + Port: 5678, + Target: "api.port.foo-1.workload.default.ns.default.ap.consul.", + }, + &dns.SRV{ + Hdr: dns.RR_Header{ + Name: "foo.service.consul.", + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + Weight: 3, + Priority: 1, + Port: 5678, + Target: "api.port.foo-2.workload.default.ns.default.ap.consul.", + }, + &dns.SRV{ + Hdr: dns.RR_Header{ + Name: "foo.service.consul.", + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + Weight: 3, + Priority: 1, + Port: 21000, + Target: "mesh.port.foo-2.workload.default.ns.default.ap.consul.", + }, + }, + Extra: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "api.port.foo-1.workload.default.ns.default.ap.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + A: net.ParseIP("10.0.0.1"), + }, + &dns.A{ + Hdr: dns.RR_Header{ + Name: "api.port.foo-2.workload.default.ns.default.ap.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + A: net.ParseIP("10.0.0.2"), + }, + &dns.A{ + Hdr: dns.RR_Header{ + Name: "mesh.port.foo-2.workload.default.ns.default.ap.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + A: net.ParseIP("10.0.0.2"), + }, + }, + }, + }, + { + name: "SRV Query with a multi-port service where the client requests a specific port, returns SRV and A records", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "mesh.port.foo.service.consul.", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo-2", Address: "10.0.0.2"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + Ports: []discovery.Port{ + { + Name: "mesh", + Number: 21000, + }, + }, + DNS: discovery.DNSConfig{ + Weight: 3, + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + reqType := args.Get(2).(discovery.LookupType) + + require.Equal(t, "foo", req.Name) + require.Equal(t, "mesh", req.PortName) + require.Equal(t, discovery.LookupTypeService, reqType) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "mesh.port.foo.service.consul.", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.SRV{ + Hdr: dns.RR_Header{ + Name: "mesh.port.foo.service.consul.", + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + Weight: 3, + Priority: 1, + Port: 21000, + Target: "mesh.port.foo-2.workload.default.ns.default.ap.consul.", + }, + }, + Extra: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "mesh.port.foo-2.workload.default.ns.default.ap.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + A: net.ParseIP("10.0.0.2"), + }, + }, + }, + }, + { + name: "SRV Query with a multi-port service that has workloads w/ hostnames (no recursors)", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "foo.service.consul.", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo-1", Address: "foo-1.example.com"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + Ports: []discovery.Port{ + { + Name: "api", + Number: 5678, + }, + { + Name: "web", + Number: 8080, + }, + }, + DNS: discovery.DNSConfig{ + Weight: 2, + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + reqType := args.Get(2).(discovery.LookupType) + + require.Equal(t, "foo", req.Name) + require.Empty(t, req.PortName) + require.Equal(t, discovery.LookupTypeService, reqType) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "foo.service.consul.", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.SRV{ + Hdr: dns.RR_Header{ + Name: "foo.service.consul.", + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + Weight: 2, + Priority: 1, + Port: 5678, + Target: "foo-1.example.com.", + }, + &dns.SRV{ + Hdr: dns.RR_Header{ + Name: "foo.service.consul.", + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + Weight: 2, + Priority: 1, + Port: 8080, + Target: "foo-1.example.com.", + }, + }, + }, + }, + { + name: "SRV Query with a multi-port service that has workloads w/ hostnames (no recursor)", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "foo.service.consul.", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + results := []*discovery.Result{ + { + Node: &discovery.Location{Name: "foo-1", Address: "foo-1.example.com"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: resource.DefaultNamespaceName, + Partition: resource.DefaultPartitionName, + }, + Ports: []discovery.Port{ + { + Name: "api", + Number: 5678, + }, + { + Name: "web", + Number: 8080, + }, + }, + DNS: discovery.DNSConfig{ + Weight: 2, + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchEndpoints", mock.Anything, mock.Anything, mock.Anything). + Return(results, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + reqType := args.Get(2).(discovery.LookupType) + + require.Equal(t, "foo", req.Name) + require.Empty(t, req.PortName) + require.Equal(t, discovery.LookupTypeService, reqType) + }) + }, + agentConfig: &config.RuntimeConfig{ + DNSRecursors: []string{"8.8.8.8"}, + DNSDomain: "consul", + DNSNodeTTL: 123 * time.Second, + DNSSOA: config.RuntimeSOAConfig{ + Refresh: 1, + Retry: 2, + Expire: 3, + Minttl: 4, + }, + DNSUDPAnswerLimit: maxUDPAnswerLimit, + }, + configureRecursor: func(recursor dnsRecursor) { + resp := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "foo-1.example.com.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo-1.example.com.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + } + recursor.(*mockDnsRecursor).On("handle", + mock.Anything, mock.Anything, mock.Anything).Return(resp, nil) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + RecursionAvailable: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "foo.service.consul.", + Qtype: dns.TypeSRV, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.SRV{ + Hdr: dns.RR_Header{ + Name: "foo.service.consul.", + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + Weight: 2, + Priority: 1, + Port: 5678, + Target: "foo-1.example.com.", + }, + &dns.SRV{ + Hdr: dns.RR_Header{ + Name: "foo.service.consul.", + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + Weight: 2, + Priority: 1, + Port: 8080, + Target: "foo-1.example.com.", + }, + }, + Extra: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo-1.example.com.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + A: net.ParseIP("1.2.3.4"), + }, + // TODO (v2-dns): This needs to be de-duplicated (NET-8064) + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo-1.example.com.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: uint32(123), + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runHandleTestCases(t, tc) + }) + } +} diff --git a/agent/dns/router_virtual_test.go b/agent/dns/router_virtual_test.go new file mode 100644 index 0000000000..5d70425e0e --- /dev/null +++ b/agent/dns/router_virtual_test.go @@ -0,0 +1,122 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "github.com/hashicorp/consul/agent/discovery" + "github.com/miekg/dns" + "github.com/stretchr/testify/mock" + "net" + "testing" +) + +func Test_HandleRequest_Virtual(t *testing.T) { + testCases := []HandleTestCase{ + { + name: "test A 'virtual.' query, ipv4 response", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "c000020a.virtual.dc1.consul", // "intentionally missing the trailing dot" + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + fetcher.(*discovery.MockCatalogDataFetcher).On("FetchVirtualIP", + mock.Anything, mock.Anything).Return(&discovery.Result{ + Node: &discovery.Location{Address: "240.0.0.2"}, + Type: discovery.ResultTypeVirtual, + }, nil) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "c000020a.virtual.dc1.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "c000020a.virtual.dc1.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("240.0.0.2"), + }, + }, + }, + }, + { + name: "test A 'virtual.' query, ipv6 response", + // Since we asked for an A record, the AAAA record that resolves from the address is attached as an extra + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "20010db800010002cafe000000001337.virtual.dc1.consul", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + fetcher.(*discovery.MockCatalogDataFetcher).On("FetchVirtualIP", + mock.Anything, mock.Anything).Return(&discovery.Result{ + Node: &discovery.Location{Address: "2001:db8:1:2:cafe::1337"}, + Type: discovery.ResultTypeVirtual, + }, nil) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "20010db800010002cafe000000001337.virtual.dc1.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Extra: []dns.RR{ + &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: "20010db800010002cafe000000001337.virtual.dc1.consul.", + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET, + Ttl: 123, + }, + AAAA: net.ParseIP("2001:db8:1:2:cafe::1337"), + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + runHandleTestCases(t, tc) + }) + } +} diff --git a/agent/dns/router_workload_test.go b/agent/dns/router_workload_test.go new file mode 100644 index 0000000000..04170a495c --- /dev/null +++ b/agent/dns/router_workload_test.go @@ -0,0 +1,515 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package dns + +import ( + "net" + "testing" + "time" + + "github.com/miekg/dns" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/hashicorp/consul/agent/config" + "github.com/hashicorp/consul/agent/discovery" +) + +func Test_HandleRequest_workloads(t *testing.T) { + testCases := []HandleTestCase{ + { + name: "workload A query w/ port, returns A record", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "api.port.foo.workload.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + result := &discovery.Result{ + Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{}, + Ports: []discovery.Port{ + { + Name: "api", + Number: 5678, + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchWorkload", mock.Anything, mock.Anything). + Return(result, nil). //TODO + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + + require.Equal(t, "foo", req.Name) + require.Equal(t, "api", req.PortName) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "api.port.foo.workload.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "api.port.foo.workload.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + }, + { + name: "workload ANY query w/o port, returns A record", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "foo.workload.consul.", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + result := &discovery.Result{ + Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{}, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchWorkload", mock.Anything, mock.Anything). + Return(result, nil). //TODO + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + + require.Equal(t, "foo", req.Name) + require.Empty(t, req.PortName) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "foo.workload.consul.", + Qtype: dns.TypeANY, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.workload.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + }, + { + name: "workload A query with namespace, partition, and cluster id; IPV4 address; returns A record", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "foo.workload.bar.ns.baz.ap.dc3.dc.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + result := &discovery.Result{ + Node: &discovery.Location{Name: "foo", Address: "1.2.3.4"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{ + Namespace: "bar", + Partition: "baz", + // We currently don't set the datacenter in any of the V2 results. + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchWorkload", mock.Anything, mock.Anything). + Return(result, nil). + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + + require.Equal(t, "foo", req.Name) + require.Empty(t, req.PortName) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "foo.workload.bar.ns.baz.ap.dc3.dc.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.workload.bar.ns.baz.ap.dc3.dc.consul.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + }, + { + name: "workload w/hostname address, ANY query (no recursor)", + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "api.port.foo.workload.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + result := &discovery.Result{ + Node: &discovery.Location{Name: "foo", Address: "foo.example.com"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{}, + Ports: []discovery.Port{ + { + Name: "api", + Number: 5678, + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchWorkload", mock.Anything, mock.Anything). + Return(result, nil). //TODO + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + + require.Equal(t, "foo", req.Name) + require.Equal(t, "api", req.PortName) + }) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "api.port.foo.workload.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.CNAME{ + Hdr: dns.RR_Header{ + Name: "api.port.foo.workload.consul.", + Rrtype: dns.TypeCNAME, + Class: dns.ClassINET, + Ttl: 123, + }, + Target: "foo.example.com.", + }, + }, + }, + }, + { + name: "workload w/hostname address, ANY query (w/ recursor)", + // https://datatracker.ietf.org/doc/html/rfc1034#section-3.6.2 both the CNAME and the A record should be in the answer + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "api.port.foo.workload.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + result := &discovery.Result{ + Node: &discovery.Location{Name: "foo", Address: "foo.example.com"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{}, + Ports: []discovery.Port{ + { + Name: "api", + Number: 5678, + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchWorkload", mock.Anything, mock.Anything). + Return(result, nil). //TODO + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + + require.Equal(t, "foo", req.Name) + require.Equal(t, "api", req.PortName) + }) + }, + agentConfig: &config.RuntimeConfig{ + DNSRecursors: []string{"8.8.8.8"}, + DNSDomain: "consul", + DNSNodeTTL: 123 * time.Second, + DNSSOA: config.RuntimeSOAConfig{ + Refresh: 1, + Retry: 2, + Expire: 3, + Minttl: 4, + }, + DNSUDPAnswerLimit: maxUDPAnswerLimit, + }, + configureRecursor: func(recursor dnsRecursor) { + resp := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "foo.example.com.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.example.com.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + } + recursor.(*mockDnsRecursor).On("handle", + mock.Anything, mock.Anything, mock.Anything).Return(resp, nil) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + RecursionAvailable: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "api.port.foo.workload.consul.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.CNAME{ + Hdr: dns.RR_Header{ + Name: "api.port.foo.workload.consul.", + Rrtype: dns.TypeCNAME, + Class: dns.ClassINET, + Ttl: 123, + }, + Target: "foo.example.com.", + }, + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.example.com.", + Rrtype: dns.TypeA, + Class: dns.ClassINET, + Ttl: 123, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + }, + }, + { + name: "workload w/hostname address, CNAME query (w/ recursor)", + // https://datatracker.ietf.org/doc/html/rfc1034#section-3.6.2 only the CNAME should be in the answer + request: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + }, + Question: []dns.Question{ + { + Name: "api.port.foo.workload.consul.", + Qtype: dns.TypeCNAME, + Qclass: dns.ClassINET, + }, + }, + }, + configureDataFetcher: func(fetcher discovery.CatalogDataFetcher) { + result := &discovery.Result{ + Node: &discovery.Location{Name: "foo", Address: "foo.example.com"}, + Type: discovery.ResultTypeWorkload, + Tenancy: discovery.ResultTenancy{}, + Ports: []discovery.Port{ + { + Name: "api", + Number: 5678, + }, + }, + } + + fetcher.(*discovery.MockCatalogDataFetcher). + On("FetchWorkload", mock.Anything, mock.Anything). + Return(result, nil). //TODO + Run(func(args mock.Arguments) { + req := args.Get(1).(*discovery.QueryPayload) + + require.Equal(t, "foo", req.Name) + require.Equal(t, "api", req.PortName) + }) + }, + agentConfig: &config.RuntimeConfig{ + DNSRecursors: []string{"8.8.8.8"}, + DNSDomain: "consul", + DNSNodeTTL: 123 * time.Second, + DNSSOA: config.RuntimeSOAConfig{ + Refresh: 1, + Retry: 2, + Expire: 3, + Minttl: 4, + }, + DNSUDPAnswerLimit: maxUDPAnswerLimit, + }, + configureRecursor: func(recursor dnsRecursor) { + resp := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + Rcode: dns.RcodeSuccess, + }, + Question: []dns.Question{ + { + Name: "foo.example.com.", + Qtype: dns.TypeA, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.example.com.", + Rrtype: dns.TypeCNAME, + Class: dns.ClassINET, + }, + A: net.ParseIP("1.2.3.4"), + }, + }, + } + recursor.(*mockDnsRecursor).On("handle", + mock.Anything, mock.Anything, mock.Anything).Return(resp, nil) + }, + validateAndNormalizeExpected: true, + response: &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Opcode: dns.OpcodeQuery, + Response: true, + Authoritative: true, + RecursionAvailable: true, + }, + Compress: true, + Question: []dns.Question{ + { + Name: "api.port.foo.workload.consul.", + Qtype: dns.TypeCNAME, + Qclass: dns.ClassINET, + }, + }, + Answer: []dns.RR{ + &dns.CNAME{ + Hdr: dns.RR_Header{ + Name: "api.port.foo.workload.consul.", + Rrtype: dns.TypeCNAME, + Class: dns.ClassINET, + Ttl: 123, + }, + Target: "foo.example.com.", + }, + // TODO (v2-dns): this next record is wrong per the RFC-1034 mentioned in the comment above (NET-8060) + &dns.A{ + Hdr: dns.RR_Header{ + Name: "foo.example.com.", + Rrtype: dns.TypeCNAME, + 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) + }) + } +}