consul/agent/dns/router.go

1295 lines
44 KiB
Go
Raw Normal View History

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1
package dns
import (
"encoding/hex"
"errors"
"fmt"
"github.com/hashicorp/consul/acl"
"net"
"regexp"
"strings"
"sync/atomic"
"time"
"github.com/armon/go-radix"
"github.com/miekg/dns"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/consul/agent/config"
"github.com/hashicorp/consul/agent/discovery"
"github.com/hashicorp/consul/agent/structs"
"github.com/hashicorp/consul/internal/dnsutil"
"github.com/hashicorp/consul/logging"
)
const (
addrLabel = "addr"
arpaDomain = "arpa."
arpaLabel = "arpa"
suffixFailover = "failover."
suffixNoFailover = "no-failover."
maxRecursionLevelDefault = 3 // This field comes from the V1 DNS server and affects V1 catalog lookups
maxRecurseRecords = 5
)
var (
errInvalidQuestion = fmt.Errorf("invalid question")
errNameNotFound = fmt.Errorf("name not found")
errNotImplemented = fmt.Errorf("not implemented")
errRecursionFailed = fmt.Errorf("recursion failed")
trailingSpacesRE = regexp.MustCompile(" +$")
)
// TODO (v2-dns): metrics
// Context is used augment a DNS message with Consul-specific metadata.
type Context struct {
Token string
DefaultPartition string
}
// RouterDynamicConfig is the dynamic configuration that can be hot-reloaded
type RouterDynamicConfig struct {
ARecordLimit int
DisableCompression bool
EnableDefaultFailover bool // TODO (v2-dns): plumbing required for this new V2 setting. This is the agent configured default
EnableTruncate bool
NodeMetaTXT bool
NodeTTL time.Duration
Recursors []string
RecursorTimeout time.Duration
RecursorStrategy structs.RecursorStrategy
SOAConfig SOAConfig
// TTLRadix sets service TTLs by prefix, eg: "database-*"
TTLRadix *radix.Tree
// TTLStrict sets TTLs to service by full name match. It Has higher priority than TTLRadix
TTLStrict map[string]time.Duration
UDPAnswerLimit int
}
type SOAConfig struct {
Refresh uint32 // 3600 by default
Retry uint32 // 600
Expire uint32 // 86400
Minttl uint32 // 0
}
// DiscoveryQueryProcessor is an interface that can be used by any consumer requesting Service Discovery results.
// This could be attached to a gRPC endpoint in the future in addition to DNS.
// Making this an interface means testing the router with a mock is trivial.
type DiscoveryQueryProcessor interface {
QueryByName(*discovery.Query, discovery.Context) ([]*discovery.Result, error)
QueryByIP(net.IP, discovery.Context) ([]*discovery.Result, error)
}
// dnsRecursor is an interface that can be used to mock calls to external DNS servers for unit testing.
//
//go:generate mockery --name dnsRecursor --inpackage
type dnsRecursor interface {
handle(req *dns.Msg, cfgCtx *RouterDynamicConfig, remoteAddress net.Addr) (*dns.Msg, error)
}
// Router replaces miekg/dns.ServeMux with a simpler router that only checks for the 2-3 valid domains
// that Consul supports and forwards to a single DiscoveryQueryProcessor handler. If there is no match, it will recurse.
type Router struct {
processor DiscoveryQueryProcessor
recursor dnsRecursor
domain string
altDomain string
datacenter string
logger hclog.Logger
tokenFunc func() string
// dynamicConfig stores the config as an atomic value (for hot-reloading).
// It is always of type *RouterDynamicConfig
dynamicConfig atomic.Value
}
var _ = dns.Handler(&Router{})
var _ = DNSRouter(&Router{})
func NewRouter(cfg Config) (*Router, error) {
// Make sure domains are FQDN, make them case-insensitive for DNSRequestRouter
domain := dns.CanonicalName(cfg.AgentConfig.DNSDomain)
altDomain := dns.CanonicalName(cfg.AgentConfig.DNSAltDomain)
// TODO (v2-dns): need to figure out tenancy information here in a way that work for V2 and V1
logger := cfg.Logger.Named(logging.DNS)
router := &Router{
processor: cfg.Processor,
recursor: newRecursor(logger),
domain: domain,
altDomain: altDomain,
datacenter: cfg.AgentConfig.Datacenter,
logger: logger,
tokenFunc: cfg.TokenFunc,
}
if err := router.ReloadConfig(cfg.AgentConfig); err != nil {
return nil, err
}
return router, nil
}
// HandleRequest is used to process an individual DNS request. It returns a message in success or fail cases.
func (r *Router) HandleRequest(req *dns.Msg, reqCtx Context, remoteAddress net.Addr) *dns.Msg {
return r.handleRequestRecursively(req, reqCtx, 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,
remoteAddress net.Addr, maxRecursionLevel int) *dns.Msg {
configCtx := r.dynamicConfig.Load().(*RouterDynamicConfig)
err := validateAndNormalizeRequest(req)
if err != nil {
r.logger.Error("error parsing DNS query", "error", err)
if errors.Is(err, errInvalidQuestion) {
return createRefusedResponse(req)
}
return createServerFailureResponse(req, configCtx, false)
}
responseDomain, needRecurse := r.parseDomain(req.Question[0].Name)
if needRecurse && !canRecurse(configCtx) {
// This is the same error as an unmatched domain
return createRefusedResponse(req)
}
if needRecurse {
// This assumes `canRecurse(configCtx)` is true above
resp, err := r.recursor.handle(req, configCtx, remoteAddress)
if err != nil && !errors.Is(err, errRecursionFailed) {
r.logger.Error("unhandled error recursing DNS query", "error", err)
}
if err != nil {
return createServerFailureResponse(req, configCtx, true)
}
return resp
}
// Need to pass the question name to properly support recursion and the
// trimming of the domain suffixes.
qName := dns.CanonicalName(req.Question[0].Name)
if maxRecursionLevel < maxRecursionLevelDefault {
// Get the QName without the domain suffix
qName = r.trimDomain(qName)
}
reqType := parseRequestType(req)
results, query, err := r.getQueryResults(req, reqCtx, reqType, qName, remoteAddress)
// incase of the wrapped ECSNotGlobalError, extract the error from it.
isECSGlobal := !errors.Is(err, discovery.ErrECSNotGlobal)
err = getErrorFromECSNotGlobalError(err)
if err != nil {
switch {
case errors.Is(err, errInvalidQuestion):
r.logger.Error("invalid question", "name", qName)
ecsGlobal := !errors.Is(err, discovery.ErrECSNotGlobal)
return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeNameError, ecsGlobal)
case errors.Is(err, errNameNotFound):
r.logger.Error("name not found", "name", qName)
ecsGlobal := !errors.Is(err, discovery.ErrECSNotGlobal)
return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeNameError, ecsGlobal)
case errors.Is(err, errNotImplemented):
r.logger.Error("query not implemented", "name", qName, "type", dns.Type(req.Question[0].Qtype).String())
ecsGlobal := !errors.Is(err, discovery.ErrECSNotGlobal)
return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeNotImplemented, ecsGlobal)
case errors.Is(err, discovery.ErrNotSupported):
r.logger.Debug("query name syntax not supported", "name", req.Question[0].Name)
ecsGlobal := !errors.Is(err, discovery.ErrECSNotGlobal)
return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeNameError, ecsGlobal)
case errors.Is(err, discovery.ErrNotFound):
r.logger.Debug("query name not found", "name", req.Question[0].Name)
ecsGlobal := !errors.Is(err, discovery.ErrECSNotGlobal)
return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeNameError, ecsGlobal)
case errors.Is(err, discovery.ErrNoData):
r.logger.Debug("no data available", "name", qName)
ecsGlobal := !errors.Is(err, discovery.ErrECSNotGlobal)
return createAuthoritativeResponse(req, configCtx, responseDomain, dns.RcodeSuccess, ecsGlobal)
default:
r.logger.Error("error processing discovery query", "error", err)
return createServerFailureResponse(req, configCtx, canRecurse(configCtx))
}
}
// 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)
if err != nil {
r.logger.Error("error serializing DNS results", "error", err)
return createServerFailureResponse(req, configCtx, false)
}
// 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)
return resp
}
// trimDomain trims the domain from the question name.
func (r *Router) trimDomain(questionName string) string {
longer := r.domain
shorter := r.altDomain
if len(shorter) > len(longer) {
longer, shorter = shorter, longer
}
if strings.HasSuffix(questionName, "."+strings.TrimLeft(longer, ".")) {
return strings.TrimSuffix(questionName, longer)
}
return strings.TrimSuffix(questionName, shorter)
}
// getTTLForResult returns the TTL for a given result.
func getTTLForResult(name string, 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)
}
switch query.QueryType {
// TODO (v2-dns): currently have to do this related to the results type being changed to node whe
// the v1 data fetcher encounters a blank service address and uses the node address instead.
// we will revisiting this when look at modifying the discovery result struct to
// possibly include additional metadata like the node address.
case discovery.QueryTypeWorkload:
// TODO (v2-dns): we need to discuss what we want to do for workload TTLs
return 0
case discovery.QueryTypeService:
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) {
reqCtx := r.defaultAgentDNSRequestContext()
out := r.HandleRequest(req, reqCtx, w.RemoteAddr())
w.WriteMsg(out)
}
// ReloadConfig hot-reloads the router config with new parameters
func (r *Router) ReloadConfig(newCfg *config.RuntimeConfig) error {
cfg, err := getDynamicRouterConfig(newCfg)
if err != nil {
return fmt.Errorf("error loading DNS config: %w", err)
}
r.dynamicConfig.Store(cfg)
return nil
}
// 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
}
}
// 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 {
ans, ex, ns := r.getAnswerExtraAndNs(result, 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 {
ans, ex, ns := r.getAnswerExtraAndNs(result, 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 {
// 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.
//serviceAddress := d.agent.TranslateServiceAddress(lookup.Datacenter, node.Service.Address, node.Service.TaggedAddresses, TranslateAddressAcceptAny)
//servicePort := d.agent.TranslateServicePort(lookup.Datacenter, node.Service.Port, node.Service.TaggedAddresses)
//tuple := fmt.Sprintf("%s:%s:%d", node.Node.Node, serviceAddress, servicePort)
tuple := fmt.Sprintf("%s:%s:%d", result.Node.Name, result.Service.Address, result.PortNumber)
if _, ok := handled[tuple]; ok {
continue
}
handled[tuple] = struct{}{}
ans, ex, ns := r.getAnswerExtraAndNs(result, 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)
}
return resp, nil
}
// 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 {
// Add the node record
had_answer := false
ans, extra, _ := r.getAnswerExtraAndNs(result, 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(),
// 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(cfg *RouterDynamicConfig, name string, reqCtx Context,
remoteAddress net.Addr, maxRecursionLevel int) []dns.RR {
// If the CNAME record points to a Consul address, resolve it internally
// Convert query to lowercase because DNS is case-insensitive; d.domain and
// d.altDomain are already converted
if ln := strings.ToLower(name); strings.HasSuffix(ln, "."+r.domain) || strings.HasSuffix(ln, "."+r.altDomain) {
if maxRecursionLevel < 1 {
//d.logger.Error("Infinite recursion detected for name, won't perform any CNAME resolution.", "name", name)
return nil
}
req := &dns.Msg{}
req.SetQuestion(name, dns.TypeANY)
// TODO: handle error response
resp := r.handleRequestRecursively(req, reqCtx, nil, maxRecursionLevel-1)
return resp.Answer
}
// Do nothing if we don't have a recursor
if !canRecurse(cfg) {
return nil
}
// Ask for any A records
m := new(dns.Msg)
m.SetQuestion(name, dns.TypeA)
// Make a DNS lookup request
recursorResponse, err := r.recursor.handle(m, cfg, remoteAddress)
if err == nil {
return recursorResponse.Answer
}
r.logger.Error("all resolvers failed for name", "name", name)
return nil
}
// 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,
// but we will only answer the first.
if len(req.Question) == 0 {
return errInvalidQuestion
}
// We mutate the request name to respond with the canonical name.
// This is Consul convention.
req.Question[0].Name = dns.CanonicalName(req.Question[0].Name)
return nil
}
// stripSuffix strips off the suffixes that may have been added to the request name.
func stripSuffix(target string) (string, bool) {
enableFailover := false
// Strip off any suffixes that may have been added.
offset, underflow := dns.PrevLabel(target, 1)
if !underflow {
maybeSuffix := target[offset:]
switch maybeSuffix {
case suffixFailover:
target = target[:offset]
enableFailover = true
case suffixNoFailover:
target = target[:offset]
}
}
return target, enableFailover
}
// isAddrSubdomain returns true if the domain is a valid addr subdomain.
func isAddrSubdomain(domain string) bool {
labels := dns.SplitDomainName(domain)
// Looking for <hexadecimal-encoded IP>.addr.<optional datacenter>.consul.
if len(labels) > 2 {
return labels[1] == addrLabel
}
return false
}
// isPTRSubdomain returns true if the domain ends in the PTR domain, "in-addr.arpa.".
func isPTRSubdomain(domain string) bool {
labels := dns.SplitDomainName(domain)
labelCount := len(labels)
// We keep this check brief so we can have more specific error handling later.
if labelCount < 1 {
return false
}
return labels[labelCount-1] == arpaLabel
}
// getDynamicRouterConfig takes agent config and creates/resets the config used by DNS Router
func getDynamicRouterConfig(conf *config.RuntimeConfig) (*RouterDynamicConfig, error) {
cfg := &RouterDynamicConfig{
ARecordLimit: conf.DNSARecordLimit,
EnableTruncate: conf.DNSEnableTruncate,
NodeTTL: conf.DNSNodeTTL,
RecursorStrategy: conf.DNSRecursorStrategy,
RecursorTimeout: conf.DNSRecursorTimeout,
UDPAnswerLimit: conf.DNSUDPAnswerLimit,
NodeMetaTXT: conf.DNSNodeMetaTXT,
DisableCompression: conf.DNSDisableCompression,
SOAConfig: SOAConfig{
Expire: conf.DNSSOA.Expire,
Minttl: conf.DNSSOA.Minttl,
Refresh: conf.DNSSOA.Refresh,
Retry: conf.DNSSOA.Retry,
},
}
if conf.DNSServiceTTL != nil {
cfg.TTLRadix = radix.New()
cfg.TTLStrict = make(map[string]time.Duration)
for key, ttl := range conf.DNSServiceTTL {
// All suffix with '*' are put in radix
// This include '*' that will match anything
if strings.HasSuffix(key, "*") {
cfg.TTLRadix.Insert(key[:len(key)-1], ttl)
} else {
cfg.TTLStrict[key] = ttl
}
}
} else {
cfg.TTLRadix = nil
cfg.TTLStrict = nil
}
for _, r := range conf.DNSRecursors {
ra, err := formatRecursorAddress(r)
if err != nil {
return nil, fmt.Errorf("invalid recursor address: %w", err)
}
cfg.Recursors = append(cfg.Recursors, ra)
}
return cfg, nil
}
// canRecurse returns true if the router can recurse on the request.
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, 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 := newDNSAddress("")
if result.Service != nil {
serviceAddress = newDNSAddress(result.Service.Address)
}
nodeAddress := newDNSAddress("")
if result.Node != nil {
nodeAddress = newDNSAddress(result.Node.Address)
}
qName := req.Question[0].Name
ttlLookupName := qName
if query != nil {
ttlLookupName = query.QueryPayload.Name
}
ttl := getTTLForResult(ttlLookupName, 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, result.PortName),
}
answer = append(answer, ptr)
case qType == dns.TypeNS:
// TODO (v2-dns): fqdn in V1 has the datacenter included, this would need to be added to discovery.Result
resultType := result.Type
target := result.Node.Name
if parseRequestType(req) == requestTypeConsul && resultType == discovery.ResultTypeService {
resultType = discovery.ResultTypeNode
}
fqdn := canonicalNameForResult(resultType, target, domain, result.Tenancy, result.PortName)
extraRecord := makeIPBasedRecord(fqdn, nodeAddress, ttl) // TODO (v2-dns): this is not sufficient, because recursion and CNAMES are supported
answer = append(answer, makeNSRecord(domain, fqdn, ttl))
extra = append(extra, extraRecord)
case qType == dns.TypeSOA:
// TODO (v2-dns): fqdn in V1 has the datacenter included, this would need to be added to discovery.Result
// to be returned in the result.
fqdn := canonicalNameForResult(result.Type, result.Node.Name, domain, result.Tenancy, result.PortName)
extraRecord := makeIPBasedRecord(fqdn, nodeAddress, ttl) // TODO (v2-dns): this is not sufficient, because recursion and CNAMES are supported
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, ttl, remoteAddress, cfg, domain, maxRecursionLevel)
answer = append(answer, a...)
extra = append(extra, e...)
default:
a, e := r.getAnswerExtrasForAddressAndTarget(nodeAddress, serviceAddress, req, reqCtx,
result, ttl, remoteAddress, cfg, domain, maxRecursionLevel)
answer = append(answer, a...)
extra = append(extra, e...)
}
a, e := getAnswerAndExtraTXT(req, cfg, qName, result, ttl, domain, query)
answer = append(answer, a...)
extra = append(extra, e...)
return
}
// 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, 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)
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, result.PortName)
a, e := getAnswerExtrasForIP(canonicalNodeName, nodeAddress, req.Question[0], reqType,
result, ttl, domain)
answer = append(answer, a...)
extra = append(extra, e...)
case result.Type == discovery.ResultTypeNode && !nodeAddress.IsIP():
a, e := r.makeRecordFromFQDN(serviceAddress.FQDN(), result, req, reqCtx, cfg,
ttl, remoteAddress, maxRecursionLevel)
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():
canonicalNodeName := canonicalNameForResult(discovery.ResultTypeNode, result.Node.Name, domain, result.Tenancy, result.PortName)
a, e := getAnswerExtrasForIP(canonicalNodeName, nodeAddress, req.Question[0], reqType,
result, ttl, domain)
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(nodeAddress.FQDN(), result, req, reqCtx, cfg,
ttl, remoteAddress, maxRecursionLevel)
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, result.PortName)
a, e := getAnswerExtrasForIP(canonicalServiceName, serviceAddress, req.Question[0], reqType,
result, ttl, domain)
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, result.PortName)
a, e := getAnswerExtrasForIP(canonicalNodeName, nodeAddress, req.Question[0], reqType,
result, ttl, domain)
answer = append(answer, a...)
extra = append(extra, e...)
// The service address is a FQDN (internal or external service name)
default:
a, e := r.makeRecordFromFQDN(serviceAddress.FQDN(), result, req, reqCtx, cfg,
ttl, remoteAddress, maxRecursionLevel)
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) (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, result.PortName)
}
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, _ string) (answer []dns.RR, extra []dns.RR) {
qType := question.Qtype
// 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
}
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)
}
if reqType != requestTypeAddress && qType == dns.TypeSRV {
srv := makeSRVRecord(name, recHdrName, result, ttl)
answer = append(answer, srv)
}
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(fqdn string, result *discovery.Result,
req *dns.Msg, reqCtx Context, cfg *RouterDynamicConfig, ttl uint32,
remoteAddress net.Addr, maxRecursionLevel int) ([]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 {
answers := []dns.RR{
makeSRVRecord(q.Name, fqdn, result, ttl),
}
return answers, 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) *dns.SRV {
return &dns.SRV{
Hdr: dns.RR_Header{
Name: name,
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
Ttl: ttl,
},
Priority: 1,
Weight: uint16(result.Weight),
Port: uint16(result.PortNumber),
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
}