1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379
|
package certmagic
import (
"context"
"errors"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"go.uber.org/zap"
)
// Code in this file adapted from go-acme/lego, July 2020:
// https://github.com/go-acme/lego
// by Ludovic Fernandez and Dominik Menke
//
// It has been modified.
// FindZoneByFQDN determines the zone apex for the given fully-qualified
// domain name (FQDN) by recursing up the domain labels until the nameserver
// returns a SOA record in the answer section. The logger must be non-nil.
//
// EXPERIMENTAL: This API was previously unexported, and may be changed or
// unexported again in the future. Do not rely on it at this time.
func FindZoneByFQDN(ctx context.Context, logger *zap.Logger, fqdn string, nameservers []string) (string, error) {
if !strings.HasSuffix(fqdn, ".") {
fqdn += "."
}
soa, err := lookupSoaByFqdn(ctx, logger, fqdn, nameservers)
if err != nil {
return "", err
}
return soa.zone, nil
}
func lookupSoaByFqdn(ctx context.Context, logger *zap.Logger, fqdn string, nameservers []string) (*soaCacheEntry, error) {
logger = logger.Named("soa_lookup")
if !strings.HasSuffix(fqdn, ".") {
fqdn += "."
}
fqdnSOACacheMu.Lock()
defer fqdnSOACacheMu.Unlock()
if err := ctx.Err(); err != nil {
return nil, err
}
// prefer cached version if fresh
if ent := fqdnSOACache[fqdn]; ent != nil && !ent.isExpired() {
logger.Debug("using cached SOA result", zap.String("entry", ent.zone))
return ent, nil
}
ent, err := fetchSoaByFqdn(ctx, logger, fqdn, nameservers)
if err != nil {
return nil, err
}
// save result to cache, but don't allow
// the cache to grow out of control
if len(fqdnSOACache) >= 1000 {
for key := range fqdnSOACache {
delete(fqdnSOACache, key)
break
}
}
fqdnSOACache[fqdn] = ent
return ent, nil
}
func fetchSoaByFqdn(ctx context.Context, logger *zap.Logger, fqdn string, nameservers []string) (*soaCacheEntry, error) {
var err error
var in *dns.Msg
labelIndexes := dns.Split(fqdn)
for _, index := range labelIndexes {
if err := ctx.Err(); err != nil {
return nil, err
}
domain := fqdn[index:]
in, err = dnsQuery(ctx, domain, dns.TypeSOA, nameservers, true)
if err != nil {
continue
}
if in == nil {
continue
}
logger.Debug("fetched SOA", zap.String("msg", in.String()))
switch in.Rcode {
case dns.RcodeSuccess:
// Check if we got a SOA RR in the answer section
if len(in.Answer) == 0 {
continue
}
// CNAME records cannot/should not exist at the root of a zone.
// So we skip a domain when a CNAME is found.
if dnsMsgContainsCNAME(in) {
continue
}
for _, ans := range in.Answer {
if soa, ok := ans.(*dns.SOA); ok {
return newSoaCacheEntry(soa), nil
}
}
case dns.RcodeNameError:
// NXDOMAIN
default:
// Any response code other than NOERROR and NXDOMAIN is treated as error
return nil, fmt.Errorf("unexpected response code '%s' for %s", dns.RcodeToString[in.Rcode], domain)
}
}
return nil, fmt.Errorf("could not find the start of authority for %s%s", fqdn, formatDNSError(in, err))
}
// dnsMsgContainsCNAME checks for a CNAME answer in msg
func dnsMsgContainsCNAME(msg *dns.Msg) bool {
for _, ans := range msg.Answer {
if _, ok := ans.(*dns.CNAME); ok {
return true
}
}
return false
}
func dnsQuery(ctx context.Context, fqdn string, rtype uint16, nameservers []string, recursive bool) (*dns.Msg, error) {
m := createDNSMsg(fqdn, rtype, recursive)
var in *dns.Msg
var err error
for _, ns := range nameservers {
in, err = sendDNSQuery(ctx, m, ns)
if err == nil && len(in.Answer) > 0 {
break
}
}
return in, err
}
func createDNSMsg(fqdn string, rtype uint16, recursive bool) *dns.Msg {
m := new(dns.Msg)
m.SetQuestion(fqdn, rtype)
// See: https://caddy.community/t/hard-time-getting-a-response-on-a-dns-01-challenge/15721/16
m.SetEdns0(1232, false)
if !recursive {
m.RecursionDesired = false
}
return m
}
func sendDNSQuery(ctx context.Context, m *dns.Msg, ns string) (*dns.Msg, error) {
udp := &dns.Client{Net: "udp", Timeout: dnsTimeout}
in, _, err := udp.ExchangeContext(ctx, m, ns)
// two kinds of errors we can handle by retrying with TCP:
// truncation and timeout; see https://github.com/caddyserver/caddy/issues/3639
truncated := in != nil && in.Truncated
timeoutErr := err != nil && strings.Contains(err.Error(), "timeout")
if truncated || timeoutErr {
tcp := &dns.Client{Net: "tcp", Timeout: dnsTimeout}
in, _, err = tcp.ExchangeContext(ctx, m, ns)
}
return in, err
}
func formatDNSError(msg *dns.Msg, err error) string {
var parts []string
if msg != nil {
parts = append(parts, dns.RcodeToString[msg.Rcode])
}
if err != nil {
parts = append(parts, err.Error())
}
if len(parts) > 0 {
return ": " + strings.Join(parts, " ")
}
return ""
}
// soaCacheEntry holds a cached SOA record (only selected fields)
type soaCacheEntry struct {
zone string // zone apex (a domain name)
primaryNs string // primary nameserver for the zone apex
expires time.Time // time when this cache entry should be evicted
}
func newSoaCacheEntry(soa *dns.SOA) *soaCacheEntry {
return &soaCacheEntry{
zone: soa.Hdr.Name,
primaryNs: soa.Ns,
expires: time.Now().Add(time.Duration(soa.Refresh) * time.Second),
}
}
// isExpired checks whether a cache entry should be considered expired.
func (cache *soaCacheEntry) isExpired() bool {
return time.Now().After(cache.expires)
}
// systemOrDefaultNameservers attempts to get system nameservers from the
// resolv.conf file given by path before falling back to hard-coded defaults.
func systemOrDefaultNameservers(path string, defaults []string) []string {
config, err := dns.ClientConfigFromFile(path)
if err != nil || len(config.Servers) == 0 {
return defaults
}
return config.Servers
}
// populateNameserverPorts ensures that all nameservers have a port number
// If not, the the default DNS server port of 53 will be appended.
func populateNameserverPorts(servers []string) {
for i := range servers {
_, port, _ := net.SplitHostPort(servers[i])
if port == "" {
servers[i] = net.JoinHostPort(servers[i], "53")
}
}
}
// checkDNSPropagation checks if the expected record has been propagated to all authoritative nameservers.
func checkDNSPropagation(ctx context.Context, logger *zap.Logger, fqdn string, recType uint16, expectedValue string, checkAuthoritativeServers bool, resolvers []string) (bool, error) {
logger = logger.Named("propagation")
if !strings.HasSuffix(fqdn, ".") {
fqdn += "."
}
// Initial attempt to resolve at the recursive NS - but do not actually
// dereference (follow) a CNAME record if we are targeting a CNAME record
// itself
if recType != dns.TypeCNAME {
r, err := dnsQuery(ctx, fqdn, recType, resolvers, true)
if err != nil {
return false, fmt.Errorf("CNAME dns query: %v", err)
}
if r.Rcode == dns.RcodeSuccess {
fqdn = updateDomainWithCName(r, fqdn)
}
}
if checkAuthoritativeServers {
authoritativeServers, err := lookupNameservers(ctx, logger, fqdn, resolvers)
if err != nil {
return false, fmt.Errorf("looking up authoritative nameservers: %v", err)
}
populateNameserverPorts(authoritativeServers)
resolvers = authoritativeServers
}
logger.Debug("checking authoritative nameservers", zap.Strings("resolvers", resolvers))
return checkAuthoritativeNss(ctx, fqdn, recType, expectedValue, resolvers)
}
// checkAuthoritativeNss queries each of the given nameservers for the expected record.
func checkAuthoritativeNss(ctx context.Context, fqdn string, recType uint16, expectedValue string, nameservers []string) (bool, error) {
for _, ns := range nameservers {
r, err := dnsQuery(ctx, fqdn, recType, []string{ns}, true)
if err != nil {
return false, fmt.Errorf("querying authoritative nameservers: %v", err)
}
if r.Rcode != dns.RcodeSuccess {
if r.Rcode == dns.RcodeNameError || r.Rcode == dns.RcodeServerFailure {
// if Present() succeeded, then it must show up eventually, or else
// something is really broken in the DNS provider or their API;
// no need for error here, simply have the caller try again
return false, nil
}
return false, fmt.Errorf("NS %s returned %s for %s", ns, dns.RcodeToString[r.Rcode], fqdn)
}
for _, rr := range r.Answer {
switch recType {
case dns.TypeTXT:
if txt, ok := rr.(*dns.TXT); ok {
record := strings.Join(txt.Txt, "")
if record == expectedValue {
return true, nil
}
}
case dns.TypeCNAME:
if cname, ok := rr.(*dns.CNAME); ok {
// TODO: whether a DNS provider assumes a trailing dot or not varies, and we may have to standardize this in libdns packages
if strings.TrimSuffix(cname.Target, ".") == strings.TrimSuffix(expectedValue, ".") {
return true, nil
}
}
default:
return false, fmt.Errorf("unsupported record type: %d", recType)
}
}
}
return false, nil
}
// lookupNameservers returns the authoritative nameservers for the given fqdn.
func lookupNameservers(ctx context.Context, logger *zap.Logger, fqdn string, resolvers []string) ([]string, error) {
var authoritativeNss []string
zone, err := FindZoneByFQDN(ctx, logger, fqdn, resolvers)
if err != nil {
return nil, fmt.Errorf("could not determine the zone for '%s': %w", fqdn, err)
}
r, err := dnsQuery(ctx, zone, dns.TypeNS, resolvers, true)
if err != nil {
return nil, fmt.Errorf("querying NS resolver for zone '%s' recursively: %v", zone, err)
}
for _, rr := range r.Answer {
if ns, ok := rr.(*dns.NS); ok {
authoritativeNss = append(authoritativeNss, strings.ToLower(ns.Ns))
}
}
if len(authoritativeNss) > 0 {
return authoritativeNss, nil
}
return nil, errors.New("could not determine authoritative nameservers")
}
// Update FQDN with CNAME if any
func updateDomainWithCName(r *dns.Msg, fqdn string) string {
for _, rr := range r.Answer {
if cn, ok := rr.(*dns.CNAME); ok {
if cn.Hdr.Name == fqdn {
return cn.Target
}
}
}
return fqdn
}
// RecursiveNameservers are used to pre-check DNS propagation. It
// picks user-configured nameservers (custom) OR the defaults
// obtained from resolv.conf and defaultNameservers if none is
// configured and ensures that all server addresses have a port value.
//
// EXPERIMENTAL: This API was previously unexported, and may be
// be unexported again in the future. Do not rely on it at this time.
func RecursiveNameservers(custom []string) []string {
var servers []string
if len(custom) == 0 {
servers = systemOrDefaultNameservers(defaultResolvConf, defaultNameservers)
} else {
servers = make([]string, len(custom))
copy(servers, custom)
}
populateNameserverPorts(servers)
return servers
}
var defaultNameservers = []string{
"8.8.8.8:53",
"8.8.4.4:53",
"1.1.1.1:53",
"1.0.0.1:53",
}
var dnsTimeout = 10 * time.Second
var (
fqdnSOACache = map[string]*soaCacheEntry{}
fqdnSOACacheMu sync.Mutex
)
const defaultResolvConf = "/etc/resolv.conf"
|