From 0a5c9095acb889a6f77e336114a3ccadd8d26808 Mon Sep 17 00:00:00 2001 From: Pablo Montepagano Date: Wed, 10 Nov 2021 08:06:09 -0300 Subject: [PATCH] feat: allow configuration of ACME certificates duration --- docs/content/https/acme.md | 50 ++++++++++++++++- docs/content/https/ref-acme.toml | 8 +++ docs/content/https/ref-acme.txt | 8 +++ docs/content/https/ref-acme.yaml | 8 +++ .../reference/static-configuration/cli-ref.md | 3 + .../reference/static-configuration/env-ref.md | 3 + .../reference/static-configuration/file.toml | 2 + .../reference/static-configuration/file.yaml | 2 + pkg/anonymize/anonymize_config_test.go | 11 ++-- .../testdata/anonymized-static-config.json | 1 + pkg/provider/acme/provider.go | 55 ++++++++++++++----- pkg/provider/acme/provider_test.go | 51 +++++++++++++++++ 12 files changed, 183 insertions(+), 19 deletions(-) diff --git a/docs/content/https/acme.md b/docs/content/https/acme.md index 38b25746f..72367fc3e 100644 --- a/docs/content/https/acme.md +++ b/docs/content/https/acme.md @@ -140,7 +140,11 @@ Please check the [configuration examples below](#configuration-examples) for mor Traefik automatically tracks the expiry date of ACME certificates it generates. -If there are less than 30 days remaining before the certificate expires, Traefik will attempt to renew it automatically. +By default, Traefik manages 90 days certificates, +and starts to renew certificates 30 days before their expiry. + +When using a certificates resolver that issues certificates with custom durations, +one can configure the certificates' duration with the [`certificatesDuration`](#certificatesduration) option. !!! info "" Certificates that are no longer used may still be renewed, as Traefik does not currently check if the certificate is being used before renewing. @@ -533,6 +537,50 @@ docker run -v "/my/host/acme:/etc/traefik/acme" traefik !!! warning For concurrency reasons, this file cannot be shared across multiple instances of Traefik. +### `certificatesDuration` + +_Optional, Default=2160_ + +The `certificatesDuration` option defines the certificates' duration in hours. +It defaults to `2160` (90 days) to follow Let's Encrypt certificates' duration. + +!!! warning "Traefik cannot manage certificates with a duration lower than 1 hour." + +```yaml tab="File (YAML)" +certificatesResolvers: + myresolver: + acme: + # ... + certificatesDuration: 72 + # ... +``` + +```toml tab="File (TOML)" +[certificatesResolvers.myresolver.acme] + # ... + certificatesDuration=72 + # ... +``` + +```bash tab="CLI" +# ... +--certificatesresolvers.myresolver.acme.certificatesduration=72 +# ... +``` + +`certificatesDuration` is used to calculate two durations: + +- `Renew Period`: the period before the end of the certificate duration, during which the certificate should be renewed. +- `Renew Interval`: the interval between renew attempts. + +| Certificate Duration | Renew Period | Renew Interval | +|----------------------|-------------------|-------------------------| +| >= 1 year | 4 months | 1 week | +| >= 90 days | 30 days | 1 day | +| >= 7 days | 1 day | 1 hour | +| >= 24 hours | 6 hours | 10 min | +| < 24 hours | 20 min | 1 min | + ### `preferredChain` _Optional, Default=""_ diff --git a/docs/content/https/ref-acme.toml b/docs/content/https/ref-acme.toml index 39cd22689..e5db57a53 100644 --- a/docs/content/https/ref-acme.toml +++ b/docs/content/https/ref-acme.toml @@ -22,6 +22,14 @@ # # caServer = "https://acme-staging-v02.api.letsencrypt.org/directory" + # The certificates' duration in hours. + # It defaults to 2160 (90 days) to follow Let's Encrypt certificates' duration. + # + # Optional + # Default: 2160 + # + # certificatesDuration=2160 + # Preferred chain to use. # # If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. diff --git a/docs/content/https/ref-acme.txt b/docs/content/https/ref-acme.txt index 7144e7932..d817a4dbe 100644 --- a/docs/content/https/ref-acme.txt +++ b/docs/content/https/ref-acme.txt @@ -21,6 +21,14 @@ # --certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory +# The certificates' duration in hours. +# It defaults to 2160 (90 days) to follow Let's Encrypt certificates' duration. +# +# Optional +# Default: 2160 +# +--certificatesresolvers.myresolver.acme.certificatesDuration=2160 + # Preferred chain to use. # # If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. diff --git a/docs/content/https/ref-acme.yaml b/docs/content/https/ref-acme.yaml index 43802083a..044c7ff9b 100644 --- a/docs/content/https/ref-acme.yaml +++ b/docs/content/https/ref-acme.yaml @@ -24,6 +24,14 @@ certificatesResolvers: # # caServer: "https://acme-staging-v02.api.letsencrypt.org/directory" + # The certificates' duration in hours. + # It defaults to 2160 (90 days) to follow Let's Encrypt certificates' duration. + # + # Optional + # Default: 2160 + # + # certificatesDuration: 2160 + # Preferred chain to use. # # If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. diff --git a/docs/content/reference/static-configuration/cli-ref.md b/docs/content/reference/static-configuration/cli-ref.md index 5cd35d0a6..1048193aa 100644 --- a/docs/content/reference/static-configuration/cli-ref.md +++ b/docs/content/reference/static-configuration/cli-ref.md @@ -54,6 +54,9 @@ Certificates resolvers configuration. (Default: ```false```) `--certificatesresolvers..acme.caserver`: CA server to use. (Default: ```https://acme-v02.api.letsencrypt.org/directory```) +`--certificatesresolvers..acme.certificatesduration`: +Certificates' duration in hours. (Default: ```2160```) + `--certificatesresolvers..acme.dnschallenge`: Activate DNS-01 Challenge. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/env-ref.md b/docs/content/reference/static-configuration/env-ref.md index 4f31461e1..f29d8263d 100644 --- a/docs/content/reference/static-configuration/env-ref.md +++ b/docs/content/reference/static-configuration/env-ref.md @@ -54,6 +54,9 @@ Certificates resolvers configuration. (Default: ```false```) `TRAEFIK_CERTIFICATESRESOLVERS__ACME_CASERVER`: CA server to use. (Default: ```https://acme-v02.api.letsencrypt.org/directory```) +`TRAEFIK_CERTIFICATESRESOLVERS__ACME_CERTIFICATESDURATION`: +Certificates' duration in hours. (Default: ```2160```) + `TRAEFIK_CERTIFICATESRESOLVERS__ACME_DNSCHALLENGE`: Activate DNS-01 Challenge. (Default: ```false```) diff --git a/docs/content/reference/static-configuration/file.toml b/docs/content/reference/static-configuration/file.toml index 5e77ec768..fd4c77461 100644 --- a/docs/content/reference/static-configuration/file.toml +++ b/docs/content/reference/static-configuration/file.toml @@ -358,6 +358,7 @@ [certificatesResolvers.CertificateResolver0.acme] email = "foobar" caServer = "foobar" + certificatesDuration = 2160 preferredChain = "foobar" storage = "foobar" keyType = "foobar" @@ -376,6 +377,7 @@ [certificatesResolvers.CertificateResolver1.acme] email = "foobar" caServer = "foobar" + certificatesDuration = 2160 preferredChain = "foobar" storage = "foobar" keyType = "foobar" diff --git a/docs/content/reference/static-configuration/file.yaml b/docs/content/reference/static-configuration/file.yaml index d445be38d..e875055eb 100644 --- a/docs/content/reference/static-configuration/file.yaml +++ b/docs/content/reference/static-configuration/file.yaml @@ -376,6 +376,7 @@ certificatesResolvers: acme: email: foobar caServer: foobar + certificatesDuration: 2160 preferredChain: foobar storage: foobar keyType: foobar @@ -396,6 +397,7 @@ certificatesResolvers: acme: email: foobar caServer: foobar + certificatesDuration: 2160 preferredChain: foobar storage: foobar keyType: foobar diff --git a/pkg/anonymize/anonymize_config_test.go b/pkg/anonymize/anonymize_config_test.go index d9536e75e..c7c0c9bba 100644 --- a/pkg/anonymize/anonymize_config_test.go +++ b/pkg/anonymize/anonymize_config_test.go @@ -914,11 +914,12 @@ func TestDo_staticConfiguration(t *testing.T) { config.CertificatesResolvers = map[string]static.CertificateResolver{ "CertificateResolver0": { ACME: &acme.Configuration{ - Email: "acme Email", - CAServer: "CAServer", - PreferredChain: "foobar", - Storage: "Storage", - KeyType: "MyKeyType", + Email: "acme Email", + CAServer: "CAServer", + CertificatesDuration: 42, + PreferredChain: "foobar", + Storage: "Storage", + KeyType: "MyKeyType", DNSChallenge: &acme.DNSChallenge{ Provider: "DNSProvider", DelayBeforeCheck: 42, diff --git a/pkg/anonymize/testdata/anonymized-static-config.json b/pkg/anonymize/testdata/anonymized-static-config.json index 76ff7cdc2..f5582f6f2 100644 --- a/pkg/anonymize/testdata/anonymized-static-config.json +++ b/pkg/anonymize/testdata/anonymized-static-config.json @@ -426,6 +426,7 @@ "preferredChain": "foobar", "storage": "Storage", "keyType": "MyKeyType", + "certificatesDuration": 42, "dnsChallenge": { "provider": "DNSProvider", "delayBeforeCheck": "42ns", diff --git a/pkg/provider/acme/provider.go b/pkg/provider/acme/provider.go index 3e221bfa6..3a9aedada 100644 --- a/pkg/provider/acme/provider.go +++ b/pkg/provider/acme/provider.go @@ -33,12 +33,13 @@ var oscpMustStaple = false // Configuration holds ACME configuration provided by users. type Configuration struct { - Email string `description:"Email address used for registration." json:"email,omitempty" toml:"email,omitempty" yaml:"email,omitempty"` - CAServer string `description:"CA server to use." json:"caServer,omitempty" toml:"caServer,omitempty" yaml:"caServer,omitempty"` - PreferredChain string `description:"Preferred chain to use." json:"preferredChain,omitempty" toml:"preferredChain,omitempty" yaml:"preferredChain,omitempty" export:"true"` - Storage string `description:"Storage to use." json:"storage,omitempty" toml:"storage,omitempty" yaml:"storage,omitempty" export:"true"` - KeyType string `description:"KeyType used for generating certificate private key. Allow value 'EC256', 'EC384', 'RSA2048', 'RSA4096', 'RSA8192'." json:"keyType,omitempty" toml:"keyType,omitempty" yaml:"keyType,omitempty" export:"true"` - EAB *EAB `description:"External Account Binding to use." json:"eab,omitempty" toml:"eab,omitempty" yaml:"eab,omitempty"` + Email string `description:"Email address used for registration." json:"email,omitempty" toml:"email,omitempty" yaml:"email,omitempty"` + CAServer string `description:"CA server to use." json:"caServer,omitempty" toml:"caServer,omitempty" yaml:"caServer,omitempty"` + PreferredChain string `description:"Preferred chain to use." json:"preferredChain,omitempty" toml:"preferredChain,omitempty" yaml:"preferredChain,omitempty" export:"true"` + Storage string `description:"Storage to use." json:"storage,omitempty" toml:"storage,omitempty" yaml:"storage,omitempty" export:"true"` + KeyType string `description:"KeyType used for generating certificate private key. Allow value 'EC256', 'EC384', 'RSA2048', 'RSA4096', 'RSA8192'." json:"keyType,omitempty" toml:"keyType,omitempty" yaml:"keyType,omitempty" export:"true"` + EAB *EAB `description:"External Account Binding to use." json:"eab,omitempty" toml:"eab,omitempty" yaml:"eab,omitempty"` + CertificatesDuration int `description:"Certificates' duration in hours." json:"certificatesDuration,omitempty" toml:"certificatesDuration,omitempty" yaml:"certificatesDuration,omitempty" export:"true"` DNSChallenge *DNSChallenge `description:"Activate DNS-01 Challenge." json:"dnsChallenge,omitempty" toml:"dnsChallenge,omitempty" yaml:"dnsChallenge,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"` HTTPChallenge *HTTPChallenge `description:"Activate HTTP-01 Challenge." json:"httpChallenge,omitempty" toml:"httpChallenge,omitempty" yaml:"httpChallenge,omitempty" label:"allowEmpty" file:"allowEmpty" export:"true"` @@ -50,6 +51,7 @@ func (a *Configuration) SetDefaults() { a.CAServer = lego.LEDirectoryProduction a.Storage = "acme.json" a.KeyType = "RSA4096" + a.CertificatesDuration = 3 * 30 * 24 // 90 Days } // CertAndStore allows mapping a TLS certificate to a TLS store. @@ -133,6 +135,10 @@ func (p *Provider) Init() error { return errors.New("unable to initialize ACME provider with no storage location for the certificates") } + if p.CertificatesDuration < 1 { + return errors.New("cannot manage certificates with duration lower than 1 hour") + } + var err error p.account, err = p.Store.GetAccount(p.ResolverName) if err != nil { @@ -177,7 +183,9 @@ func isAccountMatchingCaServer(ctx context.Context, accountURI, serverURI string // Provide allows the file provider to provide configurations to traefik // using the given Configuration channel. func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { - ctx := log.With(context.Background(), log.Str(log.ProviderName, p.ResolverName+".acme")) + ctx := log.With(context.Background(), + log.Str(log.ProviderName, p.ResolverName+".acme"), + log.Str("ACME CA", p.Configuration.CAServer)) p.pool = pool @@ -187,14 +195,18 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe. p.configurationChan = configurationChan p.refreshCertificates() - p.renewCertificates(ctx) + renewPeriod, renewInterval := getCertificateRenewDurations(p.CertificatesDuration) + log.FromContext(ctx).Debugf("Attempt to renew certificates %q before expiry and check every %q", + renewPeriod, renewInterval) - ticker := time.NewTicker(24 * time.Hour) + p.renewCertificates(ctx, renewPeriod) + + ticker := time.NewTicker(renewInterval) pool.GoCtx(func(ctxPool context.Context) { for { select { case <-ticker.C: - p.renewCertificates(ctx) + p.renewCertificates(ctx, renewPeriod) case <-ctxPool.Done(): ticker.Stop() return @@ -515,6 +527,24 @@ func (p *Provider) addCertificateForDomain(domain types.Domain, certificate, key p.certsChan <- &CertAndStore{Certificate: Certificate{Certificate: certificate, Key: key, Domain: domain}, Store: tlsStore} } +// getCertificateRenewDurations returns renew durations calculated from the given certificatesDuration in hours. +// The first (RenewPeriod) is the period before the end of the certificate duration, during which the certificate should be renewed. +// The second (RenewInterval) is the interval between renew attempts. +func getCertificateRenewDurations(certificatesDuration int) (time.Duration, time.Duration) { + switch { + case certificatesDuration >= 265*24: // >= 1 year + return 4 * 30 * 24 * time.Hour, 7 * 24 * time.Hour // 4 month, 1 week + case certificatesDuration >= 3*30*24: // >= 90 days + return 30 * 24 * time.Hour, 24 * time.Hour // 30 days, 1 day + case certificatesDuration >= 7*24: // >= 7 days + return 24 * time.Hour, time.Hour // 1 days, 1 hour + case certificatesDuration >= 24: // >= 1 days + return 6 * time.Hour, 10 * time.Minute // 6 hours, 10 minutes + default: + return 20 * time.Minute, time.Minute + } +} + // deleteUnnecessaryDomains deletes from the configuration : // - Duplicated domains // - Domains which are checked by wildcard domain. @@ -637,15 +667,14 @@ func (p *Provider) refreshCertificates() { p.configurationChan <- conf } -func (p *Provider) renewCertificates(ctx context.Context) { +func (p *Provider) renewCertificates(ctx context.Context, renewPeriod time.Duration) { logger := log.FromContext(ctx) logger.Info("Testing certificate renew...") 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 - // <= 30 days left, renew certificate - if err != nil || crt == nil || crt.NotAfter.Before(time.Now().Add(24*30*time.Hour)) { + 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) diff --git a/pkg/provider/acme/provider_test.go b/pkg/provider/acme/provider_test.go index 5174f3c14..64bcf1085 100644 --- a/pkg/provider/acme/provider_test.go +++ b/pkg/provider/acme/provider_test.go @@ -4,6 +4,7 @@ import ( "context" "crypto/tls" "testing" + "time" "github.com/go-acme/lego/v4/certcrypto" "github.com/stretchr/testify/assert" @@ -592,3 +593,53 @@ func TestInitAccount(t *testing.T) { }) } } + +func Test_getCertificateRenewDurations(t *testing.T) { + testCases := []struct { + desc string + certificatesDurations int + expectRenewPeriod time.Duration + expectRenewInterval time.Duration + }{ + { + desc: "Less than 24 Hours certificates: 20 minutes renew period, 1 minutes renew interval", + certificatesDurations: 1, + expectRenewPeriod: time.Minute * 20, + expectRenewInterval: time.Minute, + }, + { + desc: "1 Year certificates: 2 months renew period, 1 week renew interval", + certificatesDurations: 24 * 365, + expectRenewPeriod: time.Hour * 24 * 30 * 4, + expectRenewInterval: time.Hour * 24 * 7, + }, + { + desc: "90 Days certificates: 30 days renew period, 1 day renew interval", + certificatesDurations: 24 * 90, + expectRenewPeriod: time.Hour * 24 * 30, + expectRenewInterval: time.Hour * 24, + }, + { + desc: "7 Days certificates: 1 days renew period, 1 hour renew interval", + certificatesDurations: 24 * 7, + expectRenewPeriod: time.Hour * 24, + expectRenewInterval: time.Hour, + }, + { + desc: "24 Hours certificates: 6 hours renew period, 10 minutes renew interval", + certificatesDurations: 24, + expectRenewPeriod: time.Hour * 6, + expectRenewInterval: time.Minute * 10, + }, + } + for _, test := range testCases { + test := test + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + renewPeriod, renewInterval := getCertificateRenewDurations(test.certificatesDurations) + assert.Equal(t, test.expectRenewPeriod, renewPeriod) + assert.Equal(t, test.expectRenewInterval, renewInterval) + }) + } +}