1// Package dns helps parse internationalized domain names (IDNA), canonicalize
2// names and provides a strict and metrics-keeping logging DNS resolver.
3package dns
4
5import (
6 "errors"
7 "fmt"
8 "strings"
9
10 "golang.org/x/net/idna"
11
12 "github.com/mjl-/adns"
13)
14
15// Pedantic enables stricter parsing.
16var Pedantic bool
17
18var (
19 errTrailingDot = errors.New("dns name has trailing dot")
20 errUnderscore = errors.New("domain name with underscore")
21 errIDNA = errors.New("idna")
22)
23
24// Domain is a domain name, with one or more labels, with at least an ASCII
25// representation, and for IDNA non-ASCII domains a unicode representation.
26// The ASCII string must be used for DNS lookups. The strings do not have a
27// trailing dot. When using with StrictResolver, add the trailing dot.
28type Domain struct {
29 // A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved
30 // letters/digits/hyphens) labels. Always in lower case. No trailing dot.
31 ASCII string
32
33 // Name as U-labels, in Unicode NFC. Empty if this is an ASCII-only domain. No
34 // trailing dot.
35 Unicode string
36}
37
38// Name returns the unicode name if set, otherwise the ASCII name.
39func (d Domain) Name() string {
40 if d.Unicode != "" {
41 return d.Unicode
42 }
43 return d.ASCII
44}
45
46// XName is like Name, but only returns a unicode name when utf8 is true.
47func (d Domain) XName(utf8 bool) string {
48 if utf8 && d.Unicode != "" {
49 return d.Unicode
50 }
51 return d.ASCII
52}
53
54// ASCIIExtra returns the ASCII version of the domain name if smtputf8 is true and
55// this is a unicode domain name. Otherwise it returns an empty string.
56//
57// This function is used to add the punycode name in a comment to SMTP message
58// headers, e.g. Received and Authentication-Results.
59func (d Domain) ASCIIExtra(smtputf8 bool) string {
60 if smtputf8 && d.Unicode != "" {
61 return d.ASCII
62 }
63 return ""
64}
65
66// Strings returns a human-readable string.
67// For IDNA names, the string contains both the unicode and ASCII name.
68func (d Domain) String() string {
69 return d.LogString()
70}
71
72// LogString returns a domain for logging.
73// For IDNA names, the string is the slash-separated Unicode and ASCII name.
74// For ASCII-only domain names, just the ASCII string is returned.
75func (d Domain) LogString() string {
76 if d.Unicode == "" {
77 return d.ASCII
78 }
79 return d.Unicode + "/" + d.ASCII
80}
81
82// IsZero returns if this is an empty Domain.
83func (d Domain) IsZero() bool {
84 return d == Domain{}
85}
86
87// ParseDomain parses a domain name that can consist of ASCII-only labels or U
88// labels (unicode).
89// Names are IDN-canonicalized and lower-cased.
90// Characters in unicode can be replaced by equivalents. E.g. "Ⓡ" to "r". This
91// means you should only compare parsed domain names, never unparsed strings
92// directly.
93func ParseDomain(s string) (Domain, error) {
94 if strings.HasSuffix(s, ".") {
95 return Domain{}, errTrailingDot
96 }
97
98 ascii, err := idna.Lookup.ToASCII(s)
99 if err != nil {
100 return Domain{}, fmt.Errorf("%w: to ascii: %v", errIDNA, err)
101 }
102 unicode, err := idna.Lookup.ToUnicode(s)
103 if err != nil {
104 return Domain{}, fmt.Errorf("%w: to unicode: %w", errIDNA, err)
105 }
106 // todo: should we cause errors for unicode domains that were not in
107 // canonical form? we are now accepting all kinds of obscure spellings
108 // for even a basic ASCII domain name.
109 // Also see https://daniel.haxx.se/blog/2022/12/14/idn-is-crazy/
110 if ascii == unicode {
111 return Domain{ascii, ""}, nil
112 }
113 return Domain{ascii, unicode}, nil
114}
115
116// ParseDomainLax parses a domain like ParseDomain, but allows labels with
117// underscores if the entire domain name is ASCII-only non-IDNA and Pedantic mode
118// is not enabled. Used for interoperability, e.g. domains may specify MX
119// targets with underscores.
120func ParseDomainLax(s string) (Domain, error) {
121 if Pedantic || !strings.Contains(s, "_") {
122 return ParseDomain(s)
123 }
124
125 // If there is any non-ASCII, this is certainly not an A-label-only domain.
126 s = strings.ToLower(s)
127 for _, c := range s {
128 if c >= 0x80 {
129 return Domain{}, fmt.Errorf("%w: underscore and non-ascii not allowed", errUnderscore)
130 }
131 }
132
133 // Try parsing with underscores replaced with allowed ASCII character.
134 // If that's not valid, the version with underscore isn't either.
135 repl := strings.ReplaceAll(s, "_", "a")
136 d, err := ParseDomain(repl)
137 if err != nil {
138 return Domain{}, fmt.Errorf("%w: %v", errUnderscore, err)
139 }
140 // If we found an IDNA domain, we're not going to allow it.
141 if d.Unicode != "" {
142 return Domain{}, fmt.Errorf("%w: idna domain with underscores not allowed", errUnderscore)
143 }
144 // Just to be safe, ensure no unexpected conversions happened.
145 if d.ASCII != repl {
146 return Domain{}, fmt.Errorf("%w: underscores and non-canonical names not allowed", errUnderscore)
147 }
148 return Domain{ASCII: s}, nil
149}
150
151// IsNotFound returns whether an error is an adns.DNSError with IsNotFound set.
152// IsNotFound means the requested type does not exist for the given domain (a
153// nodata or nxdomain response). It doesn't not necessarily mean no other types for
154// that name exist.
155//
156// A DNS server can respond to a lookup with an error "nxdomain" to indicate a
157// name does not exist (at all), or with a success status with an empty list.
158// The adns resolver (just like the Go resolver) returns an IsNotFound error for
159// both cases, there is no need to explicitly check for zero entries.
160func IsNotFound(err error) bool {
161 var dnsErr *adns.DNSError
162 return err != nil && errors.As(err, &dnsErr) && dnsErr.IsNotFound
163}
164