diff --git a/acme/acme.go b/acme/acme.go index 623fe72d5..805b2b744 100644 --- a/acme/acme.go +++ b/acme/acme.go @@ -46,9 +46,9 @@ type ACME struct { OnHostRule bool `description:"Enable certificate generation on frontends Host rules."` CAServer string `description:"CA server to use."` EntryPoint string `description:"Entrypoint to proxy acme challenge to."` - DNSChallenge *acmeprovider.DNSChallenge `description:"Activate DNS-02 Challenge"` + DNSChallenge *acmeprovider.DNSChallenge `description:"Activate DNS-01 Challenge"` HTTPChallenge *acmeprovider.HTTPChallenge `description:"Activate HTTP-01 Challenge"` - DNSProvider string `description:"Activate DNS-02 Challenge (Deprecated)"` // deprecated + DNSProvider string `description:"Activate DNS-01 Challenge (Deprecated)"` // deprecated DelayDontCheckDNS flaeg.Duration `description:"Assume DNS propagates after a delay in seconds rather than finding and querying nameservers."` // deprecated ACMELogging bool `description:"Enable debug logging of ACME actions."` client *acme.Client @@ -62,20 +62,6 @@ type ACME struct { } func (a *ACME) init() error { - // FIXME temporary fix, waiting for https://github.com/xenolf/lego/pull/478 - acme.HTTPClient = http.Client{ - Transport: &http.Transport{ - Proxy: http.ProxyFromEnvironment, - Dial: (&net.Dialer{ - Timeout: 30 * time.Second, - KeepAlive: 30 * time.Second, - }).Dial, - TLSHandshakeTimeout: 15 * time.Second, - ResponseHeaderTimeout: 15 * time.Second, - ExpectContinueTimeout: 1 * time.Second, - }, - } - if a.ACMELogging { acme.Logger = fmtlog.New(os.Stderr, "legolog: ", fmtlog.LstdFlags) } else { @@ -651,6 +637,7 @@ func (a *ACME) runJobs() { // getValidDomains checks if given domain is allowed to generate a ACME certificate and return it func (a *ACME) getValidDomains(domains []string, wildcardAllowed bool) ([]string, error) { + // Check if the domains array is empty or contains only one empty value if len(domains) == 0 || (len(domains) == 1 && len(domains[0]) == 0) { return nil, errors.New("unable to generate a certificate when no domain is given") } @@ -663,15 +650,14 @@ func (a *ACME) getValidDomains(domains []string, wildcardAllowed bool) ([]string if a.DNSChallenge == nil && len(a.DNSProvider) == 0 { return nil, fmt.Errorf("unable to generate a wildcard certificate for domain %q : ACME needs a DNSChallenge", strings.Join(domains, ",")) } - - if len(domains) > 1 { - return nil, fmt.Errorf("unable to generate a wildcard certificate for domain %q : SANs are not allowed", strings.Join(domains, ",")) + if strings.HasPrefix(domains[0], "*.*") { + return nil, fmt.Errorf("unable to generate a wildcard certificate for domain %q : ACME does not allow '*.*' wildcard domain", strings.Join(domains, ",")) } - } else { - for _, san := range domains[1:] { - if strings.HasPrefix(san, "*") { - return nil, fmt.Errorf("unable to generate a certificate in ACME provider for domains %q: SANs can not be a wildcard domain", strings.Join(domains, ",")) - } + } + for _, san := range domains[1:] { + if strings.HasPrefix(san, "*") { + return nil, fmt.Errorf("unable to generate a certificate for domains %q: SANs can not be a wildcard domain", strings.Join(domains, ",")) + } } @@ -710,31 +696,37 @@ func (a *ACME) deleteUnnecessaryDomains() { keepDomain = false } break - } else if strings.HasPrefix(domain.Main, "*") && domain.SANs == nil { - // Check if domains can be validated by the wildcard domain - - var newDomainsToCheck []string - - // Check if domains can be validated by the wildcard domain - domainsMap := make(map[string]*tls.Certificate) - domainsMap[domain.Main] = &tls.Certificate{} - - for _, domainProcessed := range domainToCheck.ToStrArray() { - if isDomainAlreadyChecked(domainProcessed, domainsMap) { - log.Warnf("Domain %q will not be processed by ACME because it is validated by the wildcard %q", domainProcessed, domain.Main) - continue - } - newDomainsToCheck = append(newDomainsToCheck, domainProcessed) - } - - // Delete the domain if both Main and SANs can be validated by the wildcard domain - // otherwise keep the unchecked values - if newDomainsToCheck == nil { - keepDomain = false - break - } - domainToCheck.Set(newDomainsToCheck) } + + var newDomainsToCheck []string + + // Check if domains can be validated by the wildcard domain + domainsMap := make(map[string]*tls.Certificate) + domainsMap[domain.Main] = &tls.Certificate{} + if len(domain.SANs) > 0 { + domainsMap[strings.Join(domain.SANs, ",")] = &tls.Certificate{} + } + + for _, domainProcessed := range domainToCheck.ToStrArray() { + if idxDomain < idxDomainToCheck && isDomainAlreadyChecked(domainProcessed, domainsMap) { + // The domain is duplicated in a CN + log.Warnf("Domain %q is duplicated in the configuration or validated by the domain %v. It will be processed once.", domainProcessed, domain) + continue + } else if domain.Main != domainProcessed && strings.HasPrefix(domain.Main, "*") && types.MatchDomain(domainProcessed, domain.Main) { + // Check if a wildcard can validate the domain + log.Warnf("Domain %q will not be processed by ACME provider because it is validated by the wildcard %q", domainProcessed, domain.Main) + continue + } + newDomainsToCheck = append(newDomainsToCheck, domainProcessed) + } + + // Delete the domain if both Main and SANs can be validated by the wildcard domain + // otherwise keep the unchecked values + if newDomainsToCheck == nil { + keepDomain = false + break + } + domainToCheck.Set(newDomainsToCheck) } if keepDomain { diff --git a/acme/acme_test.go b/acme/acme_test.go index 066929f73..8c7c78280 100644 --- a/acme/acme_test.go +++ b/acme/acme_test.go @@ -417,11 +417,27 @@ func TestAcme_getValidDomain(t *testing.T) { expectedDomains: nil, }, { - desc: "unexpected SANs", - domains: []string{"*.traefik.wtf", "foo.traefik.wtf"}, + desc: "unauthorized wildcard with SAN", + domains: []string{"*.*.traefik.wtf", "foo.traefik.wtf"}, dnsChallenge: &acmeprovider.DNSChallenge{}, wildcardAllowed: true, - expectedErr: "unable to generate a wildcard certificate for domain \"*.traefik.wtf,foo.traefik.wtf\" : SANs are not allowed", + expectedErr: "unable to generate a wildcard certificate for domain \"*.*.traefik.wtf,foo.traefik.wtf\" : ACME does not allow '*.*' wildcard domain", + expectedDomains: nil, + }, + { + desc: "wildcard with SANs", + domains: []string{"*.traefik.wtf", "traefik.wtf"}, + dnsChallenge: &acmeprovider.DNSChallenge{}, + wildcardAllowed: true, + expectedErr: "", + expectedDomains: []string{"*.traefik.wtf", "traefik.wtf"}, + }, + { + desc: "unexpected SANs", + domains: []string{"*.traefik.wtf", "*.acme.wtf"}, + dnsChallenge: &acmeprovider.DNSChallenge{}, + wildcardAllowed: true, + expectedErr: "unable to generate a certificate for domains \"*.traefik.wtf,*.acme.wtf\": SANs can not be a wildcard domain", expectedDomains: nil, }, } diff --git a/docs/configuration/acme.md b/docs/configuration/acme.md index d980a0521..aadf4873a 100644 --- a/docs/configuration/acme.md +++ b/docs/configuration/acme.md @@ -112,7 +112,7 @@ entryPoint = "https" # entryPoint = "http" -# Use a DNS-01/DNS-02 acme challenge rather than HTTP-01 challenge. +# Use a DNS-01/DNS-01 acme challenge rather than HTTP-01 challenge. # Note : Mandatory for wildcard certificates generation. # # Optional @@ -264,7 +264,7 @@ defaultEntryPoints = ["http", "https"] ### `dnsChallenge` -Use `DNS-01/DNS-02` challenge to generate/renew ACME certificates. +Use `DNS-01/DNS-01` challenge to generate/renew ACME certificates. ```toml [acme] @@ -276,7 +276,7 @@ Use `DNS-01/DNS-02` challenge to generate/renew ACME certificates. ``` !!! note - ACME wildcard certificates can only be generated thanks to a `DNS-02` challenge. + ACME wildcard certificates can only be generated thanks to a `DNS-01` challenge. #### `provider` @@ -397,14 +397,18 @@ CA server to use. main = "local3.com" [[acme.domains]] main = "*.local4.com" + sans = ["local4.com", "test1.test1.local4.com"] # ... ``` #### Wildcard domains -Wildcard domain has to be defined as a main domain **with no SANs** (alternative domains). +Wildcard domain has to be defined as a main domain. All domains must have A/AAAA records pointing to Træfik. +Due to ACME limitation, it's not possible to define a wildcard as a SAN (alternative domains). +It's neither possible to define a wildcard on a wildcard domain (for example `*.*.local.com`). + !!! warning Note that Let's Encrypt has [rate limiting](https://letsencrypt.org/docs/rate-limits). @@ -435,9 +439,9 @@ Each domain & SANs will lead to a certificate request. [ACME V2](https://community.letsencrypt.org/t/acme-v2-and-wildcard-certificate-support-is-live/55579) allows wildcard certificate support. However, this feature needs a specific configuration. -### DNS-02 Challenge +### DNS-01 Challenge -As described in [Let's Encrypt post](https://community.letsencrypt.org/t/staging-endpoint-for-acme-v2/49605), wildcard certificates can only be generated through a `DNS-02`Challenge. +As described in [Let's Encrypt post](https://community.letsencrypt.org/t/staging-endpoint-for-acme-v2/49605), wildcard certificates can only be generated through a `DNS-01` Challenge. This challenge is linked to the Træfik option `acme.dnsChallenge`. ```toml @@ -454,16 +458,88 @@ For more information about this option, please refer to the [dnsChallenge sectio ### Wildcard domain Wildcard domains can currently be provided only by to the `acme.domains` option. -Theses domains can not have SANs. ```toml [acme] # ... [[acme.domains]] - main = "*local1.com" + main = "*.local1.com" + sans = ["local1.com"] [[acme.domains]] main = "*.local2.com" # ... ``` For more information about this option, please refer to the [domains section](/configuration/acme/#domains). + +### Limitations + +Let's Encrypt wildcard support have some limitations to take into account : + +- Wildcard domain can not be a SAN (alternative domain), +- Wildcard domain on a wildcard domain is forbidden (for example `*.*.local.com`), +- A DNS-01 Challenge is executed for each domain (CN and SANs), DNS provider can not manage correctly this behavior as explained in the [DNS provider support section](/configuration/acme/#dns-provider-support) + + +### DNS provider support + +All DNS providers allow creating ACME wildcard certificates. +However, many troubles can appear for wildcard domains with SANs. + +If a wildcard domain is defined with it root domain as SAN, as described below, 2 DNS-01 Challenges will be executed. + +```toml +[acme] +# ... +[[acme.domains]] + main = "*.local1.com" + sans = ["local1.com"] +# ... +``` + +When a DNS-01 Challenge is done, Let's Encrypt checks if a TXT record is created with a given name and a given value. +When a certificate is generated for a wildcard domain is defined with it root domain as SAN, the requested TXT record name for both the wildcard domain and the root domain is the same. + +The [DNS RFC](https://community.letsencrypt.org/t/wildcard-issuance-two-txt-records-for-the-same-name/54528/2) allows this behavior. +But all DNS providers keep TXT records values in a cache with a TTL. +In function of the parameters given by the Træfik ACME client library ([LEGO](https://github.com/xenolf/lego)), the TXT record TTL can be superior to challenge Timeout. +In that event, the DNS-01 Challenge will not work correctly. + +[LEGO](https://github.com/xenolf/lego) will involve in the way to be adapted to all of DNS providers. +Meanwhile, the table described below contains all the DNS providers supported by Træfik and indicates if they allow generating certificates for a wildcard domain and its root domain. +Do not hesitate to complete it. + +| Provider Name | Provider code | Wildcard and Root Domain Support | +|--------------------------------------------------------|----------------|----------------------------------| +| [Auroradns](https://www.pcextreme.com/aurora/dns) | `auroradns` | Not tested yet | +| [Azure](https://azure.microsoft.com/services/dns/) | `azure` | Not tested yet | +| [Blue Cat](https://www.bluecatnetworks.com/) | `bluecat` | Not tested yet | +| [Cloudflare](https://www.cloudflare.com) | `cloudflare` | YES | +| [CloudXNS](https://www.cloudxns.net) | `cloudxns` | Not tested yet | +| [DigitalOcean](https://www.digitalocean.com) | `digitalocean` | YES | +| [DNSimple](https://dnsimple.com) | `dnsimple` | Not tested yet | +| [DNS Made Easy](https://dnsmadeeasy.com) | `dnsmadeeasy` | Not tested yet | +| [DNSPod](http://www.dnspod.net/) | `dnspod` | Not tested yet | +| [Duck DNS](https://www.duckdns.org/) | `duckdns` | Not tested yet | +| [Dyn](https://dyn.com) | `dyn` | Not tested yet | +| External Program | `exec` | Not tested yet | +| [Exoscale](https://www.exoscale.ch) | `exoscale` | Not tested yet | +| [Fast DNS](https://www.akamai.com/) | `fastdns` | Not tested yet | +| [Gandi](https://www.gandi.net) | `gandi` | Not tested yet | +| [Gandi V5](http://doc.livedns.gandi.net) | `gandiv5` | Not tested yet | +| [Glesys](https://glesys.com/) | `glesys` | Not tested yet | +| [GoDaddy](https://godaddy.com/domains) | `godaddy` | Not tested yet | +| [Google Cloud DNS](https://cloud.google.com/dns/docs/) | `gcloud` | YES | +| [Lightsail](https://aws.amazon.com/lightsail/) | `lightsail` | Not tested yet | +| [Linode](https://www.linode.com) | `linode` | Not tested yet | +| manual | - | YES | +| [Namecheap](https://www.namecheap.com) | `namecheap` | Not tested yet | +| [name.com](https://www.name.com/) | `namedotcom` | Not tested yet | +| [Ns1](https://ns1.com/) | `ns1` | Not tested yet | +| [Open Telekom Cloud](https://cloud.telekom.de/en/) | `otc` | Not tested yet | +| [OVH](https://www.ovh.com) | `ovh` | YES | +| [PowerDNS](https://www.powerdns.com) | `pdns` | Not tested yet | +| [Rackspace](https://www.rackspace.com/cloud/dns) | `rackspace` | Not tested yet | +| [RFC2136](https://tools.ietf.org/html/rfc2136) | `rfc2136` | Not tested yet | +| [Route 53](https://aws.amazon.com/route53/) | `route53` | YES | +| [VULTR](https://www.vultr.com) | `vultr` | Not tested yet | diff --git a/provider/acme/provider.go b/provider/acme/provider.go index f23b030da..26fc66ccd 100644 --- a/provider/acme/provider.go +++ b/provider/acme/provider.go @@ -42,7 +42,7 @@ type Configuration struct { EntryPoint string `description:"EntryPoint to use."` OnHostRule bool `description:"Enable certificate generation on frontends Host rules."` OnDemand bool `description:"Enable on demand certificate generation. This will request a certificate from Let's Encrypt during the first TLS handshake for a hostname that does not yet have a certificate."` //deprecated - DNSChallenge *DNSChallenge `description:"Activate DNS-02 Challenge"` + DNSChallenge *DNSChallenge `description:"Activate DNS-01 Challenge"` HTTPChallenge *HTTPChallenge `description:"Activate HTTP-01 Challenge"` Domains []types.Domain `description:"CN and SANs (alternative domains) to each main domain using format: --acme.domains='main.com,san1.com,san2.com' --acme.domains='*.main.net'. No SANs for wildcards domain. Wildcard domains only accepted with DNSChallenge"` } @@ -72,7 +72,7 @@ type Certificate struct { // DNSChallenge contains DNS challenge Configuration type DNSChallenge struct { - Provider string `description:"Use a DNS-02 based challenge provider rather than HTTPS."` + 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."` } @@ -565,16 +565,16 @@ func (p *Provider) getValidDomains(domain types.Domain, wildcardAllowed bool) ([ if p.DNSChallenge == nil { return nil, fmt.Errorf("unable to generate a wildcard certificate in ACME provider for domain %q : ACME needs a DNSChallenge", strings.Join(domains, ",")) } - if len(domain.SANs) > 0 { - return nil, fmt.Errorf("unable to generate a wildcard certificate in ACME provider for domain %q : SANs are not allowed", strings.Join(domains, ",")) - } - } else { - for _, san := range domain.SANs { - if strings.HasPrefix(san, "*") { - return nil, fmt.Errorf("unable to generate a certificate in ACME provider for domains %q: SANs can not be a wildcard domain", strings.Join(domains, ",")) - } + if strings.HasPrefix(domain.Main, "*.*") { + return nil, fmt.Errorf("unable to generate a wildcard certificate in ACME provider for domain %q : ACME does not allow '*.*' wildcard domain", strings.Join(domains, ",")) } } + for _, san := range domain.SANs { + if strings.HasPrefix(san, "*") { + return nil, fmt.Errorf("unable to generate a certificate in ACME provider for domains %q: SAN %q can not be a wildcard domain", strings.Join(domains, ","), san) + } + } + domains = fun.Map(types.CanonicalDomain, domains).([]string) return domains, nil } @@ -610,26 +610,31 @@ func (p *Provider) deleteUnnecessaryDomains() { keepDomain = false } break - } else if strings.HasPrefix(domain.Main, "*") && domain.SANs == nil { - - // Check if domains can be validated by the wildcard domain - var newDomainsToCheck []string - for _, domainProcessed := range domainToCheck.ToStrArray() { - if isDomainAlreadyChecked(domainProcessed, domain.ToStrArray()) { - log.Warnf("Domain %q will not be processed by ACME provider because it is validated by the wildcard %q", domainProcessed, domain.Main) - continue - } - newDomainsToCheck = append(newDomainsToCheck, domainProcessed) - } - - // Delete the domain if both Main and SANs can be validated by the wildcard domain - // otherwise keep the unchecked values - if newDomainsToCheck == nil { - keepDomain = false - break - } - domainToCheck.Set(newDomainsToCheck) } + + // Check if CN or SANS to check already exists + // or can not be checked by a wildcard + var newDomainsToCheck []string + for _, domainProcessed := range domainToCheck.ToStrArray() { + if idxDomain < idxDomainToCheck && isDomainAlreadyChecked(domainProcessed, domain.ToStrArray()) { + // The domain is duplicated in a CN + log.Warnf("Domain %q is duplicated in the configuration or validated by the domain %v. It will be processed once.", domainProcessed, domain) + continue + } else if domain.Main != domainProcessed && strings.HasPrefix(domain.Main, "*") && isDomainAlreadyChecked(domainProcessed, []string{domain.Main}) { + // Check if a wildcard can validate the domain + log.Warnf("Domain %q will not be processed by ACME provider because it is validated by the wildcard %q", domainProcessed, domain.Main) + continue + } + newDomainsToCheck = append(newDomainsToCheck, domainProcessed) + } + + // Delete the domain if both Main and SANs can be validated by the wildcard domain + // otherwise keep the unchecked values + if newDomainsToCheck == nil { + keepDomain = false + break + } + domainToCheck.Set(newDomainsToCheck) } if keepDomain { diff --git a/provider/acme/provider_test.go b/provider/acme/provider_test.go index 97f3318a5..2f6a3db96 100644 --- a/provider/acme/provider_test.go +++ b/provider/acme/provider_test.go @@ -207,11 +207,27 @@ func TestGetValidDomain(t *testing.T) { expectedDomains: nil, }, { - desc: "unexpected SANs", - domains: types.Domain{Main: "*.traefik.wtf", SANs: []string{"foo.traefik.wtf"}}, + desc: "unauthorized wildcard with SAN", + domains: types.Domain{Main: "*.*.traefik.wtf", SANs: []string{"foo.traefik.wtf"}}, dnsChallenge: &DNSChallenge{}, wildcardAllowed: true, - expectedErr: "unable to generate a wildcard certificate in ACME provider for domain \"*.traefik.wtf,foo.traefik.wtf\" : SANs are not allowed", + expectedErr: "unable to generate a wildcard certificate in ACME provider for domain \"*.*.traefik.wtf,foo.traefik.wtf\" : ACME does not allow '*.*' wildcard domain", + expectedDomains: nil, + }, + { + desc: "wildcard and SANs", + domains: types.Domain{Main: "*.traefik.wtf", SANs: []string{"traefik.wtf"}}, + dnsChallenge: &DNSChallenge{}, + wildcardAllowed: true, + expectedErr: "", + expectedDomains: []string{"*.traefik.wtf", "traefik.wtf"}, + }, + { + desc: "unexpected SANs", + domains: types.Domain{Main: "*.traefik.wtf", SANs: []string{"*.acme.wtf"}}, + dnsChallenge: &DNSChallenge{}, + wildcardAllowed: true, + expectedErr: "unable to generate a certificate in ACME provider for domains \"*.traefik.wtf,*.acme.wtf\": SAN \"*.acme.wtf\" can not be a wildcard domain", expectedDomains: nil, }, } @@ -251,8 +267,8 @@ func TestDeleteUnnecessaryDomains(t *testing.T) { Main: "*.foo.acme.wtf", }, { - Main: "acme.wtf", - SANs: []string{"traefik.acme.wtf", "bar.foo"}, + Main: "acme02.wtf", + SANs: []string{"traefik.acme02.wtf", "bar.foo"}, }, }, expectedDomains: []types.Domain{ @@ -262,15 +278,38 @@ func TestDeleteUnnecessaryDomains(t *testing.T) { }, { Main: "*.foo.acme.wtf", + SANs: []string{}, }, { - Main: "acme.wtf", - SANs: []string{"traefik.acme.wtf", "bar.foo"}, + Main: "acme02.wtf", + SANs: []string{"traefik.acme02.wtf", "bar.foo"}, }, }, }, { - desc: "2 domains with same values", + desc: "wildcard and root domain", + domains: []types.Domain{ + { + Main: "acme.wtf", + }, + { + Main: "*.acme.wtf", + SANs: []string{"acme.wtf"}, + }, + }, + expectedDomains: []types.Domain{ + { + Main: "acme.wtf", + SANs: []string{}, + }, + { + Main: "*.acme.wtf", + SANs: []string{}, + }, + }, + }, + { + desc: "2 equals domains", domains: []types.Domain{ { Main: "acme.wtf", @@ -288,6 +327,29 @@ func TestDeleteUnnecessaryDomains(t *testing.T) { }, }, }, + { + desc: "2 domains with same values", + domains: []types.Domain{ + { + Main: "acme.wtf", + SANs: []string{"traefik.acme.wtf"}, + }, + { + Main: "acme.wtf", + SANs: []string{"traefik.acme.wtf", "foo.bar"}, + }, + }, + expectedDomains: []types.Domain{ + { + Main: "acme.wtf", + SANs: []string{"traefik.acme.wtf"}, + }, + { + Main: "foo.bar", + SANs: []string{}, + }, + }, + }, { desc: "domain totally checked by wildcard", domains: []types.Domain{ @@ -302,6 +364,25 @@ func TestDeleteUnnecessaryDomains(t *testing.T) { expectedDomains: []types.Domain{ { Main: "*.acme.wtf", + SANs: []string{}, + }, + }, + }, + { + desc: "duplicated wildcard", + domains: []types.Domain{ + { + Main: "*.acme.wtf", + SANs: []string{"acme.wtf"}, + }, + { + Main: "*.acme.wtf", + }, + }, + expectedDomains: []types.Domain{ + { + Main: "*.acme.wtf", + SANs: []string{"acme.wtf"}, }, }, }, @@ -315,6 +396,10 @@ func TestDeleteUnnecessaryDomains(t *testing.T) { { Main: "*.acme.wtf", }, + { + Main: "who.acme.wtf", + SANs: []string{"traefik.acme.wtf", "bar.acme.wtf"}, + }, }, expectedDomains: []types.Domain{ { @@ -323,6 +408,7 @@ func TestDeleteUnnecessaryDomains(t *testing.T) { }, { Main: "*.acme.wtf", + SANs: []string{}, }, }, },