github.com/mailgun/holster/v4@v4.20.0/mxresolv/mxresolv.go (about)

     1  package mxresolv
     2  
     3  import (
     4  	"context"
     5  	"math/rand"
     6  	"net"
     7  	"sort"
     8  	"strings"
     9  	"sync"
    10  	"time"
    11  	"unicode"
    12  	_ "unsafe" // For go:linkname
    13  
    14  	"github.com/mailgun/holster/v4/clock"
    15  	"github.com/mailgun/holster/v4/collections"
    16  	"github.com/mailgun/holster/v4/errors"
    17  	"golang.org/x/net/idna"
    18  )
    19  
    20  const (
    21  	cacheSize = 1000
    22  	cacheTTL  = 10 * clock.Minute
    23  )
    24  
    25  var (
    26  	errNullMXRecord   = errors.New("domain accepts no mail")
    27  	errNoValidMXHosts = errors.New("no valid MX hosts")
    28  	lookupResultCache *collections.LRUCache
    29  
    30  	// randomizer allows the seed function to be patched in tests using SetDeterministic()
    31  	randomizerMu sync.Mutex
    32  	randomizer   = rand.New(rand.NewSource(time.Now().UnixNano()))
    33  
    34  	// Resolver is exposed to be patched in tests
    35  	Resolver = net.DefaultResolver
    36  )
    37  
    38  func init() {
    39  	lookupResultCache = collections.NewLRUCache(cacheSize)
    40  }
    41  
    42  // Lookup performs a DNS lookup of MX records for the specified domain. It
    43  // returns a prioritised list of MX hostnames, where hostnames with the same
    44  // priority are shuffled. If the second returned value is true, then the host
    45  // does not have explicit MX records, and its A record is returned instead.
    46  //
    47  // It uses an LRU cache with a timeout to reduce the number of network requests.
    48  func Lookup(ctx context.Context, domainName string) (mxHosts []string, implicit bool, err error) {
    49  	mxRecords, implicit, err := LookupWithPref(ctx, domainName)
    50  	if err != nil {
    51  		return nil, false, err
    52  	}
    53  	if len(mxRecords) == 1 {
    54  		return []string{mxRecords[0].Host}, implicit, err
    55  	}
    56  	return shuffleMXRecords(mxRecords), false, nil
    57  }
    58  
    59  // LookupWithPref performs a DNS lookup of MX records for the specified domain.
    60  // It returns a slice of net.MX records that are ordered by preference. Records
    61  // with the same preference are sorted by hostname to ensure deterministic
    62  // behaviour. If the second returned value is true, then the host does not have
    63  // explicit MX records, and its A record is used instead.
    64  //
    65  // It uses an LRU cache with a timeout to reduce the number of network requests.
    66  func LookupWithPref(ctx context.Context, domainName string) (mxRecords []*net.MX, implicit bool, err error) {
    67  	if cachedVal, ok := lookupResultCache.Get(domainName); ok {
    68  		cachedLookupResult := cachedVal.(lookupResult)
    69  		return cachedLookupResult.mxRecords, cachedLookupResult.implicit, cachedLookupResult.err
    70  	}
    71  
    72  	asciiDomainName, err := ensureASCII(domainName)
    73  	if err != nil {
    74  		return nil, false, errors.Wrap(err, "invalid domain name")
    75  	}
    76  	mxRecords, err = lookupMX(Resolver, ctx, asciiDomainName)
    77  	if err != nil {
    78  		var netDNSError *net.DNSError
    79  		if errors.As(err, &netDNSError) && netDNSError.IsNotFound {
    80  			if _, err := Resolver.LookupIPAddr(ctx, asciiDomainName); err != nil {
    81  				return cacheAndReturn(domainName, nil, false, errors.WithStack(err))
    82  			}
    83  			return cacheAndReturn(domainName, []*net.MX{{Host: asciiDomainName, Pref: 1}}, true, nil)
    84  		}
    85  		if mxRecords == nil {
    86  			return cacheAndReturn(domainName, nil, false, errors.WithStack(err))
    87  		}
    88  	}
    89  	// Check for "Null MX" record (https://tools.ietf.org/html/rfc7505).
    90  	if len(mxRecords) == 1 {
    91  		if mxRecords[0].Host == "." {
    92  			return cacheAndReturn(domainName, nil, false, errNullMXRecord)
    93  		}
    94  		// 0.0.0.0 is not really a "Null MX" record, but some people apparently
    95  		// have never heard of RFC7505 and configure it this way.
    96  		if strings.HasPrefix(mxRecords[0].Host, "0.0.0.0") {
    97  			return cacheAndReturn(domainName, nil, false, errNullMXRecord)
    98  		}
    99  	}
   100  	// Purge records with non-ASCII characters. we have seen such records in
   101  	// production, they are obviously products of human errors.
   102  	for i := 0; i < len(mxRecords); {
   103  		if isASCII(mxRecords[i].Host) {
   104  			i++
   105  			continue
   106  		}
   107  		copy(mxRecords[i:], mxRecords[i+1:])
   108  		mxRecords = mxRecords[:len(mxRecords)-1]
   109  	}
   110  	// If there are no valid records left, then return an error.
   111  	if len(mxRecords) == 0 {
   112  		return cacheAndReturn(domainName, nil, false, errNoValidMXHosts)
   113  	}
   114  	// Normalize returned hostnames: drop trailing '.' and lowercase.
   115  	for _, mxRecord := range mxRecords {
   116  		lastCharIndex := len(mxRecord.Host) - 1
   117  		if mxRecord.Host[lastCharIndex] == '.' {
   118  			mxRecord.Host = strings.ToLower(mxRecord.Host[:lastCharIndex])
   119  		}
   120  	}
   121  	// Sort records in order of preference and lexicographically within a
   122  	// preference group. The latter is only to make tests deterministic.
   123  	sort.Slice(mxRecords, func(i, j int) bool {
   124  		return mxRecords[i].Pref < mxRecords[j].Pref ||
   125  			(mxRecords[i].Pref == mxRecords[j].Pref && mxRecords[i].Host < mxRecords[j].Host)
   126  	})
   127  	return cacheAndReturn(domainName, mxRecords, false, nil)
   128  }
   129  
   130  // SetDeterministicInTests sets rand to deterministic seed for testing, and is
   131  // not Thread-Safe.
   132  func SetDeterministicInTests() func() {
   133  	randomizerMu.Lock()
   134  	old := randomizer
   135  	randomizer = rand.New(rand.NewSource(1))
   136  	randomizerMu.Unlock()
   137  	return func() {
   138  		randomizerMu.Lock()
   139  		randomizer = old
   140  		randomizerMu.Unlock()
   141  	}
   142  }
   143  
   144  // ResetCache clears the cache for use in tests, and is not Thread-Safe
   145  func ResetCache() {
   146  	lookupResultCache = collections.NewLRUCache(1000)
   147  }
   148  
   149  func shuffleMXRecords(mxRecords []*net.MX) []string {
   150  	// Shuffle the hosts within the preference groups.
   151  	var (
   152  		mxHosts    []string
   153  		groupBegin = 0
   154  		groupEnd   = 0
   155  		groupPref  uint16
   156  	)
   157  	for _, mxRecord := range mxRecords {
   158  		// If a hostname has non-ASCII characters then ignore it, for it is
   159  		// a kind of human error that we saw in production.
   160  		if !isASCII(mxRecord.Host) {
   161  			continue
   162  		}
   163  		// Just being overly cautious, so checking for empty values.
   164  		if mxRecord.Host == "" {
   165  			continue
   166  		}
   167  		// If it is the first valid record in the set, then allocate a slice
   168  		// for MX hosts and put it there.
   169  		if mxHosts == nil {
   170  			mxHosts = make([]string, 0, len(mxRecords))
   171  			mxHosts = append(mxHosts, mxRecord.Host)
   172  			groupPref = mxRecord.Pref
   173  			groupEnd = 1
   174  			continue
   175  		}
   176  		// Put the next valid record to the slice.
   177  		mxHosts = append(mxHosts, mxRecord.Host)
   178  		// If the added host has the same preference as the first one in the
   179  		// current group, then continue the MX record set traversal.
   180  		if groupPref == mxRecord.Pref {
   181  			groupEnd++
   182  			continue
   183  		}
   184  		// After finding the end of the current preference group, shuffle it.
   185  		if groupEnd-groupBegin > 1 {
   186  			shuffleHosts(mxHosts[groupBegin:groupEnd])
   187  		}
   188  		// Set up the next preference group.
   189  		groupBegin = groupEnd
   190  		groupEnd++
   191  		groupPref = mxRecord.Pref
   192  	}
   193  	// Shuffle the last preference group, if there is one.
   194  	if groupEnd-groupBegin > 1 {
   195  		shuffleHosts(mxHosts[groupBegin:groupEnd])
   196  	}
   197  	return mxHosts
   198  }
   199  
   200  func shuffleHosts(hosts []string) {
   201  	randomizerMu.Lock()
   202  	randomizer.Shuffle(len(hosts), func(i, j int) { hosts[i], hosts[j] = hosts[j], hosts[i] })
   203  	randomizerMu.Unlock()
   204  }
   205  
   206  func ensureASCII(domainName string) (string, error) {
   207  	if isASCII(domainName) {
   208  		return domainName, nil
   209  	}
   210  	domainName, err := idna.ToASCII(domainName)
   211  	if err != nil {
   212  		return "", errors.WithStack(err)
   213  	}
   214  	return domainName, nil
   215  }
   216  
   217  func isASCII(s string) bool {
   218  	for i := 0; i < len(s); i++ {
   219  		if s[i] > unicode.MaxASCII {
   220  			return false
   221  		}
   222  	}
   223  	return true
   224  }
   225  
   226  type lookupResult struct {
   227  	mxRecords []*net.MX
   228  	implicit  bool
   229  	err       error
   230  }
   231  
   232  func cacheAndReturn(domainName string, mxRecords []*net.MX, implicit bool, err error) ([]*net.MX, bool, error) {
   233  	lookupResultCache.AddWithTTL(domainName, lookupResult{mxRecords: mxRecords, implicit: implicit, err: err}, cacheTTL)
   234  	return mxRecords, implicit, err
   235  }
   236  
   237  // lookupMX exposes the respective private function of net.Resolver. The public
   238  // alternative net.(*Resolver).LookupMX considers MX records that contain an IP
   239  // address invalid. It is indeed invalid according to an RFC, but in reality
   240  // some people do not read RFC and configure IP addresses in MX records.
   241  //
   242  // An issue against the Golang proper was created to remove the strict MX DNS
   243  // record validation https://github.com/golang/go/issues/56025. When it is
   244  // fixed we will be able to remove this unsafe binding and get back to calling
   245  // the public method.
   246  //
   247  //go:linkname lookupMX net.(*Resolver).lookupMX
   248  func lookupMX(r *net.Resolver, ctx context.Context, name string) ([]*net.MX, error)