diff --git a/docs/content/https/tls.md b/docs/content/https/tls.md index 62a5098cc..dd756dd44 100644 --- a/docs/content/https/tls.md +++ b/docs/content/https/tls.md @@ -157,7 +157,75 @@ data: tls.key: LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCi0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0= ``` -If no default certificate is provided, Traefik generates and uses a self-signed certificate. +If no `defaultCertificate` is provided, Traefik will use the generated one. + +### ACME Default Certificate + +You can configure Traefik to use an ACME provider (like Let's Encrypt) to generate the default certificate. +The configuration to resolve the default certificate should be defined in a TLS store: + +!!! important "Precedence with the `defaultGeneratedCert` option" + + The `defaultGeneratedCert` definition takes precedence over the ACME default certificate configuration. + +```yaml tab="File (YAML)" +# Dynamic configuration + +tls: + stores: + default: + defaultGeneratedCert: + resolver: myresolver + domain: + main: example.org + sans: + - foo.example.org + - bar.example.org +``` + +```toml tab="File (TOML)" +# Dynamic configuration + +[tls.stores] + [tls.stores.default.defaultGeneratedCert] + resolver = "myresolver" + [tls.stores.default.defaultGeneratedCert.domain] + main = "example.org" + sans = ["foo.example.org", "bar.example.org"] +``` + +```yaml tab="Kubernetes" +apiVersion: traefik.containo.us/v1alpha1 +kind: TLSStore +metadata: + name: default + namespace: default + +spec: + defaultGeneratedCert: + resolver: myresolver + domain: + main: example.org + sans: + - foo.example.org + - bar.example.org +``` + +```yaml tab="Docker" +## Dynamic configuration +labels: + - "traefik.tls.stores.default.defaultgeneratedcert.resolver=myresolver" + - "traefik.tls.stores.default.defaultgeneratedcert.domain.main=example.org" + - "traefik.tls.stores.default.defaultgeneratedcert.domain.sans=foo.example.org, bar.example.org" +``` + +```json tab="Marathon" +labels: { + "traefik.tls.stores.default.defaultgeneratedcert.resolver": "myresolver", + "traefik.tls.stores.default.defaultgeneratedcert.domain.main": "example.org", + "traefik.tls.stores.default.defaultgeneratedcert.domain.sans": "foo.example.org, bar.example.org", +} +``` ## TLS Options diff --git a/docs/content/reference/dynamic-configuration/docker-labels.yml b/docs/content/reference/dynamic-configuration/docker-labels.yml index 6716ef8e5..c413f27bd 100644 --- a/docs/content/reference/dynamic-configuration/docker-labels.yml +++ b/docs/content/reference/dynamic-configuration/docker-labels.yml @@ -201,3 +201,13 @@ - "traefik.udp.routers.udprouter1.entrypoints=foobar, foobar" - "traefik.udp.routers.udprouter1.service=foobar" - "traefik.udp.services.udpservice01.loadbalancer.server.port=foobar" +- "traefik.tls.stores.Store0.defaultcertificate.certfile=foobar" +- "traefik.tls.stores.Store0.defaultcertificate.keyfile=foobar" +- "traefik.tls.stores.Store0.defaultgeneratedcert.domain.main=foobar" +- "traefik.tls.stores.Store0.defaultgeneratedcert.domain.sans=foobar, foobar" +- "traefik.tls.stores.Store0.defaultgeneratedcert.resolver=foobar" +- "traefik.tls.stores.Store1.defaultcertificate.certfile=foobar" +- "traefik.tls.stores.Store1.defaultcertificate.keyfile=foobar" +- "traefik.tls.stores.Store1.defaultgeneratedcert.domain.main=foobar" +- "traefik.tls.stores.Store1.defaultgeneratedcert.domain.sans=foobar, foobar" +- "traefik.tls.stores.Store1.defaultgeneratedcert.resolver=foobar" diff --git a/docs/content/reference/dynamic-configuration/file.toml b/docs/content/reference/dynamic-configuration/file.toml index a7e23da17..895a1f97f 100644 --- a/docs/content/reference/dynamic-configuration/file.toml +++ b/docs/content/reference/dynamic-configuration/file.toml @@ -463,7 +463,17 @@ [tls.stores.Store0.defaultCertificate] certFile = "foobar" keyFile = "foobar" + [tls.stores.Store0.defaultGeneratedCert] + resolver = "foobar" + [tls.stores.Store0.defaultGeneratedCert.domain] + main = "foobar" + sans = ["foobar", "foobar"] [tls.stores.Store1] [tls.stores.Store1.defaultCertificate] certFile = "foobar" keyFile = "foobar" + [tls.stores.Store1.defaultGeneratedCert] + resolver = "foobar" + [tls.stores.Store1.defaultGeneratedCert.domain] + main = "foobar" + sans = ["foobar", "foobar"] diff --git a/docs/content/reference/dynamic-configuration/file.yaml b/docs/content/reference/dynamic-configuration/file.yaml index 7d95a4e8f..ec3081778 100644 --- a/docs/content/reference/dynamic-configuration/file.yaml +++ b/docs/content/reference/dynamic-configuration/file.yaml @@ -518,7 +518,21 @@ tls: defaultCertificate: certFile: foobar keyFile: foobar + defaultGeneratedCert: + resolver: foobar + domain: + main: foobar + sans: + - foobar + - foobar Store1: defaultCertificate: certFile: foobar keyFile: foobar + defaultGeneratedCert: + resolver: foobar + domain: + main: foobar + sans: + - foobar + - foobar diff --git a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml index 2bcfe5b28..72845c098 100644 --- a/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml +++ b/docs/content/reference/dynamic-configuration/kubernetes-crd-definition-v1.yml @@ -1870,6 +1870,27 @@ spec: required: - secretName type: object + defaultGeneratedCert: + description: DefaultGeneratedCert defines the default generated certificate + configuration. + properties: + domain: + description: Domain is the domain definition for the DefaultCertificate. + properties: + main: + description: Main defines the main domain name. + type: string + sans: + description: SANs defines the subject alternative domain names. + items: + type: string + type: array + type: object + resolver: + description: Resolver is the name of the resolver that will be + used to issue the DefaultCertificate. + type: string + type: object type: object required: - metadata diff --git a/docs/content/reference/dynamic-configuration/kv-ref.md b/docs/content/reference/dynamic-configuration/kv-ref.md index 7927f2687..c12162ccc 100644 --- a/docs/content/reference/dynamic-configuration/kv-ref.md +++ b/docs/content/reference/dynamic-configuration/kv-ref.md @@ -319,8 +319,16 @@ | `traefik/tls/options/Options1/sniStrict` | `true` | | `traefik/tls/stores/Store0/defaultCertificate/certFile` | `foobar` | | `traefik/tls/stores/Store0/defaultCertificate/keyFile` | `foobar` | +| `traefik/tls/stores/Store0/defaultGeneratedCert/domain/main` | `foobar` | +| `traefik/tls/stores/Store0/defaultGeneratedCert/domain/sans/0` | `foobar` | +| `traefik/tls/stores/Store0/defaultGeneratedCert/domain/sans/1` | `foobar` | +| `traefik/tls/stores/Store0/defaultGeneratedCert/resolver` | `foobar` | | `traefik/tls/stores/Store1/defaultCertificate/certFile` | `foobar` | | `traefik/tls/stores/Store1/defaultCertificate/keyFile` | `foobar` | +| `traefik/tls/stores/Store1/defaultGeneratedCert/domain/main` | `foobar` | +| `traefik/tls/stores/Store1/defaultGeneratedCert/domain/sans/0` | `foobar` | +| `traefik/tls/stores/Store1/defaultGeneratedCert/domain/sans/1` | `foobar` | +| `traefik/tls/stores/Store1/defaultGeneratedCert/resolver` | `foobar` | | `traefik/udp/routers/UDPRouter0/entryPoints/0` | `foobar` | | `traefik/udp/routers/UDPRouter0/entryPoints/1` | `foobar` | | `traefik/udp/routers/UDPRouter0/service` | `foobar` | diff --git a/docs/content/reference/dynamic-configuration/marathon-labels.json b/docs/content/reference/dynamic-configuration/marathon-labels.json index 7eba46989..5da004819 100644 --- a/docs/content/reference/dynamic-configuration/marathon-labels.json +++ b/docs/content/reference/dynamic-configuration/marathon-labels.json @@ -201,3 +201,13 @@ "traefik.udp.routers.udprouter1.entrypoints": "foobar, foobar", "traefik.udp.routers.udprouter1.service": "foobar", "traefik.udp.services.udpservice01.loadbalancer.server.port": "foobar", +"traefik.tls.stores.Store0.defaultcertificate.certfile": "foobar", +"traefik.tls.stores.Store0.defaultcertificate.keyfile": "foobar", +"traefik.tls.stores.Store0.defaultgeneratedcert.domain.main": "foobar", +"traefik.tls.stores.Store0.defaultgeneratedcert.domain.sans": "foobar, foobar", +"traefik.tls.stores.Store0.defaultgeneratedcert.resolver": "foobar", +"traefik.tls.stores.Store1.defaultcertificate.certfile": "foobar", +"traefik.tls.stores.Store1.defaultcertificate.keyfile": "foobar", +"traefik.tls.stores.Store1.defaultgeneratedcert.domain.main": "foobar", +"traefik.tls.stores.Store1.defaultgeneratedcert.domain.sans": "foobar, foobar", +"traefik.tls.stores.Store1.defaultgeneratedcert.resolver": "foobar", diff --git a/docs/content/reference/dynamic-configuration/traefik.containo.us_tlsstores.yaml b/docs/content/reference/dynamic-configuration/traefik.containo.us_tlsstores.yaml index dd31abc3a..924eba285 100644 --- a/docs/content/reference/dynamic-configuration/traefik.containo.us_tlsstores.yaml +++ b/docs/content/reference/dynamic-configuration/traefik.containo.us_tlsstores.yaml @@ -63,6 +63,27 @@ spec: required: - secretName type: object + defaultGeneratedCert: + description: DefaultGeneratedCert defines the default generated certificate + configuration. + properties: + domain: + description: Domain is the domain definition for the DefaultCertificate. + properties: + main: + description: Main defines the main domain name. + type: string + sans: + description: SANs defines the subject alternative domain names. + items: + type: string + type: array + type: object + resolver: + description: Resolver is the name of the resolver that will be + used to issue the DefaultCertificate. + type: string + type: object type: object required: - metadata diff --git a/integration/acme_test.go b/integration/acme_test.go index 16b0b8398..73e9958bb 100644 --- a/integration/acme_test.go +++ b/integration/acme_test.go @@ -40,6 +40,7 @@ type acmeTestCase struct { } type templateModel struct { + Domain types.Domain Domains []types.Domain PortHTTP string PortHTTPS string @@ -149,6 +150,29 @@ func (s *AcmeSuite) TestHTTP01Domains(c *check.C) { s.retrieveAcmeCertificate(c, testCase) } +func (s *AcmeSuite) TestHTTP01StoreDomains(c *check.C) { + testCase := acmeTestCase{ + traefikConfFilePath: "fixtures/acme/acme_store_domains.toml", + subCases: []subCases{{ + host: acmeDomain, + expectedCommonName: acmeDomain, + expectedAlgorithm: x509.RSA, + }}, + template: templateModel{ + Domain: types.Domain{ + Main: "traefik.acme.wtf", + }, + Acme: map[string]static.CertificateResolver{ + "default": {ACME: &acme.Configuration{ + HTTPChallenge: &acme.HTTPChallenge{EntryPoint: "web"}, + }}, + }, + }, + } + + s.retrieveAcmeCertificate(c, testCase) +} + func (s *AcmeSuite) TestHTTP01DomainsInSAN(c *check.C) { testCase := acmeTestCase{ traefikConfFilePath: "fixtures/acme/acme_domains.toml", diff --git a/integration/fixtures/acme/acme_store_domains.toml b/integration/fixtures/acme/acme_store_domains.toml new file mode 100644 index 000000000..14bb799d9 --- /dev/null +++ b/integration/fixtures/acme/acme_store_domains.toml @@ -0,0 +1,60 @@ +[global] + checkNewVersion = false + sendAnonymousUsage = false + +[log] + level = "DEBUG" + +[entryPoints] + [entryPoints.web] + address = "{{ .PortHTTP }}" + [entryPoints.websecure] + address = "{{ .PortHTTPS }}" + +{{range $name, $resolvers := .Acme }} + +[certificatesResolvers.{{ $name }}.acme] + email = "test@traefik.io" + storage = "/tmp/acme.json" + keyType = "{{ $resolvers.ACME.KeyType }}" + caServer = "{{ $resolvers.ACME.CAServer }}" + + {{if $resolvers.ACME.HTTPChallenge }} + [certificatesResolvers.{{ $name }}.acme.httpChallenge] + entryPoint = "{{ $resolvers.ACME.HTTPChallenge.EntryPoint }}" + {{end}} + + {{if $resolvers.ACME.TLSChallenge }} + [certificatesResolvers.{{ $name }}.acme.tlsChallenge] + {{end}} + +{{end}} + +[api] + insecure = true + +[providers.file] + filename = "{{ .SelfFilename }}" + +## dynamic configuration ## + +[http.services] + [http.services.test.loadBalancer] + [[http.services.test.loadBalancer.servers]] + url = "http://127.0.0.1:9010" + +[http.routers] + [http.routers.test] + entryPoints = ["websecure"] + rule = "PathPrefix(`/`)" + service = "test" + [http.routers.test.tls] + +[tls.stores] + [tls.stores.default.defaultGeneratedCert] + resolver = "default" + [tls.stores.default.defaultGeneratedCert.domain] + main = "{{ .Domain.Main }}" + sans = [{{range .Domain.SANs }} + "{{.}}", + {{end}}] diff --git a/integration/fixtures/k8s/01-traefik-crd.yml b/integration/fixtures/k8s/01-traefik-crd.yml index 2bcfe5b28..72845c098 100644 --- a/integration/fixtures/k8s/01-traefik-crd.yml +++ b/integration/fixtures/k8s/01-traefik-crd.yml @@ -1870,6 +1870,27 @@ spec: required: - secretName type: object + defaultGeneratedCert: + description: DefaultGeneratedCert defines the default generated certificate + configuration. + properties: + domain: + description: Domain is the domain definition for the DefaultCertificate. + properties: + main: + description: Main defines the main domain name. + type: string + sans: + description: SANs defines the subject alternative domain names. + items: + type: string + type: array + type: object + resolver: + description: Resolver is the name of the resolver that will be + used to issue the DefaultCertificate. + type: string + type: object type: object required: - metadata diff --git a/integration/https_test.go b/integration/https_test.go index d30eebcc1..868d31aff 100644 --- a/integration/https_test.go +++ b/integration/https_test.go @@ -325,7 +325,7 @@ func (s *HTTPSSuite) TestWithDefaultCertificate(c *check.C) { cs := conn.ConnectionState() err = cs.PeerCertificates[0].VerifyHostname("snitest.com") - c.Assert(err, checker.IsNil, check.Commentf("certificate did not serve correct default certificate")) + c.Assert(err, checker.IsNil, check.Commentf("server did not serve correct default certificate")) proto := cs.NegotiatedProtocol c.Assert(proto, checker.Equals, "h2") @@ -360,7 +360,7 @@ func (s *HTTPSSuite) TestWithDefaultCertificateNoSNI(c *check.C) { cs := conn.ConnectionState() err = cs.PeerCertificates[0].VerifyHostname("snitest.com") - c.Assert(err, checker.IsNil, check.Commentf("certificate did not serve correct default certificate")) + c.Assert(err, checker.IsNil, check.Commentf("server did not serve correct default certificate")) proto := cs.NegotiatedProtocol c.Assert(proto, checker.Equals, "h2") @@ -397,7 +397,7 @@ func (s *HTTPSSuite) TestWithOverlappingStaticCertificate(c *check.C) { cs := conn.ConnectionState() err = cs.PeerCertificates[0].VerifyHostname("www.snitest.com") - c.Assert(err, checker.IsNil, check.Commentf("certificate did not serve correct default certificate")) + c.Assert(err, checker.IsNil, check.Commentf("server did not serve correct default certificate")) proto := cs.NegotiatedProtocol c.Assert(proto, checker.Equals, "h2") @@ -434,7 +434,7 @@ func (s *HTTPSSuite) TestWithOverlappingDynamicCertificate(c *check.C) { cs := conn.ConnectionState() err = cs.PeerCertificates[0].VerifyHostname("www.snitest.com") - c.Assert(err, checker.IsNil, check.Commentf("certificate did not serve correct default certificate")) + c.Assert(err, checker.IsNil, check.Commentf("server did not serve correct default certificate")) proto := cs.NegotiatedProtocol c.Assert(proto, checker.Equals, "h2") diff --git a/pkg/provider/acme/provider.go b/pkg/provider/acme/provider.go index a403b94ba..81a77ab5b 100644 --- a/pkg/provider/acme/provider.go +++ b/pkg/provider/acme/provider.go @@ -8,6 +8,7 @@ import ( "fmt" "net/url" "reflect" + "sort" "strings" "sync" "time" @@ -29,8 +30,8 @@ import ( "github.com/traefik/traefik/v2/pkg/version" ) -// oscpMustStaple enables OSCP stapling as from https://github.com/go-acme/lego/issues/270. -var oscpMustStaple = false +// ocspMustStaple enables OCSP stapling as from https://github.com/go-acme/lego/issues/270. +var ocspMustStaple = false // Configuration holds ACME configuration provided by users. type Configuration struct { @@ -99,10 +100,11 @@ type Provider struct { TLSChallengeProvider challenge.Provider HTTPChallengeProvider challenge.Provider - certificates []*CertAndStore + certificates []*CertAndStore + certificatesMu sync.RWMutex + account *Account client *lego.Client - certsChan chan *CertAndStore configurationChan chan<- dynamic.Message tlsManager *traefiktls.Manager clientMutex sync.Mutex @@ -152,7 +154,10 @@ func (p *Provider) Init() error { p.account = nil } + p.certificatesMu.Lock() p.certificates, err = p.Store.GetCertificates(p.ResolverName) + p.certificatesMu.Unlock() + if err != nil { return fmt.Errorf("unable to get ACME certificates : %w", err) } @@ -195,11 +200,15 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. p.pool = pool - p.watchCertificate(ctx) p.watchNewDomains(ctx) p.configurationChan = configurationChan - p.refreshCertificates() + + p.certificatesMu.RLock() + msg := p.buildMessage() + p.certificatesMu.RUnlock() + + p.configurationChan <- msg renewPeriod, renewInterval := getCertificateRenewDurations(p.CertificatesDuration) log.FromContext(ctx).Debugf("Attempt to renew certificates %q before expiry and check every %q", @@ -365,12 +374,14 @@ func (p *Provider) register(ctx context.Context, client *lego.Client) (*registra } func (p *Provider) resolveDomains(ctx context.Context, domains []string, tlsStore string) { + logger := log.FromContext(ctx) + if len(domains) == 0 { - log.FromContext(ctx).Debug("No domain parsed in provider ACME") + logger.Debug("No domain parsed in provider ACME") return } - log.FromContext(ctx).Debugf("Try to challenge certificate for domain %v found in HostSNI rule", domains) + logger.Debugf("Trying to challenge certificate for domain %v found in HostSNI rule", domains) var domain types.Domain if len(domains) > 0 { @@ -380,14 +391,22 @@ func (p *Provider) resolveDomains(ctx context.Context, domains []string, tlsStor } safe.Go(func() { - if _, err := p.resolveCertificate(ctx, domain, tlsStore); err != nil { - log.FromContext(ctx).Errorf("Unable to obtain ACME certificate for domains %q: %v", strings.Join(domains, ","), err) + dom, cert, err := p.resolveCertificate(ctx, domain, tlsStore) + if err != nil { + logger.Errorf("Unable to obtain ACME certificate for domains %q: %v", strings.Join(domains, ","), err) + return + } + + err = p.addCertificateForDomain(dom, cert.Certificate, cert.PrivateKey, tlsStore) + if err != nil { + logger.WithError(err).Error("Error adding certificate for domain") } }) } } func (p *Provider) watchNewDomains(ctx context.Context) { + ctx = log.With(ctx, log.Str(log.ProviderName, p.ResolverName+".acme")) p.pool.GoCtx(func(ctxPool context.Context) { for { select { @@ -402,31 +421,26 @@ func (p *Provider) watchNewDomains(ctx context.Context) { logger := log.FromContext(ctxRouter) if len(route.TLS.Domains) > 0 { - for _, domain := range route.TLS.Domains { - if domain.Main != dns01.UnFqdn(domain.Main) { - logger.Warnf("FQDN detected, please remove the trailing dot: %s", domain.Main) - } - for _, san := range domain.SANs { - if san != dns01.UnFqdn(san) { - logger.Warnf("FQDN detected, please remove the trailing dot: %s", san) - } - } - } - domains := deleteUnnecessaryDomains(ctxRouter, route.TLS.Domains) for i := 0; i < len(domains); i++ { domain := domains[i] safe.Go(func() { - if _, err := p.resolveCertificate(ctx, domain, traefiktls.DefaultTLSStoreName); err != nil { - log.WithoutContext().WithField(log.ProviderName, p.ResolverName+".acme"). - Errorf("Unable to obtain ACME certificate for domains %q : %v", strings.Join(domain.ToStrArray(), ","), err) + dom, cert, err := p.resolveCertificate(ctx, domain, traefiktls.DefaultTLSStoreName) + if err != nil { + logger.WithError(err).Errorf("Unable to obtain ACME certificate for domains %q", strings.Join(domain.ToStrArray(), ",")) + return + } + + err = p.addCertificateForDomain(dom, cert.Certificate, cert.PrivateKey, traefiktls.DefaultTLSStoreName) + if err != nil { + logger.WithError(err).Error("Error adding certificate for domain") } }) } } else { domains, err := tcpmuxer.ParseHostSNI(route.Rule) if err != nil { - logger.Errorf("Error parsing domains in provider ACME: %v", err) + logger.WithError(err).Errorf("Error parsing domains in provider ACME") continue } p.resolveDomains(ctxRouter, domains, traefiktls.DefaultTLSStoreName) @@ -434,32 +448,98 @@ func (p *Provider) watchNewDomains(ctx context.Context) { } } - for routerName, route := range config.HTTP.Routers { - if route.TLS == nil || route.TLS.CertResolver != p.ResolverName { + if config.HTTP != nil { + for routerName, route := range config.HTTP.Routers { + if route.TLS == nil || route.TLS.CertResolver != p.ResolverName { + continue + } + + ctxRouter := log.With(ctx, log.Str(log.RouterName, routerName), log.Str(log.Rule, route.Rule)) + logger := log.FromContext(ctxRouter) + + if len(route.TLS.Domains) > 0 { + domains := deleteUnnecessaryDomains(ctxRouter, route.TLS.Domains) + for i := 0; i < len(domains); i++ { + domain := domains[i] + safe.Go(func() { + dom, cert, err := p.resolveCertificate(ctx, domain, traefiktls.DefaultTLSStoreName) + if err != nil { + logger.WithError(err).Errorf("Unable to obtain ACME certificate for domains %q", strings.Join(domain.ToStrArray(), ",")) + return + } + + err = p.addCertificateForDomain(dom, cert.Certificate, cert.PrivateKey, traefiktls.DefaultTLSStoreName) + if err != nil { + logger.WithError(err).Error("Error adding certificate for domain") + } + }) + } + } else { + domains, err := httpmuxer.ParseDomains(route.Rule) + if err != nil { + logger.WithError(err).Errorf("Error parsing domains in provider ACME") + continue + } + p.resolveDomains(ctxRouter, domains, traefiktls.DefaultTLSStoreName) + } + } + } + + if config.TLS == nil { + continue + } + + for tlsStoreName, tlsStore := range config.TLS.Stores { + ctxTLSStore := log.With(ctx, log.Str(log.TLSStoreName, tlsStoreName)) + logger := log.FromContext(ctxTLSStore) + + if tlsStore.DefaultCertificate != nil && tlsStore.DefaultGeneratedCert != nil { + logger.Warn("defaultCertificate and defaultGeneratedCert cannot be defined at the same time.") + } + + // Gives precedence to the user defined default certificate. + if tlsStore.DefaultCertificate != nil || tlsStore.DefaultGeneratedCert == nil { continue } - ctxRouter := log.With(ctx, log.Str(log.RouterName, routerName), log.Str(log.Rule, route.Rule)) - - if len(route.TLS.Domains) > 0 { - domains := deleteUnnecessaryDomains(ctxRouter, route.TLS.Domains) - for i := 0; i < len(domains); i++ { - domain := domains[i] - safe.Go(func() { - if _, err := p.resolveCertificate(ctx, domain, traefiktls.DefaultTLSStoreName); err != nil { - log.WithoutContext().WithField(log.ProviderName, p.ResolverName+".acme"). - Errorf("Unable to obtain ACME certificate for domains %q : %v", strings.Join(domain.ToStrArray(), ","), err) - } - }) - } - } else { - domains, err := httpmuxer.ParseDomains(route.Rule) - if err != nil { - log.FromContext(ctxRouter).Errorf("Error parsing domains in provider ACME: %v", err) - continue - } - p.resolveDomains(ctxRouter, domains, traefiktls.DefaultTLSStoreName) + if tlsStore.DefaultGeneratedCert.Domain == nil || tlsStore.DefaultGeneratedCert.Resolver == "" { + logger.Warn("default generated certificate domain or resolver is missing.") + continue } + + if tlsStore.DefaultGeneratedCert.Resolver != p.ResolverName { + continue + } + + validDomains, err := p.sanitizeDomains(ctx, *tlsStore.DefaultGeneratedCert.Domain) + if err != nil { + logger.WithError(err).Errorf("domains validation: %s", strings.Join(tlsStore.DefaultGeneratedCert.Domain.ToStrArray(), ",")) + } + + if p.certExists(validDomains) { + logger.Debug("Default ACME certificate generation is not required.") + continue + } + + safe.Go(func() { + cert, err := p.resolveDefaultCertificate(ctx, validDomains) + if err != nil { + logger.WithError(err).Errorf("Unable to obtain ACME certificate for domain %q", strings.Join(validDomains, ",")) + return + } + + domain := types.Domain{ + Main: validDomains[0], + } + if len(validDomains) > 0 { + domain.SANs = validDomains[1:] + } + + err = p.addCertificateForDomain(domain, cert.Certificate, cert.PrivateKey, traefiktls.DefaultTLSStoreName) + if err != nil { + logger.WithError(err).Error("Error adding certificate for domain") + } + }) } case <-ctxPool.Done(): return @@ -468,22 +548,30 @@ func (p *Provider) watchNewDomains(ctx context.Context) { }) } -func (p *Provider) resolveCertificate(ctx context.Context, domain types.Domain, tlsStore string) (*certificate.Resource, error) { - domains, err := p.getValidDomains(ctx, domain) - if err != nil { - return nil, err - } +func (p *Provider) resolveDefaultCertificate(ctx context.Context, domains []string) (*certificate.Resource, error) { + logger := log.FromContext(ctx) - // Check if provided certificates are not already in progress and lock them if needed - uncheckedDomains := p.getUncheckedDomains(ctx, domains, tlsStore) - if len(uncheckedDomains) == 0 { + p.resolvingDomainsMutex.Lock() + + sort.Strings(domains) + domainKey := strings.Join(domains, ",") + + if _, ok := p.resolvingDomains[domainKey]; ok { + p.resolvingDomainsMutex.Unlock() return nil, nil } - defer p.removeResolvingDomains(uncheckedDomains) + p.resolvingDomains[domainKey] = struct{}{} - logger := log.FromContext(ctx) - logger.Debugf("Loading ACME certificates %+v...", uncheckedDomains) + for _, certDomain := range domains { + p.resolvingDomains[certDomain] = struct{}{} + } + + p.resolvingDomainsMutex.Unlock() + + defer p.removeResolvingDomains(append(domains, domainKey)) + + logger.Debugf("Loading ACME certificates %+v...", domains) client, err := p.getClient() if err != nil { @@ -493,31 +581,74 @@ func (p *Provider) resolveCertificate(ctx context.Context, domain types.Domain, request := certificate.ObtainRequest{ Domains: domains, Bundle: true, - MustStaple: oscpMustStaple, + MustStaple: ocspMustStaple, PreferredChain: p.PreferredChain, } cert, err := client.Certificate.Obtain(request) if err != nil { - return nil, fmt.Errorf("unable to generate a certificate for the domains %v: %w", uncheckedDomains, err) + return nil, fmt.Errorf("unable to generate a certificate for the domains %v: %w", domains, err) } if cert == nil { - return nil, fmt.Errorf("domains %v do not generate a certificate", uncheckedDomains) + return nil, fmt.Errorf("unable to generate a certificate for the domains %v", domains) } if len(cert.Certificate) == 0 || len(cert.PrivateKey) == 0 { - return nil, fmt.Errorf("domains %v generate certificate with no value: %v", uncheckedDomains, cert) + return nil, fmt.Errorf("certificate for domains %v is empty: %v", domains, cert) + } + + logger.Debugf("Default certificate obtained for domains %+v", domains) + + return cert, nil +} + +func (p *Provider) resolveCertificate(ctx context.Context, domain types.Domain, tlsStore string) (types.Domain, *certificate.Resource, error) { + domains, err := p.sanitizeDomains(ctx, domain) + if err != nil { + return types.Domain{}, nil, err + } + + // Check if provided certificates are not already in progress and lock them if needed + uncheckedDomains := p.getUncheckedDomains(ctx, domains, tlsStore) + if len(uncheckedDomains) == 0 { + return types.Domain{}, nil, nil + } + + defer p.removeResolvingDomains(uncheckedDomains) + + logger := log.FromContext(ctx) + logger.Debugf("Loading ACME certificates %+v...", uncheckedDomains) + + client, err := p.getClient() + if err != nil { + return types.Domain{}, nil, fmt.Errorf("cannot get ACME client %w", err) + } + + request := certificate.ObtainRequest{ + Domains: domains, + Bundle: true, + MustStaple: ocspMustStaple, + PreferredChain: p.PreferredChain, + } + + cert, err := client.Certificate.Obtain(request) + if err != nil { + return types.Domain{}, nil, fmt.Errorf("unable to generate a certificate for the domains %v: %w", uncheckedDomains, err) + } + if cert == nil { + return types.Domain{}, nil, fmt.Errorf("unable to generate a certificate for the domains %v", uncheckedDomains) + } + if len(cert.Certificate) == 0 || len(cert.PrivateKey) == 0 { + return types.Domain{}, nil, fmt.Errorf("certificate for domains %v is empty: %v", uncheckedDomains, cert) } logger.Debugf("Certificates obtained for domains %+v", uncheckedDomains) + domain = types.Domain{Main: uncheckedDomains[0]} if len(uncheckedDomains) > 1 { - domain = types.Domain{Main: uncheckedDomains[0], SANs: uncheckedDomains[1:]} - } else { - domain = types.Domain{Main: uncheckedDomains[0]} + domain.SANs = uncheckedDomains[1:] } - p.addCertificateForDomain(domain, cert.Certificate, cert.PrivateKey, tlsStore) - return cert, nil + return domain, cert, nil } func (p *Provider) removeResolvingDomains(resolvingDomains []string) { @@ -529,8 +660,28 @@ func (p *Provider) removeResolvingDomains(resolvingDomains []string) { } } -func (p *Provider) addCertificateForDomain(domain types.Domain, certificate, key []byte, tlsStore string) { - p.certsChan <- &CertAndStore{Certificate: Certificate{Certificate: certificate, Key: key, Domain: domain}, Store: tlsStore} +func (p *Provider) addCertificateForDomain(domain types.Domain, certificate, key []byte, tlsStore string) error { + p.certificatesMu.Lock() + defer p.certificatesMu.Unlock() + + cert := Certificate{Certificate: certificate, Key: key, Domain: domain} + + certUpdated := false + for _, domainsCertificate := range p.certificates { + if reflect.DeepEqual(domain, domainsCertificate.Certificate.Domain) { + domainsCertificate.Certificate = cert + certUpdated = true + break + } + } + + if !certUpdated { + p.certificates = append(p.certificates, &CertAndStore{Certificate: cert, Store: tlsStore}) + } + + p.configurationChan <- p.buildMessage() + + return p.Store.SaveCertificates(p.ResolverName, p.certificates) } // getCertificateRenewDurations returns renew durations calculated from the given certificatesDuration in hours. @@ -608,45 +759,7 @@ func deleteUnnecessaryDomains(ctx context.Context, domains []types.Domain) []typ return newDomains } -func (p *Provider) watchCertificate(ctx context.Context) { - p.certsChan = make(chan *CertAndStore) - - p.pool.GoCtx(func(ctxPool context.Context) { - for { - select { - case cert := <-p.certsChan: - certUpdated := false - for _, domainsCertificate := range p.certificates { - if reflect.DeepEqual(cert.Domain, domainsCertificate.Certificate.Domain) { - domainsCertificate.Certificate = cert.Certificate - certUpdated = true - break - } - } - if !certUpdated { - p.certificates = append(p.certificates, cert) - } - - err := p.saveCertificates() - if err != nil { - log.FromContext(ctx).Error(err) - } - case <-ctxPool.Done(): - return - } - } - }) -} - -func (p *Provider) saveCertificates() error { - err := p.Store.SaveCertificates(p.ResolverName, p.certificates) - - p.refreshCertificates() - - return err -} - -func (p *Provider) refreshCertificates() { +func (p *Provider) buildMessage() dynamic.Message { conf := dynamic.Message{ ProviderName: p.ResolverName + ".acme", Configuration: &dynamic.Configuration{ @@ -670,41 +783,54 @@ func (p *Provider) refreshCertificates() { conf.Configuration.TLS.Certificates = append(conf.Configuration.TLS.Certificates, certConf) } - p.configurationChan <- conf + return conf } func (p *Provider) renewCertificates(ctx context.Context, renewPeriod time.Duration) { logger := log.FromContext(ctx) logger.Info("Testing certificate renew...") + + p.certificatesMu.RLock() + + var certificates []*CertAndStore for _, cert := range p.certificates { crt, err := getX509Certificate(ctx, &cert.Certificate) // If there's an error, we assume the cert is broken, and needs update if err != nil || crt == nil || crt.NotAfter.Before(time.Now().Add(renewPeriod)) { - client, err := p.getClient() - if err != nil { - logger.Infof("Error renewing certificate from LE : %+v, %v", cert.Domain, err) - continue - } + certificates = append(certificates, cert) + } + } - logger.Infof("Renewing certificate from LE : %+v", cert.Domain) + p.certificatesMu.RUnlock() - renewedCert, err := client.Certificate.Renew(certificate.Resource{ - Domain: cert.Domain.Main, - PrivateKey: cert.Key, - Certificate: cert.Certificate.Certificate, - }, true, oscpMustStaple, p.PreferredChain) - if err != nil { - logger.Errorf("Error renewing certificate from LE: %v, %v", cert.Domain, err) - continue - } + for _, cert := range certificates { + client, err := p.getClient() + if err != nil { + logger.WithError(err).Infof("Error renewing certificate from LE : %+v", cert.Domain) + continue + } - if len(renewedCert.Certificate) == 0 || len(renewedCert.PrivateKey) == 0 { - logger.Errorf("domains %v renew certificate with no value: %v", cert.Domain.ToStrArray(), cert) - continue - } + logger.Infof("Renewing certificate from LE : %+v", cert.Domain) - p.addCertificateForDomain(cert.Domain, renewedCert.Certificate, renewedCert.PrivateKey, cert.Store) + renewedCert, err := client.Certificate.Renew(certificate.Resource{ + Domain: cert.Domain.Main, + PrivateKey: cert.Key, + Certificate: cert.Certificate.Certificate, + }, true, ocspMustStaple, p.PreferredChain) + if err != nil { + logger.WithError(err).Errorf("Error renewing certificate from LE: %v", cert.Domain) + continue + } + + if len(renewedCert.Certificate) == 0 || len(renewedCert.PrivateKey) == 0 { + logger.Errorf("domains %v renew certificate with no value: %v", cert.Domain.ToStrArray(), cert) + continue + } + + err = p.addCertificateForDomain(cert.Domain, renewedCert.Certificate, renewedCert.PrivateKey, cert.Store) + if err != nil { + logger.WithError(err).Error("Error adding certificate for domain") } } } @@ -712,17 +838,24 @@ func (p *Provider) renewCertificates(ctx context.Context, renewPeriod time.Durat // Get provided certificate which check a domains list (Main and SANs) // from static and dynamic provided certificates. func (p *Provider) getUncheckedDomains(ctx context.Context, domainsToCheck []string, tlsStore string) []string { - p.resolvingDomainsMutex.Lock() - defer p.resolvingDomainsMutex.Unlock() - log.FromContext(ctx).Debugf("Looking for provided certificate(s) to validate %q...", domainsToCheck) - allDomains := p.tlsManager.GetStore(tlsStore).GetAllDomains() + var allDomains []string + store := p.tlsManager.GetStore(tlsStore) + if store != nil { + allDomains = append(allDomains, store.GetAllDomains()...) + } // Get ACME certificates + + p.certificatesMu.RLock() for _, cert := range p.certificates { allDomains = append(allDomains, strings.Join(cert.Domain.ToStrArray(), ",")) } + p.certificatesMu.RUnlock() + + p.resolvingDomainsMutex.Lock() + defer p.resolvingDomainsMutex.Unlock() // Get currently resolved domains for domain := range p.resolvingDomains { @@ -761,7 +894,7 @@ func getX509Certificate(ctx context.Context, cert *Certificate) (*x509.Certifica tlsCert, err := tls.X509KeyPair(cert.Certificate, cert.Key) if err != nil { - logger.Errorf("Failed to load TLS key pair from ACME certificate for domain %q (SAN : %q), certificate will be renewed : %v", cert.Domain.Main, strings.Join(cert.Domain.SANs, ","), err) + logger.WithError(err).Errorf("Failed to load TLS key pair from ACME certificate for domain %q (SAN : %q), certificate will be renewed", cert.Domain.Main, strings.Join(cert.Domain.SANs, ",")) return nil, err } @@ -769,43 +902,62 @@ func getX509Certificate(ctx context.Context, cert *Certificate) (*x509.Certifica if crt == nil { crt, err = x509.ParseCertificate(tlsCert.Certificate[0]) if err != nil { - logger.Errorf("Failed to parse TLS key pair from ACME certificate for domain %q (SAN : %q), certificate will be renewed : %v", cert.Domain.Main, strings.Join(cert.Domain.SANs, ","), err) + logger.WithError(err).Errorf("Failed to parse TLS key pair from ACME certificate for domain %q (SAN : %q), certificate will be renewed", cert.Domain.Main, strings.Join(cert.Domain.SANs, ",")) } } return crt, err } -// getValidDomains checks if given domain is allowed to generate a ACME certificate and return it. -func (p *Provider) getValidDomains(ctx context.Context, domain types.Domain) ([]string, error) { +// sanitizeDomains checks if given domain is allowed to generate a ACME certificate and return it. +func (p *Provider) sanitizeDomains(ctx context.Context, domain types.Domain) ([]string, error) { domains := domain.ToStrArray() if len(domains) == 0 { - return nil, errors.New("unable to generate a certificate in ACME provider when no domain is given") - } - - if strings.HasPrefix(domain.Main, "*") { - 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 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, ",")) - } + return nil, errors.New("no domain was given") } var cleanDomains []string - for _, domain := range domains { - canonicalDomain := types.CanonicalDomain(domain) + for _, dom := range domains { + if strings.HasPrefix(dom, "*") { + 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 strings.HasPrefix(dom, "*.*") { + 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, ",")) + } + } + + canonicalDomain := types.CanonicalDomain(dom) cleanDomain := dns01.UnFqdn(canonicalDomain) if canonicalDomain != cleanDomain { log.FromContext(ctx).Warnf("FQDN detected, please remove the trailing dot: %s", canonicalDomain) } + cleanDomains = append(cleanDomains, cleanDomain) } return cleanDomains, nil } +// certExists returns whether a certificate already exists for given domains. +func (p *Provider) certExists(validDomains []string) bool { + p.certificatesMu.RLock() + defer p.certificatesMu.RUnlock() + + sort.Strings(validDomains) + + for _, cert := range p.certificates { + domains := cert.Certificate.Domain.ToStrArray() + sort.Strings(domains) + if reflect.DeepEqual(domains, validDomains) { + return true + } + } + + return false +} + func isDomainAlreadyChecked(domainToCheck string, existentDomains []string) bool { for _, certDomains := range existentDomains { for _, certDomain := range strings.Split(certDomains, ",") { diff --git a/pkg/provider/acme/provider_test.go b/pkg/provider/acme/provider_test.go index 799ab6b11..3268b1c92 100644 --- a/pkg/provider/acme/provider_test.go +++ b/pkg/provider/acme/provider_test.go @@ -188,7 +188,7 @@ func TestGetUncheckedCertificates(t *testing.T) { } } -func TestGetValidDomain(t *testing.T) { +func TestProvider_sanitizeDomains(t *testing.T) { testCases := []struct { desc string domains types.Domain @@ -214,7 +214,7 @@ func TestGetValidDomain(t *testing.T) { desc: "no domain", domains: types.Domain{}, dnsChallenge: nil, - expectedErr: "unable to generate a certificate in ACME provider when no domain is given", + expectedErr: "no domain was given", expectedDomains: nil, }, { @@ -254,7 +254,7 @@ func TestGetValidDomain(t *testing.T) { acmeProvider := Provider{Configuration: &Configuration{DNSChallenge: test.dnsChallenge}} - domains, err := acmeProvider.getValidDomains(context.Background(), test.domains) + domains, err := acmeProvider.sanitizeDomains(context.Background(), test.domains) if len(test.expectedErr) > 0 { assert.EqualError(t, err, test.expectedErr, "Unexpected error.") diff --git a/pkg/provider/kubernetes/crd/kubernetes.go b/pkg/provider/kubernetes/crd/kubernetes.go index 1052ba0f8..a43345e67 100644 --- a/pkg/provider/kubernetes/crd/kubernetes.go +++ b/pkg/provider/kubernetes/crd/kubernetes.go @@ -942,6 +942,13 @@ func buildTLSStores(ctx context.Context, client Client) (map[string]tls.Store, m } } + if t.Spec.DefaultGeneratedCert != nil { + tlsStore.DefaultGeneratedCert = &tls.GeneratedCert{ + Resolver: t.Spec.DefaultGeneratedCert.Resolver, + Domain: t.Spec.DefaultGeneratedCert.Domain, + } + } + if err := buildCertificates(client, id, t.Namespace, t.Spec.Certificates, tlsConfigs); err != nil { logger.Errorf("Failed to load certificates: %v", err) continue diff --git a/pkg/provider/kubernetes/crd/traefik/v1alpha1/tlsstore.go b/pkg/provider/kubernetes/crd/traefik/v1alpha1/tlsstore.go index 305b403c1..0d69d084f 100644 --- a/pkg/provider/kubernetes/crd/traefik/v1alpha1/tlsstore.go +++ b/pkg/provider/kubernetes/crd/traefik/v1alpha1/tlsstore.go @@ -1,6 +1,7 @@ package v1alpha1 import ( + "github.com/traefik/traefik/v2/pkg/tls" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -27,6 +28,10 @@ type TLSStore struct { type TLSStoreSpec struct { // DefaultCertificate defines the default certificate configuration. DefaultCertificate *Certificate `json:"defaultCertificate,omitempty"` + + // DefaultGeneratedCert defines the default generated certificate configuration. + DefaultGeneratedCert *tls.GeneratedCert `json:"defaultGeneratedCert,omitempty"` + // Certificates is a list of secret names, each secret holding a key/certificate pair to add to the store. Certificates []Certificate `json:"certificates,omitempty"` } diff --git a/pkg/provider/kubernetes/crd/traefik/v1alpha1/zz_generated.deepcopy.go b/pkg/provider/kubernetes/crd/traefik/v1alpha1/zz_generated.deepcopy.go index 5d44aeef3..5cc7903fc 100644 --- a/pkg/provider/kubernetes/crd/traefik/v1alpha1/zz_generated.deepcopy.go +++ b/pkg/provider/kubernetes/crd/traefik/v1alpha1/zz_generated.deepcopy.go @@ -31,6 +31,7 @@ package v1alpha1 import ( dynamic "github.com/traefik/traefik/v2/pkg/config/dynamic" + tls "github.com/traefik/traefik/v2/pkg/tls" types "github.com/traefik/traefik/v2/pkg/types" v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" runtime "k8s.io/apimachinery/pkg/runtime" @@ -1450,6 +1451,11 @@ func (in *TLSStoreSpec) DeepCopyInto(out *TLSStoreSpec) { *out = new(Certificate) **out = **in } + if in.DefaultGeneratedCert != nil { + in, out := &in.DefaultGeneratedCert, &out.DefaultGeneratedCert + *out = new(tls.GeneratedCert) + (*in).DeepCopyInto(*out) + } if in.Certificates != nil { in, out := &in.Certificates, &out.Certificates *out = make([]Certificate, len(*in)) diff --git a/pkg/tls/certificate.go b/pkg/tls/certificate.go index 8d2c8b61d..fca4a2586 100644 --- a/pkg/tls/certificate.go +++ b/pkg/tls/certificate.go @@ -11,7 +11,6 @@ import ( "strings" "github.com/traefik/traefik/v2/pkg/log" - "github.com/traefik/traefik/v2/pkg/tls/generate" ) var ( @@ -101,55 +100,8 @@ func (f FileOrContent) Read() ([]byte, error) { return content, nil } -// CreateTLSConfig creates a TLS config from Certificate structures. -func (c *Certificates) CreateTLSConfig(entryPointName string) (*tls.Config, error) { - config := &tls.Config{} - domainsCertificates := make(map[string]map[string]*tls.Certificate) - - if c.isEmpty() { - config.Certificates = []tls.Certificate{} - - cert, err := generate.DefaultCertificate() - if err != nil { - return nil, err - } - - config.Certificates = append(config.Certificates, *cert) - } else { - for _, certificate := range *c { - err := certificate.AppendCertificate(domainsCertificates, entryPointName) - if err != nil { - log.Errorf("Unable to add a certificate to the entryPoint %q : %v", entryPointName, err) - continue - } - - for _, certDom := range domainsCertificates { - for _, cert := range certDom { - config.Certificates = append(config.Certificates, *cert) - } - } - } - } - return config, nil -} - -// isEmpty checks if the certificates list is empty. -func (c *Certificates) isEmpty() bool { - if len(*c) == 0 { - return true - } - var key int - for _, cert := range *c { - if len(cert.CertFile.String()) != 0 && len(cert.KeyFile.String()) != 0 { - break - } - key++ - } - return key == len(*c) -} - -// AppendCertificate appends a Certificate to a certificates map keyed by entrypoint. -func (c *Certificate) AppendCertificate(certs map[string]map[string]*tls.Certificate, ep string) error { +// AppendCertificate appends a Certificate to a certificates map keyed by store name. +func (c *Certificate) AppendCertificate(certs map[string]map[string]*tls.Certificate, storeName string) error { certContent, err := c.CertFile.Read() if err != nil { return fmt.Errorf("unable to read CertFile : %w", err) @@ -171,7 +123,6 @@ func (c *Certificate) AppendCertificate(certs map[string]map[string]*tls.Certifi SANs = append(SANs, strings.ToLower(parsedCert.Subject.CommonName)) } if parsedCert.DNSNames != nil { - sort.Strings(parsedCert.DNSNames) for _, dnsName := range parsedCert.DNSNames { if dnsName != parsedCert.Subject.CommonName { SANs = append(SANs, strings.ToLower(dnsName)) @@ -185,13 +136,16 @@ func (c *Certificate) AppendCertificate(certs map[string]map[string]*tls.Certifi } } } + + // Guarantees the order to produce a unique cert key. + sort.Strings(SANs) certKey := strings.Join(SANs, ",") certExists := false - if certs[ep] == nil { - certs[ep] = make(map[string]*tls.Certificate) + if certs[storeName] == nil { + certs[storeName] = make(map[string]*tls.Certificate) } else { - for domains := range certs[ep] { + for domains := range certs[storeName] { if domains == certKey { certExists = true break @@ -199,10 +153,10 @@ func (c *Certificate) AppendCertificate(certs map[string]map[string]*tls.Certifi } } if certExists { - log.Debugf("Skipping addition of certificate for domain(s) %q, to EntryPoint %s, as it already exists for this Entrypoint.", certKey, ep) + log.Debugf("Skipping addition of certificate for domain(s) %q, to TLS Store %s, as it already exists for this store.", certKey, storeName) } else { log.Debugf("Adding certificate for domain(s) %s", certKey) - certs[ep][certKey] = &tlsCert + certs[storeName][certKey] = &tlsCert } return err diff --git a/pkg/tls/certificate_store.go b/pkg/tls/certificate_store.go index 8017c981a..1a954a0e0 100644 --- a/pkg/tls/certificate_store.go +++ b/pkg/tls/certificate_store.go @@ -22,8 +22,11 @@ type CertificateStore struct { // NewCertificateStore create a store for dynamic certificates. func NewCertificateStore() *CertificateStore { + s := &safe.Safe{} + s.Set(make(map[string]*tls.Certificate)) + return &CertificateStore{ - DynamicCerts: &safe.Safe{}, + DynamicCerts: s, CertCache: cache.New(1*time.Hour, 10*time.Minute), } } @@ -114,6 +117,45 @@ func (c *CertificateStore) GetBestCertificate(clientHello *tls.ClientHelloInfo) return nil } +// GetCertificate returns the first certificate matching all the given domains. +func (c *CertificateStore) GetCertificate(domains []string) *tls.Certificate { + if c == nil { + return nil + } + + sort.Strings(domains) + domainsKey := strings.Join(domains, ",") + + if cert, ok := c.CertCache.Get(domainsKey); ok { + return cert.(*tls.Certificate) + } + + if c.DynamicCerts != nil && c.DynamicCerts.Get() != nil { + for certDomains, cert := range c.DynamicCerts.Get().(map[string]*tls.Certificate) { + if domainsKey == certDomains { + c.CertCache.SetDefault(domainsKey, cert) + return cert + } + + var matchedDomains []string + for _, certDomain := range strings.Split(certDomains, ",") { + for _, checkDomain := range domains { + if certDomain == checkDomain { + matchedDomains = append(matchedDomains, certDomain) + } + } + } + + if len(matchedDomains) == len(domains) { + c.CertCache.SetDefault(domainsKey, cert) + return cert + } + } + } + + return nil +} + // ResetCache clears the cache in the store. func (c CertificateStore) ResetCache() { if c.CertCache != nil { diff --git a/pkg/tls/tls.go b/pkg/tls/tls.go index 26c1b393f..85bbeada1 100644 --- a/pkg/tls/tls.go +++ b/pkg/tls/tls.go @@ -1,5 +1,7 @@ package tls +import "github.com/traefik/traefik/v2/pkg/types" + const certificateHeader = "-----BEGIN CERTIFICATE-----\n" // +k8s:deepcopy-gen=true @@ -36,7 +38,18 @@ func (o *Options) SetDefaults() { // Store holds the options for a given Store. type Store struct { - DefaultCertificate *Certificate `json:"defaultCertificate,omitempty" toml:"defaultCertificate,omitempty" yaml:"defaultCertificate,omitempty" export:"true"` + DefaultCertificate *Certificate `json:"defaultCertificate,omitempty" toml:"defaultCertificate,omitempty" yaml:"defaultCertificate,omitempty" export:"true"` + DefaultGeneratedCert *GeneratedCert `json:"defaultGeneratedCert,omitempty" toml:"defaultGeneratedCert,omitempty" yaml:"defaultGeneratedCert,omitempty" export:"true"` +} + +// +k8s:deepcopy-gen=true + +// GeneratedCert defines the default generated certificate configuration. +type GeneratedCert struct { + // Resolver is the name of the resolver that will be used to issue the DefaultCertificate. + Resolver string `json:"resolver,omitempty" toml:"resolver,omitempty" yaml:"resolver,omitempty" export:"true"` + // Domain is the domain definition for the DefaultCertificate. + Domain *types.Domain `json:"domain,omitempty" toml:"domain,omitempty" yaml:"domain,omitempty" export:"true"` } // +k8s:deepcopy-gen=true diff --git a/pkg/tls/tlsmanager.go b/pkg/tls/tlsmanager.go index 73b98fc63..0c7daa992 100644 --- a/pkg/tls/tlsmanager.go +++ b/pkg/tls/tlsmanager.go @@ -6,8 +6,10 @@ import ( "crypto/x509" "errors" "fmt" + "strings" "sync" + "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/challenge/tlsalpn01" "github.com/sirupsen/logrus" "github.com/traefik/traefik/v2/pkg/log" @@ -81,17 +83,6 @@ func (m *Manager) UpdateConfigs(ctx context.Context, stores map[string]Store, co m.storesConfig[tlsalpn01.ACMETLS1Protocol] = Store{} } - m.stores = make(map[string]*CertificateStore) - for storeName, storeConfig := range m.storesConfig { - ctxStore := log.With(ctx, log.Str(log.TLSStoreName, storeName)) - store, err := buildCertificateStore(ctxStore, storeConfig, storeName) - if err != nil { - log.FromContext(ctxStore).Errorf("Error while creating certificate store: %v", err) - continue - } - m.stores[storeName] = store - } - storesCertificates := make(map[string]map[string]*tls.Certificate) for _, conf := range certs { if len(conf.Stores) == 0 { @@ -99,26 +90,68 @@ func (m *Manager) UpdateConfigs(ctx context.Context, stores map[string]Store, co log.FromContext(ctx).Debugf("No store is defined to add the certificate %s, it will be added to the default store.", conf.Certificate.GetTruncatedCertificateName()) } - conf.Stores = []string{"default"} + conf.Stores = []string{DefaultTLSStoreName} } + for _, store := range conf.Stores { ctxStore := log.With(ctx, log.Str(log.TLSStoreName, store)) - if err := conf.Certificate.AppendCertificate(storesCertificates, store); err != nil { + + if _, ok := m.storesConfig[store]; !ok { + m.storesConfig[store] = Store{} + } + + err := conf.Certificate.AppendCertificate(storesCertificates, store) + if err != nil { log.FromContext(ctxStore).Errorf("Unable to append certificate %s to store: %v", conf.Certificate.GetTruncatedCertificateName(), err) } } } - for storeName, certs := range storesCertificates { - st, ok := m.stores[storeName] - if !ok { - st, _ = buildCertificateStore(context.Background(), Store{}, storeName) - m.stores[storeName] = st + m.stores = make(map[string]*CertificateStore) + + for storeName, storeConfig := range m.storesConfig { + st := NewCertificateStore() + m.stores[storeName] = st + + if certs, ok := storesCertificates[storeName]; ok { + st.DynamicCerts.Set(certs) } - st.DynamicCerts.Set(certs) + + // a default cert for the ACME store does not make any sense, so generating one is a waste. + if storeName == tlsalpn01.ACMETLS1Protocol { + continue + } + + ctxStore := log.With(ctx, log.Str(log.TLSStoreName, storeName)) + + certificate, err := getDefaultCertificate(ctxStore, storeConfig, st) + if err != nil { + log.FromContext(ctxStore).Errorf("Error while creating certificate store: %v", err) + } + + st.DefaultCertificate = certificate } } +// sanitizeDomains sanitizes the domain definition Main and SANS, +// and returns them as a slice. +// This func apply the same sanitization as the ACME provider do before resolving certificates. +func sanitizeDomains(domain types.Domain) ([]string, error) { + domains := domain.ToStrArray() + if len(domains) == 0 { + return nil, errors.New("no domain was given") + } + + var cleanDomains []string + for _, domain := range domains { + canonicalDomain := types.CanonicalDomain(domain) + cleanDomain := dns01.UnFqdn(canonicalDomain) + cleanDomains = append(cleanDomains, cleanDomain) + } + + return cleanDomains, nil +} + // Get gets the TLS configuration to use for a given store / configuration. func (m *Manager) Get(storeName, configName string) (*tls.Config, error) { m.lock.RLock() @@ -234,32 +267,37 @@ func (m *Manager) GetStore(storeName string) *CertificateStore { return m.getStore(storeName) } -func buildCertificateStore(ctx context.Context, tlsStore Store, storename string) (*CertificateStore, error) { - certificateStore := NewCertificateStore() - certificateStore.DynamicCerts.Set(make(map[string]*tls.Certificate)) - +func getDefaultCertificate(ctx context.Context, tlsStore Store, st *CertificateStore) (*tls.Certificate, error) { if tlsStore.DefaultCertificate != nil { cert, err := buildDefaultCertificate(tlsStore.DefaultCertificate) if err != nil { - return certificateStore, err + return nil, err } - certificateStore.DefaultCertificate = cert - return certificateStore, nil + + return cert, nil } - // a default cert for the ACME store does not make any sense, so generating one - // is a waste. - if storename == tlsalpn01.ACMETLS1Protocol { - return certificateStore, nil - } - - log.FromContext(ctx).Debug("No default certificate, generating one") - cert, err := generate.DefaultCertificate() + defaultCert, err := generate.DefaultCertificate() if err != nil { - return certificateStore, err + return nil, err } - certificateStore.DefaultCertificate = cert - return certificateStore, nil + + if tlsStore.DefaultGeneratedCert != nil && tlsStore.DefaultGeneratedCert.Domain != nil && tlsStore.DefaultGeneratedCert.Resolver != "" { + domains, err := sanitizeDomains(*tlsStore.DefaultGeneratedCert.Domain) + if err != nil { + return defaultCert, fmt.Errorf("falling back to the internal generated certificate because invalid domains: %w", err) + } + + defaultACMECert := st.GetCertificate(domains) + if defaultACMECert == nil { + return defaultCert, fmt.Errorf("unable to find certificate for domains %q: falling back to the internal generated certificate", strings.Join(domains, ",")) + } + + return defaultACMECert, nil + } + + log.FromContext(ctx).Debug("No default certificate, fallback to the internal generated certificate") + return defaultCert, nil } // creates a TLS config that allows terminating HTTPS for multiple domains using SNI. diff --git a/pkg/tls/zz_generated.deepcopy.go b/pkg/tls/zz_generated.deepcopy.go index fbdd8e508..c85405a37 100644 --- a/pkg/tls/zz_generated.deepcopy.go +++ b/pkg/tls/zz_generated.deepcopy.go @@ -29,6 +29,10 @@ THE SOFTWARE. package tls +import ( + types "github.com/traefik/traefik/v2/pkg/types" +) + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *CertAndStores) DeepCopyInto(out *CertAndStores) { *out = *in @@ -72,6 +76,27 @@ func (in *ClientAuth) DeepCopy() *ClientAuth { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GeneratedCert) DeepCopyInto(out *GeneratedCert) { + *out = *in + if in.Domain != nil { + in, out := &in.Domain, &out.Domain + *out = new(types.Domain) + (*in).DeepCopyInto(*out) + } + return +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GeneratedCert. +func (in *GeneratedCert) DeepCopy() *GeneratedCert { + if in == nil { + return nil + } + out := new(GeneratedCert) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Options) DeepCopyInto(out *Options) { *out = *in @@ -112,6 +137,11 @@ func (in *Store) DeepCopyInto(out *Store) { *out = new(Certificate) **out = **in } + if in.DefaultGeneratedCert != nil { + in, out := &in.DefaultGeneratedCert, &out.DefaultGeneratedCert + *out = new(GeneratedCert) + (*in).DeepCopyInto(*out) + } return }