feat: use file configuration

This commit is contained in:
Fernandez Ludovic 2026-03-11 15:59:03 +01:00
commit 0fe1f47b1d
11 changed files with 992 additions and 5 deletions

View file

@ -258,6 +258,15 @@ linters:
text: Function 'renewForDomains' has too many statements
linters:
- funlen
- path: cmd/internal/root/process_obtain.go
text: (cognitive|cyclomatic) complexity \d+ of func `obtain` is high
linters:
- gocognit
- gocyclo
- path: cmd/internal/root/process_renew.go
text: cyclomatic complexity \d+ of func `renewForDomains` is high
linters:
- gocyclo
- path: providers/dns/cpanel/cpanel.go
text: cyclomatic complexity 13 of func `\(\*DNSProvider\)\.CleanUp` is high
linters:

View file

@ -3,7 +3,9 @@ package cmd
import (
"context"
"github.com/go-acme/lego/v5/cmd/internal/configuration"
"github.com/go-acme/lego/v5/cmd/internal/flags"
"github.com/go-acme/lego/v5/cmd/internal/root"
"github.com/urfave/cli/v3"
)
@ -14,10 +16,11 @@ func CreateRootCommand() *cli.Command {
Usage: "ACME client written in Go",
EnableShellCompletion: true,
Before: func(ctx context.Context, cmd *cli.Command) (context.Context, error) {
setUpLogger(cmd)
setUpLogger(cmd, nil)
return ctx, nil
},
Action: rootRun,
Flags: flags.CreateRootFlags(),
Commands: CreateCommands(),
}
@ -35,3 +38,39 @@ func CreateCommands() []*cli.Command {
createMigrate(),
}
}
func rootRun(ctx context.Context, cmd *cli.Command) error {
filename, err := getConfigurationPath(cmd)
if err != nil {
return err
}
cfg, err := configuration.ReadConfiguration(filename)
if err != nil {
return err
}
setUpLogger(cmd, cfg.Log)
configuration.ApplyDefaults(cfg)
err = configuration.Validate(cfg)
if err != nil {
return err
}
// Set effective User Agent.
cfg.UserAgent = getUserAgent(cmd, cfg.UserAgent)
return root.Process(ctx, cfg)
}
func getConfigurationPath(cmd *cli.Command) (string, error) {
configPath := cmd.String(flags.FlgConfig)
if configPath != "" {
return configPath, nil
}
return configuration.FindDefaultConfigurationFile()
}

View file

