mirror of
https://github.com/go-acme/lego
synced 2026-03-14 14:35:48 +01:00
477 lines
13 KiB
Go
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()
|
|
}
|