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)