@ -20,7 +20,19 @@ import (
)
func CreateRootFlags() []cli.Flag {
return createLogFlags()
flags := []cli.Flag{
&cli.StringFlag{
Name: FlgConfig,
Aliases: []string{FlgConfig},
Sources: cli.EnvVars(toEnvName(FlgConfig)),
Usage: "Path to the configuration file.",
Local: true,
},
}
flags = append(flags, createLogFlags()...)
return flags
}
func CreateRunFlags() []cli.Flag {

View file

@ -142,6 +142,11 @@ const (
FlgLogFormat = "log.format"
)
// Flag names related to the configuration file.
const (
FlgConfig = "config"
)
// Flag names related to the specific run command.
const (
FlgPrivateKey = "private-key"

View file

@ -0,0 +1,42 @@
package root
import (
"context"
"fmt"
"net"
"strings"
"github.com/go-acme/lego/v5/cmd/internal/configuration"
"github.com/go-acme/lego/v5/cmd/internal/storage"
)
func Process(ctx context.Context, cfg *configuration.Configuration) error {
archiver := storage.NewArchiver(cfg.Storage)
err := archiver.Accounts(cfg)
if err != nil {
return err
}
err = archiver.Certificates(cfg.Certificates)
if err != nil {
return err
}
return obtain(ctx, cfg)
}
// NOTE(ldez): this is partially a duplication with flags parsing, but the errors are slightly different.
func parseAddress(address string) (string, string, error) {
if !strings.Contains(address, ":") {
return "", "", fmt.Errorf("the address only accepts 'interface:port' or ':port' for its argument: '%s'",
address)
}
host, port, err := net.SplitHostPort(address)
if err != nil {
return "", "", fmt.Errorf("could not split address '%s': %w", address, err)
}
return host, port, nil
}

View file

@ -0,0 +1,205 @@
package root
import (
"log/slog"
"net"
"strings"
"time"
"github.com/go-acme/lego/v5/challenge"
"github.com/go-acme/lego/v5/challenge/dns01"
"github.com/go-acme/lego/v5/challenge/dnspersist01"
"github.com/go-acme/lego/v5/challenge/http01"
"github.com/go-acme/lego/v5/challenge/tlsalpn01"
"github.com/go-acme/lego/v5/cmd/internal/configuration"
"github.com/go-acme/lego/v5/lego"
"github.com/go-acme/lego/v5/log"
"github.com/go-acme/lego/v5/providers/dns"
"github.com/go-acme/lego/v5/providers/http/memcached"
"github.com/go-acme/lego/v5/providers/http/s3"
"github.com/go-acme/lego/v5/providers/http/webroot"
)
func setupChallenges(client *lego.Client, chlgConfig *configuration.Challenge, networkStack challenge.NetworkStack) {
if chlgConfig.HTTP != nil {
err := client.Challenge.SetHTTP01Provider(setupHTTPProvider(chlgConfig.HTTP, networkStack), http01.SetDelay(chlgConfig.HTTP.Delay))
if err != nil {
log.Fatal("Could not set HTTP challenge provider.", log.ErrorAttr(err))
}
}
if chlgConfig.TLS != nil {
err := client.Challenge.SetTLSALPN01Provider(setupTLSProvider(chlgConfig.TLS, networkStack), tlsalpn01.SetDelay(chlgConfig.TLS.Delay))
if err != nil {
log.Fatal("Could not set TLS challenge provider.", log.ErrorAttr(err))
}
}
if chlgConfig.DNS != nil {
err := setupDNS(client, chlgConfig.DNS, networkStack)
if err != nil {
log.Fatal("Could not set DNS challenge provider.", log.ErrorAttr(err))
}
}
if chlgConfig.DNSPersist != nil {
err := setupDNSPersist(client, chlgConfig.DNSPersist, networkStack)
if err != nil {
log.Fatal("Could not set DNS-PERSIST challenge provider.", log.ErrorAttr(err))
}
}
}
func setupHTTPProvider(chlg *configuration.HTTPChallenge, networkStack challenge.NetworkStack) challenge.Provider {
switch {
case chlg.Webroot != "":
ps, err := webroot.NewHTTPProvider(chlg.Webroot)
if err != nil {
log.Fatal("Could not create the webroot provider.",
slog.String("webRoot", chlg.Webroot),
log.ErrorAttr(err),
)
}
return ps
case len(chlg.MemcachedHosts) > 0:
ps, err := memcached.NewMemcachedProvider(chlg.MemcachedHosts)
if err != nil {
log.Fatal("Could not create the memcached provider.",
slog.String("memcachedHosts", strings.Join(chlg.MemcachedHosts, ", ")),
log.ErrorAttr(err),
)
}
return ps
case chlg.S3Bucket != "":
ps, err := s3.NewHTTPProvider(chlg.S3Bucket)
if err != nil {
log.Fatal("Could not create the S3 provider.",
slog.String("bucket", chlg.S3Bucket),
log.ErrorAttr(err),
)
}
return ps
case chlg.Address != "":
host, port, err := parseAddress(chlg.Address)
if err != nil {
log.Fatal("Could not split host and port.", slog.String("iface", chlg.Address), log.ErrorAttr(err))
}
srv := http01.NewProviderServerWithOptions(http01.Options{
Network: networkStack.Network("tcp"),
Address: net.JoinHostPort(host, port),
})
if header := chlg.ProxyHeader; header != "" {
srv.SetProxyHeader(header)
}
return srv
default:
srv := http01.NewProviderServerWithOptions(http01.Options{
Network: networkStack.Network("tcp"),
Address: net.JoinHostPort("", ":80"),
})
if header := chlg.ProxyHeader; header != "" {
srv.SetProxyHeader(header)
}
return srv
}
}
func setupTLSProvider(chlg *configuration.TLSChallenge, networkStack challenge.NetworkStack) challenge.Provider {
switch {
case chlg.Address != "":
host, port, err := parseAddress(chlg.Address)
if err != nil {
log.Fatal("Could not split host and port.", slog.String("iface", chlg.Address), log.ErrorAttr(err))
}
return tlsalpn01.NewProviderServerWithOptions(tlsalpn01.Options{
Network: networkStack.Network("tcp"),
Host: host,
Port: port,
})
default:
return tlsalpn01.NewProviderServerWithOptions(tlsalpn01.Options{
Network: networkStack.Network("tcp"),
})
}
}
func setupDNS(client *lego.Client, chlg *configuration.DNSChallenge, networkStack challenge.NetworkStack) error {
provider, err := dns.NewDNSChallengeProviderByName(chlg.Provider)
if err != nil {
return err
}
opts := &dns01.Options{RecursiveNameservers: chlg.Resolvers}
if chlg.DNSTimeout > 0 {
opts.Timeout = time.Duration(chlg.DNSTimeout) * time.Second
}
opts.NetworkStack = networkStack
dns01.SetDefaultClient(dns01.NewClient(opts))
return client.Challenge.SetDNS01Provider(provider,
dns01.LazyCondOption(chlg.Propagation != nil, func() dns01.ChallengeOption {
if chlg.Propagation.Wait > 0 {
return dns01.PropagationWait(chlg.Propagation.Wait, true)
}
return dns01.CombineOptions(
dns01.CondOptions(chlg.Propagation.DisableAuthoritativeNameservers,
dns01.DisableAuthoritativeNssPropagationRequirement(),
),
dns01.CondOptions(chlg.Propagation.DisableRecursiveNameservers,
dns01.DisableRecursiveNSsPropagationRequirement(),
),
)
}),
)
}
func setupDNSPersist(client *lego.Client, chlg *configuration.DNSPersistChallenge, networkStack challenge.NetworkStack) error {
opts := &dns01.Options{RecursiveNameservers: chlg.Resolvers}
if chlg.DNSTimeout > 0 {
opts.Timeout = time.Duration(chlg.DNSTimeout) * time.Second
}
opts.NetworkStack = networkStack
dnspersist01.SetDefaultClient(dnspersist01.NewClient(opts))
return client.Challenge.SetDNSPersist01(
dnspersist01.WithIssuerDomainName(chlg.IssuerDomainName),
dnspersist01.CondOptions(!chlg.PersistUntil.IsZero(),
dnspersist01.WithPersistUntil(chlg.PersistUntil),
),
dnspersist01.LazyCondOption(chlg.Propagation != nil, func() dnspersist01.ChallengeOption {
if chlg.Propagation.Wait > 0 {
return dnspersist01.PropagationWait(chlg.Propagation.Wait, true)
}
return dnspersist01.CombineOptions(
dnspersist01.CondOptions(chlg.Propagation.DisableAuthoritativeNameservers,
dnspersist01.DisableAuthoritativeNssPropagationRequirement(),
),
dnspersist01.CondOptions(chlg.Propagation.DisableRecursiveNameservers,
dnspersist01.DisableRecursiveNSsPropagationRequirement(),
),
)
}),
)
}

View file

@ -0,0 +1,185 @@
package root
import (
"context"
"errors"
"fmt"
"net/http"
"sync"
"time"
"github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/certcrypto"
"github.com/go-acme/lego/v5/challenge"
"github.com/go-acme/lego/v5/cmd/internal"
"github.com/go-acme/lego/v5/cmd/internal/configuration"
"github.com/go-acme/lego/v5/cmd/internal/storage"
"github.com/go-acme/lego/v5/lego"
"github.com/go-acme/lego/v5/registration"
)
func obtain(ctx context.Context, cfg *configuration.Configuration) error {
networkStack := getNetworkStack(cfg)
for accountID, challengesInfo := range createCertificatesMapping(cfg) {
accountConfig := cfg.Accounts[accountID]
keyType, err := certcrypto.GetKeyType(accountConfig.KeyType)
if err != nil {
return err
}
serverConfig := configuration.GetServerConfig(cfg, accountID)
accountsStorage, err := storage.NewAccountsStorage(storage.AccountsStorageConfig{
BasePath: cfg.Storage,
Server: serverConfig.URL,
})
if err != nil {
return err
}
account, err := accountsStorage.Get(ctx, keyType, accountConfig.Email, accountID)
if err != nil {
return err
}
lazyClient := sync.OnceValues(func() (*lego.Client, error) {
client, errC := lego.NewClient(newClientConfig(serverConfig, account, keyType, cfg.UserAgent))
if errC != nil {
return nil, errC
}
if client.GetServerMetadata().ExternalAccountRequired && accountConfig.ExternalAccountBinding != nil {
return nil, errors.New("server requires External Account Binding (EAB)")
}
return client, nil
})
if account.Registration == nil {
client, errC := lazyClient()
if errC != nil {
return fmt.Errorf("set up client: %w", errC)
}
var reg *acme.ExtendedAccount
reg, errC = registerAccount(ctx, client, accountConfig)
if errC != nil {
return fmt.Errorf("could not complete registration: %w", errC)
}
account.Registration = reg
if errC = accountsStorage.Save(keyType, account); errC != nil {
return fmt.Errorf("could not save the account file: %w", errC)
}
fmt.Printf(storage.RootPathWarningMessage, accountsStorage.GetRootPath())
}
certsStorage := storage.NewCertificatesStorage(cfg.Storage)
for challengeID, certIDs := range challengesInfo {
chlgConfig := cfg.Challenges[challengeID]
lazySetup := sync.OnceValues(func() (*lego.Client, error) {
client, errC := lazyClient()
if errC != nil {
return nil, fmt.Errorf("set up client: %w", errC)
}
client.Challenge.RemoveAll()
setupChallenges(client, chlgConfig, networkStack)
return client, nil
})
for _, certID := range certIDs {
certConfig := cfg.Certificates[certID]
// Renew
if certsStorage.ExistsFile(certID, storage.ExtResource) {
err = renewCertificate(ctx, lazyClient, certID, certConfig, certsStorage)
if err != nil {
return err
}
continue
}
// Run
err := runCertificate(ctx, lazySetup, certConfig, certsStorage)
if err != nil {
return err
}
}
}
}
return nil
}
// createCertificatesMapping creates a mapping of account -> challenge -> certificate IDs.
func createCertificatesMapping(cfg *configuration.Configuration) map[string]map[string][]string {
// Accounts -> Challenges -> Certificates
certsMappings := make(map[string]map[string][]string)
for certID, certDesc := range cfg.Certificates {
if _, ok := certsMappings[certDesc.Account]; !ok {
certsMappings[certDesc.Account] = make(map[string][]string)
}
certsMappings[certDesc.Account][certDesc.Challenge] = append(certsMappings[certDesc.Account][certDesc.Challenge], certID)
}
return certsMappings
}
func getNetworkStack(cfg *configuration.Configuration) challenge.NetworkStack {
switch cfg.NetworkStack {
case "ipv4only", "ipv4":
return challenge.IPv4Only
case "ipv6only", "ipv6":
return challenge.IPv6Only
default:
return challenge.DualStack
}
}
func newClientConfig(serverConfig *configuration.Server, account registration.User, keyType certcrypto.KeyType, ua string) *lego.Config {
config := lego.NewConfig(account)
config.CADirURL = serverConfig.URL
config.UserAgent = ua
config.Certificate = lego.CertificateConfig{KeyType: keyType}
if serverConfig.OverallRequestLimit > 0 {
config.Certificate.OverallRequestLimit = serverConfig.OverallRequestLimit
}
if serverConfig.CertTimeout > 0 {
config.Certificate.Timeout = time.Duration(serverConfig.CertTimeout) * time.Second
}
if serverConfig.HTTPTimeout > 0 {
config.HTTPClient.Timeout = time.Duration(serverConfig.HTTPTimeout) * time.Second
}
if serverConfig.TLSSkipVerify {
defaultTransport, ok := config.HTTPClient.Transport.(*http.Transport)
if ok { // This is always true because the default client used by the CLI defined the transport.
tr := defaultTransport.Clone()
tr.TLSClientConfig.InsecureSkipVerify = true
config.HTTPClient.Transport = tr
}
}
config.HTTPClient = internal.NewRetryableClient(config.HTTPClient)
return config
}

View file

@ -0,0 +1,73 @@
package root
import (
"bufio"
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"github.com/go-acme/lego/v5/acme"
"github.com/go-acme/lego/v5/cmd/internal/configuration"
"github.com/go-acme/lego/v5/lego"
"github.com/go-acme/lego/v5/log"
"github.com/go-acme/lego/v5/registration"
"github.com/go-acme/lego/v5/registration/zerossl"
)
func registerAccount(ctx context.Context, client *lego.Client, accountConfig *configuration.Account) (*acme.ExtendedAccount, error) {
accepted := handleTOS(client, accountConfig)
if !accepted {
return nil, errors.New("you did not accept the TOS: unable to proceed")
}
if accountConfig.ExternalAccountBinding != nil {
return client.Registration.RegisterWithExternalAccountBinding(ctx, registration.RegisterEABOptions{
TermsOfServiceAgreed: true,
Kid: accountConfig.ExternalAccountBinding.KID,
HmacEncoded: accountConfig.ExternalAccountBinding.HmacKey,
})
} else if zerossl.IsZeroSSL(accountConfig.Server) {
return registration.RegisterWithZeroSSL(ctx, client.Registration, accountConfig.Email)
}
return client.Registration.Register(ctx, registration.RegisterOptions{TermsOfServiceAgreed: true})
}
func handleTOS(client *lego.Client, accountConfig *configuration.Account) bool {
// metadata items are optional, and termsOfService too.
urlTOS := client.GetServerMetadata().TermsOfService
if urlTOS == "" {
return true
}
// Check for a global acceptance override
if accountConfig.AcceptsTermsOfService {
return true
}
reader := bufio.NewReader(os.Stdin)
log.Warn("Please review the TOS", slog.String("url", urlTOS))
for {
fmt.Println("Do you accept the TOS? Y/n")
text, err := reader.ReadString('\n')
if err != nil {
log.Fatal("Could not read from the console", log.ErrorAttr(err))
}
text = strings.Trim(text, "\r\n")
switch text {
case "", "y", "Y":
return true
case "n", "N":
return false
default:
fmt.Println("Your input was invalid. Please answer with one of Y/y, n/N or by pressing enter.")
}
}
}

View file

@ -0,0 +1,328 @@
package root
import (
"context"
"crypto/x509"
"errors"
"fmt"
"log/slog"
"math/rand/v2"
"os"
"slices"
"sort"
"strings"
"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/configuration"
"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"
)
type lzSetUp func() (*lego.Client, error)
func renewCertificate(ctx context.Context, lazyClient lzSetUp, certID string, certConfig *configuration.Certificate, certsStorage *storage.CertificatesStorage) error {
// CSR
if certConfig.CSR != "" {
return renewForCSR(ctx, lazyClient, certID, certConfig, certsStorage)
}
// Domains
return renewForDomains(ctx, lazyClient, certID, certConfig, certsStorage)
}
func renewForDomains(ctx context.Context, lazyClient lzSetUp, certID string, certConfig *configuration.Certificate, certsStorage *storage.CertificatesStorage) error {
certificates, err := certsStorage.ReadCertificate(certID)
if err != nil {
return fmt.Errorf("error while reading the certificate for %q: %w", certID, err)
}
cert := certificates[0]
if cert.IsCA {
return fmt.Errorf("certificate bundle for %q starts with a CA certificate", certID)
}
ariRenewalTime, replacesCertID, err := getARIInfo(ctx, lazyClient, certID, certConfig.Renew, cert)
if err != nil {
return err
}
certDomains := certcrypto.ExtractDomains(cert)
renewalDomains := slices.Clone(certConfig.Domains)
if ariRenewalTime == nil && sameDomains(certDomains, renewalDomains) &&
!isInRenewalPeriod(cert, certID, certConfig.Renew.Days, time.Now()) {
return nil
}
// This is just meant to be informal for the user.
log.Info("acme: Trying renewal.",
log.CertNameAttr(certID),
slog.Any("time-remaining", log.FormattableDuration(cert.NotAfter.Sub(time.Now().UTC()))),
)
client, err := lazyClient()
if err != nil {
return fmt.Errorf("set up client: %w", err)
}
randomSleep()
request := certificate.ObtainRequest{
Domains: renewalDomains,
MustStaple: certConfig.MustStaple,
NotBefore: certConfig.NotBefore,
NotAfter: certConfig.NotAfter,
Bundle: !certConfig.NoBundle,
PreferredChain: certConfig.PreferredChain,
EnableCommonName: certConfig.EnableCommonName,
Profile: certConfig.Profile,
AlwaysDeactivateAuthorizations: certConfig.AlwaysDeactivateAuthorizations,
}
if certConfig.Renew != nil && certConfig.Renew.ReuseKey {
request.PrivateKey, err = certsStorage.ReadPrivateKey(certID)
if err != nil {
return err
}
}
if replacesCertID != "" {
request.ReplacesCertID = replacesCertID
}
certRes, err := client.Certificate.Obtain(ctx, request)
if err != nil {
return fmt.Errorf("could not obtain the certificate for %q: %w", certID, err)
}
certRes.ID = certID
err = certsStorage.Save(certRes, &storage.SaveOptions{PEM: true})
if err != nil {
return fmt.Errorf("could not save the resource: %w", err)
}
return nil
}
func renewForCSR(ctx context.Context, lazyClient lzSetUp, certID string, certConfig *configuration.Certificate, certsStorage *storage.CertificatesStorage) error {
csr, err := storage.ReadCSRFile(certConfig.CSR)
if err != nil {
return fmt.Errorf("could not read CSR file %q: %w", certConfig.CSR, err)
}
certificates, err := certsStorage.ReadCertificate(certID)
if err != nil {
return fmt.Errorf("CSR: error while reading the certificate for domains %q: %w",
strings.Join(certcrypto.ExtractDomainsCSR(csr), ","), err)
}
cert := certificates[0]
if cert.IsCA {
return fmt.Errorf("certificate bundle for %q starts with a CA certificate", certID)
}
ariRenewalTime, replacesCertID, err := getARIInfo(ctx, lazyClient, certID, certConfig.Renew, cert)
if err != nil {
return fmt.Errorf("CSR: %w", err)
}
if ariRenewalTime == nil && sameDomainsCertificate(cert, csr) &&
!isInRenewalPeriod(cert, certID, certConfig.Renew.Days, time.Now()) {
return nil
}
// This is just meant to be informal for the user.
log.Info("acme: Trying renewal.",
log.CertNameAttr(certID),
slog.Any("time-remaining", log.FormattableDuration(cert.NotAfter.Sub(time.Now().UTC()))),
)
client, err := lazyClient()
if err != nil {
return fmt.Errorf("set up client: %w", err)
}
request := certificate.ObtainForCSRRequest{
CSR: csr,
NotBefore: certConfig.NotBefore,
NotAfter: certConfig.NotAfter,
Bundle: !certConfig.NoBundle,
PreferredChain: certConfig.PreferredChain,
EnableCommonName: certConfig.EnableCommonName,
Profile: certConfig.Profile,
AlwaysDeactivateAuthorizations: certConfig.AlwaysDeactivateAuthorizations,
}
if replacesCertID != "" {
request.ReplacesCertID = replacesCertID
}
certRes, err := client.Certificate.ObtainForCSR(ctx, request)
if err != nil {
return fmt.Errorf("CSR: could not obtain the certificate: %w", err)
}
certRes.ID = certID
err = certsStorage.Save(certRes, &storage.SaveOptions{PEM: true})
if err != nil {
return fmt.Errorf("CSR: could not save the resource: %w", err)
}
return nil
}
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),
log.FormattableDuration(dueDate.Sub(now)),
),
log.CertNameAttr(domain),
)
return false
}
func getDueDate(x509Cert *x509.Certificate, days int, now time.Time) time.Time {
if days == 0 {
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, lazyClient lzSetUp, certID string, renewConfig *configuration.RenewConfiguration, cert *x509.Certificate) (*time.Time, string, error) {
// renewConfig and renewConfig.ARI cannot be nil: they are always defined in the default.
if renewConfig == nil || renewConfig.ARI == nil || renewConfig.ARI.Disable {
return nil, "", nil
}
client, err := lazyClient()
if err != nil {
return nil, "", fmt.Errorf("set up client: %w", err)
}
ariRenewalTime := getARIRenewalTime(ctx, renewConfig.ARI.WaitToRenewDuration, cert, certID, 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.CertNameAttr(certID),
slog.Duration("sleep", ariRenewalTime.Sub(now)),
slog.Time("renewalTime", *ariRenewalTime),
)
time.Sleep(ariRenewalTime.Sub(now))
}
}
replacesCertID, err := api.MakeARICertID(cert)
if err != nil {
return nil, "", fmt.Errorf("error while constructing the ARI CertID for domain %q: %w", certID, 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 {
renewalInfo, err := client.Certificate.GetRenewalInfo(ctx, cert)
if err != nil {
if errors.Is(err, api.ErrNoARI) {
log.Warn("acme: the server does not advertise a renewal info endpoint.",
log.CertNameAttr(domain),
log.ErrorAttr(err),
)
return nil
}
log.Warn("acme: calling renewal info endpoint",
log.CertNameAttr(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.CertNameAttr(domain))
return nil
}
log.Info("acme: renewalInfo endpoint indicates that renewal is needed.", log.CertNameAttr(domain))
if renewalInfo.ExplanationURL != "" {
log.Info("acme: renewalInfo endpoint provided an explanation.",
log.CertNameAttr(domain),
slog.String("explanationURL", renewalInfo.ExplanationURL),
)
}
return renewalTime
}
func randomSleep() {
// 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()) {
// https://github.com/certbot/certbot/blob/284023a1b7672be2bd4018dd7623b3b92197d4b0/certbot/certbot/_internal/renewal.py#L472
const jitter = 8 * time.Minute
sleepTime := time.Duration(rand.Int64N(int64(jitter)))
log.Info("renewal: random delay.", slog.Duration("sleep", sleepTime))
time.Sleep(sleepTime)
}
}
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)
}

