feat: allow configuration of ACME certificates duration

This commit is contained in:
Pablo Montepagano 2021-11-10 08:06:09 -03:00 committed by GitHub
parent 1f17731369
commit 0a5c9095ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 183 additions and 19 deletions

View file

@ -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. 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 "" !!! 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. 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 !!! warning
For concurrency reasons, this file cannot be shared across multiple instances of Traefik. 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` ### `preferredChain`
_Optional, Default=""_ _Optional, Default=""_

View file

@ -22,6 +22,14 @@
# #
# caServer = "https://acme-staging-v02.api.letsencrypt.org/directory" # 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. # Preferred chain to use.
# #
# If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. # If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name.

View file

@ -21,6 +21,14 @@
# #
--certificatesresolvers.myresolver.acme.caserver=https://acme-staging-v02.api.letsencrypt.org/directory --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. # Preferred chain to use.
# #
# If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. # If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name.

View file

@ -24,6 +24,14 @@ certificatesResolvers:
# #
# caServer: "https://acme-staging-v02.api.letsencrypt.org/directory" # 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. # Preferred chain to use.
# #
# If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name. # If the CA offers multiple certificate chains, prefer the chain with an issuer matching this Subject Common Name.

View file

@ -54,6 +54,9 @@ Certificates resolvers configuration. (Default: ```false```)
`--certificatesresolvers.<name>.acme.caserver`: `--certificatesresolvers.<name>.acme.caserver`:
CA server to use. (Default: ```https://acme-v02.api.letsencrypt.org/directory```) CA server to use. (Default: ```https://acme-v02.api.letsencrypt.org/directory```)
`--certificatesresolvers.<name>.acme.certificatesduration`:
Certificates' duration in hours. (Default: ```2160```)
`--certificatesresolvers.<name>.acme.dnschallenge`: `--certificatesresolvers.<name>.acme.dnschallenge`:
Activate DNS-01 Challenge. (Default: ```false```) Activate DNS-01 Challenge. (Default: ```false```)

View file

@ -54,6 +54,9 @@ Certificates resolvers configuration. (Default: ```false```)
`TRAEFIK_CERTIFICATESRESOLVERS_<NAME>_ACME_CASERVER`: `TRAEFIK_CERTIFICATESRESOLVERS_<NAME>_ACME_CASERVER`:
CA server to use. (Default: ```https://acme-v02.api.letsencrypt.org/directory```) CA server to use. (Default: ```https://acme-v02.api.letsencrypt.org/directory```)
`TRAEFIK_CERTIFICATESRESOLVERS_<NAME>_ACME_CERTIFICATESDURATION`:
Certificates' duration in hours. (Default: ```2160```)
`TRAEFIK_CERTIFICATESRESOLVERS_<NAME>_ACME_DNSCHALLENGE`: `TRAEFIK_CERTIFICATESRESOLVERS_<NAME>_ACME_DNSCHALLENGE`:
Activate DNS-01 Challenge. (Default: ```false```) Activate DNS-01 Challenge. (Default: ```false```)

View file

@ -358,6 +358,7 @@
[certificatesResolvers.CertificateResolver0.acme] [certificatesResolvers.CertificateResolver0.acme]
email = "foobar" email = "foobar"
caServer = "foobar" caServer = "foobar"
certificatesDuration = 2160
preferredChain = "foobar" preferredChain = "foobar"
storage = "foobar" storage = "foobar"
keyType = "foobar" keyType = "foobar"
@ -376,6 +377,7 @@
[certificatesResolvers.CertificateResolver1.acme] [certificatesResolvers.CertificateResolver1.acme]
email = "foobar" email = "foobar"
caServer = "foobar" caServer = "foobar"
certificatesDuration = 2160
preferredChain = "foobar" preferredChain = "foobar"
storage = "foobar" storage = "foobar"
keyType = "foobar" keyType = "foobar"

View file

