From 7d9dfc3fe638c92bb04936ca2fd8849cb9fc6417 Mon Sep 17 00:00:00 2001 From: Matthew Holt Date: Thu, 30 Jul 2020 14:07:04 -0600 Subject: [PATCH] Add DNS-01 solver implementation that uses acmez and libdns APIs Before when we used lego as our ACME library, DNS solvers abounded in the lego repository and they could be used directly. Our new acmez lib is very lightweight, and "bring-your-own-solvers", let alone your own DNS provider implementations. DNS providers are implemented in libdns: https://github.com/libdns This commit adds an implementation of acmez.Solver that solves the DNS challenge using libdns providers. Unlike the other solvers, this one is exported because it is not a challenge type that is enabled by default, and there is more config surface. We borrowed some DNS utility functions and tests from the lego repo. But this is a very lightweight implementation that has a much, much simpler API and smaller footprint. --- acmeclient.go | 4 +- acmemanager.go | 11 +- dnsutil.go | 334 +++++++++++++++++++++++++++++++++++++++++ dnsutil_test.go | 211 ++++++++++++++++++++++++++ go.mod | 5 +- go.sum | 13 +- solvers.go | 139 +++++++++++++++++ testdata/resolv.conf.1 | 5 + 8 files changed, 711 insertions(+), 11 deletions(-) create mode 100644 dnsutil.go create mode 100644 dnsutil_test.go create mode 100644 testdata/resolv.conf.1 diff --git a/acmeclient.go b/acmeclient.go index a9960ef..44259eb 100644 --- a/acmeclient.go +++ b/acmeclient.go @@ -142,7 +142,7 @@ func (am *ACMEManager) newACMEClient(ctx context.Context, useTestCA, interactive // configure challenges (most of the time, DNS challenge is // exclusive of other ones because it is usually only used // in situations where the default challenges would fail) - if am.DNSProvider == nil { + if am.DNS01Solver == nil { // enable HTTP-01 challenge if !am.DisableHTTPChallenge { useHTTPPort := HTTPChallengePort @@ -182,7 +182,7 @@ func (am *ACMEManager) newACMEClient(ctx context.Context, useTestCA, interactive } } else { // use DNS challenge exclusively - client.ChallengeSolvers[acme.ChallengeTypeDNS01] = am.DNSProvider + client.ChallengeSolvers[acme.ChallengeTypeDNS01] = am.DNS01Solver } // register account if it is new diff --git a/acmemanager.go b/acmemanager.go index e611414..7f3736e 100644 --- a/acmemanager.go +++ b/acmemanager.go @@ -68,9 +68,10 @@ type ACMEManager struct { // challenge to succeed AltTLSALPNPort int - // The DNS provider to use when solving the - // ACME DNS challenge - DNSProvider acmez.Solver + // The solver for the dns-01 challenge; + // usually this is a DNS01Solver value + // from this package + DNS01Solver acmez.Solver // TrustedRoots specifies a pool of root CA // certificates to trust when communicating @@ -136,8 +137,8 @@ func NewACMEManager(cfg *Config, template ACMEManager) *ACMEManager { if template.AltTLSALPNPort == 0 { template.AltTLSALPNPort = DefaultACME.AltTLSALPNPort } - if template.DNSProvider == nil { - template.DNSProvider = DefaultACME.DNSProvider + if template.DNS01Solver == nil { + template.DNS01Solver = DefaultACME.DNS01Solver } if template.TrustedRoots == nil { template.TrustedRoots = DefaultACME.TrustedRoots diff --git a/dnsutil.go b/dnsutil.go new file mode 100644 index 0000000..01a207a --- /dev/null +++ b/dnsutil.go @@ -0,0 +1,334 @@ +package certmagic + +import ( + "errors" + "fmt" + "net" + "strings" + "sync" + "time" + + "github.com/miekg/dns" +) + +// 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 fqdn by recursing +// up the domain labels until the nameserver returns a SOA record in the +// answer section. +func findZoneByFQDN(fqdn string, nameservers []string) (string, error) { + if !strings.HasSuffix(fqdn, ".") { + fqdn += "." + } + soa, err := lookupSoaByFqdn(fqdn, nameservers) + if err != nil { + return "", err + } + return soa.zone, nil +} + +func lookupSoaByFqdn(fqdn string, nameservers []string) (*soaCacheEntry, error) { + if !strings.HasSuffix(fqdn, ".") { + fqdn += "." + } + + fqdnSOACacheMu.Lock() + defer fqdnSOACacheMu.Unlock() + + // prefer cached version if fresh + if ent := fqdnSOACache[fqdn]; ent != nil && !ent.isExpired() { + return ent, nil + } + + ent, err := fetchSoaByFqdn(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(fqdn string, nameservers []string) (*soaCacheEntry, error) { + var err error + var in *dns.Msg + + labelIndexes := dns.Split(fqdn) + for _, index := range labelIndexes { + domain := fqdn[index:] + + in, err = dnsQuery(domain, dns.TypeSOA, nameservers, true) + if err != nil { + continue + } + if in == nil { + continue + } + + 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(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(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) + m.SetEdns0(4096, false) + if !recursive { + m.RecursionDesired = false + } + return m +} + +func sendDNSQuery(m *dns.Msg, ns string) (*dns.Msg, error) { + udp := &dns.Client{Net: "udp", Timeout: dnsTimeout} + in, _, err := udp.Exchange(m, ns) + if in != nil && in.Truncated { + tcp := &dns.Client{Net: "tcp", Timeout: dnsTimeout} + // If the TCP request succeeds, the err will reset to nil + in, _, err = tcp.Exchange(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, fmt.Sprintf("%v", err)) + } + 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) +} + +// getNameservers attempts to get systems nameservers before falling back to the defaults +func getNameservers(path string, defaults []string) []string { + config, err := dns.ClientConfigFromFile(path) + if err != nil || len(config.Servers) == 0 { + return defaults + } + return parseNameservers(config.Servers) +} + +func parseNameservers(servers []string) []string { + var resolvers []string + for _, resolver := range servers { + // ensure all servers have a port number + if _, _, err := net.SplitHostPort(resolver); err != nil { + resolvers = append(resolvers, net.JoinHostPort(resolver, "53")) + } else { + resolvers = append(resolvers, resolver) + } + } + return resolvers +} + +// checkDNSPropagation checks if the expected TXT record has been propagated to all authoritative nameservers. +func checkDNSPropagation(fqdn, value string) (bool, error) { + if !strings.HasSuffix(fqdn, ".") { + fqdn += "." + } + + // Initial attempt to resolve at the recursive NS + r, err := dnsQuery(fqdn, dns.TypeTXT, recursiveNameservers, true) + if err != nil { + return false, err + } + + // TODO: make this configurable, maybe + // if !p.requireCompletePropagation { + // return true, nil + // } + + if r.Rcode == dns.RcodeSuccess { + fqdn = updateDomainWithCName(r, fqdn) + } + + authoritativeNss, err := lookupNameservers(fqdn) + if err != nil { + return false, err + } + + return checkAuthoritativeNss(fqdn, value, authoritativeNss) +} + +// checkAuthoritativeNss queries each of the given nameservers for the expected TXT record. +func checkAuthoritativeNss(fqdn, value string, nameservers []string) (bool, error) { + for _, ns := range nameservers { + r, err := dnsQuery(fqdn, dns.TypeTXT, []string{net.JoinHostPort(ns, "53")}, false) + if err != nil { + return false, err + } + + if r.Rcode != dns.RcodeSuccess { + if r.Rcode == dns.RcodeNameError { + // 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) + } + + var records []string + + var found bool + for _, rr := range r.Answer { + if txt, ok := rr.(*dns.TXT); ok { + record := strings.Join(txt.Txt, "") + records = append(records, record) + if record == value { + found = true + break + } + } + } + + if !found { + return false, fmt.Errorf("NS %s did not return the expected TXT record [fqdn: %s, value: %s]: %s", ns, fqdn, value, strings.Join(records, " ,")) + } + } + + return true, nil +} + +// lookupNameservers returns the authoritative nameservers for the given fqdn. +func lookupNameservers(fqdn string) ([]string, error) { + var authoritativeNss []string + + zone, err := findZoneByFQDN(fqdn, recursiveNameservers) + if err != nil { + return nil, fmt.Errorf("could not determine the zone: %w", err) + } + + r, err := dnsQuery(zone, dns.TypeNS, recursiveNameservers, true) + if err != nil { + return nil, 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 +var recursiveNameservers = getNameservers(defaultResolvConf, defaultNameservers) + +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" diff --git a/dnsutil_test.go b/dnsutil_test.go new file mode 100644 index 0000000..19b03f2 --- /dev/null +++ b/dnsutil_test.go @@ -0,0 +1,211 @@ +package certmagic + +// 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. + +import ( + "reflect" + "sort" + "strings" + "testing" +) + +func TestLookupNameserversOK(t *testing.T) { + testCases := []struct { + fqdn string + nss []string + }{ + { + fqdn: "books.google.com.ng.", + nss: []string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."}, + }, + { + fqdn: "www.google.com.", + nss: []string{"ns1.google.com.", "ns2.google.com.", "ns3.google.com.", "ns4.google.com."}, + }, + { + fqdn: "physics.georgetown.edu.", + nss: []string{"ns4.georgetown.edu.", "ns5.georgetown.edu.", "ns6.georgetown.edu."}, + }, + } + + for i, test := range testCases { + test := test + t.Run(test.fqdn, func(t *testing.T) { + t.Parallel() + + nss, err := lookupNameservers(test.fqdn) + if err != nil { + t.Errorf("Expected no error, got: %v", err) + } + + sort.Strings(nss) + sort.Strings(test.nss) + + if !reflect.DeepEqual(test.nss, nss) { + t.Errorf("Test %d: expected %+v but got %+v", i, test.nss, nss) + } + }) + } +} + +func TestLookupNameserversErr(t *testing.T) { + testCases := []struct { + desc string + fqdn string + error string + }{ + { + desc: "invalid tld", + fqdn: "_null.n0n0.", + error: "could not determine the zone", + }, + } + + for i, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + _, err := lookupNameservers(test.fqdn) + if err == nil { + t.Errorf("expected error, got none") + } + if !strings.Contains(err.Error(), test.error) { + t.Errorf("Test %d: Expected error to contain '%s' but got '%s'", i, test.error, err.Error()) + } + }) + } +} + +var findXByFqdnTestCases = []struct { + desc string + fqdn string + zone string + primaryNs string + nameservers []string + expectedError string +}{ + { + desc: "domain is a CNAME", + fqdn: "mail.google.com.", + zone: "google.com.", + primaryNs: "ns1.google.com.", + nameservers: recursiveNameservers, + }, + { + desc: "domain is a non-existent subdomain", + fqdn: "foo.google.com.", + zone: "google.com.", + primaryNs: "ns1.google.com.", + nameservers: recursiveNameservers, + }, + { + desc: "domain is a eTLD", + fqdn: "example.com.ac.", + zone: "ac.", + primaryNs: "a0.nic.ac.", + nameservers: recursiveNameservers, + }, + { + desc: "domain is a cross-zone CNAME", + fqdn: "cross-zone-example.assets.sh.", + zone: "assets.sh.", + primaryNs: "gina.ns.cloudflare.com.", + nameservers: recursiveNameservers, + }, + { + desc: "NXDOMAIN", + fqdn: "test.loho.jkl.", + zone: "loho.jkl.", + nameservers: []string{"1.1.1.1:53"}, + expectedError: "could not find the start of authority for test.loho.jkl.: NXDOMAIN", + }, + { + desc: "several non existent nameservers", + fqdn: "mail.google.com.", + zone: "google.com.", + primaryNs: "ns1.google.com.", + nameservers: []string{":7053", ":8053", "1.1.1.1:53"}, + }, + { + desc: "only non existent nameservers", + fqdn: "mail.google.com.", + zone: "google.com.", + nameservers: []string{":7053", ":8053", ":9053"}, + expectedError: "could not find the start of authority for mail.google.com.: read udp", + }, + { + desc: "no nameservers", + fqdn: "test.ldez.com.", + zone: "ldez.com.", + nameservers: []string{}, + expectedError: "could not find the start of authority for test.ldez.com.", + }, +} + +func TestFindZoneByFqdn(t *testing.T) { + for _, test := range findXByFqdnTestCases { + t.Run(test.desc, func(t *testing.T) { + clearFqdnCache() + + zone, err := findZoneByFQDN(test.fqdn, test.nameservers) + if test.expectedError != "" { + if err == nil { + t.Errorf("expected error, got none") + } + if !strings.Contains(err.Error(), test.expectedError) { + t.Errorf("Expected error to contain '%s' but got '%s'", test.expectedError, err.Error()) + } + } else { + if err != nil { + t.Errorf("Expected no error, but got: %v", err) + } + if zone != test.zone { + t.Errorf("Expected zone '%s' but got '%s'", zone, test.zone) + } + } + }) + } +} + +func TestResolveConfServers(t *testing.T) { + var testCases = []struct { + fixture string + expected []string + defaults []string + }{ + { + fixture: "testdata/resolv.conf.1", + defaults: []string{"127.0.0.1:53"}, + expected: []string{"10.200.3.249:53", "10.200.3.250:5353", "[2001:4860:4860::8844]:53", "[10.0.0.1]:5353"}, + }, + { + fixture: "testdata/resolv.conf.nonexistant", + defaults: []string{"127.0.0.1:53"}, + expected: []string{"127.0.0.1:53"}, + }, + } + + for i, test := range testCases { + t.Run(test.fixture, func(t *testing.T) { + result := getNameservers(test.fixture, test.defaults) + + sort.Strings(result) + sort.Strings(test.expected) + + if !reflect.DeepEqual(test.expected, result) { + t.Errorf("Test %d: Expected %v but got %v", i, test.expected, result) + } + }) + } +} + +func clearFqdnCache() { + fqdnSOACacheMu.Lock() + fqdnSOACache = make(map[string]*soaCacheEntry) + fqdnSOACacheMu.Unlock() +} diff --git a/go.mod b/go.mod index 109a0f4..1cd1b27 100644 --- a/go.mod +++ b/go.mod @@ -4,8 +4,9 @@ go 1.14 require ( github.com/klauspost/cpuid v1.2.5 + github.com/libdns/libdns v0.0.0-20200501023120-186724ffc821 github.com/mholt/acmez v0.1.0 + github.com/miekg/dns v1.1.30 go.uber.org/zap v1.15.0 - golang.org/x/crypto v0.0.0-20200427165652-729f1e841bcc - golang.org/x/tools v0.0.0-20191216052735-49a3e744a425 // indirect + golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de ) diff --git a/go.sum b/go.sum index d709e45..0393690 100644 --- a/go.sum +++ b/go.sum @@ -12,8 +12,12 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/libdns/libdns v0.0.0-20200501023120-186724ffc821 h1:663opx/RKxiISi1ozf0WbvweQpYBgf34dx8hKSIau3w= +github.com/libdns/libdns v0.0.0-20200501023120-186724ffc821/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40= github.com/mholt/acmez v0.1.0 h1:LL7WQVCjZvwbo3pjAUdsqEao5kI8nyHubAGtCzrTS5g= github.com/mholt/acmez v0.1.0/go.mod h1:rNLSzYu5VR6ggyarXDKDCNefo06cOkJn+VObTDJCk0U= +github.com/miekg/dns v1.1.30 h1:Qww6FseFn8PRfw07jueqIXqodm0JKiiKuK0DeXSqfyo= +github.com/miekg/dns v1.1.30/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -34,8 +38,8 @@ go.uber.org/zap v1.15.0/go.mod h1:Mb2vm2krFEG5DV0W9qcHBYFtp/Wku1cvYaqPsS/WYfc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200427165652-729f1e841bcc h1:ZGI/fILM2+ueot/UixBSoj9188jCAxVHEZEGhqq67I4= -golang.org/x/crypto v0.0.0-20200427165652-729f1e841bcc/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de h1:ikNHVSjEfnvz6sxdSPCaPt572qowuyMDMJLLm3Db3ig= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/lint v0.0.0-20190930215403-16217165b5de h1:5hukYrvBGR8/eNkX5mdUezrA6JiaEZDtJb9Ei+1LlBs= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= @@ -44,9 +48,14 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859 h1:R/3boaszxrf1GEUWTVDzSKVwLmSJpwZ1yqXm8j0v2QI= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478 h1:l5EDrHhldLYb3ZRHDUhXF7Om7MvYXnkV9/iQNo1lX6g= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe h1:6fAMxZRR6sl1Uq8U61gxU+kPTs2tR8uOySCbBP7BN/M= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= diff --git a/solvers.go b/solvers.go index 3cc294f..a1fea81 100644 --- a/solvers.go +++ b/solvers.go @@ -29,6 +29,7 @@ import ( "sync/atomic" "time" + "github.com/libdns/libdns" "github.com/mholt/acmez" "github.com/mholt/acmez/acme" ) @@ -246,6 +247,138 @@ func tlsALPNCertKeyName(sniName string) string { return sniName + ":acme-tls-alpn" } +// DNS01Solver is a type that makes libdns providers usable +// as ACME dns-01 challenge solvers. +// See https://github.com/libdns/libdns +type DNS01Solver struct { + // The implementation that interacts with the DNS + // provider to set or delete records. (REQUIRED) + DNSProvider ACMEDNSProvider + + // The TTL for the temporary challenge records. + TTL time.Duration + + // Maximum time to wait for temporary record to appear. + PropagationTimeout time.Duration + + txtRecords map[string]dnsPresentMemory // keyed by domain name + txtRecordsMu sync.Mutex +} + +// Present creates the DNS TXT record for the given ACME challenge. +func (s *DNS01Solver) Present(ctx context.Context, challenge acme.Challenge) error { + dnsName := challenge.DNS01TXTRecordName() + keyAuth := challenge.DNS01KeyAuthorization() + + rec := libdns.Record{ + Type: "TXT", + Name: dnsName, + Value: keyAuth, + TTL: s.TTL, + } + + zone, err := findZoneByFQDN(dnsName, recursiveNameservers) + if err != nil { + return fmt.Errorf("could not determine zone for domain %q: %v", dnsName, err) + } + + results, err := s.DNSProvider.AppendRecords(ctx, zone, []libdns.Record{rec}) + if err != nil { + return fmt.Errorf("adding temporary record for zone %s: %w", zone, err) + } + if len(results) != 1 { + return fmt.Errorf("expected one record, got %d: %v", len(results), results) + } + + // remember the record and zone we got so we can clean up more efficiently + s.txtRecordsMu.Lock() + if s.txtRecords == nil { + s.txtRecords = make(map[string]dnsPresentMemory) + } + s.txtRecords[dnsName] = dnsPresentMemory{dnsZone: zone, rec: results[0]} + s.txtRecordsMu.Unlock() + + return nil +} + +// Wait blocks until the TXT record created in Present() appears in +// authoritative lookups, i.e. until it has propagated, or until +// timeout, whichever is first. +func (s *DNS01Solver) Wait(ctx context.Context, challenge acme.Challenge) error { + dnsName := challenge.DNS01TXTRecordName() + keyAuth := challenge.DNS01KeyAuthorization() + + timeout := s.PropagationTimeout + if timeout == 0 { + timeout = 2 * time.Minute + } + const interval = 2 * time.Second + + var err error + start := time.Now() + for time.Since(start) < timeout { + select { + case <-time.After(interval): + case <-ctx.Done(): + return ctx.Err() + } + var ready bool + ready, err = checkDNSPropagation(dnsName, keyAuth) + if err != nil { + return fmt.Errorf("checking DNS propagation of %s: %w", dnsName, err) + } + if ready { + return nil + } + } + + return fmt.Errorf("timed out waiting for record to fully propagate; verify DNS provider configuration is correct - last error: %v", err) +} + +// CleanUp deletes the DNS TXT record created in Present(). +func (s *DNS01Solver) CleanUp(ctx context.Context, challenge acme.Challenge) error { + dnsName := challenge.DNS01TXTRecordName() + + defer func() { + // always forget about it so we don't leak memory + s.txtRecordsMu.Lock() + delete(s.txtRecords, dnsName) + s.txtRecordsMu.Unlock() + }() + + // recall the record we created and zone we looked up + s.txtRecordsMu.Lock() + memory, ok := s.txtRecords[dnsName] + if !ok { + s.txtRecordsMu.Unlock() + return fmt.Errorf("no memory of presenting a DNS record for %s (probably OK if presenting failed)", challenge.Identifier.Value) + } + s.txtRecordsMu.Unlock() + + // clean up the record + _, err := s.DNSProvider.DeleteRecords(ctx, memory.dnsZone, []libdns.Record{memory.rec}) + if err != nil { + return fmt.Errorf("deleting temporary record for zone %s: %w", memory.dnsZone, err) + } + + return nil +} + +type dnsPresentMemory struct { + dnsZone string + rec libdns.Record +} + +// ACMEDNSProvider defines the set of operations required for +// ACME challenges. A DNS provider must be able to append and +// delete records in order to solve ACME challenges. Find one +// you can use at https://github.com/libdns. If your provider +// isn't implemented yet, feel free to contribute! +type ACMEDNSProvider interface { + libdns.RecordAppender + libdns.RecordDeleter +} + // distributedSolver allows the ACME HTTP-01 and TLS-ALPN challenges // to be solved by an instance other than the one which initiated it. // This is useful behind load balancers or in other cluster/fleet @@ -425,3 +558,9 @@ var ( solvers = make(map[string]*solverInfo) solversMu sync.Mutex ) + +// Interface guards +var ( + _ acmez.Solver = (*DNS01Solver)(nil) + _ acmez.Waiter = (*DNS01Solver)(nil) +) diff --git a/testdata/resolv.conf.1 b/testdata/resolv.conf.1 new file mode 100644 index 0000000..3098f99 --- /dev/null +++ b/testdata/resolv.conf.1 @@ -0,0 +1,5 @@ +domain company.com +nameserver 10.200.3.249 +nameserver 10.200.3.250:5353 +nameserver 2001:4860:4860::8844 +nameserver [10.0.0.1]:5353