View file

@ -0,0 +1,78 @@
package root
import (
"context"
"fmt"
"github.com/go-acme/lego/v5/certificate"
"github.com/go-acme/lego/v5/cmd/internal/configuration"
"github.com/go-acme/lego/v5/cmd/internal/storage"
"github.com/go-acme/lego/v5/lego"
)
func runCertificate(ctx context.Context, lazySetup lzSetUp, certConfig *configuration.Certificate, certsStorage *storage.CertificatesStorage) error {
client, err := lazySetup()
if err != nil {
return err
}
certRes, err := obtainCertificate(ctx, client, certConfig)
if err != nil {
return err
}
err = certsStorage.Save(certRes, &storage.SaveOptions{PEM: true})
if err != nil {
return fmt.Errorf("could not save the resource: %w", err)
}
return nil
}
func obtainCertificate(ctx context.Context, client *lego.Client, certConfig *configuration.Certificate) (*certificate.Resource, error) {
domains := certConfig.Domains
if len(domains) > 0 {
request := certificate.ObtainRequest{
Domains: domains,
MustStaple: certConfig.MustStaple,
NotBefore: certConfig.NotBefore,
NotAfter: certConfig.NotAfter,
Bundle: !certConfig.NoBundle,
PreferredChain: certConfig.PreferredChain,
EnableCommonName: certConfig.EnableCommonName,
Profile: certConfig.Profile,
AlwaysDeactivateAuthorizations: certConfig.AlwaysDeactivateAuthorizations,
}
// NOTE(ldez): I didn't add an option to set a private key as the file.
// I didn't find a use case for it when using the file configuration.
// Maybe this can be added in the future.
return client.Certificate.Obtain(ctx, request)
}
// read the CSR
csr, err := storage.ReadCSRFile(certConfig.CSR)
if err != nil {
return nil, err
}
// obtain a certificate for this CSR
request := certificate.ObtainForCSRRequest{
CSR: csr,
NotBefore: certConfig.NotBefore,
NotAfter: certConfig.NotAfter,
Bundle: !certConfig.NoBundle,
PreferredChain: certConfig.PreferredChain,
EnableCommonName: certConfig.EnableCommonName,
Profile: certConfig.Profile,
AlwaysDeactivateAuthorizations: certConfig.AlwaysDeactivateAuthorizations,
}
// NOTE(ldez): I didn't add an option to set a private key as the file.
// I didn't find a use case for it when using the file configuration.
// Maybe this can be added in the future.
return client.Certificate.ObtainForCSR(ctx, request)
}

View file

@ -1,6 +1,7 @@
package cmd
import (
"cmp"
"log/slog"
"os"
"strings"
@ -15,12 +16,22 @@ import (
const rfc3339NanoNatural = "2006-01-02T15:04:05.000000000Z07:00"
func setUpLogger(cmd *cli.Command) {
level := getLogLeveler(cmd.String(flags.FlgLogLevel))
func setUpLogger(cmd *cli.Command, logCfg *configuration.Log) {
cfg := &configuration.Log{}
if logCfg == nil {
cfg.Level = cmd.String(flags.FlgLogLevel)
cfg.Format = cmd.String(flags.FlgLogFormat)
} else {
cfg.Level = cmp.Or(logCfg.Level, cmd.String(flags.FlgLogLevel))
cfg.Format = cmp.Or(logCfg.Format, cmd.String(flags.FlgLogFormat))
}
level := getLogLeveler(cfg.Level)
var logger *slog.Logger
switch cmd.String(flags.FlgLogFormat) {
switch cfg.Format {
case configuration.LogFormatJSON:
opts := &slog.HandlerOptions{
Level: level,