From 0fe1f47b1d1ba2cd3d08ca5ef297c12ee94c1cd8 Mon Sep 17 00:00:00 2001 From: Fernandez Ludovic Date: Wed, 11 Mar 2026 15:59:03 +0100 Subject: [PATCH] feat: use file configuration --- .golangci.yml | 9 + cmd/cmd_root.go | 41 ++- cmd/internal/flags/flags.go | 14 +- cmd/internal/flags/names.go | 5 + cmd/internal/root/process.go | 42 +++ cmd/internal/root/process_challenges.go | 205 +++++++++++++++ cmd/internal/root/process_obtain.go | 185 +++++++++++++ cmd/internal/root/process_register.go | 73 ++++++ cmd/internal/root/process_renew.go | 328 ++++++++++++++++++++++++ cmd/internal/root/process_run.go | 78 ++++++ cmd/logger.go | 17 +- 11 files changed, 992 insertions(+), 5 deletions(-) create mode 100644 cmd/internal/root/process.go create mode 100644 cmd/internal/root/process_challenges.go create mode 100644 cmd/internal/root/process_obtain.go create mode 100644 cmd/internal/root/process_register.go create mode 100644 cmd/internal/root/process_renew.go create mode 100644 cmd/internal/root/process_run.go diff --git a/.golangci.yml b/.golangci.yml index 17e75c798..8c73eb833 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -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: diff --git a/cmd/cmd_root.go b/cmd/cmd_root.go index 86e9231ce..a3dd6f9e0 100644 --- a/cmd/cmd_root.go +++ b/cmd/cmd_root.go @@ -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() +} diff --git a/cmd/internal/flags/flags.go b/cmd/internal/flags/flags.go index 58a68b477..b1373a154 100644 --- a/cmd/internal/flags/flags.go +++ b/cmd/internal/flags/flags.go @@ -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 { diff --git a/cmd/internal/flags/names.go b/cmd/internal/flags/names.go index 6d7f00bbf..d661bd58d 100644 --- a/cmd/internal/flags/names.go +++ b/cmd/internal/flags/names.go @@ -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" diff --git a/cmd/internal/root/process.go b/cmd/internal/root/process.go new file mode 100644 index 000000000..63b821632 --- /dev/null +++ b/cmd/internal/root/process.go @@ -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 +} diff --git a/cmd/internal/root/process_challenges.go b/cmd/internal/root/process_challenges.go new file mode 100644 index 000000000..bbc8db70a --- /dev/null +++ b/cmd/internal/root/process_challenges.go @@ -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(), + ), + ) + }), + ) +} diff --git a/cmd/internal/root/process_obtain.go b/cmd/internal/root/process_obtain.go new file mode 100644 index 000000000..fb9ed7807 --- /dev/null +++ b/cmd/internal/root/process_obtain.go @@ -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 +} diff --git a/cmd/internal/root/process_register.go b/cmd/internal/root/process_register.go new file mode 100644 index 000000000..9b50b1d86 --- /dev/null +++ b/cmd/internal/root/process_register.go @@ -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.") + } + } +} diff --git a/cmd/internal/root/process_renew.go b/cmd/internal/root/process_renew.go new file mode 100644 index 000000000..5d6dd6069 --- /dev/null +++ b/cmd/internal/root/process_renew.go @@ -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) +} diff --git a/cmd/internal/root/process_run.go b/cmd/internal/root/process_run.go new file mode 100644 index 000000000..0a4a98174 --- /dev/null +++ b/cmd/internal/root/process_run.go @@ -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) +} diff --git a/cmd/logger.go b/cmd/logger.go index 3999d0abb..55d2df78b 100644 --- a/cmd/logger.go +++ b/cmd/logger.go @@ -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,