@ -376,6 +376,7 @@ certificatesResolvers:
acme: acme:
email: foobar email: foobar
caServer: foobar caServer: foobar
certificatesDuration: 2160
preferredChain: foobar preferredChain: foobar
storage: foobar storage: foobar
keyType: foobar keyType: foobar
@ -396,6 +397,7 @@ certificatesResolvers:
acme: acme:
email: foobar email: foobar
caServer: foobar caServer: foobar
certificatesDuration: 2160
preferredChain: foobar preferredChain: foobar
storage: foobar storage: foobar
keyType: foobar keyType: foobar

View file

@ -916,6 +916,7 @@ func TestDo_staticConfiguration(t *testing.T) {
ACME: &acme.Configuration{ ACME: &acme.Configuration{
Email: "acme Email", Email: "acme Email",
CAServer: "CAServer", CAServer: "CAServer",
CertificatesDuration: 42,
PreferredChain: "foobar", PreferredChain: "foobar",
Storage: "Storage", Storage: "Storage",
KeyType: "MyKeyType", KeyType: "MyKeyType",

View file

@ -426,6 +426,7 @@
"preferredChain": "foobar", "preferredChain": "foobar",
"storage": "Storage", "storage": "Storage",
"keyType": "MyKeyType", "keyType": "MyKeyType",
"certificatesDuration": 42,
"dnsChallenge": { "dnsChallenge": {
"provider": "DNSProvider", "provider": "DNSProvider",
"delayBeforeCheck": "42ns", "delayBeforeCheck": "42ns",

View file

@ -39,6 +39,7 @@ type Configuration struct {
Storage string `description:"Storage to use." json:"storage,omitempty" toml:"storage,omitempty" yaml:"storage,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"` 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"` 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"` 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"` 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.CAServer = lego.LEDirectoryProduction
a.Storage = "acme.json" a.Storage = "acme.json"
a.KeyType = "RSA4096" a.KeyType = "RSA4096"
a.CertificatesDuration = 3 * 30 * 24 // 90 Days
} }
// CertAndStore allows mapping a TLS certificate to a TLS store. // 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") 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 var err error
p.account, err = p.Store.GetAccount(p.ResolverName) p.account, err = p.Store.GetAccount(p.ResolverName)
if err != nil { 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 // Provide allows the file provider to provide configurations to traefik
// using the given Configuration channel. // using the given Configuration channel.
func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.Pool) error { 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 p.pool = pool
@ -187,14 +195,18 @@ func (p *Provider) Provide(configurationChan chan<- dynamic.Message, pool *safe.
p.configurationChan = configurationChan p.configurationChan = configurationChan
p.refreshCertificates() 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) { pool.GoCtx(func(ctxPool context.Context) {
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
p.renewCertificates(ctx) p.renewCertificates(ctx, renewPeriod)
case <-ctxPool.Done(): case <-ctxPool.Done():
ticker.Stop() ticker.Stop()
return 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} 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 : // deleteUnnecessaryDomains deletes from the configuration :
// - Duplicated domains // - Duplicated domains
// - Domains which are checked by wildcard domain. // - Domains which are checked by wildcard domain.
@ -637,15 +667,14 @@ func (p *Provider) refreshCertificates() {
p.configurationChan <- conf 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 := log.FromContext(ctx)
logger.Info("Testing certificate renew...") logger.Info("Testing certificate renew...")
for _, cert := range p.certificates { for _, cert := range p.certificates {
crt, err := getX509Certificate(ctx, &cert.Certificate) crt, err := getX509Certificate(ctx, &cert.Certificate)
// If there's an error, we assume the cert is broken, and needs update // 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(renewPeriod)) {
if err != nil || crt == nil || crt.NotAfter.Before(time.Now().Add(24*30*time.Hour)) {
client, err := p.getClient() client, err := p.getClient()
if err != nil { if err != nil {
logger.Infof("Error renewing certificate from LE : %+v, %v", cert.Domain, err) logger.Infof("Error renewing certificate from LE : %+v, %v", cert.Domain, err)

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/tls" "crypto/tls"
"testing" "testing"
"time"
"github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certcrypto"
"github.com/stretchr/testify/assert" "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)
})
}
}