From 402f7011d4fa1aceace7680cbffa38e2f32e22d8 Mon Sep 17 00:00:00 2001 From: NicoMen Date: Tue, 31 Jul 2018 12:32:04 +0200 Subject: [PATCH] Fix ACME certificate for wildcard and root domains --- provider/acme/provider.go | 82 ++++++++++++++++++++++++++++++++-- provider/acme/provider_test.go | 58 ++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/provider/acme/provider.go b/provider/acme/provider.go index 57377cfd2..4a3ccdec2 100644 --- a/provider/acme/provider.go +++ b/provider/acme/provider.go @@ -13,6 +13,7 @@ import ( "time" "github.com/BurntSushi/ty/fun" + "github.com/cenk/backoff" "github.com/containous/flaeg" "github.com/containous/traefik/log" "github.com/containous/traefik/rules" @@ -74,6 +75,8 @@ type Certificate struct { type DNSChallenge struct { Provider string `description:"Use a DNS-01 based challenge provider rather than HTTPS."` DelayBeforeCheck flaeg.Duration `description:"Assume DNS propagates after a delay in seconds rather than finding and querying nameservers."` + preCheckTimeout time.Duration + preCheckInterval time.Duration } // HTTPChallenge contains HTTP challenge Configuration @@ -262,6 +265,16 @@ func (p *Provider) getClient() (*acme.Client, error) { if err != nil { return nil, err } + + // Same default values than LEGO + p.DNSChallenge.preCheckTimeout = 60 * time.Second + p.DNSChallenge.preCheckInterval = 2 * time.Second + + // Set the precheck timeout into the DNSChallenge provider + if challengeProviderTimeout, ok := provider.(acme.ChallengeProviderTimeout); ok { + p.DNSChallenge.preCheckTimeout, p.DNSChallenge.preCheckInterval = challengeProviderTimeout.Timeout() + } + } else if p.HTTPChallenge != nil && len(p.HTTPChallenge.EntryPoint) > 0 { log.Debug("Using HTTP Challenge provider.") @@ -361,13 +374,20 @@ func (p *Provider) resolveCertificate(domain types.Domain, domainFromConfigurati return nil, fmt.Errorf("cannot get ACME client %v", err) } + var certificate *acme.CertificateResource bundle := true - - certificate, err := client.ObtainCertificate(uncheckedDomains, bundle, nil, OSCPMustStaple) - if err != nil { - return nil, fmt.Errorf("cannot obtain certificates: %+v", err) + if p.useCertificateWithRetry(uncheckedDomains) { + certificate, err = obtainCertificateWithRetry(domains, client, p.DNSChallenge.preCheckTimeout, p.DNSChallenge.preCheckInterval, bundle) + } else { + certificate, err = client.ObtainCertificate(domains, bundle, nil, OSCPMustStaple) } + if err != nil { + return nil, fmt.Errorf("unable to generate a certificate for the domains %v: %v", uncheckedDomains, err) + } + if certificate == nil { + return nil, fmt.Errorf("domains %v do not generate a certificate", uncheckedDomains) + } if len(certificate.Certificate) == 0 || len(certificate.PrivateKey) == 0 { return nil, fmt.Errorf("domains %v generate certificate with no value: %v", uncheckedDomains, certificate) } @@ -384,6 +404,60 @@ func (p *Provider) resolveCertificate(domain types.Domain, domainFromConfigurati return certificate, nil } +func (p *Provider) useCertificateWithRetry(domains []string) bool { + // Check if we can use the retry mechanism only if we use the DNS Challenge and if is there are at least 2 domains to check + if p.DNSChallenge != nil && len(domains) > 1 { + rootDomain := "" + for _, searchWildcardDomain := range domains { + // Search a wildcard domain if not already found + if len(rootDomain) == 0 && strings.HasPrefix(searchWildcardDomain, "*.") { + rootDomain = strings.TrimPrefix(searchWildcardDomain, "*.") + if len(rootDomain) > 0 { + // Look for a root domain which matches the wildcard domain + for _, searchRootDomain := range domains { + if rootDomain == searchRootDomain { + // If the domains list contains a wildcard domain and its root domain, we can use the retry mechanism to obtain the certificate + return true + } + } + } + // There is only one wildcard domain in the slice, if its root domain has not been found, the retry mechanism does not have to be used + return false + } + } + } + + return false +} + +func obtainCertificateWithRetry(domains []string, client *acme.Client, timeout, interval time.Duration, bundle bool) (*acme.CertificateResource, error) { + var certificate *acme.CertificateResource + var err error + + operation := func() error { + certificate, err = client.ObtainCertificate(domains, bundle, nil, OSCPMustStaple) + return err + } + + notify := func(err error, time time.Duration) { + log.Errorf("Error obtaining certificate retrying in %s", time) + } + + // Define a retry backOff to let LEGO tries twice to obtain a certificate for both wildcard and root domain + ebo := backoff.NewExponentialBackOff() + ebo.MaxElapsedTime = 2 * timeout + ebo.MaxInterval = interval + rbo := backoff.WithMaxRetries(ebo, 2) + + err = backoff.RetryNotify(safe.OperationWithRecover(operation), rbo, notify) + if err != nil { + log.Errorf("Error obtaining certificate: %v", err) + return nil, err + } + + return certificate, nil +} + func dnsOverrideDelay(delay flaeg.Duration) error { if delay == 0 { return nil diff --git a/provider/acme/provider_test.go b/provider/acme/provider_test.go index 869b916fb..abbe34a0f 100644 --- a/provider/acme/provider_test.go +++ b/provider/acme/provider_test.go @@ -504,3 +504,61 @@ func TestIsAccountMatchingCaServer(t *testing.T) { }) } } + +func TestUseBackOffToObtainCertificate(t *testing.T) { + testCases := []struct { + desc string + domains []string + dnsChallenge *DNSChallenge + expectedResponse bool + }{ + { + desc: "only one single domain", + domains: []string{"acme.wtf"}, + dnsChallenge: &DNSChallenge{}, + expectedResponse: false, + }, + { + desc: "only one wildcard domain", + domains: []string{"*.acme.wtf"}, + dnsChallenge: &DNSChallenge{}, + expectedResponse: false, + }, + { + desc: "wildcard domain with no root domain", + domains: []string{"*.acme.wtf", "foo.acme.wtf", "bar.acme.wtf", "foo.bar"}, + dnsChallenge: &DNSChallenge{}, + expectedResponse: false, + }, + { + desc: "wildcard and root domain", + domains: []string{"*.acme.wtf", "foo.acme.wtf", "bar.acme.wtf", "acme.wtf"}, + dnsChallenge: &DNSChallenge{}, + expectedResponse: true, + }, + { + desc: "wildcard and root domain but no DNS challenge", + domains: []string{"*.acme.wtf", "acme.wtf"}, + dnsChallenge: nil, + expectedResponse: false, + }, + { + desc: "two wildcard domains (must never happen)", + domains: []string{"*.acme.wtf", "*.bar.foo"}, + dnsChallenge: nil, + expectedResponse: false, + }, + } + + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + acmeProvider := Provider{Configuration: &Configuration{DNSChallenge: test.dnsChallenge}} + + actualResponse := acmeProvider.useCertificateWithRetry(test.domains) + assert.Equal(t, test.expectedResponse, actualResponse, "unexpected response to use backOff") + }) + } +}