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