1// Package spf implements Sender Policy Framework (SPF, RFC 7208) for verifying
2// remote mail server IPs with their published records.
4// With SPF a domain can publish a policy as a DNS TXT record describing which IPs
5// are allowed to send email with SMTP with the domain in the MAIL FROM command,
6// and how to treat SMTP transactions coming from other IPs.
19 "github.com/prometheus/client_golang/prometheus"
20 "github.com/prometheus/client_golang/prometheus/promauto"
22 "github.com/mjl-/mox/dns"
23 "github.com/mjl-/mox/mlog"
24 "github.com/mjl-/mox/smtp"
27// The net package always returns DNS names in absolute, lower-case form. We make
28// sure we make names absolute when looking up. For verifying, we do not want to
29// verify names relative to our local search domain.
31var xlog = mlog.New("spf")
34 metricSPFVerify = promauto.NewHistogramVec(
35 prometheus.HistogramOpts{
36 Name: "mox_spf_verify_duration_seconds",
37 Help: "SPF verify, including lookup, duration and result.",
38 Buckets: []float64{0.001, 0.005, 0.01, 0.05, 0.100, 0.5, 1, 5, 10, 20},
46// cross-link rfc and errata
54 ErrName = errors.New("spf: bad domain name")
55 ErrNoRecord = errors.New("spf: no txt record")
56 ErrMultipleRecords = errors.New("spf: multiple spf txt records in dns")
57 ErrDNS = errors.New("spf: lookup of dns record")
58 ErrRecordSyntax = errors.New("spf: malformed spf txt record")
61 ErrTooManyDNSRequests = errors.New("spf: too many dns requests")
62 ErrTooManyVoidLookups = errors.New("spf: too many void lookups")
63 ErrMacroSyntax = errors.New("spf: bad macro syntax")
67 // Maximum number of DNS requests to execute. This excludes some requests, such as
68 // lookups of MX host results.
71 // Maximum number of DNS lookups that result in no records before a StatusPermerror
72 // is returned. This limit aims to prevent abuse.
76// Status is the result of an SPF verification.
83 StatusNone Status = "none" // E.g. no DNS domain name in session, or no SPF record in DNS.
84 StatusNeutral Status = "neutral" // Explicit statement that nothing is said about the IP, "?" qualifier. None and Neutral must be treated the same.
85 StatusPass Status = "pass" // IP is authorized.
86 StatusFail Status = "fail" // IP is exlicitly not authorized. "-" qualifier.
87 StatusSoftfail Status = "softfail" // Weak statement that IP is probably not authorized, "~" qualifier.
88 StatusTemperror Status = "temperror" // Trying again later may succeed, e.g. for temporary DNS lookup error.
89 StatusPermerror Status = "permerror" // Error requiring some intervention to correct. E.g. invalid DNS record.
92// Args are the parameters to the SPF verification algorithm ("check_host" in the RFC).
94// All fields should be set as they can be required for macro expansions.
96 // RemoteIP will be checked as sender for email.
99 // Address from SMTP MAIL FROM command. Zero values for a null reverse path (used for DSNs).
100 MailFromLocalpart smtp.Localpart
101 MailFromDomain dns.Domain
103 // HelloDomain is from the SMTP EHLO/HELO command.
104 HelloDomain dns.IPDomain
107 LocalHostname dns.Domain
109 // Explanation string to use for failure. In case of "include", where explanation
110 // from original domain must be used.
111 // May be set for recursive calls.
114 // Domain to validate.
117 // Effective sender. Equal to MailFrom if non-zero, otherwise set to "postmaster" at HelloDomain.
118 senderLocalpart smtp.Localpart
119 senderDomain dns.Domain
121 // To enforce the limit on lookups. Initialized automatically if nil.
126// Mocked for testing expanding "t" macro.
127var timeNow = time.Now
129// Lookup looks up and parses an SPF TXT record for domain.
131// authentic indicates if the DNS results were DNSSEC-verified.
132func Lookup(ctx context.Context, resolver dns.Resolver, domain dns.Domain) (rstatus Status, rtxt string, rrecord *Record, authentic bool, rerr error) {
133 log := xlog.WithContext(ctx)
136 log.Debugx("spf lookup result", rerr, mlog.Field("domain", domain), mlog.Field("status", rstatus), mlog.Field("record", rrecord), mlog.Field("duration", time.Since(start)))
140 host := domain.ASCII + "."
141 if err := validateDNS(host); err != nil {
142 return StatusNone, "", nil, false, fmt.Errorf("%w: %s: %s", ErrName, domain, err)
145 // Lookup spf record.
146 txts, result, err := dns.WithPackage(resolver, "spf").LookupTXT(ctx, host)
147 if dns.IsNotFound(err) {
148 return StatusNone, "", nil, result.Authentic, fmt.Errorf("%w for %s", ErrNoRecord, host)
149 } else if err != nil {
150 return StatusTemperror, "", nil, result.Authentic, fmt.Errorf("%w: %s: %s", ErrDNS, host, err)
153 // Parse the records. We only handle those that look like spf records.
156 for _, txt := range txts {
158 r, isspf, err := ParseRecord(txt)
162 } else if err != nil {
164 return StatusPermerror, txt, nil, result.Authentic, fmt.Errorf("%w: %s", ErrRecordSyntax, err)
168 return StatusPermerror, "", nil, result.Authentic, ErrMultipleRecords
175 return StatusNone, "", nil, result.Authentic, ErrNoRecord
177 return StatusNone, text, record, result.Authentic, nil
180// Verify checks if a remote IP is allowed to send email for a domain.
182// If the SMTP "MAIL FROM" is set, it is used as identity (domain) to verify.
183// Otherwise, the EHLO domain is verified if it is a valid domain.
185// The returned Received.Result status will always be set, regardless of whether an
187// For status Temperror and Permerror, an error is always returned.
188// For Fail, explanation may be set, and should be returned in the SMTP session if
189// it is the reason the message is rejected. The caller should ensure the
190// explanation is valid for use in SMTP, taking line length and ascii-only
191// requirement into account.
193// Verify takes the maximum number of 10 DNS requests into account, and the maximum
194// of 2 lookups resulting in no records ("void lookups").
196// authentic indicates if the DNS results were DNSSEC-verified.
197func Verify(ctx context.Context, resolver dns.Resolver, args Args) (received Received, domain dns.Domain, explanation string, authentic bool, rerr error) {
198 log := xlog.WithContext(ctx)
201 metricSPFVerify.WithLabelValues(string(received.Result)).Observe(float64(time.Since(start)) / float64(time.Second))
202 log.Debugx("spf verify result", rerr, mlog.Field("domain", args.domain), mlog.Field("ip", args.RemoteIP), mlog.Field("status", received.Result), mlog.Field("explanation", explanation), mlog.Field("duration", time.Since(start)))
205 isHello, ok := prepare(&args)
209 Comment: "no domain, ehlo is an ip literal and mailfrom is empty",
210 ClientIP: args.RemoteIP,
211 EnvelopeFrom: fmt.Sprintf("%s@%s", args.senderLocalpart, args.HelloDomain.IP.String()),
212 Helo: args.HelloDomain,
213 Receiver: args.LocalHostname.ASCII,
215 return received, dns.Domain{}, "", false, nil
218 status, mechanism, expl, authentic, err := checkHost(ctx, resolver, args)
219 comment := fmt.Sprintf("domain %s", args.domain.ASCII)
221 comment += ", from ehlo because mailfrom is empty"
226 ClientIP: args.RemoteIP,
227 EnvelopeFrom: fmt.Sprintf("%s@%s", args.senderLocalpart, args.senderDomain.ASCII), //
../rfc/7208:2090, explicitly "sender", not "mailfrom".
228 Helo: args.HelloDomain,
229 Receiver: args.LocalHostname.ASCII,
230 Mechanism: mechanism,
233 received.Problem = err.Error()
236 received.Identity = "helo"
238 received.Identity = "mailfrom"
240 return received, args.domain, expl, authentic, err
243// prepare args, setting fields sender* and domain as required for checkHost.
244func prepare(args *Args) (isHello bool, ok bool) {
245 // If MAIL FROM is set, that identity is used. Otherwise the EHLO identity is used.
246 // MAIL FROM is preferred, because if we accept the message, and we have to send a
247 // DSN, it helps to know it is a verified sender. If we would check an EHLO
248 // identity, and it is different from the MAIL FROM, we may be sending the DSN to
249 // an address with a domain that would not allow sending from the originating IP.
254 args.explanation = nil
255 args.dnsRequests = nil
256 args.voidLookups = nil
257 if args.MailFromDomain.IsZero() {
258 // If there is on EHLO, and it is an IP, there is nothing to SPF-validate.
259 if !args.HelloDomain.IsDomain() {
262 // If we have a mailfrom, we also have a localpart. But for EHLO we won't.
../rfc/7208:810
263 args.senderLocalpart = "postmaster"
264 args.senderDomain = args.HelloDomain.Domain
267 args.senderLocalpart = args.MailFromLocalpart
268 args.senderDomain = args.MailFromDomain
270 args.domain = args.senderDomain
274// lookup spf record, then evaluate args against it.
275func checkHost(ctx context.Context, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rauthentic bool, rerr error) {
276 status, _, record, rauthentic, err := Lookup(ctx, resolver, args.domain)
278 return status, "", "", rauthentic, err
281 var evalAuthentic bool
282 rstatus, mechanism, rexplanation, evalAuthentic, rerr = evaluate(ctx, record, resolver, args)
283 rauthentic = rauthentic && evalAuthentic
287// Evaluate evaluates the IP and names from args against the SPF DNS record for the domain.
288func Evaluate(ctx context.Context, record *Record, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rauthentic bool, rerr error) {
289 _, ok := prepare(&args)
291 return StatusNone, "default", "", false, fmt.Errorf("no domain name to validate")
293 return evaluate(ctx, record, resolver, args)
296// evaluate RemoteIP against domain from args, given record.
297func evaluate(ctx context.Context, record *Record, resolver dns.Resolver, args Args) (rstatus Status, mechanism, rexplanation string, rauthentic bool, rerr error) {
298 log := xlog.WithContext(ctx)
301 log.Debugx("spf evaluate result", rerr, mlog.Field("dnsrequests", *args.dnsRequests), mlog.Field("voidlookups", *args.voidLookups), mlog.Field("domain", args.domain), mlog.Field("status", rstatus), mlog.Field("mechanism", mechanism), mlog.Field("explanation", rexplanation), mlog.Field("duration", time.Since(start)))
304 resolver = dns.WithPackage(resolver, "spf")
306 if args.dnsRequests == nil {
307 args.dnsRequests = new(int)
308 args.voidLookups = new(int)
311 // Response is authentic until we find a non-authentic DNS response.
314 // To4 returns nil for an IPv6 address. To16 will return an IPv4-to-IPv6-mapped address.
316 remote4 := args.RemoteIP.To4()
318 remote6 = args.RemoteIP.To16()
321 // Check if ip matches remote ip, taking cidr mask into account.
322 checkIP := func(ip net.IP, d Directive) bool {
330 if d.IP4CIDRLen != nil {
333 mask := net.CIDRMask(ones, 32)
334 return ip4.Mask(mask).Equal(remote4.Mask(mask))
342 if d.IP6CIDRLen != nil {
345 mask := net.CIDRMask(ones, 128)
346 return ip6.Mask(mask).Equal(remote6.Mask(mask))
349 // Used for "a" and "mx".
350 checkHostIP := func(domain dns.Domain, d Directive, args *Args) (bool, Status, error) {
351 ips, result, err := resolver.LookupIP(ctx, "ip", domain.ASCII+".")
352 rauthentic = rauthentic && result.Authentic
353 trackVoidLookup(err, args)
354 // If "not found", we must ignore the error and treat as zero records in answer.
../rfc/7208:1116
355 if err != nil && !dns.IsNotFound(err) {
356 return false, StatusTemperror, err
358 for _, ip := range ips {
360 return true, StatusPass, nil
363 return false, StatusNone, nil
366 for _, d := range record.Directives {
370 case "include", "a", "mx", "ptr", "exists":
371 if err := trackLookupLimits(&args); err != nil {
372 return StatusPermerror, d.MechanismString(), "", rauthentic, err
383 name, authentic, err := expandDomainSpecDNS(ctx, resolver, d.DomainSpec, args)
384 rauthentic = rauthentic && authentic
386 return StatusPermerror, d.MechanismString(), "", rauthentic, fmt.Errorf("expanding domain-spec for include: %w", err)
389 nargs.domain = dns.Domain{ASCII: strings.TrimSuffix(name, ".")}
391 status, _, _, authentic, err := checkHost(ctx, resolver, nargs)
392 rauthentic = rauthentic && authentic
397 case StatusTemperror:
398 return StatusTemperror, d.MechanismString(), "", rauthentic, fmt.Errorf("include %q: %w", name, err)
399 case StatusPermerror, StatusNone:
400 return StatusPermerror, d.MechanismString(), "", rauthentic, fmt.Errorf("include %q resulted in status %q: %w", name, status, err)
405 // note: the syntax for DomainSpec hints that macros should be expanded. But
406 // expansion is explicitly documented, and only for "include", "exists" and
407 // "redirect". This reason for this could be low-effort reuse of the domain-spec
408 // ABNF rule. It could be an oversight. We are not implementing expansion for the
409 // mechanism for which it isn't specified.
410 host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
412 return StatusPermerror, d.MechanismString(), "", rauthentic, err
414 hmatch, status, err := checkHostIP(host, d, &args)
416 return status, d.MechanismString(), "", rauthentic, err
422 host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
424 return StatusPermerror, d.MechanismString(), "", rauthentic, err
426 // Note: LookupMX can return an error and still return MX records.
427 mxs, result, err := resolver.LookupMX(ctx, host.ASCII+".")
428 rauthentic = rauthentic && result.Authentic
429 trackVoidLookup(err, &args)
430 // note: we handle "not found" simply as a result of zero mx records.
431 if err != nil && !dns.IsNotFound(err) {
432 return StatusTemperror, d.MechanismString(), "", rauthentic, err
434 if err == nil && len(mxs) == 1 && mxs[0].Host == "." {
438 for i, mx := range mxs {
440 // requests. This seems independent of the overall limit of 10 DNS requests. So an
441 // MX request resulting in 11 names is valid, but we must return a permerror if we
442 // found no match before the 11th name.
445 return StatusPermerror, d.MechanismString(), "", rauthentic, ErrTooManyDNSRequests
447 // Parsing lax (unless in pedantic mode) for MX targets with underscores as seen in the wild.
448 mxd, err := dns.ParseDomainLax(strings.TrimSuffix(mx.Host, "."))
450 return StatusPermerror, d.MechanismString(), "", rauthentic, err
452 hmatch, status, err := checkHostIP(mxd, d, &args)
454 return status, d.MechanismString(), "", rauthentic, err
464 host, err := evaluateDomainSpec(d.DomainSpec, args.domain)
466 return StatusPermerror, d.MechanismString(), "", rauthentic, err
469 rnames, result, err := resolver.LookupAddr(ctx, args.RemoteIP.String())
470 rauthentic = rauthentic && result.Authentic
471 trackVoidLookup(err, &args)
472 if err != nil && !dns.IsNotFound(err) {
473 return StatusTemperror, d.MechanismString(), "", rauthentic, err
477 for _, rname := range rnames {
478 rd, err := dns.ParseDomain(strings.TrimSuffix(rname, "."))
480 log.Errorx("bad address in ptr record", err, mlog.Field("address", rname))
484 if rd.ASCII != host.ASCII && !strings.HasSuffix(rd.ASCII, "."+host.ASCII) {
493 ips, result, err := resolver.LookupIP(ctx, "ip", rd.ASCII+".")
494 rauthentic = rauthentic && result.Authentic
495 trackVoidLookup(err, &args)
496 for _, ip := range ips {
507 match = checkIP(d.IP, d)
511 match = checkIP(d.IP, d)
516 name, authentic, err := expandDomainSpecDNS(ctx, resolver, d.DomainSpec, args)
517 rauthentic = rauthentic && authentic
519 return StatusPermerror, d.MechanismString(), "", rauthentic, fmt.Errorf("expanding domain-spec for exists: %w", err)
522 ips, result, err := resolver.LookupIP(ctx, "ip4", ensureAbsDNS(name))
523 rauthentic = rauthentic && result.Authentic
524 // Note: we do count this for void lookups, as that is an anti-abuse mechanism.
526 trackVoidLookup(err, &args)
527 if err != nil && !dns.IsNotFound(err) {
528 return StatusTemperror, d.MechanismString(), "", rauthentic, err
533 return StatusNone, d.MechanismString(), "", rauthentic, fmt.Errorf("internal error, unexpected mechanism %q", d.Mechanism)
541 return StatusPass, d.MechanismString(), "", rauthentic, nil
543 return StatusNeutral, d.MechanismString(), "", rauthentic, nil
547 authentic, expl := explanation(ctx, resolver, record, nargs)
548 rauthentic = rauthentic && authentic
549 return StatusFail, d.MechanismString(), expl, rauthentic, nil
551 return StatusSoftfail, d.MechanismString(), "", rauthentic, nil
553 return StatusNone, d.MechanismString(), "", rauthentic, fmt.Errorf("internal error, unexpected qualifier %q", d.Qualifier)
556 if record.Redirect != "" {
560 name, authentic, err := expandDomainSpecDNS(ctx, resolver, record.Redirect, args)
561 rauthentic = rauthentic && authentic
563 return StatusPermerror, "", "", rauthentic, fmt.Errorf("expanding domain-spec: %w", err)
566 nargs.domain = dns.Domain{ASCII: strings.TrimSuffix(name, ".")}
568 status, mechanism, expl, authentic, err := checkHost(ctx, resolver, nargs)
569 rauthentic = rauthentic && authentic
570 if status == StatusNone {
571 return StatusPermerror, mechanism, "", rauthentic, err
573 return status, mechanism, expl, rauthentic, err
577 return StatusNeutral, "default", "", rauthentic, nil
580// evaluateDomainSpec returns the parsed dns domain for spec if non-empty, and
581// otherwise returns d, which must be the Domain in checkHost Args.
582func evaluateDomainSpec(spec string, d dns.Domain) (dns.Domain, error) {
587 d, err := dns.ParseDomain(spec)
589 return d, fmt.Errorf("%w: %s", ErrName, err)
594func expandDomainSpecDNS(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args) (string, bool, error) {
595 return expandDomainSpec(ctx, resolver, domainSpec, args, true)
598func expandDomainSpecExp(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args) (string, bool, error) {
599 return expandDomainSpec(ctx, resolver, domainSpec, args, false)
602// expandDomainSpec interprets macros in domainSpec.
603// The expansion can fail due to macro syntax errors or DNS errors.
607func expandDomainSpec(ctx context.Context, resolver dns.Resolver, domainSpec string, args Args, dns bool) (string, bool, error) {
610 rauthentic := true // Until non-authentic record is found.
614 b := &strings.Builder{}
626 return "", rauthentic, fmt.Errorf("%w: trailing bare %%", ErrMacroSyntax)
640 return "", rauthentic, fmt.Errorf("%w: invalid macro opening %%%c", ErrMacroSyntax, c)
644 return "", rauthentic, fmt.Errorf("%w: missing macro ending }", ErrMacroSyntax)
650 if c >= 'A' && c <= 'Z' {
658 // todo: should we check for utf8 in localpart, and fail? we may now generate utf8 strings to places that may not be able to parse them. it will probably lead to relatively harmless error somewhere else. perhaps we can just transform the localpart to IDN? because it may be used in a dns lookup.
../rfc/7208:1507
659 v = smtp.NewAddress(args.senderLocalpart, args.senderDomain).String()
661 // todo: same about utf8 as for 's'.
662 v = string(args.senderLocalpart)
664 v = args.senderDomain.ASCII
666 v = args.domain.ASCII
668 v = expandIP(args.RemoteIP)
671 if err := trackLookupLimits(&args); err != nil {
672 return "", rauthentic, err
674 names, result, err := resolver.LookupAddr(ctx, args.RemoteIP.String())
675 rauthentic = rauthentic && result.Authentic
676 trackVoidLookup(err, &args)
677 if len(names) == 0 || err != nil {
683 // Verify finds the first dns name that resolves to the remote ip.
684 verify := func(matchfn func(string) bool) (string, error) {
685 for _, name := range names {
689 ips, result, err := resolver.LookupIP(ctx, "ip", name)
690 rauthentic = rauthentic && result.Authentic
691 trackVoidLookup(err, &args)
693 for _, ip := range ips {
694 if ip.Equal(args.RemoteIP) {
702 // First exact domain name matches, then subdomains, finally other names.
703 domain := args.domain.ASCII + "."
704 dotdomain := "." + domain
705 v, err = verify(func(name string) bool { return name == domain })
707 return "", rauthentic, err
710 v, err = verify(func(name string) bool { return strings.HasSuffix(name, dotdomain) })
712 return "", rauthentic, err
716 v, err = verify(func(name string) bool { return name != domain && !strings.HasSuffix(name, dotdomain) })
718 return "", rauthentic, err
727 if args.RemoteIP.To4() != nil {
733 if args.HelloDomain.IsIP() {
734 //
../rfc/7208:1621 explicitly says "domain", not "ip". We'll handle IP, probably does no harm.
735 v = expandIP(args.HelloDomain.IP)
737 v = args.HelloDomain.Domain.ASCII
741 return "", rauthentic, fmt.Errorf("%w: macro letter %c only allowed in exp", ErrMacroSyntax, c)
745 v = args.LocalIP.String()
747 v = args.LocalHostname.ASCII
749 v = fmt.Sprintf("%d", timeNow().Unix())
752 return "", rauthentic, fmt.Errorf("%w: unknown macro letter %c", ErrMacroSyntax, c)
756 for i < n && s[i] >= '0' && s[i] <= '9' {
757 digits += string(s[i])
762 v, err := strconv.Atoi(digits)
764 return "", rauthentic, fmt.Errorf("%w: bad macro transformer digits %q: %s", ErrMacroSyntax, digits, err)
768 return "", rauthentic, fmt.Errorf("%w: zero labels for digits transformer", ErrMacroSyntax)
772 // If "r" follows, we must reverse the resulting name, splitting on a dot by default.
775 if i < n && (s[i] == 'r' || s[i] == 'R') {
780 // Delimiters to split on, for subset of labels and/or reversing.
784 case '.', '-', '+', ',', '/', '_', '=':
785 delim += string(s[i])
792 if i >= n || s[i] != '}' {
793 return "", rauthentic, fmt.Errorf("%w: missing closing } for macro", ErrMacroSyntax)
797 // Only split and subset and/or reverse if necessary.
798 if nlabels >= 0 || reverse || delim != "" {
807 for i := 0; i < h; i++ {
808 t[i], t[nt-1-i] = t[nt-1-i], t[i]
811 if nlabels > 0 && nlabels < len(t) {
812 t = t[len(t)-nlabels:]
815 v = strings.Join(t, ".")
820 v = url.QueryEscape(v)
827 isAbs := strings.HasSuffix(r, ".")
829 if err := validateDNS(r); err != nil {
830 return "", rauthentic, fmt.Errorf("invalid dns name: %s", err)
832 // If resulting name is too large, cut off labels on the left until it fits.
../rfc/7208:1749
834 labels := strings.Split(r, ".")
835 for i := range labels {
836 if i == len(labels)-1 {
837 return "", rauthentic, fmt.Errorf("expanded dns name too long")
839 s := strings.Join(labels[i+1:], ".")
850 return r, rauthentic, nil
853func expandIP(ip net.IP) string {
859 for i, b := range ip.To16() {
863 v += fmt.Sprintf("%x.%x", b>>4, b&0xf)
868// validateDNS checks if a DNS name is valid. Must not end in dot. This does not
869// check valid host names, e.g. _ is allows in DNS but not in a host name.
870func validateDNS(s string) error {
872 // note: we are not checking for max 253 bytes length, because one of the callers may be chopping off labels to "correct" the name.
873 labels := strings.Split(s, ".")
874 if len(labels) > 128 {
875 return fmt.Errorf("more than 128 labels")
877 for _, label := range labels[:len(labels)-1] {
879 return fmt.Errorf("label longer than 63 bytes")
883 return fmt.Errorf("empty dns label")
889func split(v, delim string) (r []string) {
890 isdelim := func(c rune) bool {
891 for _, d := range delim {
900 for i, c := range v {
902 r = append(r, v[s:i])
910// explanation does a best-effort attempt to fetch an explanation for a StatusFail response.
911// If no explanation could be composed, an empty string is returned.
912func explanation(ctx context.Context, resolver dns.Resolver, r *Record, args Args) (bool, string) {
915 // If this record is the result of an "include", we have to use the explanation
916 // string of the original domain, not of this domain.
918 expl := r.Explanation
919 if args.explanation != nil {
920 expl = *args.explanation
928 // Limits for dns requests and void lookups should not be taken into account.
929 // Starting with zero ensures they aren't triggered.
930 args.dnsRequests = new(int)
931 args.voidLookups = new(int)
932 name, authentic, err := expandDomainSpecDNS(ctx, resolver, r.Explanation, args)
933 if err != nil || name == "" {
936 txts, result, err := resolver.LookupTXT(ctx, ensureAbsDNS(name))
937 authentic = authentic && result.Authentic
938 if err != nil || len(txts) == 0 {
941 txt := strings.Join(txts, "")
942 s, exauthentic, err := expandDomainSpecExp(ctx, resolver, txt, args)
943 authentic = authentic && exauthentic
950func ensureAbsDNS(s string) string {
951 if !strings.HasSuffix(s, ".") {
957func trackLookupLimits(args *Args) error {
959 if *args.dnsRequests >= dnsRequestsMax {
960 return ErrTooManyDNSRequests
963 if *args.voidLookups >= voidLookupsMax {
964 return ErrTooManyVoidLookups
970func trackVoidLookup(err error, args *Args) {
971 if dns.IsNotFound(err) {