lego/cmd/cmd_renew.go
2026-02-12 20:14:48 +01:00

477 lines
13 KiB
Go

package cmd
import (
"context"
"crypto"
"crypto/x509"
"errors"
"fmt"
"log/slog"
"math"
"math/rand"
"os"
"slices"
"sort"
"strings"
"sync"
"time"
"github.com/go-acme/lego/v5/acme/api"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/cmd/internal/hook"
"github.com/go-acme/lego/v5/cmd/internal/storage"
"github.com/go-acme/lego/v5/lego"
"github.com/go-acme/lego/v5/log"
"github.com/mattn/go-isatty"
"github.com/urfave/cli/v3"
)
const noDays = -math.MaxInt
type lzSetUp func() (*lego.Client, error)
func createRenew() *cli.Command {
return &cli.Command{
Name: "renew",
Usage: "Renew a certificate",
Action: renew,
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
// we require either domains or csr, but not both
hasDomains := len(cmd.StringSlice(flgDomains)) > 0
hasCsr := cmd.String(flgCSR) != ""
if hasDomains && hasCsr {
log.Fatal(fmt.Sprintf("Please specify either --%s/-d or --%s/-c, but not both", flgDomains, flgCSR))
}
if !hasDomains && !hasCsr {
log.Fatal(fmt.Sprintf("Please specify --%s/-d (or --%s/-c if you already have a CSR)", flgDomains, flgCSR))
}
if cmd.Bool(flgForceCertDomains) && hasCsr {
log.Fatal(fmt.Sprintf("--%s only works with --%s/-d, --%s/-c doesn't support this option.", flgForceCertDomains, flgDomains, flgCSR))
}
return ctx, validateNetworkStack(cmd)
},
Flags: createRenewFlags(),
}
}
func renew(ctx context.Context, cmd *cli.Command) error {
keyType, err := certcrypto.GetKeyType(cmd.String(flgKeyType))
if err != nil {
return fmt.Errorf("get the key type: %w", err)
}
accountsStorage, err := storage.NewAccountsStorage(newAccountsStorageConfig(cmd))
if err != nil {
return fmt.Errorf("accounts storage initialization: %w", err)
}
account, err := accountsStorage.Get(ctx, keyType, cmd.String(flgEmail), cmd.String(flgAccountID))
if err != nil {
return fmt.Errorf("set up account: %w", err)
}
if account.Registration == nil {
return fmt.Errorf("the account %s is not registered", account.GetID())
}
certsStorage := storage.NewCertificatesStorage(cmd.String(flgPath))
meta := map[string]string{
// TODO(ldez) add account ID.
hook.EnvAccountEmail: account.Email,
}
lazyClient := sync.OnceValues(func() (*lego.Client, error) {
client, err := newClient(cmd, account, keyType)
if err != nil {
return nil, fmt.Errorf("new client: %w", err)
}
setupChallenges(cmd, client)
return client, nil
})
// CSR
if cmd.IsSet(flgCSR) {
return renewForCSR(ctx, cmd, lazyClient, certsStorage, meta)
}
// Domains
return renewForDomains(ctx, cmd, lazyClient, certsStorage, meta)
}
func renewForDomains(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, certsStorage *storage.CertificatesStorage, meta map[string]string) error {
domains := cmd.StringSlice(flgDomains)
domain := domains[0]
// load the cert resource from files.
// We store the certificate, private key and metadata in different files
// as web servers would not be able to work with a combined file.
certificates, err := certsStorage.ReadCertificate(domain, storage.ExtCert)
if err != nil {
return fmt.Errorf("error while reading the certificate for domain %q: %w", domain, err)
}
cert := certificates[0]
if cert.IsCA {
return fmt.Errorf("certificate bundle for %q starts with a CA certificate", domain)
}
ariRenewalTime, replacesCertID, err := getARIInfo(ctx, cmd, lazyClient, domain, cert)
if err != nil {
return err
}
certDomains := certcrypto.ExtractDomains(cert)
renewalDomains := slices.Clone(domains)
if !cmd.Bool(flgForceCertDomains) {
renewalDomains = merge(certDomains, domains)
}
if ariRenewalTime == nil && !cmd.Bool(flgRenewForce) && sameDomains(certDomains, renewalDomains) &&
!isInRenewalPeriod(cert, domain, getFlagRenewDays(cmd), time.Now()) {
return nil
}
// This is just meant to be informal for the user.
timeLeft := cert.NotAfter.Sub(time.Now().UTC())
log.Info("acme: Trying renewal.",
log.DomainAttr(domain),
slog.Int("hoursRemaining", int(timeLeft.Hours())),
)
client, err := lazyClient()
if err != nil {
return fmt.Errorf("set up client: %w", err)
}
var privateKey crypto.PrivateKey
if cmd.Bool(flgReuseKey) {
keyBytes, errR := certsStorage.ReadFile(domain, storage.ExtKey)
if errR != nil {
return fmt.Errorf("error while reading the private key for domain %q: %w", domain, errR)
}
privateKey, errR = certcrypto.ParsePEMPrivateKey(keyBytes)
if errR != nil {
return fmt.Errorf("error while parsing the private key for domain %q: %w", domain, errR)
}
}
randomSleep(cmd)
request := newObtainRequest(cmd, renewalDomains)
request.PrivateKey = privateKey
if replacesCertID != "" {
request.ReplacesCertID = replacesCertID
}
certRes, err := client.Certificate.Obtain(ctx, request)
if err != nil {
return fmt.Errorf("could not obtain the certificate for domain %q: %w", domain, err)
}
certRes.Domain = domain
options := newSaveOptions(cmd)
err = certsStorage.SaveResource(certRes, options)
if err != nil {
return fmt.Errorf("could not save the resource: %w", err)
}
hook.AddPathToMetadata(meta, certRes.Domain, certRes, certsStorage, options)
return hook.Launch(ctx, cmd.String(flgDeployHook), cmd.Duration(flgDeployHookTimeout), meta)
}
func renewForCSR(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, certsStorage *storage.CertificatesStorage, meta map[string]string) error {
csr, err := readCSRFile(cmd.String(flgCSR))
if err != nil {
return fmt.Errorf("could not read CSR file %q: %w", cmd.String(flgCSR), err)
}
domain, err := certcrypto.GetCSRMainDomain(csr)
if err != nil {
return fmt.Errorf("could not get CSR main domain: %w", err)
}
// load the cert resource from files.
// We store the certificate, private key and metadata in different files
// as web servers would not be able to work with a combined file.
certificates, err := certsStorage.ReadCertificate(domain, storage.ExtCert)
if err != nil {
return fmt.Errorf("error while reading the certificate for domain %q: %w", domain, err)
}
cert := certificates[0]
if cert.IsCA {
return fmt.Errorf("certificate bundle for %q starts with a CA certificate", domain)
}
ariRenewalTime, replacesCertID, err := getARIInfo(ctx, cmd, lazyClient, domain, cert)
if err != nil {
return err
}
if ariRenewalTime == nil && !cmd.Bool(flgRenewForce) && sameDomainsCertificate(cert, csr) &&
!isInRenewalPeriod(cert, domain, getFlagRenewDays(cmd), time.Now()) {
return nil
}
// This is just meant to be informal for the user.
timeLeft := cert.NotAfter.Sub(time.Now().UTC())
log.Info("acme: Trying renewal.",
log.DomainAttr(domain),
slog.Int("hoursRemaining", int(timeLeft.Hours())),
)
client, err := lazyClient()
if err != nil {
return fmt.Errorf("set up client: %w", err)
}
request := newObtainForCSRRequest(cmd, csr)
if replacesCertID != "" {
request.ReplacesCertID = replacesCertID
}
certRes, err := client.Certificate.ObtainForCSR(ctx, request)
if err != nil {
return fmt.Errorf("could not obtain the certificate for CSR: %w", err)
}
options := newSaveOptions(cmd)
err = certsStorage.SaveResource(certRes, options)
if err != nil {
return fmt.Errorf("could not save the resource: %w", err)
}
hook.AddPathToMetadata(meta, domain, certRes, certsStorage, options)
return hook.Launch(ctx, cmd.String(flgDeployHook), cmd.Duration(flgDeployHookTimeout), meta)
}
func getFlagRenewDays(cmd *cli.Command) int {
if cmd.IsSet(flgRenewDays) {
return cmd.Int(flgRenewDays)
}
return noDays
}
func isInRenewalPeriod(cert *x509.Certificate, domain string, days int, now time.Time) bool {
dueDate := getDueDate(cert, days, now)
if dueDate.Before(now) || dueDate.Equal(now) {
return true
}
log.Infof(
log.LazySprintf("Skip renewal: The certificate expires at %s, the renewal can be performed in %s.",
cert.NotAfter.Format(time.RFC3339),
FormattableDuration(dueDate.Sub(now)),
),
log.DomainAttr(domain),
)
return false
}
func getDueDate(x509Cert *x509.Certificate, days int, now time.Time) time.Time {
if days == noDays {
lifetime := x509Cert.NotAfter.Sub(x509Cert.NotBefore)
var divisor int64 = 3
if lifetime.Round(24*time.Hour).Hours()/24.0 <= 10 {
divisor = 2
}
return x509Cert.NotAfter.Add(-1 * time.Duration(lifetime.Nanoseconds()/divisor))
}
if days < 0 {
// if the number of days is negative: always renew the certificate.
return now
}
return x509Cert.NotAfter.Add(-1 * time.Duration(days) * 24 * time.Hour)
}
func getARIInfo(ctx context.Context, cmd *cli.Command, lazyClient lzSetUp, domain string, cert *x509.Certificate) (*time.Time, string, error) {
if cmd.Bool(flgARIDisable) {
return nil, "", nil
}
client, err := lazyClient()
if err != nil {
return nil, "", fmt.Errorf("set up client: %w", err)
}
willingToSleep := cmd.Duration(flgARIWaitToRenewDuration)
ariRenewalTime := getARIRenewalTime(ctx, willingToSleep, cert, domain, client)
if ariRenewalTime != nil {
now := time.Now().UTC()
// Figure out if we need to sleep before renewing.
if ariRenewalTime.After(now) {
log.Info("Sleeping until renewal time",
log.DomainAttr(domain),
slog.Duration("sleep", ariRenewalTime.Sub(now)),
slog.Time("renewalTime", *ariRenewalTime),
)
time.Sleep(ariRenewalTime.Sub(now))
}
}
replacesCertID, err := certificate.MakeARICertID(cert)
if err != nil {
return nil, "", fmt.Errorf("error while constructing the ARI CertID for domain %q: %w", domain, err)
}
return ariRenewalTime, replacesCertID, nil
}
// getARIRenewalTime checks if the certificate needs to be renewed using the renewalInfo endpoint.
func getARIRenewalTime(ctx context.Context, willingToSleep time.Duration, cert *x509.Certificate, domain string, client *lego.Client) *time.Time {
if cert.IsCA {
log.Fatal("Certificate bundle starts with a CA certificate.", log.DomainAttr(domain))
}
renewalInfo, err := client.Certificate.GetRenewalInfo(ctx, certificate.RenewalInfoRequest{Cert: cert})
if err != nil {
if errors.Is(err, api.ErrNoARI) {
log.Warn("acme: the server does not advertise a renewal info endpoint.",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
return nil
}
log.Warn("acme: calling renewal info endpoint",
log.DomainAttr(domain),
log.ErrorAttr(err),
)
return nil
}
now := time.Now().UTC()
renewalTime := renewalInfo.ShouldRenewAt(now, willingToSleep)
if renewalTime == nil {
log.Info("acme: renewalInfo endpoint indicates that renewal is not needed.", log.DomainAttr(domain))
return nil
}
log.Info("acme: renewalInfo endpoint indicates that renewal is needed.", log.DomainAttr(domain))
if renewalInfo.ExplanationURL != "" {
log.Info("acme: renewalInfo endpoint provided an explanation.",
log.DomainAttr(domain),
slog.String("explanationURL", renewalInfo.ExplanationURL),
)
}
return renewalTime
}
func randomSleep(cmd *cli.Command) {
// https://github.com/go-acme/lego/issues/1656
// https://github.com/certbot/certbot/blob/284023a1b7672be2bd4018dd7623b3b92197d4b0/certbot/certbot/_internal/renewal.py#L435-L440
if !isatty.IsTerminal(os.Stdout.Fd()) && !cmd.Bool(flgNoRandomSleep) {
// https://github.com/certbot/certbot/blob/284023a1b7672be2bd4018dd7623b3b92197d4b0/certbot/certbot/_internal/renewal.py#L472
const jitter = 8 * time.Minute
rnd := rand.New(rand.NewSource(time.Now().UnixNano()))
sleepTime := time.Duration(rnd.Int63n(int64(jitter)))
log.Info("renewal: random delay.", slog.Duration("sleep", sleepTime))
time.Sleep(sleepTime)
}
}
func merge(prevDomains, nextDomains []string) []string {
for _, next := range nextDomains {
if slices.Contains(prevDomains, next) {
continue
}
prevDomains = append(prevDomains, next)
}
return prevDomains
}
func sameDomainsCertificate(cert *x509.Certificate, csr *x509.CertificateRequest) bool {
return sameDomains(certcrypto.ExtractDomains(cert), certcrypto.ExtractDomainsCSR(csr))
}
func sameDomains(a, b []string) bool {
if len(a) != len(b) {
return false
}
aClone := slices.Clone(a)
sort.Strings(aClone)
bClone := slices.Clone(b)
sort.Strings(bClone)
return slices.Equal(aClone, bClone)
}
type FormattableDuration time.Duration
func (f FormattableDuration) String() string {
d := time.Duration(f)
days := int(math.Trunc(d.Hours() / 24))
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
seconds := int(d.Seconds()) % 60
ns := int(d.Nanoseconds()) % int(time.Second)
var s strings.Builder
if days > 0 {
s.WriteString(fmt.Sprintf("%dd", days))
}
if hours > 0 {
s.WriteString(fmt.Sprintf("%dh", hours))
}
if minutes > 0 {
s.WriteString(fmt.Sprintf("%dm", minutes))
}
if seconds > 0 {
s.WriteString(fmt.Sprintf("%ds", seconds))
}
if ns > 0 {
s.WriteString(fmt.Sprintf("%dns", ns))
}
return s.String()
}